Skip to main content

powerio_capi/
arrow_export.rs

1//! Raw network tables over the Arrow C Data Interface.
2//!
3//! Builds the parsed [`BalancedNetwork`] element tables (bus/branch/gen/load/shunt) as
4//! Arrow record batches and lends them across the C ABI zero-copy via
5//! [`arrow::ffi::to_ffi`]. This is the in-memory, self-describing sibling of
6//! the `powerio-json` snapshot and the `pio_branches`-style numeric
7//! extractors: any Arrow consumer (pyarrow, Arrow.jl, Arrow C++, polars, DuckDB)
8//! can pull a whole table without a copy or a temp file. The schema is the
9//! ABI's evolution valve: richer columns arrive here, never as new C
10//! signatures.
11//!
12//! Tables 0..5 are the *raw* network fields, with EXTERNAL bus ids (the same id
13//! space as `pio_bus_ids`), not the gridfm-datakit schema. Tables 6..14 are the
14//! normalized solver table rules: per unit/radian values and dense zero based
15//! row ids. Matrix table ids after that carry COO triplets in the same dense bus
16//! index space, with matrix dimensions stored in Arrow schema metadata.
17
18use std::collections::HashMap;
19use std::sync::Arc;
20
21use arrow::array::{Array, ArrayRef, Float64Array, Int64Array, StructArray, UInt8Array};
22use arrow::datatypes::{Field, Schema};
23use arrow::error::ArrowError;
24use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema};
25use arrow::record_batch::RecordBatch;
26#[cfg(feature = "matrix")]
27use powerio::IndexedNetwork;
28use powerio::{BalancedNetwork, BusId, IndexCore, NormalizedSolverTables, SolverArcTerminal};
29
30/// Table selectors for [`pio_to_arrow`](crate::pio_to_arrow); the C
31/// header mirrors these as `PIO_ARROW_TABLE_*`.
32pub const PIO_ARROW_TABLE_BUS: i32 = 0;
33pub const PIO_ARROW_TABLE_BRANCH: i32 = 1;
34pub const PIO_ARROW_TABLE_GEN: i32 = 2;
35pub const PIO_ARROW_TABLE_LOAD: i32 = 3;
36pub const PIO_ARROW_TABLE_SHUNT: i32 = 4;
37pub const PIO_ARROW_TABLE_SWITCH: i32 = 5;
38pub const PIO_ARROW_TABLE_SOLVER_BUS: i32 = 6;
39pub const PIO_ARROW_TABLE_SOLVER_LOAD: i32 = 7;
40pub const PIO_ARROW_TABLE_SOLVER_SHUNT: i32 = 8;
41pub const PIO_ARROW_TABLE_SOLVER_BRANCH: i32 = 9;
42pub const PIO_ARROW_TABLE_SOLVER_SWITCH: i32 = 10;
43pub const PIO_ARROW_TABLE_SOLVER_ARC: i32 = 11;
44pub const PIO_ARROW_TABLE_SOLVER_GEN: i32 = 12;
45pub const PIO_ARROW_TABLE_SOLVER_STORAGE: i32 = 13;
46pub const PIO_ARROW_TABLE_SOLVER_HVDC: i32 = 14;
47pub const PIO_ARROW_TABLE_YBUS: i32 = 15;
48pub const PIO_ARROW_TABLE_INCIDENCE: i32 = 16;
49pub const PIO_ARROW_TABLE_BPRIME: i32 = 17;
50pub const PIO_ARROW_TABLE_BDOUBLEPRIME: i32 = 18;
51pub const PIO_ARROW_TABLE_MATRIX_BUS: i32 = 19;
52pub const PIO_ARROW_TABLE_MATRIX_BRANCH: i32 = 20;
53
54// These values are the ABI: the `PIO_ARROW_TABLE_*` macros in include/powerio.h
55// are hand-synced to them. The set is append-only: these ids and each table's
56// column order are frozen, a new table takes the next id and extends
57// this assert, and new columns append (nullable) at the end so consumers read by
58// name. Pin them so a Rust-side edit that drifts from the header (a renumber, a
59// reorder, a dropped table) fails the build instead of silently exporting the
60// wrong table.
61const _: () = assert!(
62    PIO_ARROW_TABLE_BUS == 0
63        && PIO_ARROW_TABLE_BRANCH == 1
64        && PIO_ARROW_TABLE_GEN == 2
65        && PIO_ARROW_TABLE_LOAD == 3
66        && PIO_ARROW_TABLE_SHUNT == 4
67        && PIO_ARROW_TABLE_SWITCH == 5
68        && PIO_ARROW_TABLE_SOLVER_BUS == 6
69        && PIO_ARROW_TABLE_SOLVER_LOAD == 7
70        && PIO_ARROW_TABLE_SOLVER_SHUNT == 8
71        && PIO_ARROW_TABLE_SOLVER_BRANCH == 9
72        && PIO_ARROW_TABLE_SOLVER_SWITCH == 10
73        && PIO_ARROW_TABLE_SOLVER_ARC == 11
74        && PIO_ARROW_TABLE_SOLVER_GEN == 12
75        && PIO_ARROW_TABLE_SOLVER_STORAGE == 13
76        && PIO_ARROW_TABLE_SOLVER_HVDC == 14
77        && PIO_ARROW_TABLE_YBUS == 15
78        && PIO_ARROW_TABLE_INCIDENCE == 16
79        && PIO_ARROW_TABLE_BPRIME == 17
80        && PIO_ARROW_TABLE_BDOUBLEPRIME == 18
81        && PIO_ARROW_TABLE_MATRIX_BUS == 19
82        && PIO_ARROW_TABLE_MATRIX_BRANCH == 20
83);
84
85/// Build the requested table and export it over the C Data Interface. The
86/// returned FFI structs own the columnar buffers until the consumer releases
87/// them.
88pub fn export(
89    net: &BalancedNetwork,
90    core: &IndexCore,
91    table: i32,
92) -> Result<(FFI_ArrowArray, FFI_ArrowSchema), String> {
93    let rb = match table {
94        PIO_ARROW_TABLE_BUS => bus_batch(net).map_err(|e| e.to_string())?,
95        PIO_ARROW_TABLE_BRANCH => branch_batch(net).map_err(|e| e.to_string())?,
96        PIO_ARROW_TABLE_GEN => gen_batch(net).map_err(|e| e.to_string())?,
97        PIO_ARROW_TABLE_LOAD => load_batch(net).map_err(|e| e.to_string())?,
98        PIO_ARROW_TABLE_SHUNT => shunt_batch(net).map_err(|e| e.to_string())?,
99        PIO_ARROW_TABLE_SWITCH => switch_batch(net).map_err(|e| e.to_string())?,
100        PIO_ARROW_TABLE_SOLVER_BUS => {
101            solver_bus_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
102        }
103        PIO_ARROW_TABLE_SOLVER_LOAD => {
104            solver_load_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
105        }
106        PIO_ARROW_TABLE_SOLVER_SHUNT => {
107            solver_shunt_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
108        }
109        PIO_ARROW_TABLE_SOLVER_BRANCH => {
110            solver_branch_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
111        }
112        PIO_ARROW_TABLE_SOLVER_SWITCH => {
113            solver_switch_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
114        }
115        PIO_ARROW_TABLE_SOLVER_ARC => {
116            solver_arc_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
117        }
118        PIO_ARROW_TABLE_SOLVER_GEN => {
119            solver_gen_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
120        }
121        PIO_ARROW_TABLE_SOLVER_STORAGE => {
122            solver_storage_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
123        }
124        PIO_ARROW_TABLE_SOLVER_HVDC => {
125            solver_hvdc_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
126        }
127        PIO_ARROW_TABLE_YBUS => matrix_ybus_batch(net, core)?,
128        PIO_ARROW_TABLE_INCIDENCE => matrix_incidence_batch(net, core)?,
129        PIO_ARROW_TABLE_BPRIME => matrix_bprime_batch(net, core)?,
130        PIO_ARROW_TABLE_BDOUBLEPRIME => matrix_bdoubleprime_batch(net, core)?,
131        PIO_ARROW_TABLE_MATRIX_BUS => matrix_bus_batch(net, core)?,
132        PIO_ARROW_TABLE_MATRIX_BRANCH => matrix_branch_batch(net, core)?,
133        other => return Err(format!("unknown Arrow table id {other}")),
134    };
135
136    // The C Data Interface represents a record batch as a struct array. Build
137    // the schema from the RecordBatch, not from ArrayData, so table metadata
138    // such as matrix dimensions survives the FFI boundary.
139    let schema = FFI_ArrowSchema::try_from(rb.schema().as_ref()).map_err(|e| e.to_string())?;
140    let data = StructArray::from(rb).into_data();
141    Ok((FFI_ArrowArray::new(&data), schema))
142}
143
144/// Return the Arrow table catalog as compact JSON.
145pub fn catalog_json() -> String {
146    serde_json::to_string(&catalog_value()).expect("Arrow catalog JSON is serializable")
147}
148
149fn catalog_value() -> serde_json::Value {
150    let matrix_available = cfg!(feature = "matrix");
151    let table_spec = |id: i32,
152                      name: &str,
153                      format: &str,
154                      feature_requirements: &[&str],
155                      available: bool,
156                      row_axis: Option<&str>,
157                      col_axis: Option<&str>,
158                      units: serde_json::Value,
159                      columns: &[(&str, &str)]| {
160        serde_json::json!({
161            "id": id,
162            "name": name,
163            "format": format,
164            "feature_requirements": feature_requirements,
165            "available": available,
166            "row_axis": row_axis,
167            "col_axis": col_axis,
168            "units": units,
169            "columns": columns.iter().map(|(name, dtype)| {
170                serde_json::json!({"name": name, "type": dtype, "nullable": false})
171            }).collect::<Vec<_>>(),
172        })
173    };
174    serde_json::json!({
175        powerio::version::VERSION_KEY: powerio::VERSION,
176        "producer": "powerio-capi",
177        "tables": [
178            table_spec(PIO_ARROW_TABLE_BUS, "bus", "record_batch", &["arrow"], true, None, None, units_source(), &[
179                ("id", "int64"), ("kind", "int64"), ("vm", "float64"), ("va", "float64"),
180                ("base_kv", "float64"), ("vmax", "float64"), ("vmin", "float64"),
181                ("area", "int64"), ("zone", "int64"),
182            ]),
183            table_spec(PIO_ARROW_TABLE_BRANCH, "branch", "record_batch", &["arrow"], true, None, None, units_source(), &[
184                ("from", "int64"), ("to", "int64"), ("r", "float64"), ("x", "float64"),
185                ("b", "float64"), ("rate_a", "float64"), ("rate_b", "float64"),
186                ("rate_c", "float64"), ("tap", "float64"), ("shift", "float64"),
187                ("in_service", "uint8"), ("angmin", "float64"), ("angmax", "float64"),
188                ("g_fr", "float64"), ("b_fr", "float64"), ("g_to", "float64"),
189                ("b_to", "float64"), ("c_rating_a", "float64"), ("c_rating_b", "float64"),
190                ("c_rating_c", "float64"), ("pf", "float64"), ("qf", "float64"),
191                ("pt", "float64"), ("qt", "float64"),
192            ]),
193            table_spec(PIO_ARROW_TABLE_GEN, "gen", "record_batch", &["arrow"], true, None, None, units_source(), &[
194                ("bus", "int64"), ("pg", "float64"), ("qg", "float64"),
195                ("pmax", "float64"), ("pmin", "float64"), ("qmax", "float64"),
196                ("qmin", "float64"), ("vg", "float64"), ("mbase", "float64"),
197                ("in_service", "uint8"),
198            ]),
199            table_spec(PIO_ARROW_TABLE_LOAD, "load", "record_batch", &["arrow"], true, None, None, units_source(), &[
200                ("bus", "int64"), ("p", "float64"), ("q", "float64"), ("in_service", "uint8"),
201            ]),
202            table_spec(PIO_ARROW_TABLE_SHUNT, "shunt", "record_batch", &["arrow"], true, None, None, units_source(), &[
203                ("bus", "int64"), ("g", "float64"), ("b", "float64"), ("in_service", "uint8"),
204            ]),
205            table_spec(PIO_ARROW_TABLE_SWITCH, "switch", "record_batch", &["arrow"], true, None, None, units_source(), &[
206                ("from", "int64"), ("to", "int64"), ("closed", "uint8"),
207                ("thermal_rating", "float64"), ("current_rating", "float64"),
208                ("pf", "float64"), ("qf", "float64"), ("pt", "float64"), ("qt", "float64"),
209            ]),
210            table_spec(PIO_ARROW_TABLE_SOLVER_BUS, "solver_bus", "record_batch", &["arrow"], true, Some("solver_bus"), None, units_solver(), &[
211                ("index", "int64"), ("bus_id", "int64"), ("source_row", "int64"),
212                ("kind", "int64"), ("vm", "float64"), ("va", "float64"),
213                ("base_kv", "float64"), ("vmax", "float64"), ("vmin", "float64"),
214                ("pd", "float64"), ("qd", "float64"), ("gs", "float64"),
215                ("bs", "float64"), ("component_label", "int64"), ("is_reference", "uint8"),
216            ]),
217            table_spec(PIO_ARROW_TABLE_SOLVER_LOAD, "solver_load", "record_batch", &["arrow"], true, Some("solver_load"), None, units_solver(), &[
218                ("index", "int64"), ("source_row", "int64"), ("bus_index", "int64"),
219                ("p", "float64"), ("q", "float64"),
220            ]),
221            table_spec(PIO_ARROW_TABLE_SOLVER_SHUNT, "solver_shunt", "record_batch", &["arrow"], true, Some("solver_shunt"), None, units_solver(), &[
222                ("index", "int64"), ("source_row", "int64"), ("bus_index", "int64"),
223                ("g", "float64"), ("b", "float64"),
224            ]),
225            table_spec(PIO_ARROW_TABLE_SOLVER_BRANCH, "solver_branch", "record_batch", &["arrow"], true, Some("solver_branch"), None, units_solver(), &[
226                ("index", "int64"), ("source_row", "int64"), ("from_bus_index", "int64"),
227                ("to_bus_index", "int64"), ("r", "float64"), ("x", "float64"),
228                ("b", "float64"), ("g_fr", "float64"), ("b_fr", "float64"),
229                ("g_to", "float64"), ("b_to", "float64"), ("rate_a", "float64"),
230                ("rate_b", "float64"), ("rate_c", "float64"), ("tap", "float64"),
231                ("shift", "float64"), ("angmin", "float64"), ("angmax", "float64"),
232            ]),
233            table_spec(PIO_ARROW_TABLE_SOLVER_SWITCH, "solver_switch", "record_batch", &["arrow"], true, Some("solver_switch"), None, units_solver(), &[
234                ("index", "int64"), ("source_row", "int64"), ("from_bus_index", "int64"),
235                ("to_bus_index", "int64"), ("closed", "uint8"), ("thermal_rating", "float64"),
236                ("current_rating", "float64"), ("pf", "float64"), ("qf", "float64"),
237                ("pt", "float64"), ("qt", "float64"),
238            ]),
239            table_spec(PIO_ARROW_TABLE_SOLVER_ARC, "solver_arc", "record_batch", &["arrow"], true, Some("solver_arc"), None, units_solver(), &[
240                ("index", "int64"), ("branch_index", "int64"), ("terminal", "int64"),
241                ("from_bus_index", "int64"), ("to_bus_index", "int64"), ("tap", "float64"),
242                ("shift", "float64"), ("g_shunt", "float64"), ("b_shunt", "float64"),
243                ("rate_a", "float64"),
244            ]),
245            table_spec(PIO_ARROW_TABLE_SOLVER_GEN, "solver_gen", "record_batch", &["arrow"], true, Some("solver_gen"), None, units_solver(), &[
246                ("index", "int64"), ("source_row", "int64"), ("bus_index", "int64"),
247                ("pg", "float64"), ("qg", "float64"), ("pmax", "float64"),
248                ("pmin", "float64"), ("qmax", "float64"), ("qmin", "float64"),
249                ("vg", "float64"), ("mbase", "float64"), ("regulated_bus_index", "int64"),
250            ]),
251            table_spec(PIO_ARROW_TABLE_SOLVER_STORAGE, "solver_storage", "record_batch", &["arrow"], true, Some("solver_storage"), None, units_solver(), &[
252                ("index", "int64"), ("source_row", "int64"), ("bus_index", "int64"),
253                ("ps", "float64"), ("qs", "float64"), ("energy", "float64"),
254                ("energy_rating", "float64"), ("charge_rating", "float64"),
255                ("discharge_rating", "float64"), ("thermal_rating", "float64"),
256                ("qmin", "float64"), ("qmax", "float64"), ("r", "float64"),
257                ("x", "float64"), ("p_loss", "float64"), ("q_loss", "float64"),
258            ]),
259            table_spec(PIO_ARROW_TABLE_SOLVER_HVDC, "solver_hvdc", "record_batch", &["arrow"], true, Some("solver_hvdc"), None, units_solver(), &[
260                ("index", "int64"), ("source_row", "int64"), ("from_bus_index", "int64"),
261                ("to_bus_index", "int64"), ("pf", "float64"), ("pt", "float64"),
262                ("qf", "float64"), ("qt", "float64"), ("vf", "float64"), ("vt", "float64"),
263                ("pmin", "float64"), ("pmax", "float64"), ("qminf", "float64"),
264                ("qmaxf", "float64"), ("qmint", "float64"), ("qmaxt", "float64"),
265                ("loss0", "float64"), ("loss1", "float64"),
266            ]),
267            table_spec(PIO_ARROW_TABLE_YBUS, "ybus", "coo", &["arrow", "matrix"], matrix_available, Some("matrix_bus"), Some("matrix_bus"), units_matrix(), &[
268                ("row_index", "int64"), ("col_index", "int64"), ("g", "float64"), ("b", "float64"),
269            ]),
270            table_spec(PIO_ARROW_TABLE_INCIDENCE, "incidence", "coo", &["arrow", "matrix"], matrix_available, Some("matrix_bus"), Some("matrix_branch"), units_matrix(), &[
271                ("row_index", "int64"), ("col_index", "int64"), ("value", "float64"),
272            ]),
273            table_spec(PIO_ARROW_TABLE_BPRIME, "bprime", "coo", &["arrow", "matrix"], matrix_available, Some("matrix_bus"), Some("matrix_bus"), units_matrix(), &[
274                ("row_index", "int64"), ("col_index", "int64"), ("value", "float64"),
275            ]),
276            table_spec(PIO_ARROW_TABLE_BDOUBLEPRIME, "bdoubleprime", "coo", &["arrow", "matrix"], matrix_available, Some("matrix_bus"), Some("matrix_bus"), units_matrix(), &[
277                ("row_index", "int64"), ("col_index", "int64"), ("value", "float64"),
278            ]),
279            table_spec(PIO_ARROW_TABLE_MATRIX_BUS, "matrix_bus", "axis_map", &["arrow", "matrix"], matrix_available, Some("matrix_bus"), None, units_axis(), &[
280                ("index", "int64"), ("bus_id", "int64"), ("source_row", "int64"),
281                ("is_reference", "uint8"), ("component", "int64"),
282            ]),
283            table_spec(PIO_ARROW_TABLE_MATRIX_BRANCH, "matrix_branch", "axis_map", &["arrow", "matrix"], matrix_available, Some("matrix_branch"), None, units_axis(), &[
284                ("index", "int64"), ("source_row", "int64"), ("from_bus_id", "int64"),
285                ("to_bus_id", "int64"),
286            ]),
287        ]
288    })
289}
290
291fn units_source() -> serde_json::Value {
292    serde_json::json!({
293        "power": "source",
294        "voltage": "source",
295        "angle": "degree",
296        "index_base": "external_bus_id"
297    })
298}
299
300fn units_solver() -> serde_json::Value {
301    serde_json::json!({
302        "power": "per_unit",
303        "voltage": "per_unit",
304        "angle": "radian",
305        "impedance": "per_unit",
306        "admittance": "per_unit",
307        "index_base": "zero"
308    })
309}
310
311fn units_matrix() -> serde_json::Value {
312    serde_json::json!({
313        "matrix_index_base": "zero",
314        "value": "per_unit"
315    })
316}
317
318fn units_axis() -> serde_json::Value {
319    serde_json::json!({
320        "index_base": "zero",
321        "source_row_base": "zero",
322        "missing_source_row": -1
323    })
324}
325
326fn solver_tables(net: &BalancedNetwork) -> Result<NormalizedSolverTables, String> {
327    net.to_normalized_solver_tables().map_err(|e| e.to_string())
328}
329
330fn bus_batch(net: &BalancedNetwork) -> Result<RecordBatch, ArrowError> {
331    let b = &net.buses;
332    batch(vec![
333        ("id", i64s(b.iter().map(|x| ext(x.id)).collect())),
334        (
335            "kind",
336            i64s(b.iter().map(|x| i64::from(x.kind as u8)).collect()),
337        ),
338        ("vm", f64s(b.iter().map(|x| x.vm).collect())),
339        ("va", f64s(b.iter().map(|x| x.va).collect())),
340        ("base_kv", f64s(b.iter().map(|x| x.base_kv).collect())),
341        ("vmax", f64s(b.iter().map(|x| x.vmax).collect())),
342        ("vmin", f64s(b.iter().map(|x| x.vmin).collect())),
343        ("area", i64s(b.iter().map(|x| usz(x.area)).collect())),
344        ("zone", i64s(b.iter().map(|x| usz(x.zone)).collect())),
345    ])
346}
347
348fn branch_batch(net: &BalancedNetwork) -> Result<RecordBatch, ArrowError> {
349    let br = &net.branches;
350    batch(vec![
351        ("from", i64s(br.iter().map(|x| ext(x.from)).collect())),
352        ("to", i64s(br.iter().map(|x| ext(x.to)).collect())),
353        ("r", f64s(br.iter().map(|x| x.r).collect())),
354        ("x", f64s(br.iter().map(|x| x.x).collect())),
355        ("b", f64s(br.iter().map(|x| x.total_charging_b()).collect())),
356        ("rate_a", f64s(br.iter().map(|x| x.rate_a).collect())),
357        ("rate_b", f64s(br.iter().map(|x| x.rate_b).collect())),
358        ("rate_c", f64s(br.iter().map(|x| x.rate_c).collect())),
359        ("tap", f64s(br.iter().map(|x| x.tap).collect())),
360        ("shift", f64s(br.iter().map(|x| x.shift).collect())),
361        (
362            "in_service",
363            u8s(br.iter().map(|x| u8::from(x.in_service)).collect()),
364        ),
365        ("angmin", f64s(br.iter().map(|x| x.angmin).collect())),
366        ("angmax", f64s(br.iter().map(|x| x.angmax).collect())),
367        (
368            "g_fr",
369            f64s(br.iter().map(|x| x.terminal_charging().g_fr).collect()),
370        ),
371        (
372            "b_fr",
373            f64s(br.iter().map(|x| x.terminal_charging().b_fr).collect()),
374        ),
375        (
376            "g_to",
377            f64s(br.iter().map(|x| x.terminal_charging().g_to).collect()),
378        ),
379        (
380            "b_to",
381            f64s(br.iter().map(|x| x.terminal_charging().b_to).collect()),
382        ),
383        (
384            "c_rating_a",
385            f64s(
386                br.iter()
387                    .map(|x| x.current_ratings.map_or(0.0, |r| r.c_rating_a))
388                    .collect(),
389            ),
390        ),
391        (
392            "c_rating_b",
393            f64s(
394                br.iter()
395                    .map(|x| x.current_ratings.map_or(0.0, |r| r.c_rating_b))
396                    .collect(),
397            ),
398        ),
399        (
400            "c_rating_c",
401            f64s(
402                br.iter()
403                    .map(|x| x.current_ratings.map_or(0.0, |r| r.c_rating_c))
404                    .collect(),
405            ),
406        ),
407        (
408            "pf",
409            f64s(
410                br.iter()
411                    .map(|x| x.solution.map_or(0.0, |s| s.pf))
412                    .collect(),
413            ),
414        ),
415        (
416            "qf",
417            f64s(
418                br.iter()
419                    .map(|x| x.solution.map_or(0.0, |s| s.qf))
420                    .collect(),
421            ),
422        ),
423        (
424            "pt",
425            f64s(
426                br.iter()
427                    .map(|x| x.solution.map_or(0.0, |s| s.pt))
428                    .collect(),
429            ),
430        ),
431        (
432            "qt",
433            f64s(
434                br.iter()
435                    .map(|x| x.solution.map_or(0.0, |s| s.qt))
436                    .collect(),
437            ),
438        ),
439    ])
440}
441
442fn gen_batch(net: &BalancedNetwork) -> Result<RecordBatch, ArrowError> {
443    let g = &net.generators;
444    batch(vec![
445        ("bus", i64s(g.iter().map(|x| ext(x.bus)).collect())),
446        ("pg", f64s(g.iter().map(|x| x.pg).collect())),
447        ("qg", f64s(g.iter().map(|x| x.qg).collect())),
448        ("pmax", f64s(g.iter().map(|x| x.pmax).collect())),
449        ("pmin", f64s(g.iter().map(|x| x.pmin).collect())),
450        ("qmax", f64s(g.iter().map(|x| x.qmax).collect())),
451        ("qmin", f64s(g.iter().map(|x| x.qmin).collect())),
452        ("vg", f64s(g.iter().map(|x| x.vg).collect())),
453        ("mbase", f64s(g.iter().map(|x| x.mbase).collect())),
454        (
455            "in_service",
456            u8s(g.iter().map(|x| u8::from(x.in_service)).collect()),
457        ),
458    ])
459}
460
461fn load_batch(net: &BalancedNetwork) -> Result<RecordBatch, ArrowError> {
462    let l = &net.loads;
463    batch(vec![
464        ("bus", i64s(l.iter().map(|x| ext(x.bus)).collect())),
465        ("p", f64s(l.iter().map(|x| x.p).collect())),
466        ("q", f64s(l.iter().map(|x| x.q).collect())),
467        (
468            "in_service",
469            u8s(l.iter().map(|x| u8::from(x.in_service)).collect()),
470        ),
471    ])
472}
473
474fn shunt_batch(net: &BalancedNetwork) -> Result<RecordBatch, ArrowError> {
475    let s = &net.shunts;
476    batch(vec![
477        ("bus", i64s(s.iter().map(|x| ext(x.bus)).collect())),
478        ("g", f64s(s.iter().map(|x| x.g).collect())),
479        ("b", f64s(s.iter().map(|x| x.b).collect())),
480        (
481            "in_service",
482            u8s(s.iter().map(|x| u8::from(x.in_service)).collect()),
483        ),
484    ])
485}
486
487fn switch_batch(net: &BalancedNetwork) -> Result<RecordBatch, ArrowError> {
488    let s = &net.switches;
489    batch(vec![
490        ("from", i64s(s.iter().map(|x| ext(x.from)).collect())),
491        ("to", i64s(s.iter().map(|x| ext(x.to)).collect())),
492        (
493            "closed",
494            u8s(s.iter().map(|x| u8::from(x.closed)).collect()),
495        ),
496        (
497            "thermal_rating",
498            f64s(s.iter().map(|x| x.thermal_rating.unwrap_or(0.0)).collect()),
499        ),
500        (
501            "current_rating",
502            f64s(s.iter().map(|x| x.current_rating.unwrap_or(0.0)).collect()),
503        ),
504        ("pf", f64s(s.iter().map(|x| x.pf.unwrap_or(0.0)).collect())),
505        ("qf", f64s(s.iter().map(|x| x.qf.unwrap_or(0.0)).collect())),
506        ("pt", f64s(s.iter().map(|x| x.pt.unwrap_or(0.0)).collect())),
507        ("qt", f64s(s.iter().map(|x| x.qt.unwrap_or(0.0)).collect())),
508    ])
509}
510
511fn solver_bus_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
512    batch(vec![
513        (
514            "index",
515            i64s(t.buses.iter().map(|x| usz(x.index)).collect()),
516        ),
517        (
518            "bus_id",
519            i64s(t.buses.iter().map(|x| ext(x.bus_id)).collect()),
520        ),
521        (
522            "source_row",
523            i64s(t.buses.iter().map(|x| opt_usz(x.source_row)).collect()),
524        ),
525        (
526            "kind",
527            i64s(t.buses.iter().map(|x| i64::from(x.kind as u8)).collect()),
528        ),
529        ("vm", f64s(t.buses.iter().map(|x| x.vm).collect())),
530        ("va", f64s(t.buses.iter().map(|x| x.va).collect())),
531        ("base_kv", f64s(t.buses.iter().map(|x| x.base_kv).collect())),
532        ("vmax", f64s(t.buses.iter().map(|x| x.vmax).collect())),
533        ("vmin", f64s(t.buses.iter().map(|x| x.vmin).collect())),
534        ("pd", f64s(t.buses.iter().map(|x| x.pd).collect())),
535        ("qd", f64s(t.buses.iter().map(|x| x.qd).collect())),
536        ("gs", f64s(t.buses.iter().map(|x| x.gs).collect())),
537        ("bs", f64s(t.buses.iter().map(|x| x.bs).collect())),
538        (
539            "component_label",
540            i64s(t.index.component_labels.iter().map(|&x| usz(x)).collect()),
541        ),
542        (
543            "is_reference",
544            u8s(t
545                .buses
546                .iter()
547                .map(|x| u8::from(t.index.reference_bus_indices.contains(&x.index)))
548                .collect()),
549        ),
550    ])
551}
552
553fn solver_load_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
554    batch(vec![
555        (
556            "index",
557            i64s(t.loads.iter().map(|x| usz(x.index)).collect()),
558        ),
559        (
560            "source_row",
561            i64s(t.loads.iter().map(|x| opt_usz(x.source_row)).collect()),
562        ),
563        (
564            "bus_index",
565            i64s(t.loads.iter().map(|x| usz(x.bus_index)).collect()),
566        ),
567        ("p", f64s(t.loads.iter().map(|x| x.p).collect())),
568        ("q", f64s(t.loads.iter().map(|x| x.q).collect())),
569    ])
570}
571
572fn solver_shunt_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
573    batch(vec![
574        (
575            "index",
576            i64s(t.shunts.iter().map(|x| usz(x.index)).collect()),
577        ),
578        (
579            "source_row",
580            i64s(t.shunts.iter().map(|x| opt_usz(x.source_row)).collect()),
581        ),
582        (
583            "bus_index",
584            i64s(t.shunts.iter().map(|x| usz(x.bus_index)).collect()),
585        ),
586        ("g", f64s(t.shunts.iter().map(|x| x.g).collect())),
587        ("b", f64s(t.shunts.iter().map(|x| x.b).collect())),
588    ])
589}
590
591fn solver_branch_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
592    batch(vec![
593        (
594            "index",
595            i64s(t.branches.iter().map(|x| usz(x.index)).collect()),
596        ),
597        (
598            "source_row",
599            i64s(t.branches.iter().map(|x| opt_usz(x.source_row)).collect()),
600        ),
601        (
602            "from_bus_index",
603            i64s(t.branches.iter().map(|x| usz(x.from_bus_index)).collect()),
604        ),
605        (
606            "to_bus_index",
607            i64s(t.branches.iter().map(|x| usz(x.to_bus_index)).collect()),
608        ),
609        ("r", f64s(t.branches.iter().map(|x| x.r).collect())),
610        ("x", f64s(t.branches.iter().map(|x| x.x).collect())),
611        ("b", f64s(t.branches.iter().map(|x| x.b).collect())),
612        ("g_fr", f64s(t.branches.iter().map(|x| x.g_fr).collect())),
613        ("b_fr", f64s(t.branches.iter().map(|x| x.b_fr).collect())),
614        ("g_to", f64s(t.branches.iter().map(|x| x.g_to).collect())),
615        ("b_to", f64s(t.branches.iter().map(|x| x.b_to).collect())),
616        (
617            "rate_a",
618            f64s(t.branches.iter().map(|x| x.rate_a).collect()),
619        ),
620        (
621            "rate_b",
622            f64s(t.branches.iter().map(|x| x.rate_b).collect()),
623        ),
624        (
625            "rate_c",
626            f64s(t.branches.iter().map(|x| x.rate_c).collect()),
627        ),
628        ("tap", f64s(t.branches.iter().map(|x| x.tap).collect())),
629        ("shift", f64s(t.branches.iter().map(|x| x.shift).collect())),
630        (
631            "angmin",
632            f64s(t.branches.iter().map(|x| x.angmin).collect()),
633        ),
634        (
635            "angmax",
636            f64s(t.branches.iter().map(|x| x.angmax).collect()),
637        ),
638    ])
639}
640
641fn solver_switch_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
642    batch(vec![
643        (
644            "index",
645            i64s(t.switches.iter().map(|x| usz(x.index)).collect()),
646        ),
647        (
648            "source_row",
649            i64s(t.switches.iter().map(|x| opt_usz(x.source_row)).collect()),
650        ),
651        (
652            "from_bus_index",
653            i64s(t.switches.iter().map(|x| usz(x.from_bus_index)).collect()),
654        ),
655        (
656            "to_bus_index",
657            i64s(t.switches.iter().map(|x| usz(x.to_bus_index)).collect()),
658        ),
659        (
660            "closed",
661            u8s(t.switches.iter().map(|x| u8::from(x.closed)).collect()),
662        ),
663        (
664            "thermal_rating",
665            f64s(
666                t.switches
667                    .iter()
668                    .map(|x| x.thermal_rating.unwrap_or(0.0))
669                    .collect(),
670            ),
671        ),
672        (
673            "current_rating",
674            f64s(
675                t.switches
676                    .iter()
677                    .map(|x| x.current_rating.unwrap_or(0.0))
678                    .collect(),
679            ),
680        ),
681        (
682            "pf",
683            f64s(t.switches.iter().map(|x| x.pf.unwrap_or(0.0)).collect()),
684        ),
685        (
686            "qf",
687            f64s(t.switches.iter().map(|x| x.qf.unwrap_or(0.0)).collect()),
688        ),
689        (
690            "pt",
691            f64s(t.switches.iter().map(|x| x.pt.unwrap_or(0.0)).collect()),
692        ),
693        (
694            "qt",
695            f64s(t.switches.iter().map(|x| x.qt.unwrap_or(0.0)).collect()),
696        ),
697    ])
698}
699
700fn solver_arc_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
701    batch(vec![
702        ("index", i64s(t.arcs.iter().map(|x| usz(x.index)).collect())),
703        (
704            "branch_index",
705            i64s(t.arcs.iter().map(|x| usz(x.branch_index)).collect()),
706        ),
707        (
708            "terminal",
709            i64s(
710                t.arcs
711                    .iter()
712                    .map(|x| match x.terminal {
713                        SolverArcTerminal::From => 0,
714                        SolverArcTerminal::To => 1,
715                    })
716                    .collect(),
717            ),
718        ),
719        (
720            "from_bus_index",
721            i64s(t.arcs.iter().map(|x| usz(x.from_bus_index)).collect()),
722        ),
723        (
724            "to_bus_index",
725            i64s(t.arcs.iter().map(|x| usz(x.to_bus_index)).collect()),
726        ),
727        ("tap", f64s(t.arcs.iter().map(|x| x.tap).collect())),
728        ("shift", f64s(t.arcs.iter().map(|x| x.shift).collect())),
729        ("g_shunt", f64s(t.arcs.iter().map(|x| x.g_shunt).collect())),
730        ("b_shunt", f64s(t.arcs.iter().map(|x| x.b_shunt).collect())),
731        ("rate_a", f64s(t.arcs.iter().map(|x| x.rate_a).collect())),
732    ])
733}
734
735fn solver_gen_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
736    batch(vec![
737        (
738            "index",
739            i64s(t.generators.iter().map(|x| usz(x.index)).collect()),
740        ),
741        (
742            "source_row",
743            i64s(t.generators.iter().map(|x| opt_usz(x.source_row)).collect()),
744        ),
745        (
746            "bus_index",
747            i64s(t.generators.iter().map(|x| usz(x.bus_index)).collect()),
748        ),
749        ("pg", f64s(t.generators.iter().map(|x| x.pg).collect())),
750        ("qg", f64s(t.generators.iter().map(|x| x.qg).collect())),
751        ("pmax", f64s(t.generators.iter().map(|x| x.pmax).collect())),
752        ("pmin", f64s(t.generators.iter().map(|x| x.pmin).collect())),
753        ("qmax", f64s(t.generators.iter().map(|x| x.qmax).collect())),
754        ("qmin", f64s(t.generators.iter().map(|x| x.qmin).collect())),
755        ("vg", f64s(t.generators.iter().map(|x| x.vg).collect())),
756        (
757            "mbase",
758            f64s(t.generators.iter().map(|x| x.mbase).collect()),
759        ),
760        (
761            "regulated_bus_index",
762            i64s(
763                t.generators
764                    .iter()
765                    .map(|x| opt_usz(x.regulated_bus_index))
766                    .collect(),
767            ),
768        ),
769    ])
770}
771
772fn solver_storage_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
773    batch(vec![
774        (
775            "index",
776            i64s(t.storage.iter().map(|x| usz(x.index)).collect()),
777        ),
778        (
779            "source_row",
780            i64s(t.storage.iter().map(|x| opt_usz(x.source_row)).collect()),
781        ),
782        (
783            "bus_index",
784            i64s(t.storage.iter().map(|x| usz(x.bus_index)).collect()),
785        ),
786        ("ps", f64s(t.storage.iter().map(|x| x.ps).collect())),
787        ("qs", f64s(t.storage.iter().map(|x| x.qs).collect())),
788        ("energy", f64s(t.storage.iter().map(|x| x.energy).collect())),
789        (
790            "energy_rating",
791            f64s(t.storage.iter().map(|x| x.energy_rating).collect()),
792        ),
793        (
794            "charge_rating",
795            f64s(t.storage.iter().map(|x| x.charge_rating).collect()),
796        ),
797        (
798            "discharge_rating",
799            f64s(t.storage.iter().map(|x| x.discharge_rating).collect()),
800        ),
801        (
802            "thermal_rating",
803            f64s(t.storage.iter().map(|x| x.thermal_rating).collect()),
804        ),
805        ("qmin", f64s(t.storage.iter().map(|x| x.qmin).collect())),
806        ("qmax", f64s(t.storage.iter().map(|x| x.qmax).collect())),
807        ("r", f64s(t.storage.iter().map(|x| x.r).collect())),
808        ("x", f64s(t.storage.iter().map(|x| x.x).collect())),
809        ("p_loss", f64s(t.storage.iter().map(|x| x.p_loss).collect())),
810        ("q_loss", f64s(t.storage.iter().map(|x| x.q_loss).collect())),
811    ])
812}
813
814fn solver_hvdc_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
815    batch(vec![
816        ("index", i64s(t.hvdc.iter().map(|x| usz(x.index)).collect())),
817        (
818            "source_row",
819            i64s(t.hvdc.iter().map(|x| opt_usz(x.source_row)).collect()),
820        ),
821        (
822            "from_bus_index",
823            i64s(t.hvdc.iter().map(|x| usz(x.from_bus_index)).collect()),
824        ),
825        (
826            "to_bus_index",
827            i64s(t.hvdc.iter().map(|x| usz(x.to_bus_index)).collect()),
828        ),
829        ("pf", f64s(t.hvdc.iter().map(|x| x.pf).collect())),
830        ("pt", f64s(t.hvdc.iter().map(|x| x.pt).collect())),
831        ("qf", f64s(t.hvdc.iter().map(|x| x.qf).collect())),
832        ("qt", f64s(t.hvdc.iter().map(|x| x.qt).collect())),
833        ("vf", f64s(t.hvdc.iter().map(|x| x.vf).collect())),
834        ("vt", f64s(t.hvdc.iter().map(|x| x.vt).collect())),
835        ("pmin", f64s(t.hvdc.iter().map(|x| x.pmin).collect())),
836        ("pmax", f64s(t.hvdc.iter().map(|x| x.pmax).collect())),
837        ("qminf", f64s(t.hvdc.iter().map(|x| x.qminf).collect())),
838        ("qmaxf", f64s(t.hvdc.iter().map(|x| x.qmaxf).collect())),
839        ("qmint", f64s(t.hvdc.iter().map(|x| x.qmint).collect())),
840        ("qmaxt", f64s(t.hvdc.iter().map(|x| x.qmaxt).collect())),
841        ("loss0", f64s(t.hvdc.iter().map(|x| x.loss0).collect())),
842        ("loss1", f64s(t.hvdc.iter().map(|x| x.loss1).collect())),
843    ])
844}
845
846#[cfg(feature = "matrix")]
847macro_rules! real_matrix_batch {
848    ($table_name:expr, $matrix:expr, $row_axis:expr, $col_axis:expr) => {{
849        let matrix = $matrix;
850        let mut row_index = Vec::with_capacity(matrix.nnz());
851        let mut col_index = Vec::with_capacity(matrix.nnz());
852        let mut value = Vec::with_capacity(matrix.nnz());
853        for (row, vec) in matrix.outer_iterator().enumerate() {
854            for (col, &entry) in vec.iter() {
855                row_index.push(usz(row));
856                col_index.push(usz(col));
857                value.push(entry);
858            }
859        }
860        matrix_real_batch(
861            $table_name,
862            MatrixDims {
863                rows: matrix.rows(),
864                cols: matrix.cols(),
865            },
866            row_index,
867            col_index,
868            value,
869            MatrixAxes {
870                row: $row_axis,
871                col: $col_axis,
872            },
873        )
874    }};
875}
876
877#[cfg(feature = "matrix")]
878fn matrix_bus_batch(net: &BalancedNetwork, core: &IndexCore) -> Result<RecordBatch, String> {
879    let view = IndexedNetwork::with_core(net, core);
880    let refs = view.reference_bus_indices();
881    let components = view.connected_component_labels();
882    let source_rows: HashMap<BusId, usize> = net
883        .buses
884        .iter()
885        .enumerate()
886        .map(|(idx, bus)| (bus.id, idx))
887        .collect();
888
889    let buses = &view.network().buses;
890    batch_with_metadata(
891        vec![
892            ("index", i64s((0..buses.len()).map(usz).collect::<Vec<_>>())),
893            (
894                "bus_id",
895                i64s(buses.iter().map(|bus| ext(bus.id)).collect()),
896            ),
897            (
898                "source_row",
899                i64s(
900                    buses
901                        .iter()
902                        .map(|bus| source_rows.get(&bus.id).copied().map_or(-1, usz))
903                        .collect(),
904                ),
905            ),
906            (
907                "is_reference",
908                u8s((0..buses.len())
909                    .map(|idx| u8::from(refs.contains(&idx)))
910                    .collect()),
911            ),
912            (
913                "component",
914                i64s(components.iter().map(|&label| usz(label)).collect()),
915            ),
916        ],
917        axis_metadata("matrix_bus"),
918    )
919    .map_err(|e| e.to_string())
920}
921
922#[cfg(not(feature = "matrix"))]
923fn matrix_bus_batch(_net: &BalancedNetwork, _core: &IndexCore) -> Result<RecordBatch, String> {
924    Err(matrix_feature_error())
925}
926
927#[cfg(feature = "matrix")]
928fn matrix_branch_batch(net: &BalancedNetwork, core: &IndexCore) -> Result<RecordBatch, String> {
929    let view = IndexedNetwork::with_core(net, core);
930    let parts = powerio_matrix::build_incidence(
931        &view,
932        powerio_matrix::DcConvention::default(),
933        &powerio_matrix::BuildOptions::default(),
934    )
935    .map_err(|e| e.to_string())?;
936
937    let mut index = Vec::with_capacity(parts.branch_of_col.len());
938    let mut source_row = Vec::with_capacity(parts.branch_of_col.len());
939    let mut from_bus_id = Vec::with_capacity(parts.branch_of_col.len());
940    let mut to_bus_id = Vec::with_capacity(parts.branch_of_col.len());
941    for (col, &idx) in parts.branch_of_col.iter().enumerate() {
942        let br = view
943            .branches()
944            .get(idx)
945            .ok_or_else(|| format!("incidence branch column {col} points to missing row {idx}"))?;
946        index.push(usz(col));
947        source_row.push((idx < net.branches.len()).then_some(idx).map_or(-1, usz));
948        from_bus_id.push(ext(br.from));
949        to_bus_id.push(ext(br.to));
950    }
951
952    batch_with_metadata(
953        vec![
954            ("index", i64s(index)),
955            ("source_row", i64s(source_row)),
956            ("from_bus_id", i64s(from_bus_id)),
957            ("to_bus_id", i64s(to_bus_id)),
958        ],
959        axis_metadata("matrix_branch"),
960    )
961    .map_err(|e| e.to_string())
962}
963
964#[cfg(not(feature = "matrix"))]
965fn matrix_branch_batch(_net: &BalancedNetwork, _core: &IndexCore) -> Result<RecordBatch, String> {
966    Err(matrix_feature_error())
967}
968
969#[cfg(feature = "matrix")]
970fn matrix_ybus_batch(net: &BalancedNetwork, core: &IndexCore) -> Result<RecordBatch, String> {
971    let view = IndexedNetwork::with_core(net, core);
972    let parts = powerio_matrix::build_ybus(&view, &powerio_matrix::BuildOptions::default())
973        .map_err(|e| e.to_string())?;
974    let mut cols = YbusColumns {
975        row_index: Vec::with_capacity(parts.g.nnz() + parts.b.nnz()),
976        col_index: Vec::with_capacity(parts.g.nnz() + parts.b.nnz()),
977        g: Vec::with_capacity(parts.g.nnz() + parts.b.nnz()),
978        b: Vec::with_capacity(parts.g.nnz() + parts.b.nnz()),
979    };
980    for row in 0..parts.g.rows() {
981        match (parts.g.outer_view(row), parts.b.outer_view(row)) {
982            (Some(g_row), Some(b_row)) => push_ybus_row(
983                row,
984                g_row.indices(),
985                g_row.data(),
986                b_row.indices(),
987                b_row.data(),
988                &mut cols,
989            ),
990            (Some(g_row), None) => {
991                push_ybus_row(row, g_row.indices(), g_row.data(), &[], &[], &mut cols);
992            }
993            (None, Some(b_row)) => {
994                push_ybus_row(row, &[], &[], b_row.indices(), b_row.data(), &mut cols);
995            }
996            (None, None) => {}
997        }
998    }
999    matrix_ybus_record_batch(
1000        MatrixDims {
1001            rows: parts.g.rows(),
1002            cols: parts.g.cols(),
1003        },
1004        cols.row_index,
1005        cols.col_index,
1006        cols.g,
1007        cols.b,
1008        MatrixAxes {
1009            row: "matrix_bus",
1010            col: "matrix_bus",
1011        },
1012    )
1013    .map_err(|e| e.to_string())
1014}
1015
1016#[cfg(not(feature = "matrix"))]
1017fn matrix_ybus_batch(_net: &BalancedNetwork, _core: &IndexCore) -> Result<RecordBatch, String> {
1018    Err(matrix_feature_error())
1019}
1020
1021#[cfg(feature = "matrix")]
1022struct YbusColumns {
1023    row_index: Vec<i64>,
1024    col_index: Vec<i64>,
1025    g: Vec<f64>,
1026    b: Vec<f64>,
1027}
1028
1029#[cfg(feature = "matrix")]
1030fn push_ybus_row(
1031    row: usize,
1032    g_indices: &[usize],
1033    g_data: &[f64],
1034    b_indices: &[usize],
1035    b_data: &[f64],
1036    cols: &mut YbusColumns,
1037) {
1038    let mut gi = 0;
1039    let mut bi = 0;
1040    while gi < g_indices.len() || bi < b_indices.len() {
1041        let (col, g_value, b_value) = match (g_indices.get(gi), b_indices.get(bi)) {
1042            (Some(&g_col), Some(&b_col)) => match g_col.cmp(&b_col) {
1043                std::cmp::Ordering::Less => {
1044                    gi += 1;
1045                    (g_col, g_data[gi - 1], 0.0)
1046                }
1047                std::cmp::Ordering::Greater => {
1048                    bi += 1;
1049                    (b_col, 0.0, b_data[bi - 1])
1050                }
1051                std::cmp::Ordering::Equal => {
1052                    gi += 1;
1053                    bi += 1;
1054                    (g_col, g_data[gi - 1], b_data[bi - 1])
1055                }
1056            },
1057            (Some(&g_col), None) => {
1058                gi += 1;
1059                (g_col, g_data[gi - 1], 0.0)
1060            }
1061            (None, Some(&b_col)) => {
1062                bi += 1;
1063                (b_col, 0.0, b_data[bi - 1])
1064            }
1065            (None, None) => unreachable!(),
1066        };
1067        cols.row_index.push(usz(row));
1068        cols.col_index.push(usz(col));
1069        cols.g.push(g_value);
1070        cols.b.push(b_value);
1071    }
1072}
1073
1074#[cfg(feature = "matrix")]
1075fn matrix_incidence_batch(net: &BalancedNetwork, core: &IndexCore) -> Result<RecordBatch, String> {
1076    let view = IndexedNetwork::with_core(net, core);
1077    let parts = powerio_matrix::build_incidence(
1078        &view,
1079        powerio_matrix::DcConvention::default(),
1080        &powerio_matrix::BuildOptions::default(),
1081    )
1082    .map_err(|e| e.to_string())?;
1083    real_matrix_batch!("incidence", parts.a, "matrix_bus", "matrix_branch")
1084        .map_err(|e| e.to_string())
1085}
1086
1087#[cfg(not(feature = "matrix"))]
1088fn matrix_incidence_batch(
1089    _net: &BalancedNetwork,
1090    _core: &IndexCore,
1091) -> Result<RecordBatch, String> {
1092    Err(matrix_feature_error())
1093}
1094
1095#[cfg(feature = "matrix")]
1096fn matrix_bprime_batch(net: &BalancedNetwork, core: &IndexCore) -> Result<RecordBatch, String> {
1097    let view = IndexedNetwork::with_core(net, core);
1098    let matrix = powerio_matrix::build_bprime(&view, &powerio_matrix::BuildOptions::default())
1099        .map_err(|e| e.to_string())?;
1100    real_matrix_batch!("bprime", matrix, "matrix_bus", "matrix_bus").map_err(|e| e.to_string())
1101}
1102
1103#[cfg(not(feature = "matrix"))]
1104fn matrix_bprime_batch(_net: &BalancedNetwork, _core: &IndexCore) -> Result<RecordBatch, String> {
1105    Err(matrix_feature_error())
1106}
1107
1108#[cfg(feature = "matrix")]
1109fn matrix_bdoubleprime_batch(
1110    net: &BalancedNetwork,
1111    core: &IndexCore,
1112) -> Result<RecordBatch, String> {
1113    let view = IndexedNetwork::with_core(net, core);
1114    let matrix =
1115        powerio_matrix::build_bdoubleprime(&view, &powerio_matrix::BuildOptions::default())
1116            .map_err(|e| e.to_string())?;
1117    real_matrix_batch!("bdoubleprime", matrix, "matrix_bus", "matrix_bus")
1118        .map_err(|e| e.to_string())
1119}
1120
1121#[cfg(not(feature = "matrix"))]
1122fn matrix_bdoubleprime_batch(
1123    _net: &BalancedNetwork,
1124    _core: &IndexCore,
1125) -> Result<RecordBatch, String> {
1126    Err(matrix_feature_error())
1127}
1128
1129#[cfg(not(feature = "matrix"))]
1130fn matrix_feature_error() -> String {
1131    "matrix Arrow tables require the matrix cargo feature".to_owned()
1132}
1133
1134#[cfg(feature = "matrix")]
1135struct MatrixDims {
1136    rows: usize,
1137    cols: usize,
1138}
1139
1140#[cfg(feature = "matrix")]
1141struct MatrixAxes<'a> {
1142    row: &'a str,
1143    col: &'a str,
1144}
1145
1146#[cfg(feature = "matrix")]
1147fn matrix_real_batch(
1148    table: &str,
1149    dims: MatrixDims,
1150    row_index: Vec<i64>,
1151    col_index: Vec<i64>,
1152    value: Vec<f64>,
1153    axes: MatrixAxes<'_>,
1154) -> Result<RecordBatch, ArrowError> {
1155    batch_with_metadata(
1156        vec![
1157            ("row_index", i64s(row_index)),
1158            ("col_index", i64s(col_index)),
1159            ("value", f64s(value)),
1160        ],
1161        matrix_metadata(table, dims.rows, dims.cols, axes.row, axes.col),
1162    )
1163}
1164
1165#[cfg(feature = "matrix")]
1166fn matrix_ybus_record_batch(
1167    dims: MatrixDims,
1168    row_index: Vec<i64>,
1169    col_index: Vec<i64>,
1170    g: Vec<f64>,
1171    b: Vec<f64>,
1172    axes: MatrixAxes<'_>,
1173) -> Result<RecordBatch, ArrowError> {
1174    batch_with_metadata(
1175        vec![
1176            ("row_index", i64s(row_index)),
1177            ("col_index", i64s(col_index)),
1178            ("g", f64s(g)),
1179            ("b", f64s(b)),
1180        ],
1181        matrix_metadata("ybus", dims.rows, dims.cols, axes.row, axes.col),
1182    )
1183}
1184
1185fn batch(cols: Vec<(&str, ArrayRef)>) -> Result<RecordBatch, ArrowError> {
1186    batch_with_metadata(cols, HashMap::new())
1187}
1188
1189fn batch_with_metadata(
1190    cols: Vec<(&str, ArrayRef)>,
1191    metadata: HashMap<String, String>,
1192) -> Result<RecordBatch, ArrowError> {
1193    let fields: Vec<Field> = cols
1194        .iter()
1195        .map(|(name, arr)| Field::new(*name, arr.data_type().clone(), false))
1196        .collect();
1197    let arrays: Vec<ArrayRef> = cols.into_iter().map(|(_, arr)| arr).collect();
1198    RecordBatch::try_new(
1199        Arc::new(Schema::new_with_metadata(fields, metadata)),
1200        arrays,
1201    )
1202}
1203
1204#[cfg(feature = "matrix")]
1205fn matrix_metadata(
1206    table: &str,
1207    rows: usize,
1208    cols: usize,
1209    row_axis: &str,
1210    col_axis: &str,
1211) -> HashMap<String, String> {
1212    HashMap::from([
1213        ("powerio.table".to_owned(), table.to_owned()),
1214        ("powerio.version".to_owned(), powerio::VERSION.to_owned()),
1215        ("powerio.format".to_owned(), "coo".to_owned()),
1216        ("powerio.index_space".to_owned(), "solver_bus".to_owned()),
1217        ("powerio.row_axis".to_owned(), row_axis.to_owned()),
1218        ("powerio.col_axis".to_owned(), col_axis.to_owned()),
1219        ("powerio.row_count".to_owned(), rows.to_string()),
1220        ("powerio.col_count".to_owned(), cols.to_string()),
1221    ])
1222}
1223
1224#[cfg(feature = "matrix")]
1225fn axis_metadata(table: &str) -> HashMap<String, String> {
1226    HashMap::from([
1227        ("powerio.table".to_owned(), table.to_owned()),
1228        ("powerio.version".to_owned(), powerio::VERSION.to_owned()),
1229        ("powerio.format".to_owned(), "axis_map".to_owned()),
1230        ("powerio.row_axis".to_owned(), table.to_owned()),
1231    ])
1232}
1233
1234/// External bus id as i64 (`-1` if it somehow overflows), matching `pio_branches`.
1235fn ext(id: BusId) -> i64 {
1236    i64::try_from(id.0).unwrap_or(-1)
1237}
1238
1239fn usz(n: usize) -> i64 {
1240    i64::try_from(n).unwrap_or(-1)
1241}
1242
1243fn opt_usz(n: Option<usize>) -> i64 {
1244    n.map_or(-1, usz)
1245}
1246
1247fn i64s(v: Vec<i64>) -> ArrayRef {
1248    Arc::new(Int64Array::from(v))
1249}
1250
1251fn f64s(v: Vec<f64>) -> ArrayRef {
1252    Arc::new(Float64Array::from(v))
1253}
1254
1255fn u8s(v: Vec<u8>) -> ArrayRef {
1256    Arc::new(UInt8Array::from(v))
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    use super::*;
1262    use arrow::ffi::from_ffi;
1263
1264    fn net(name: &str) -> BalancedNetwork {
1265        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1266            .join("../tests/data")
1267            .join(name);
1268        powerio::parse_file(&path, None).unwrap().network
1269    }
1270
1271    fn terminal_projection_net() -> BalancedNetwork {
1272        use powerio::{Branch, BranchCharging, Bus, BusId, BusType};
1273
1274        let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.1);
1275        branch.charging = Some(BranchCharging::new(0.01, 0.02, 0.03, 0.05));
1276        branch.rate_a = 100.0;
1277        BalancedNetwork::in_memory(
1278            "terminal-projection",
1279            100.0,
1280            vec![
1281                Bus::new(BusId(1), BusType::Ref, 230.0),
1282                Bus::new(BusId(2), BusType::Pq, 230.0),
1283            ],
1284            vec![branch],
1285        )
1286    }
1287
1288    #[cfg(feature = "matrix")]
1289    fn incidence_filter_net() -> BalancedNetwork {
1290        use powerio::{Branch, Bus, BusId, BusType};
1291
1292        BalancedNetwork::in_memory(
1293            "incidence-filter",
1294            100.0,
1295            vec![
1296                Bus::new(BusId(1), BusType::Ref, 230.0),
1297                Bus::new(BusId(2), BusType::Pq, 230.0),
1298                Bus::new(BusId(3), BusType::Pq, 230.0),
1299            ],
1300            vec![
1301                Branch::new(BusId(1), BusId(1), 0.0, 0.1),
1302                Branch::new(BusId(1), BusId(2), 0.0, 0.0),
1303                Branch::new(BusId(2), BusId(3), 0.0, 0.2),
1304            ],
1305        )
1306    }
1307
1308    fn round_trip(net: &BalancedNetwork, table: i32) -> StructArray {
1309        let core = IndexCore::build(net);
1310        let (array, schema) = export(net, &core, table).unwrap();
1311        // from_ffi consumes the array and borrows the schema (zero-copy import).
1312        let data = unsafe { from_ffi(array, &schema) }.unwrap();
1313        StructArray::from(data)
1314    }
1315
1316    fn f64_col<'a>(sa: &'a StructArray, name: &str) -> &'a Float64Array {
1317        sa.column_by_name(name)
1318            .unwrap()
1319            .as_any()
1320            .downcast_ref::<Float64Array>()
1321            .unwrap()
1322    }
1323
1324    fn i64_col<'a>(sa: &'a StructArray, name: &str) -> &'a Int64Array {
1325        sa.column_by_name(name)
1326            .unwrap()
1327            .as_any()
1328            .downcast_ref::<Int64Array>()
1329            .unwrap()
1330    }
1331
1332    #[cfg(feature = "matrix")]
1333    fn u8_col<'a>(sa: &'a StructArray, name: &str) -> &'a UInt8Array {
1334        sa.column_by_name(name)
1335            .unwrap()
1336            .as_any()
1337            .downcast_ref::<UInt8Array>()
1338            .unwrap()
1339    }
1340
1341    #[cfg(feature = "matrix")]
1342    fn rb_f64_col<'a>(rb: &'a RecordBatch, name: &str) -> &'a Float64Array {
1343        rb.column_by_name(name)
1344            .unwrap()
1345            .as_any()
1346            .downcast_ref::<Float64Array>()
1347            .unwrap()
1348    }
1349
1350    #[cfg(feature = "matrix")]
1351    fn rb_i64_col<'a>(rb: &'a RecordBatch, name: &str) -> &'a Int64Array {
1352        rb.column_by_name(name)
1353            .unwrap()
1354            .as_any()
1355            .downcast_ref::<Int64Array>()
1356            .unwrap()
1357    }
1358
1359    #[cfg(feature = "matrix")]
1360    fn matrix_record_batch(net: &BalancedNetwork, table: i32) -> RecordBatch {
1361        let core = IndexCore::build(net);
1362        match table {
1363            PIO_ARROW_TABLE_YBUS => matrix_ybus_batch(net, &core).unwrap(),
1364            PIO_ARROW_TABLE_INCIDENCE => matrix_incidence_batch(net, &core).unwrap(),
1365            PIO_ARROW_TABLE_BPRIME => matrix_bprime_batch(net, &core).unwrap(),
1366            PIO_ARROW_TABLE_BDOUBLEPRIME => matrix_bdoubleprime_batch(net, &core).unwrap(),
1367            PIO_ARROW_TABLE_MATRIX_BUS => matrix_bus_batch(net, &core).unwrap(),
1368            PIO_ARROW_TABLE_MATRIX_BRANCH => matrix_branch_batch(net, &core).unwrap(),
1369            _ => panic!("not a matrix table id: {table}"),
1370        }
1371    }
1372
1373    #[cfg(feature = "matrix")]
1374    fn f64_bits(values: &Float64Array) -> Vec<String> {
1375        values
1376            .values()
1377            .iter()
1378            .map(|value| format!("0x{:016x}", value.to_bits()))
1379            .collect()
1380    }
1381
1382    #[cfg(feature = "matrix")]
1383    fn matrix_table_json(table_name: &str, rb: &RecordBatch) -> serde_json::Value {
1384        let metadata = rb.schema();
1385        let metadata = metadata.metadata();
1386        let mut obj = serde_json::Map::new();
1387        obj.insert("table".to_owned(), serde_json::json!(table_name));
1388        obj.insert(
1389            powerio::version::VERSION_KEY.to_owned(),
1390            serde_json::json!(metadata.get("powerio.version").unwrap()),
1391        );
1392        obj.insert(
1393            "format".to_owned(),
1394            serde_json::json!(metadata.get("powerio.format").unwrap()),
1395        );
1396        obj.insert(
1397            "row_axis".to_owned(),
1398            serde_json::json!(metadata.get("powerio.row_axis").unwrap()),
1399        );
1400        obj.insert(
1401            "col_axis".to_owned(),
1402            serde_json::json!(metadata.get("powerio.col_axis").unwrap()),
1403        );
1404        obj.insert(
1405            "row_count".to_owned(),
1406            serde_json::json!(
1407                metadata
1408                    .get("powerio.row_count")
1409                    .unwrap()
1410                    .parse::<usize>()
1411                    .unwrap()
1412            ),
1413        );
1414        obj.insert(
1415            "col_count".to_owned(),
1416            serde_json::json!(
1417                metadata
1418                    .get("powerio.col_count")
1419                    .unwrap()
1420                    .parse::<usize>()
1421                    .unwrap()
1422            ),
1423        );
1424        obj.insert(
1425            "row_index".to_owned(),
1426            serde_json::json!(rb_i64_col(rb, "row_index").values().to_vec()),
1427        );
1428        obj.insert(
1429            "col_index".to_owned(),
1430            serde_json::json!(rb_i64_col(rb, "col_index").values().to_vec()),
1431        );
1432        if table_name == "ybus" {
1433            obj.insert(
1434                "g_bits".to_owned(),
1435                serde_json::json!(f64_bits(rb_f64_col(rb, "g"))),
1436            );
1437            obj.insert(
1438                "b_bits".to_owned(),
1439                serde_json::json!(f64_bits(rb_f64_col(rb, "b"))),
1440            );
1441        } else {
1442            obj.insert(
1443                "value_bits".to_owned(),
1444                serde_json::json!(f64_bits(rb_f64_col(rb, "value"))),
1445            );
1446        }
1447        serde_json::Value::Object(obj)
1448    }
1449
1450    #[cfg(feature = "matrix")]
1451    fn axis_table_json(table_name: &str, rb: &RecordBatch) -> serde_json::Value {
1452        let metadata = rb.schema();
1453        let metadata = metadata.metadata();
1454        let mut obj = serde_json::Map::new();
1455        obj.insert("table".to_owned(), serde_json::json!(table_name));
1456        obj.insert(
1457            powerio::version::VERSION_KEY.to_owned(),
1458            serde_json::json!(metadata.get("powerio.version").unwrap()),
1459        );
1460        obj.insert(
1461            "format".to_owned(),
1462            serde_json::json!(metadata.get("powerio.format").unwrap()),
1463        );
1464        obj.insert(
1465            "row_axis".to_owned(),
1466            serde_json::json!(metadata.get("powerio.row_axis").unwrap()),
1467        );
1468        obj.insert(
1469            "index".to_owned(),
1470            serde_json::json!(rb_i64_col(rb, "index").values().to_vec()),
1471        );
1472        obj.insert(
1473            "source_row".to_owned(),
1474            serde_json::json!(rb_i64_col(rb, "source_row").values().to_vec()),
1475        );
1476        if table_name == "matrix_bus" {
1477            obj.insert(
1478                "bus_id".to_owned(),
1479                serde_json::json!(rb_i64_col(rb, "bus_id").values().to_vec()),
1480            );
1481            obj.insert(
1482                "is_reference".to_owned(),
1483                serde_json::json!(
1484                    rb.column_by_name("is_reference")
1485                        .unwrap()
1486                        .as_any()
1487                        .downcast_ref::<UInt8Array>()
1488                        .unwrap()
1489                        .values()
1490                        .to_vec()
1491                ),
1492            );
1493            obj.insert(
1494                "component".to_owned(),
1495                serde_json::json!(rb_i64_col(rb, "component").values().to_vec()),
1496            );
1497        } else {
1498            obj.insert(
1499                "from_bus_id".to_owned(),
1500                serde_json::json!(rb_i64_col(rb, "from_bus_id").values().to_vec()),
1501            );
1502            obj.insert(
1503                "to_bus_id".to_owned(),
1504                serde_json::json!(rb_i64_col(rb, "to_bus_id").values().to_vec()),
1505            );
1506        }
1507        serde_json::Value::Object(obj)
1508    }
1509
1510    #[cfg(feature = "matrix")]
1511    fn matrix_golden_json(case_file: &str) -> serde_json::Value {
1512        let n = net(case_file);
1513        let tables = [
1514            ("ybus", PIO_ARROW_TABLE_YBUS),
1515            ("incidence", PIO_ARROW_TABLE_INCIDENCE),
1516            ("bprime", PIO_ARROW_TABLE_BPRIME),
1517            ("bdoubleprime", PIO_ARROW_TABLE_BDOUBLEPRIME),
1518        ];
1519        let mut table_obj = serde_json::Map::new();
1520        for (name, table) in tables {
1521            let rb = matrix_record_batch(&n, table);
1522            table_obj.insert(name.to_owned(), matrix_table_json(name, &rb));
1523        }
1524        let axes = [
1525            ("matrix_bus", PIO_ARROW_TABLE_MATRIX_BUS),
1526            ("matrix_branch", PIO_ARROW_TABLE_MATRIX_BRANCH),
1527        ];
1528        let mut axis_obj = serde_json::Map::new();
1529        for (name, table) in axes {
1530            let rb = matrix_record_batch(&n, table);
1531            axis_obj.insert(name.to_owned(), axis_table_json(name, &rb));
1532        }
1533        serde_json::json!({
1534            "case": case_file,
1535            "axes": axis_obj,
1536            "tables": table_obj,
1537        })
1538    }
1539
1540    #[cfg(feature = "matrix")]
1541    fn assert_ffi_matches_record_batch(table: i32, value_cols: &[&str]) {
1542        let n = net("case9.m");
1543        let rb = matrix_record_batch(&n, table);
1544        let sa = round_trip(&n, table);
1545
1546        assert_eq!(sa.len(), rb.num_rows());
1547        assert_eq!(
1548            i64_col(&sa, "row_index").values(),
1549            rb_i64_col(&rb, "row_index").values()
1550        );
1551        assert_eq!(
1552            i64_col(&sa, "col_index").values(),
1553            rb_i64_col(&rb, "col_index").values()
1554        );
1555        for &name in value_cols {
1556            assert_eq!(
1557                f64_bits(f64_col(&sa, name)),
1558                f64_bits(rb_f64_col(&rb, name)),
1559                "{name} column changed through FFI"
1560            );
1561        }
1562    }
1563
1564    #[test]
1565    fn bus_table_round_trips_with_external_ids() {
1566        let n = net("case9.m");
1567        let sa = round_trip(&n, PIO_ARROW_TABLE_BUS);
1568        assert_eq!(sa.len(), n.buses.len());
1569        let ids = sa
1570            .column_by_name("id")
1571            .unwrap()
1572            .as_any()
1573            .downcast_ref::<Int64Array>()
1574            .unwrap();
1575        // The whole id column survives, in order (a reversed/offset column would
1576        // pass a single-cell check).
1577        let expected: Vec<i64> = n
1578            .buses
1579            .iter()
1580            .map(|b| i64::try_from(b.id.0).unwrap())
1581            .collect();
1582        assert_eq!(ids.values(), expected.as_slice());
1583    }
1584
1585    #[test]
1586    fn empty_table_exports_zero_rows() {
1587        // case9 has no shunts: a length-0 table must cross the C Data Interface
1588        // and import back without faulting (a common producer mishandling).
1589        let n = net("case9.m");
1590        assert_eq!(n.shunts.len(), 0);
1591        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_SHUNT).len(), 0);
1592    }
1593
1594    #[test]
1595    fn every_table_has_the_expected_row_count() {
1596        // case30 carries buses, branches, gens, loads, and shunts.
1597        let n = net("case30.m");
1598        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_BUS).len(), n.buses.len());
1599        assert_eq!(
1600            round_trip(&n, PIO_ARROW_TABLE_BRANCH).len(),
1601            n.branches.len()
1602        );
1603        assert_eq!(
1604            round_trip(&n, PIO_ARROW_TABLE_GEN).len(),
1605            n.generators.len()
1606        );
1607        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_LOAD).len(), n.loads.len());
1608        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_SHUNT).len(), n.shunts.len());
1609    }
1610
1611    #[test]
1612    fn normalized_solver_tables_export_dense_per_unit_rows() {
1613        let n = net("case14.m");
1614        let tables = n.to_normalized_solver_tables().unwrap();
1615
1616        assert_eq!(
1617            round_trip(&n, PIO_ARROW_TABLE_SOLVER_BUS).len(),
1618            tables.buses.len()
1619        );
1620        assert_eq!(
1621            round_trip(&n, PIO_ARROW_TABLE_SOLVER_BRANCH).len(),
1622            tables.branches.len()
1623        );
1624        assert_eq!(
1625            round_trip(&n, PIO_ARROW_TABLE_SOLVER_ARC).len(),
1626            tables.arcs.len()
1627        );
1628        assert_eq!(
1629            round_trip(&n, PIO_ARROW_TABLE_SOLVER_GEN).len(),
1630            tables.generators.len()
1631        );
1632
1633        let bus = round_trip(&n, PIO_ARROW_TABLE_SOLVER_BUS);
1634        assert_eq!(i64_col(&bus, "index").value(1), 1);
1635        assert_eq!(i64_col(&bus, "bus_id").value(1), 2);
1636        assert_eq!(i64_col(&bus, "source_row").value(1), 1);
1637        assert!((f64_col(&bus, "pd").value(1) - 21.7 / 100.0).abs() < 1e-12);
1638
1639        let branch = round_trip(&n, PIO_ARROW_TABLE_SOLVER_BRANCH);
1640        assert_eq!(i64_col(&branch, "from_bus_index").value(0), 0);
1641        assert_eq!(i64_col(&branch, "to_bus_index").value(0), 1);
1642
1643        let arc = round_trip(&n, PIO_ARROW_TABLE_SOLVER_ARC);
1644        assert_eq!(i64_col(&arc, "branch_index").value(0), 0);
1645        assert_eq!(i64_col(&arc, "terminal").value(0), 0);
1646        assert_eq!(i64_col(&arc, "branch_index").value(1), 0);
1647        assert_eq!(i64_col(&arc, "terminal").value(1), 1);
1648    }
1649
1650    #[test]
1651    fn branch_table_b_is_legacy_projection() {
1652        let n = terminal_projection_net();
1653        let sa = round_trip(&n, PIO_ARROW_TABLE_BRANCH);
1654        assert_eq!(sa.len(), 1);
1655        assert!((f64_col(&sa, "b").value(0) - 0.07).abs() < 1e-12);
1656        assert!((f64_col(&sa, "g_fr").value(0) - 0.01).abs() < 1e-12);
1657        assert!((f64_col(&sa, "b_fr").value(0) - 0.02).abs() < 1e-12);
1658        assert!((f64_col(&sa, "g_to").value(0) - 0.03).abs() < 1e-12);
1659        assert!((f64_col(&sa, "b_to").value(0) - 0.05).abs() < 1e-12);
1660    }
1661
1662    #[test]
1663    fn unknown_table_id_errors() {
1664        let n = net("case9.m");
1665        let core = IndexCore::build(&n);
1666        assert!(export(&n, &core, 99).is_err());
1667    }
1668
1669    #[test]
1670    fn arrow_table_ids_are_append_only() {
1671        assert_eq!(PIO_ARROW_TABLE_BUS, 0);
1672        assert_eq!(PIO_ARROW_TABLE_BRANCH, 1);
1673        assert_eq!(PIO_ARROW_TABLE_GEN, 2);
1674        assert_eq!(PIO_ARROW_TABLE_LOAD, 3);
1675        assert_eq!(PIO_ARROW_TABLE_SHUNT, 4);
1676        assert_eq!(PIO_ARROW_TABLE_SWITCH, 5);
1677        assert_eq!(PIO_ARROW_TABLE_SOLVER_BUS, 6);
1678        assert_eq!(PIO_ARROW_TABLE_SOLVER_LOAD, 7);
1679        assert_eq!(PIO_ARROW_TABLE_SOLVER_SHUNT, 8);
1680        assert_eq!(PIO_ARROW_TABLE_SOLVER_BRANCH, 9);
1681        assert_eq!(PIO_ARROW_TABLE_SOLVER_SWITCH, 10);
1682        assert_eq!(PIO_ARROW_TABLE_SOLVER_ARC, 11);
1683        assert_eq!(PIO_ARROW_TABLE_SOLVER_GEN, 12);
1684        assert_eq!(PIO_ARROW_TABLE_SOLVER_STORAGE, 13);
1685        assert_eq!(PIO_ARROW_TABLE_SOLVER_HVDC, 14);
1686        assert_eq!(PIO_ARROW_TABLE_YBUS, 15);
1687        assert_eq!(PIO_ARROW_TABLE_INCIDENCE, 16);
1688        assert_eq!(PIO_ARROW_TABLE_BPRIME, 17);
1689        assert_eq!(PIO_ARROW_TABLE_BDOUBLEPRIME, 18);
1690        assert_eq!(PIO_ARROW_TABLE_MATRIX_BUS, 19);
1691        assert_eq!(PIO_ARROW_TABLE_MATRIX_BRANCH, 20);
1692    }
1693
1694    #[test]
1695    fn arrow_catalog_lists_ids_columns_axes_and_features() {
1696        let catalog: serde_json::Value = serde_json::from_str(&catalog_json()).unwrap();
1697        assert_eq!(catalog[powerio::version::VERSION_KEY], powerio::VERSION);
1698        let tables = catalog["tables"].as_array().unwrap();
1699        let find = |name: &str| {
1700            tables
1701                .iter()
1702                .find(|table| table["name"] == name)
1703                .unwrap_or_else(|| panic!("missing catalog table {name}"))
1704        };
1705
1706        let bus = find("bus");
1707        assert_eq!(bus["id"], PIO_ARROW_TABLE_BUS);
1708        assert_eq!(bus["feature_requirements"], serde_json::json!(["arrow"]));
1709        assert_eq!(bus["columns"][0]["name"], "id");
1710
1711        let bprime = find("bprime");
1712        assert_eq!(bprime["id"], PIO_ARROW_TABLE_BPRIME);
1713        assert_eq!(bprime["format"], "coo");
1714        assert_eq!(bprime["row_axis"], "matrix_bus");
1715        assert_eq!(bprime["col_axis"], "matrix_bus");
1716        assert_eq!(
1717            bprime["feature_requirements"],
1718            serde_json::json!(["arrow", "matrix"])
1719        );
1720        assert_eq!(bprime["available"], cfg!(feature = "matrix"));
1721
1722        let incidence = find("incidence");
1723        assert_eq!(incidence["row_axis"], "matrix_bus");
1724        assert_eq!(incidence["col_axis"], "matrix_branch");
1725
1726        let axis = find("matrix_bus");
1727        assert_eq!(axis["id"], PIO_ARROW_TABLE_MATRIX_BUS);
1728        assert_eq!(axis["format"], "axis_map");
1729        assert_eq!(axis["columns"][1]["name"], "bus_id");
1730    }
1731
1732    #[cfg(not(feature = "matrix"))]
1733    #[test]
1734    fn matrix_table_requires_matrix_feature() {
1735        let n = net("case9.m");
1736        let core = IndexCore::build(&n);
1737        let err = export(&n, &core, PIO_ARROW_TABLE_BPRIME).unwrap_err();
1738        assert!(err.contains("matrix cargo feature"), "{err}");
1739        let err = export(&n, &core, PIO_ARROW_TABLE_MATRIX_BUS).unwrap_err();
1740        assert!(err.contains("matrix cargo feature"), "{err}");
1741    }
1742
1743    #[cfg(feature = "matrix")]
1744    #[test]
1745    fn matrix_tables_round_trip_through_ffi() {
1746        assert_ffi_matches_record_batch(PIO_ARROW_TABLE_YBUS, &["g", "b"]);
1747        assert_ffi_matches_record_batch(PIO_ARROW_TABLE_INCIDENCE, &["value"]);
1748        assert_ffi_matches_record_batch(PIO_ARROW_TABLE_BPRIME, &["value"]);
1749        assert_ffi_matches_record_batch(PIO_ARROW_TABLE_BDOUBLEPRIME, &["value"]);
1750    }
1751
1752    #[cfg(feature = "matrix")]
1753    #[test]
1754    fn matrix_tables_carry_schema_dimensions() {
1755        let n = net("case9.m");
1756        let rb = matrix_record_batch(&n, PIO_ARROW_TABLE_BPRIME);
1757        let metadata = rb.schema();
1758        let metadata = metadata.metadata();
1759        assert_eq!(metadata.get("powerio.table").unwrap(), "bprime");
1760        assert_eq!(metadata.get("powerio.version").unwrap(), powerio::VERSION);
1761        assert_eq!(metadata.get("powerio.format").unwrap(), "coo");
1762        assert_eq!(metadata.get("powerio.index_space").unwrap(), "solver_bus");
1763        assert_eq!(metadata.get("powerio.row_axis").unwrap(), "matrix_bus");
1764        assert_eq!(metadata.get("powerio.col_axis").unwrap(), "matrix_bus");
1765        assert_eq!(metadata.get("powerio.row_count").unwrap(), "9");
1766        assert_eq!(metadata.get("powerio.col_count").unwrap(), "9");
1767
1768        let core = IndexCore::build(&n);
1769        let (array, schema) = export(&n, &core, PIO_ARROW_TABLE_BPRIME).unwrap();
1770        let imported_schema = Schema::try_from(&schema).unwrap();
1771        let metadata = imported_schema.metadata();
1772        assert_eq!(metadata.get("powerio.table").unwrap(), "bprime");
1773        assert_eq!(metadata.get("powerio.version").unwrap(), powerio::VERSION);
1774        assert_eq!(metadata.get("powerio.format").unwrap(), "coo");
1775        assert_eq!(metadata.get("powerio.index_space").unwrap(), "solver_bus");
1776        assert_eq!(metadata.get("powerio.row_axis").unwrap(), "matrix_bus");
1777        assert_eq!(metadata.get("powerio.col_axis").unwrap(), "matrix_bus");
1778        assert_eq!(metadata.get("powerio.row_count").unwrap(), "9");
1779        assert_eq!(metadata.get("powerio.col_count").unwrap(), "9");
1780        let _data = unsafe { from_ffi(array, &schema) }.unwrap();
1781    }
1782
1783    #[cfg(feature = "matrix")]
1784    #[test]
1785    fn incidence_uses_branch_axis_metadata() {
1786        let n = net("case9.m");
1787        let rb = matrix_record_batch(&n, PIO_ARROW_TABLE_INCIDENCE);
1788        let metadata = rb.schema();
1789        let metadata = metadata.metadata();
1790        assert_eq!(metadata.get("powerio.table").unwrap(), "incidence");
1791        assert_eq!(metadata.get("powerio.format").unwrap(), "coo");
1792        assert_eq!(metadata.get("powerio.row_axis").unwrap(), "matrix_bus");
1793        assert_eq!(metadata.get("powerio.col_axis").unwrap(), "matrix_branch");
1794        assert_eq!(metadata.get("powerio.row_count").unwrap(), "9");
1795        assert_eq!(metadata.get("powerio.col_count").unwrap(), "9");
1796    }
1797
1798    #[cfg(feature = "matrix")]
1799    #[test]
1800    fn matrix_axis_maps_export_dense_rows() {
1801        let n = net("case14.m");
1802        let bus = round_trip(&n, PIO_ARROW_TABLE_MATRIX_BUS);
1803        assert_eq!(bus.len(), 14);
1804        assert_eq!(i64_col(&bus, "index").value(0), 0);
1805        assert_eq!(i64_col(&bus, "bus_id").value(0), 1);
1806        assert_eq!(i64_col(&bus, "source_row").value(0), 0);
1807        assert_eq!(u8_col(&bus, "is_reference").value(0), 1);
1808        assert_eq!(i64_col(&bus, "component").value(0), 0);
1809        assert_eq!(i64_col(&bus, "index").value(13), 13);
1810        assert_eq!(i64_col(&bus, "bus_id").value(13), 14);
1811
1812        let branch = round_trip(&n, PIO_ARROW_TABLE_MATRIX_BRANCH);
1813        let incidence = matrix_record_batch(&n, PIO_ARROW_TABLE_INCIDENCE);
1814        let col_count = incidence
1815            .schema()
1816            .metadata()
1817            .get("powerio.col_count")
1818            .unwrap()
1819            .parse::<usize>()
1820            .unwrap();
1821        assert_eq!(branch.len(), col_count);
1822        assert_eq!(i64_col(&branch, "index").value(0), 0);
1823        assert_eq!(i64_col(&branch, "source_row").value(0), 0);
1824        assert_eq!(i64_col(&branch, "from_bus_id").value(0), 1);
1825        assert_eq!(i64_col(&branch, "to_bus_id").value(0), 2);
1826    }
1827
1828    #[cfg(feature = "matrix")]
1829    fn transformer_3w_net() -> BalancedNetwork {
1830        // Three buses joined only by a 3-winding transformer. The indexed view
1831        // star-lowers it, adding a synthetic star bus, so the matrix bus axis has
1832        // one more row than the handle's bus count.
1833        let raw = "0, 100.00, 33, 0, 0, 60.00 / x\n\
1834CASE\n\
1835COMMENT\n\
18361,'B1          ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9\n\
18372,'B2          ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9\n\
18383,'B3          ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9\n\
18390 / END OF BUS DATA, BEGIN LOAD DATA\n\
18400 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA\n\
18410 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA\n\
18420 / END OF GENERATOR DATA, BEGIN BRANCH DATA\n\
18430 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA\n\
18441, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W         ', 1, 1, 1, 0, 1, 0, 1, 0, 1, '            '\n\
18450.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5\n\
18461.0, 230.0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0\n\
18471.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0\n\
18480.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0\n\
18490 / END OF TRANSFORMER DATA, BEGIN AREA DATA\n\
1850Q\n";
1851        powerio::parse_str(raw, "psse").unwrap().network
1852    }
1853
1854    // A 3-winding transformer star-lowers into an extra synthetic bus, so the
1855    // matrix bus axis has more rows than the handle's bus count and the matrix
1856    // bus order diverges from `pio_bus_ids`. The Julia binding maps matrix rows
1857    // to bus ids ONLY through this `matrix_bus` axis (index -> bus_id), never by
1858    // assuming handle bus order, so the producer must keep the COO row index
1859    // space, `row_count`, and `matrix_bus` in lockstep. Lock that here.
1860    #[cfg(feature = "matrix")]
1861    #[test]
1862    fn matrix_bus_axis_covers_star_lowered_rows() {
1863        let n = transformer_3w_net();
1864        assert_eq!(n.buses.len(), 3, "handle carries the three source buses");
1865        assert!(n.branches.is_empty(), "a 3W is not folded into branches");
1866        assert_eq!(n.transformers_3w.len(), 1);
1867
1868        let bus = matrix_record_batch(&n, PIO_ARROW_TABLE_MATRIX_BUS);
1869        // The matrix bus axis exceeds the handle bus count: the star point is a
1870        // matrix row with no `pio_bus_ids` entry.
1871        assert_eq!(bus.num_rows(), n.buses.len() + 1);
1872
1873        let index = rb_i64_col(&bus, "index").values();
1874        let bus_id = rb_i64_col(&bus, "bus_id").values();
1875        let source_row = rb_i64_col(&bus, "source_row").values();
1876
1877        // (a) The axis index column is dense 0..len, so matrix row k reads its
1878        // external bus id at `bus_id[k]` (the map the Julia side relies on).
1879        let expected_index: Vec<i64> = (0..bus.num_rows() as i64).collect();
1880        assert_eq!(index, expected_index.as_slice());
1881
1882        // The three source buses map to their external ids and source rows in
1883        // order; the trailing star row carries a synthetic id outside the
1884        // handle id space.
1885        assert_eq!(bus_id, &[1, 2, 3, 4]);
1886        let handle_ids: Vec<i64> = n.buses.iter().map(|b| ext(b.id)).collect();
1887        assert!(
1888            !handle_ids.contains(&bus_id[bus.num_rows() - 1]),
1889            "star bus id must not collide with a handle bus id"
1890        );
1891
1892        // (b) The star-point row has no source bus row.
1893        assert_eq!(source_row[..3], [0, 1, 2]);
1894        assert_eq!(
1895            source_row[bus.num_rows() - 1],
1896            -1,
1897            "star-point bus has no source row"
1898        );
1899
1900        // (c) Every symmetric bus-indexed matrix reports row_count/col_count
1901        // equal to the matrix bus axis length, and its COO indices stay inside
1902        // that axis, so index k always resolves through `matrix_bus`.
1903        for table in [PIO_ARROW_TABLE_YBUS, PIO_ARROW_TABLE_BPRIME] {
1904            let rb = matrix_record_batch(&n, table);
1905            let meta = rb.schema();
1906            let meta = meta.metadata();
1907            assert_eq!(
1908                meta.get("powerio.row_count").unwrap(),
1909                &bus.num_rows().to_string(),
1910                "row_count must match matrix_bus length"
1911            );
1912            assert_eq!(
1913                meta.get("powerio.col_count").unwrap(),
1914                &bus.num_rows().to_string(),
1915                "col_count must match matrix_bus length"
1916            );
1917            let n_rows = bus.num_rows() as i64;
1918            for &r in rb_i64_col(&rb, "row_index").values() {
1919                assert!((0..n_rows).contains(&r), "COO row index within axis");
1920            }
1921            for &c in rb_i64_col(&rb, "col_index").values() {
1922                assert!((0..n_rows).contains(&c), "COO col index within axis");
1923            }
1924        }
1925    }
1926
1927    #[cfg(feature = "matrix")]
1928    #[test]
1929    fn matrix_branch_axis_matches_incidence_branch_columns() {
1930        let n = incidence_filter_net();
1931        let core = IndexCore::build(&n);
1932        let view = IndexedNetwork::with_core(&n, &core);
1933        let incidence = powerio_matrix::build_incidence(
1934            &view,
1935            powerio_matrix::DcConvention::default(),
1936            &powerio_matrix::BuildOptions::default(),
1937        )
1938        .unwrap();
1939
1940        let branch = matrix_record_batch(&n, PIO_ARROW_TABLE_MATRIX_BRANCH);
1941        let source_rows = rb_i64_col(&branch, "source_row").values().to_vec();
1942        let expected = incidence
1943            .branch_of_col
1944            .iter()
1945            .map(|&idx| i64::try_from(idx).unwrap())
1946            .collect::<Vec<_>>();
1947        assert_eq!(source_rows, expected);
1948        assert_eq!(source_rows, vec![2]);
1949
1950        let incidence_batch = matrix_record_batch(&n, PIO_ARROW_TABLE_INCIDENCE);
1951        assert_eq!(
1952            incidence_batch
1953                .schema()
1954                .metadata()
1955                .get("powerio.col_count")
1956                .unwrap(),
1957            "1"
1958        );
1959        assert_eq!(branch.num_rows(), 1);
1960    }
1961
1962    #[cfg(feature = "matrix")]
1963    #[test]
1964    fn matrix_arrow_golden_fixtures_match() {
1965        let dir =
1966            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/capi_matrix");
1967        for case_file in ["case9.m", "case30.m"] {
1968            let fixture = dir.join(case_file.replace(".m", "_arrow_coo.json"));
1969            let expected: serde_json::Value =
1970                serde_json::from_str(&std::fs::read_to_string(&fixture).unwrap()).unwrap();
1971            assert_eq!(matrix_golden_json(case_file), expected, "{case_file}");
1972        }
1973    }
1974
1975    #[cfg(feature = "matrix")]
1976    #[ignore = "rewrites committed matrix Arrow COO fixtures"]
1977    #[test]
1978    fn rewrite_matrix_arrow_golden_fixtures() {
1979        let dir =
1980            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/capi_matrix");
1981        std::fs::create_dir_all(&dir).unwrap();
1982        for case_file in ["case9.m", "case30.m"] {
1983            let fixture = dir.join(case_file.replace(".m", "_arrow_coo.json"));
1984            let text = serde_json::to_string_pretty(&matrix_golden_json(case_file)).unwrap();
1985            std::fs::write(fixture, format!("{text}\n")).unwrap();
1986        }
1987    }
1988}