Skip to main content

powerio_capi/
arrow_export.rs

1//! Raw network tables over the Arrow C Data Interface.
2//!
3//! Builds the parsed [`Network`] 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 and up are
14//! the normalized solver table contract: per unit/radian values and dense zero
15//! based row ids.
16
17use std::sync::Arc;
18
19use arrow::array::{Array, ArrayRef, Float64Array, Int64Array, StructArray, UInt8Array};
20use arrow::datatypes::{Field, Schema};
21use arrow::error::ArrowError;
22use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema, to_ffi};
23use arrow::record_batch::RecordBatch;
24use powerio::{BusId, Network, NormalizedSolverTables, SolverArcTerminal};
25
26/// Table selectors for [`pio_to_arrow`](crate::pio_to_arrow); the C
27/// header mirrors these as `PIO_ARROW_TABLE_*`.
28pub const PIO_ARROW_TABLE_BUS: i32 = 0;
29pub const PIO_ARROW_TABLE_BRANCH: i32 = 1;
30pub const PIO_ARROW_TABLE_GEN: i32 = 2;
31pub const PIO_ARROW_TABLE_LOAD: i32 = 3;
32pub const PIO_ARROW_TABLE_SHUNT: i32 = 4;
33pub const PIO_ARROW_TABLE_SWITCH: i32 = 5;
34pub const PIO_ARROW_TABLE_SOLVER_BUS: i32 = 6;
35pub const PIO_ARROW_TABLE_SOLVER_LOAD: i32 = 7;
36pub const PIO_ARROW_TABLE_SOLVER_SHUNT: i32 = 8;
37pub const PIO_ARROW_TABLE_SOLVER_BRANCH: i32 = 9;
38pub const PIO_ARROW_TABLE_SOLVER_SWITCH: i32 = 10;
39pub const PIO_ARROW_TABLE_SOLVER_ARC: i32 = 11;
40pub const PIO_ARROW_TABLE_SOLVER_GEN: i32 = 12;
41pub const PIO_ARROW_TABLE_SOLVER_STORAGE: i32 = 13;
42pub const PIO_ARROW_TABLE_SOLVER_HVDC: i32 = 14;
43
44// These values are the ABI: the `PIO_ARROW_TABLE_*` macros in include/powerio.h
45// are hand-synced to them. The set is append-only: these ids and each table's
46// column order are frozen, a new table takes the next id and extends
47// this assert, and new columns append (nullable) at the end so consumers read by
48// name. Pin them so a Rust-side edit that drifts from the header (a renumber, a
49// reorder, a dropped table) fails the build instead of silently exporting the
50// wrong table.
51const _: () = assert!(
52    PIO_ARROW_TABLE_BUS == 0
53        && PIO_ARROW_TABLE_BRANCH == 1
54        && PIO_ARROW_TABLE_GEN == 2
55        && PIO_ARROW_TABLE_LOAD == 3
56        && PIO_ARROW_TABLE_SHUNT == 4
57        && PIO_ARROW_TABLE_SWITCH == 5
58        && PIO_ARROW_TABLE_SOLVER_BUS == 6
59        && PIO_ARROW_TABLE_SOLVER_LOAD == 7
60        && PIO_ARROW_TABLE_SOLVER_SHUNT == 8
61        && PIO_ARROW_TABLE_SOLVER_BRANCH == 9
62        && PIO_ARROW_TABLE_SOLVER_SWITCH == 10
63        && PIO_ARROW_TABLE_SOLVER_ARC == 11
64        && PIO_ARROW_TABLE_SOLVER_GEN == 12
65        && PIO_ARROW_TABLE_SOLVER_STORAGE == 13
66        && PIO_ARROW_TABLE_SOLVER_HVDC == 14
67);
68
69/// Build the requested table and export it over the C Data Interface. The
70/// returned FFI structs own the columnar buffers until the consumer releases
71/// them.
72pub fn export(net: &Network, table: i32) -> Result<(FFI_ArrowArray, FFI_ArrowSchema), String> {
73    let rb = match table {
74        PIO_ARROW_TABLE_BUS => bus_batch(net).map_err(|e| e.to_string())?,
75        PIO_ARROW_TABLE_BRANCH => branch_batch(net).map_err(|e| e.to_string())?,
76        PIO_ARROW_TABLE_GEN => gen_batch(net).map_err(|e| e.to_string())?,
77        PIO_ARROW_TABLE_LOAD => load_batch(net).map_err(|e| e.to_string())?,
78        PIO_ARROW_TABLE_SHUNT => shunt_batch(net).map_err(|e| e.to_string())?,
79        PIO_ARROW_TABLE_SWITCH => switch_batch(net).map_err(|e| e.to_string())?,
80        PIO_ARROW_TABLE_SOLVER_BUS => {
81            solver_bus_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
82        }
83        PIO_ARROW_TABLE_SOLVER_LOAD => {
84            solver_load_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
85        }
86        PIO_ARROW_TABLE_SOLVER_SHUNT => {
87            solver_shunt_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
88        }
89        PIO_ARROW_TABLE_SOLVER_BRANCH => {
90            solver_branch_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
91        }
92        PIO_ARROW_TABLE_SOLVER_SWITCH => {
93            solver_switch_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
94        }
95        PIO_ARROW_TABLE_SOLVER_ARC => {
96            solver_arc_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
97        }
98        PIO_ARROW_TABLE_SOLVER_GEN => {
99            solver_gen_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
100        }
101        PIO_ARROW_TABLE_SOLVER_STORAGE => {
102            solver_storage_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
103        }
104        PIO_ARROW_TABLE_SOLVER_HVDC => {
105            solver_hvdc_batch(&solver_tables(net)?).map_err(|e| e.to_string())?
106        }
107        other => return Err(format!("unknown Arrow table id {other}")),
108    };
109
110    // The C Data Interface represents a record batch as a struct array.
111    let data = StructArray::from(rb).into_data();
112    to_ffi(&data).map_err(|e| e.to_string())
113}
114
115fn solver_tables(net: &Network) -> Result<NormalizedSolverTables, String> {
116    net.to_normalized_solver_tables().map_err(|e| e.to_string())
117}
118
119fn bus_batch(net: &Network) -> Result<RecordBatch, ArrowError> {
120    let b = &net.buses;
121    batch(vec![
122        ("id", i64s(b.iter().map(|x| ext(x.id)).collect())),
123        (
124            "kind",
125            i64s(b.iter().map(|x| i64::from(x.kind as u8)).collect()),
126        ),
127        ("vm", f64s(b.iter().map(|x| x.vm).collect())),
128        ("va", f64s(b.iter().map(|x| x.va).collect())),
129        ("base_kv", f64s(b.iter().map(|x| x.base_kv).collect())),
130        ("vmax", f64s(b.iter().map(|x| x.vmax).collect())),
131        ("vmin", f64s(b.iter().map(|x| x.vmin).collect())),
132        ("area", i64s(b.iter().map(|x| usz(x.area)).collect())),
133        ("zone", i64s(b.iter().map(|x| usz(x.zone)).collect())),
134    ])
135}
136
137fn branch_batch(net: &Network) -> Result<RecordBatch, ArrowError> {
138    let br = &net.branches;
139    batch(vec![
140        ("from", i64s(br.iter().map(|x| ext(x.from)).collect())),
141        ("to", i64s(br.iter().map(|x| ext(x.to)).collect())),
142        ("r", f64s(br.iter().map(|x| x.r).collect())),
143        ("x", f64s(br.iter().map(|x| x.x).collect())),
144        (
145            "b",
146            f64s(br.iter().map(|x| x.legacy_total_charging_b()).collect()),
147        ),
148        ("rate_a", f64s(br.iter().map(|x| x.rate_a).collect())),
149        ("rate_b", f64s(br.iter().map(|x| x.rate_b).collect())),
150        ("rate_c", f64s(br.iter().map(|x| x.rate_c).collect())),
151        ("tap", f64s(br.iter().map(|x| x.tap).collect())),
152        ("shift", f64s(br.iter().map(|x| x.shift).collect())),
153        (
154            "in_service",
155            u8s(br.iter().map(|x| u8::from(x.in_service)).collect()),
156        ),
157        ("angmin", f64s(br.iter().map(|x| x.angmin).collect())),
158        ("angmax", f64s(br.iter().map(|x| x.angmax).collect())),
159        (
160            "g_fr",
161            f64s(br.iter().map(|x| x.terminal_charging().g_fr).collect()),
162        ),
163        (
164            "b_fr",
165            f64s(br.iter().map(|x| x.terminal_charging().b_fr).collect()),
166        ),
167        (
168            "g_to",
169            f64s(br.iter().map(|x| x.terminal_charging().g_to).collect()),
170        ),
171        (
172            "b_to",
173            f64s(br.iter().map(|x| x.terminal_charging().b_to).collect()),
174        ),
175        (
176            "c_rating_a",
177            f64s(
178                br.iter()
179                    .map(|x| x.current_ratings.map_or(0.0, |r| r.c_rating_a))
180                    .collect(),
181            ),
182        ),
183        (
184            "c_rating_b",
185            f64s(
186                br.iter()
187                    .map(|x| x.current_ratings.map_or(0.0, |r| r.c_rating_b))
188                    .collect(),
189            ),
190        ),
191        (
192            "c_rating_c",
193            f64s(
194                br.iter()
195                    .map(|x| x.current_ratings.map_or(0.0, |r| r.c_rating_c))
196                    .collect(),
197            ),
198        ),
199        (
200            "pf",
201            f64s(
202                br.iter()
203                    .map(|x| x.solution.map_or(0.0, |s| s.pf))
204                    .collect(),
205            ),
206        ),
207        (
208            "qf",
209            f64s(
210                br.iter()
211                    .map(|x| x.solution.map_or(0.0, |s| s.qf))
212                    .collect(),
213            ),
214        ),
215        (
216            "pt",
217            f64s(
218                br.iter()
219                    .map(|x| x.solution.map_or(0.0, |s| s.pt))
220                    .collect(),
221            ),
222        ),
223        (
224            "qt",
225            f64s(
226                br.iter()
227                    .map(|x| x.solution.map_or(0.0, |s| s.qt))
228                    .collect(),
229            ),
230        ),
231    ])
232}
233
234fn gen_batch(net: &Network) -> Result<RecordBatch, ArrowError> {
235    let g = &net.generators;
236    batch(vec![
237        ("bus", i64s(g.iter().map(|x| ext(x.bus)).collect())),
238        ("pg", f64s(g.iter().map(|x| x.pg).collect())),
239        ("qg", f64s(g.iter().map(|x| x.qg).collect())),
240        ("pmax", f64s(g.iter().map(|x| x.pmax).collect())),
241        ("pmin", f64s(g.iter().map(|x| x.pmin).collect())),
242        ("qmax", f64s(g.iter().map(|x| x.qmax).collect())),
243        ("qmin", f64s(g.iter().map(|x| x.qmin).collect())),
244        ("vg", f64s(g.iter().map(|x| x.vg).collect())),
245        ("mbase", f64s(g.iter().map(|x| x.mbase).collect())),
246        (
247            "in_service",
248            u8s(g.iter().map(|x| u8::from(x.in_service)).collect()),
249        ),
250    ])
251}
252
253fn load_batch(net: &Network) -> Result<RecordBatch, ArrowError> {
254    let l = &net.loads;
255    batch(vec![
256        ("bus", i64s(l.iter().map(|x| ext(x.bus)).collect())),
257        ("p", f64s(l.iter().map(|x| x.p).collect())),
258        ("q", f64s(l.iter().map(|x| x.q).collect())),
259        (
260            "in_service",
261            u8s(l.iter().map(|x| u8::from(x.in_service)).collect()),
262        ),
263    ])
264}
265
266fn shunt_batch(net: &Network) -> Result<RecordBatch, ArrowError> {
267    let s = &net.shunts;
268    batch(vec![
269        ("bus", i64s(s.iter().map(|x| ext(x.bus)).collect())),
270        ("g", f64s(s.iter().map(|x| x.g).collect())),
271        ("b", f64s(s.iter().map(|x| x.b).collect())),
272        (
273            "in_service",
274            u8s(s.iter().map(|x| u8::from(x.in_service)).collect()),
275        ),
276    ])
277}
278
279fn switch_batch(net: &Network) -> Result<RecordBatch, ArrowError> {
280    let s = &net.switches;
281    batch(vec![
282        ("from", i64s(s.iter().map(|x| ext(x.from)).collect())),
283        ("to", i64s(s.iter().map(|x| ext(x.to)).collect())),
284        (
285            "closed",
286            u8s(s.iter().map(|x| u8::from(x.closed)).collect()),
287        ),
288        (
289            "thermal_rating",
290            f64s(s.iter().map(|x| x.thermal_rating.unwrap_or(0.0)).collect()),
291        ),
292        (
293            "current_rating",
294            f64s(s.iter().map(|x| x.current_rating.unwrap_or(0.0)).collect()),
295        ),
296        ("pf", f64s(s.iter().map(|x| x.pf.unwrap_or(0.0)).collect())),
297        ("qf", f64s(s.iter().map(|x| x.qf.unwrap_or(0.0)).collect())),
298        ("pt", f64s(s.iter().map(|x| x.pt.unwrap_or(0.0)).collect())),
299        ("qt", f64s(s.iter().map(|x| x.qt.unwrap_or(0.0)).collect())),
300    ])
301}
302
303fn solver_bus_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
304    batch(vec![
305        (
306            "index",
307            i64s(t.buses.iter().map(|x| usz(x.index)).collect()),
308        ),
309        (
310            "bus_id",
311            i64s(t.buses.iter().map(|x| ext(x.bus_id)).collect()),
312        ),
313        (
314            "source_row",
315            i64s(t.buses.iter().map(|x| opt_usz(x.source_row)).collect()),
316        ),
317        (
318            "kind",
319            i64s(t.buses.iter().map(|x| i64::from(x.kind as u8)).collect()),
320        ),
321        ("vm", f64s(t.buses.iter().map(|x| x.vm).collect())),
322        ("va", f64s(t.buses.iter().map(|x| x.va).collect())),
323        ("base_kv", f64s(t.buses.iter().map(|x| x.base_kv).collect())),
324        ("vmax", f64s(t.buses.iter().map(|x| x.vmax).collect())),
325        ("vmin", f64s(t.buses.iter().map(|x| x.vmin).collect())),
326        ("pd", f64s(t.buses.iter().map(|x| x.pd).collect())),
327        ("qd", f64s(t.buses.iter().map(|x| x.qd).collect())),
328        ("gs", f64s(t.buses.iter().map(|x| x.gs).collect())),
329        ("bs", f64s(t.buses.iter().map(|x| x.bs).collect())),
330        (
331            "component_label",
332            i64s(t.index.component_labels.iter().map(|&x| usz(x)).collect()),
333        ),
334        (
335            "is_reference",
336            u8s(t
337                .buses
338                .iter()
339                .map(|x| u8::from(t.index.reference_bus_indices.contains(&x.index)))
340                .collect()),
341        ),
342    ])
343}
344
345fn solver_load_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
346    batch(vec![
347        (
348            "index",
349            i64s(t.loads.iter().map(|x| usz(x.index)).collect()),
350        ),
351        (
352            "source_row",
353            i64s(t.loads.iter().map(|x| opt_usz(x.source_row)).collect()),
354        ),
355        (
356            "bus_index",
357            i64s(t.loads.iter().map(|x| usz(x.bus_index)).collect()),
358        ),
359        ("p", f64s(t.loads.iter().map(|x| x.p).collect())),
360        ("q", f64s(t.loads.iter().map(|x| x.q).collect())),
361    ])
362}
363
364fn solver_shunt_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
365    batch(vec![
366        (
367            "index",
368            i64s(t.shunts.iter().map(|x| usz(x.index)).collect()),
369        ),
370        (
371            "source_row",
372            i64s(t.shunts.iter().map(|x| opt_usz(x.source_row)).collect()),
373        ),
374        (
375            "bus_index",
376            i64s(t.shunts.iter().map(|x| usz(x.bus_index)).collect()),
377        ),
378        ("g", f64s(t.shunts.iter().map(|x| x.g).collect())),
379        ("b", f64s(t.shunts.iter().map(|x| x.b).collect())),
380    ])
381}
382
383fn solver_branch_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
384    batch(vec![
385        (
386            "index",
387            i64s(t.branches.iter().map(|x| usz(x.index)).collect()),
388        ),
389        (
390            "source_row",
391            i64s(t.branches.iter().map(|x| opt_usz(x.source_row)).collect()),
392        ),
393        (
394            "from_bus_index",
395            i64s(t.branches.iter().map(|x| usz(x.from_bus_index)).collect()),
396        ),
397        (
398            "to_bus_index",
399            i64s(t.branches.iter().map(|x| usz(x.to_bus_index)).collect()),
400        ),
401        ("r", f64s(t.branches.iter().map(|x| x.r).collect())),
402        ("x", f64s(t.branches.iter().map(|x| x.x).collect())),
403        ("b", f64s(t.branches.iter().map(|x| x.b).collect())),
404        ("g_fr", f64s(t.branches.iter().map(|x| x.g_fr).collect())),
405        ("b_fr", f64s(t.branches.iter().map(|x| x.b_fr).collect())),
406        ("g_to", f64s(t.branches.iter().map(|x| x.g_to).collect())),
407        ("b_to", f64s(t.branches.iter().map(|x| x.b_to).collect())),
408        (
409            "rate_a",
410            f64s(t.branches.iter().map(|x| x.rate_a).collect()),
411        ),
412        (
413            "rate_b",
414            f64s(t.branches.iter().map(|x| x.rate_b).collect()),
415        ),
416        (
417            "rate_c",
418            f64s(t.branches.iter().map(|x| x.rate_c).collect()),
419        ),
420        ("tap", f64s(t.branches.iter().map(|x| x.tap).collect())),
421        ("shift", f64s(t.branches.iter().map(|x| x.shift).collect())),
422        (
423            "angmin",
424            f64s(t.branches.iter().map(|x| x.angmin).collect()),
425        ),
426        (
427            "angmax",
428            f64s(t.branches.iter().map(|x| x.angmax).collect()),
429        ),
430    ])
431}
432
433fn solver_switch_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
434    batch(vec![
435        (
436            "index",
437            i64s(t.switches.iter().map(|x| usz(x.index)).collect()),
438        ),
439        (
440            "source_row",
441            i64s(t.switches.iter().map(|x| opt_usz(x.source_row)).collect()),
442        ),
443        (
444            "from_bus_index",
445            i64s(t.switches.iter().map(|x| usz(x.from_bus_index)).collect()),
446        ),
447        (
448            "to_bus_index",
449            i64s(t.switches.iter().map(|x| usz(x.to_bus_index)).collect()),
450        ),
451        (
452            "closed",
453            u8s(t.switches.iter().map(|x| u8::from(x.closed)).collect()),
454        ),
455        (
456            "thermal_rating",
457            f64s(
458                t.switches
459                    .iter()
460                    .map(|x| x.thermal_rating.unwrap_or(0.0))
461                    .collect(),
462            ),
463        ),
464        (
465            "current_rating",
466            f64s(
467                t.switches
468                    .iter()
469                    .map(|x| x.current_rating.unwrap_or(0.0))
470                    .collect(),
471            ),
472        ),
473        (
474            "pf",
475            f64s(t.switches.iter().map(|x| x.pf.unwrap_or(0.0)).collect()),
476        ),
477        (
478            "qf",
479            f64s(t.switches.iter().map(|x| x.qf.unwrap_or(0.0)).collect()),
480        ),
481        (
482            "pt",
483            f64s(t.switches.iter().map(|x| x.pt.unwrap_or(0.0)).collect()),
484        ),
485        (
486            "qt",
487            f64s(t.switches.iter().map(|x| x.qt.unwrap_or(0.0)).collect()),
488        ),
489    ])
490}
491
492fn solver_arc_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
493    batch(vec![
494        ("index", i64s(t.arcs.iter().map(|x| usz(x.index)).collect())),
495        (
496            "branch_index",
497            i64s(t.arcs.iter().map(|x| usz(x.branch_index)).collect()),
498        ),
499        (
500            "terminal",
501            i64s(
502                t.arcs
503                    .iter()
504                    .map(|x| match x.terminal {
505                        SolverArcTerminal::From => 0,
506                        SolverArcTerminal::To => 1,
507                    })
508                    .collect(),
509            ),
510        ),
511        (
512            "from_bus_index",
513            i64s(t.arcs.iter().map(|x| usz(x.from_bus_index)).collect()),
514        ),
515        (
516            "to_bus_index",
517            i64s(t.arcs.iter().map(|x| usz(x.to_bus_index)).collect()),
518        ),
519        ("tap", f64s(t.arcs.iter().map(|x| x.tap).collect())),
520        ("shift", f64s(t.arcs.iter().map(|x| x.shift).collect())),
521        ("g_shunt", f64s(t.arcs.iter().map(|x| x.g_shunt).collect())),
522        ("b_shunt", f64s(t.arcs.iter().map(|x| x.b_shunt).collect())),
523        ("rate_a", f64s(t.arcs.iter().map(|x| x.rate_a).collect())),
524    ])
525}
526
527fn solver_gen_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
528    batch(vec![
529        (
530            "index",
531            i64s(t.generators.iter().map(|x| usz(x.index)).collect()),
532        ),
533        (
534            "source_row",
535            i64s(t.generators.iter().map(|x| opt_usz(x.source_row)).collect()),
536        ),
537        (
538            "bus_index",
539            i64s(t.generators.iter().map(|x| usz(x.bus_index)).collect()),
540        ),
541        ("pg", f64s(t.generators.iter().map(|x| x.pg).collect())),
542        ("qg", f64s(t.generators.iter().map(|x| x.qg).collect())),
543        ("pmax", f64s(t.generators.iter().map(|x| x.pmax).collect())),
544        ("pmin", f64s(t.generators.iter().map(|x| x.pmin).collect())),
545        ("qmax", f64s(t.generators.iter().map(|x| x.qmax).collect())),
546        ("qmin", f64s(t.generators.iter().map(|x| x.qmin).collect())),
547        ("vg", f64s(t.generators.iter().map(|x| x.vg).collect())),
548        (
549            "mbase",
550            f64s(t.generators.iter().map(|x| x.mbase).collect()),
551        ),
552        (
553            "regulated_bus_index",
554            i64s(
555                t.generators
556                    .iter()
557                    .map(|x| opt_usz(x.regulated_bus_index))
558                    .collect(),
559            ),
560        ),
561    ])
562}
563
564fn solver_storage_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
565    batch(vec![
566        (
567            "index",
568            i64s(t.storage.iter().map(|x| usz(x.index)).collect()),
569        ),
570        (
571            "source_row",
572            i64s(t.storage.iter().map(|x| opt_usz(x.source_row)).collect()),
573        ),
574        (
575            "bus_index",
576            i64s(t.storage.iter().map(|x| usz(x.bus_index)).collect()),
577        ),
578        ("ps", f64s(t.storage.iter().map(|x| x.ps).collect())),
579        ("qs", f64s(t.storage.iter().map(|x| x.qs).collect())),
580        ("energy", f64s(t.storage.iter().map(|x| x.energy).collect())),
581        (
582            "energy_rating",
583            f64s(t.storage.iter().map(|x| x.energy_rating).collect()),
584        ),
585        (
586            "charge_rating",
587            f64s(t.storage.iter().map(|x| x.charge_rating).collect()),
588        ),
589        (
590            "discharge_rating",
591            f64s(t.storage.iter().map(|x| x.discharge_rating).collect()),
592        ),
593        (
594            "thermal_rating",
595            f64s(t.storage.iter().map(|x| x.thermal_rating).collect()),
596        ),
597        ("qmin", f64s(t.storage.iter().map(|x| x.qmin).collect())),
598        ("qmax", f64s(t.storage.iter().map(|x| x.qmax).collect())),
599        ("r", f64s(t.storage.iter().map(|x| x.r).collect())),
600        ("x", f64s(t.storage.iter().map(|x| x.x).collect())),
601        ("p_loss", f64s(t.storage.iter().map(|x| x.p_loss).collect())),
602        ("q_loss", f64s(t.storage.iter().map(|x| x.q_loss).collect())),
603    ])
604}
605
606fn solver_hvdc_batch(t: &NormalizedSolverTables) -> Result<RecordBatch, ArrowError> {
607    batch(vec![
608        ("index", i64s(t.hvdc.iter().map(|x| usz(x.index)).collect())),
609        (
610            "source_row",
611            i64s(t.hvdc.iter().map(|x| opt_usz(x.source_row)).collect()),
612        ),
613        (
614            "from_bus_index",
615            i64s(t.hvdc.iter().map(|x| usz(x.from_bus_index)).collect()),
616        ),
617        (
618            "to_bus_index",
619            i64s(t.hvdc.iter().map(|x| usz(x.to_bus_index)).collect()),
620        ),
621        ("pf", f64s(t.hvdc.iter().map(|x| x.pf).collect())),
622        ("pt", f64s(t.hvdc.iter().map(|x| x.pt).collect())),
623        ("qf", f64s(t.hvdc.iter().map(|x| x.qf).collect())),
624        ("qt", f64s(t.hvdc.iter().map(|x| x.qt).collect())),
625        ("vf", f64s(t.hvdc.iter().map(|x| x.vf).collect())),
626        ("vt", f64s(t.hvdc.iter().map(|x| x.vt).collect())),
627        ("pmin", f64s(t.hvdc.iter().map(|x| x.pmin).collect())),
628        ("pmax", f64s(t.hvdc.iter().map(|x| x.pmax).collect())),
629        ("qminf", f64s(t.hvdc.iter().map(|x| x.qminf).collect())),
630        ("qmaxf", f64s(t.hvdc.iter().map(|x| x.qmaxf).collect())),
631        ("qmint", f64s(t.hvdc.iter().map(|x| x.qmint).collect())),
632        ("qmaxt", f64s(t.hvdc.iter().map(|x| x.qmaxt).collect())),
633        ("loss0", f64s(t.hvdc.iter().map(|x| x.loss0).collect())),
634        ("loss1", f64s(t.hvdc.iter().map(|x| x.loss1).collect())),
635    ])
636}
637
638fn batch(cols: Vec<(&str, ArrayRef)>) -> Result<RecordBatch, ArrowError> {
639    let fields: Vec<Field> = cols
640        .iter()
641        .map(|(name, arr)| Field::new(*name, arr.data_type().clone(), false))
642        .collect();
643    let arrays: Vec<ArrayRef> = cols.into_iter().map(|(_, arr)| arr).collect();
644    RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)
645}
646
647/// External bus id as i64 (`-1` if it somehow overflows), matching `pio_branches`.
648fn ext(id: BusId) -> i64 {
649    i64::try_from(id.0).unwrap_or(-1)
650}
651
652fn usz(n: usize) -> i64 {
653    i64::try_from(n).unwrap_or(-1)
654}
655
656fn opt_usz(n: Option<usize>) -> i64 {
657    n.map_or(-1, usz)
658}
659
660fn i64s(v: Vec<i64>) -> ArrayRef {
661    Arc::new(Int64Array::from(v))
662}
663
664fn f64s(v: Vec<f64>) -> ArrayRef {
665    Arc::new(Float64Array::from(v))
666}
667
668fn u8s(v: Vec<u8>) -> ArrayRef {
669    Arc::new(UInt8Array::from(v))
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use arrow::ffi::from_ffi;
676
677    fn net(name: &str) -> Network {
678        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
679            .join("../tests/data")
680            .join(name);
681        powerio::parse_file(&path, None).unwrap().network
682    }
683
684    fn terminal_projection_net() -> Network {
685        use powerio::{Branch, BranchCharging, Bus, BusId, BusType};
686
687        let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.1);
688        branch.charging = Some(BranchCharging::new(0.01, 0.02, 0.03, 0.05));
689        branch.rate_a = 100.0;
690        Network::in_memory(
691            "terminal-projection",
692            100.0,
693            vec![
694                Bus::new(BusId(1), BusType::Ref, 230.0),
695                Bus::new(BusId(2), BusType::Pq, 230.0),
696            ],
697            vec![branch],
698        )
699    }
700
701    fn round_trip(net: &Network, table: i32) -> StructArray {
702        let (array, schema) = export(net, table).unwrap();
703        // from_ffi consumes the array and borrows the schema (zero-copy import).
704        let data = unsafe { from_ffi(array, &schema) }.unwrap();
705        StructArray::from(data)
706    }
707
708    fn f64_col<'a>(sa: &'a StructArray, name: &str) -> &'a Float64Array {
709        sa.column_by_name(name)
710            .unwrap()
711            .as_any()
712            .downcast_ref::<Float64Array>()
713            .unwrap()
714    }
715
716    fn i64_col<'a>(sa: &'a StructArray, name: &str) -> &'a Int64Array {
717        sa.column_by_name(name)
718            .unwrap()
719            .as_any()
720            .downcast_ref::<Int64Array>()
721            .unwrap()
722    }
723
724    #[test]
725    fn bus_table_round_trips_with_external_ids() {
726        let n = net("case9.m");
727        let sa = round_trip(&n, PIO_ARROW_TABLE_BUS);
728        assert_eq!(sa.len(), n.buses.len());
729        let ids = sa
730            .column_by_name("id")
731            .unwrap()
732            .as_any()
733            .downcast_ref::<Int64Array>()
734            .unwrap();
735        // The whole id column survives, in order (a reversed/offset column would
736        // pass a single-cell check).
737        let expected: Vec<i64> = n
738            .buses
739            .iter()
740            .map(|b| i64::try_from(b.id.0).unwrap())
741            .collect();
742        assert_eq!(ids.values(), expected.as_slice());
743    }
744
745    #[test]
746    fn empty_table_exports_zero_rows() {
747        // case9 has no shunts: a length-0 table must cross the C Data Interface
748        // and import back without faulting (a common producer mishandling).
749        let n = net("case9.m");
750        assert_eq!(n.shunts.len(), 0);
751        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_SHUNT).len(), 0);
752    }
753
754    #[test]
755    fn every_table_has_the_expected_row_count() {
756        // case30 carries buses, branches, gens, loads, and shunts.
757        let n = net("case30.m");
758        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_BUS).len(), n.buses.len());
759        assert_eq!(
760            round_trip(&n, PIO_ARROW_TABLE_BRANCH).len(),
761            n.branches.len()
762        );
763        assert_eq!(
764            round_trip(&n, PIO_ARROW_TABLE_GEN).len(),
765            n.generators.len()
766        );
767        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_LOAD).len(), n.loads.len());
768        assert_eq!(round_trip(&n, PIO_ARROW_TABLE_SHUNT).len(), n.shunts.len());
769    }
770
771    #[test]
772    fn normalized_solver_tables_export_dense_per_unit_rows() {
773        let n = net("case14.m");
774        let tables = n.to_normalized_solver_tables().unwrap();
775
776        assert_eq!(
777            round_trip(&n, PIO_ARROW_TABLE_SOLVER_BUS).len(),
778            tables.buses.len()
779        );
780        assert_eq!(
781            round_trip(&n, PIO_ARROW_TABLE_SOLVER_BRANCH).len(),
782            tables.branches.len()
783        );
784        assert_eq!(
785            round_trip(&n, PIO_ARROW_TABLE_SOLVER_ARC).len(),
786            tables.arcs.len()
787        );
788        assert_eq!(
789            round_trip(&n, PIO_ARROW_TABLE_SOLVER_GEN).len(),
790            tables.generators.len()
791        );
792
793        let bus = round_trip(&n, PIO_ARROW_TABLE_SOLVER_BUS);
794        assert_eq!(i64_col(&bus, "index").value(1), 1);
795        assert_eq!(i64_col(&bus, "bus_id").value(1), 2);
796        assert_eq!(i64_col(&bus, "source_row").value(1), 1);
797        assert!((f64_col(&bus, "pd").value(1) - 21.7 / 100.0).abs() < 1e-12);
798
799        let branch = round_trip(&n, PIO_ARROW_TABLE_SOLVER_BRANCH);
800        assert_eq!(i64_col(&branch, "from_bus_index").value(0), 0);
801        assert_eq!(i64_col(&branch, "to_bus_index").value(0), 1);
802
803        let arc = round_trip(&n, PIO_ARROW_TABLE_SOLVER_ARC);
804        assert_eq!(i64_col(&arc, "branch_index").value(0), 0);
805        assert_eq!(i64_col(&arc, "terminal").value(0), 0);
806        assert_eq!(i64_col(&arc, "branch_index").value(1), 0);
807        assert_eq!(i64_col(&arc, "terminal").value(1), 1);
808    }
809
810    #[test]
811    fn branch_table_b_is_legacy_projection() {
812        let n = terminal_projection_net();
813        let sa = round_trip(&n, PIO_ARROW_TABLE_BRANCH);
814        assert_eq!(sa.len(), 1);
815        assert!((f64_col(&sa, "b").value(0) - 0.07).abs() < 1e-12);
816        assert!((f64_col(&sa, "g_fr").value(0) - 0.01).abs() < 1e-12);
817        assert!((f64_col(&sa, "b_fr").value(0) - 0.02).abs() < 1e-12);
818        assert!((f64_col(&sa, "g_to").value(0) - 0.03).abs() < 1e-12);
819        assert!((f64_col(&sa, "b_to").value(0) - 0.05).abs() < 1e-12);
820    }
821
822    #[test]
823    fn unknown_table_id_errors() {
824        let n = net("case9.m");
825        assert!(export(&n, 99).is_err());
826    }
827}