Skip to main content

powerio_capi/
lib.rs

1//! C ABI for `powerio`: ABI v4.
2//!
3//! Parse any supported power system case format into an opaque handle, query
4//! it, convert it to another format, and pull out the numeric tables a
5//! downstream solver needs to assemble matrices. Every entry point is
6//! `extern "C"`, catches panics at the boundary, and returns error text into a
7//! caller-provided buffer.
8//!
9//! The surface follows a fixed grammar, written out in the header preamble
10//! (`include/powerio.h`, generated by cbindgen, never hand-edit):
11//!
12//! - Verb-led names are operations and the verb fixes the return family:
13//!   `parse`/`read`/`normalize` return a new handle, `write` has a filesystem
14//!   effect, `convert` transcodes without keeping a handle, `free` destroys.
15//! - `to_` marks a representation change of the same network; the target is a
16//!   format string (`pio_to_format`) unless the output type differs
17//!   (`pio_to_arrow` fills Arrow C Data Interface structs).
18//! - Format names never appear in symbols: formats are strings, so a new
19//!   format never changes this ABI. The canonical snapshot is the
20//!   `powerio-json` format name.
21//! - Array extractors share the cap/count convention: write up to `cap`
22//!   values, return the total available, `NULL` out is a pure count query.
23//! - Vocabulary: a *bus* is a named connection point (this surface is bus
24//!   granular); a *node* is one conductor's point at a bus, reserved for the
25//!   multiconductor surface; a *branch* is any two-terminal series element,
26//!   lines and transformers alike.
27
28#![allow(clippy::missing_safety_doc)]
29
30use std::ffi::{CStr, CString, c_char};
31use std::panic::{AssertUnwindSafe, catch_unwind};
32
33use powerio::{IndexCore, IndexedNetwork, Network, TargetFormat};
34
35#[cfg(feature = "arrow")]
36mod arrow_export;
37#[cfg(feature = "arrow")]
38pub use arrow_export::{
39    PIO_ARROW_TABLE_BRANCH, PIO_ARROW_TABLE_BUS, PIO_ARROW_TABLE_GEN, PIO_ARROW_TABLE_LOAD,
40    PIO_ARROW_TABLE_SHUNT, PIO_ARROW_TABLE_SOLVER_ARC, PIO_ARROW_TABLE_SOLVER_BRANCH,
41    PIO_ARROW_TABLE_SOLVER_BUS, PIO_ARROW_TABLE_SOLVER_GEN, PIO_ARROW_TABLE_SOLVER_HVDC,
42    PIO_ARROW_TABLE_SOLVER_LOAD, PIO_ARROW_TABLE_SOLVER_SHUNT, PIO_ARROW_TABLE_SOLVER_STORAGE,
43    PIO_ARROW_TABLE_SOLVER_SWITCH, PIO_ARROW_TABLE_SWITCH,
44};
45
46/// Opaque parsed network handle. Carries the parsed [`Network`], the
47/// [`IndexCore`] derived from it once at parse time (so every indexed query
48/// reuses the same bus-id map and per-bus aggregates instead of rebuilding
49/// them), and the reader's fidelity warnings ([`pio_warnings`]).
50pub struct PioNetwork {
51    net: Network,
52    core: IndexCore,
53    warnings: Vec<String>,
54}
55
56// The handle is immutable after construction and the C ABI documents concurrent
57// reads from any number of threads as safe (see the cbindgen header preamble).
58// That guarantee requires `PioNetwork: Send + Sync`; pin it at compile time so
59// a future field that is not `Sync` fails the build instead of weakening it.
60const _: fn() = || {
61    fn assert_send_sync<T: Send + Sync>() {}
62    assert_send_sync::<PioNetwork>();
63};
64
65/// Copy `msg` (truncated to fit) into a caller `char[len]` buffer, always
66/// NUL-terminated. Truncation backs up to a UTF-8 character boundary so a
67/// clipped message is still valid UTF-8. Shared by the error and warning
68/// outputs.
69///
70/// # Safety
71/// A non-NULL `buf` must point to at least `len` writable bytes; the write
72/// stays within `len` (at most `len - 1` message bytes plus the terminating
73/// NUL). NULL or `len == 0` is a no-op.
74unsafe fn copy_to_buf(buf: *mut c_char, len: usize, msg: &str) {
75    unsafe {
76        if buf.is_null() || len == 0 {
77            return;
78        }
79        let bytes = msg.as_bytes();
80        let mut n = bytes.len().min(len - 1);
81        while n > 0 && !msg.is_char_boundary(n) {
82            n -= 1;
83        }
84        std::ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), buf, n);
85        *buf.add(n) = 0;
86    }
87}
88
89unsafe fn cstr<'a>(p: *const c_char) -> Option<&'a str> {
90    unsafe {
91        if p.is_null() {
92            return None;
93        }
94        CStr::from_ptr(p).to_str().ok()
95    }
96}
97
98/// Move `s` into an owned C string, or `None` if it holds an interior NUL byte
99/// (which can't cross as a C string). Callers surface the `None` as a real error
100/// rather than silently handing back an empty string.
101fn into_cstring(s: String) -> Option<*mut c_char> {
102    CString::new(s).ok().map(CString::into_raw)
103}
104
105/// Finish a `*mut c_char` entry point: hand back the owned C string, or on an
106/// interior NUL write the error into `errbuf` (NULL/0 to skip) and return NULL.
107/// The shared tail of the string-returning functions.
108fn finish_cstring(s: String, errbuf: *mut c_char, errlen: usize) -> *mut c_char {
109    match into_cstring(s) {
110        Some(p) => p,
111        None => {
112            unsafe { copy_to_buf(errbuf, errlen, "output contained an interior NUL byte") };
113            std::ptr::null_mut()
114        }
115    }
116}
117
118/// Run `f` at the FFI boundary, catching any panic so it can't unwind across
119/// `extern "C"` (UB). Returns `fallback` if `f` panics.
120unsafe fn guard<R>(fallback: R, f: impl FnOnce() -> R) -> R {
121    catch_unwind(AssertUnwindSafe(f)).unwrap_or(fallback)
122}
123
124/// Box a `Network` into an owned network handle, building its [`IndexCore`] once so
125/// every indexed query reuses it. The one constructor for `*mut PioNetwork`.
126fn make_network(net: Network, warnings: Vec<String>) -> *mut PioNetwork {
127    let core = IndexCore::build(&net);
128    Box::into_raw(Box::new(PioNetwork {
129        net,
130        core,
131        warnings,
132    }))
133}
134
135/// Finish a `*mut PioNetwork` entry point: run `f` (producing a `Network` with
136/// its read warnings, or an error message) under the panic guard, hand back an
137/// owned handle, or write the error, `panic_msg` if `f` panicked, into `errbuf`
138/// and return NULL. The shared tail of every handle-returning function
139/// (`pio_parse_file`, `pio_parse_str`, `pio_read_dir`, `pio_normalize`).
140unsafe fn finish_network(
141    errbuf: *mut c_char,
142    errlen: usize,
143    panic_msg: &str,
144    f: impl FnOnce() -> Result<(Network, Vec<String>), String>,
145) -> *mut PioNetwork {
146    unsafe {
147        // make_network runs inside the guard: IndexCore::build is part of the
148        // entry point's work and the header promises panics never cross the
149        // boundary.
150        match catch_unwind(AssertUnwindSafe(|| {
151            f().map(|(net, warnings)| make_network(net, warnings))
152        })) {
153            Ok(Ok(handle)) => handle,
154            Ok(Err(msg)) => {
155                copy_to_buf(errbuf, errlen, &msg);
156                std::ptr::null_mut()
157            }
158            Err(_) => {
159                copy_to_buf(errbuf, errlen, panic_msg);
160                std::ptr::null_mut()
161            }
162        }
163    }
164}
165
166/// ABI version of this C interface. Bump on any breaking change to an existing
167/// `pio_*` signature or to the `powerio-json` snapshot schema (new additive
168/// symbols don't require a bump). A consumer compares [`pio_abi_version`]
169/// against the value it was built against (the `PIO_ABI_VERSION` macro in
170/// `powerio.h`) and refuses a mismatched library instead of calling in blind.
171///
172/// v4 froze the naming grammar and conventions (see the header preamble); the
173/// surface evolves additively from here: new data means new symbols, and rich
174/// or multiconductor data rides the Arrow and `powerio-json` schemas, which
175/// carry their own structure and never force a signature change.
176pub const PIO_ABI_VERSION: u32 = 4;
177
178/// ABI version of the optional `pio_dist_*` C surface. This is separate from
179/// [`PIO_ABI_VERSION`] so distribution C entry points can evolve without forcing
180/// a core ABI bump. Version 1 is the supported dist surface with conversion
181/// order `(input, from, to, ...)`.
182#[cfg(feature = "dist")]
183pub const PIO_DIST_ABI_VERSION: u32 = 1;
184
185/// A comfortable error-buffer size: pass a `char[PIO_ERRBUF_MIN]` to any
186/// `errbuf`/`warnbuf` parameter and a message always fits without truncation.
187pub const PIO_ERRBUF_MIN: usize = 256;
188
189/// The ABI version the library was built with (see [`PIO_ABI_VERSION`]). Lets a
190/// consumer detect a stale or incompatible library at load time. Infallible.
191#[unsafe(no_mangle)]
192pub extern "C" fn pio_abi_version() -> u32 {
193    PIO_ABI_VERSION
194}
195
196/// The ABI version of the optional `pio_dist_*` surface. Only linked when the
197/// `dist` feature is compiled in; probe that first with `pio_has_feature("dist")`
198/// if loading dynamically.
199#[cfg(feature = "dist")]
200#[unsafe(no_mangle)]
201pub extern "C" fn pio_dist_abi_version() -> u32 {
202    PIO_DIST_ABI_VERSION
203}
204
205/// Whether an optional build feature is compiled in: pass `"arrow"`, `"gridfm"`,
206/// `"dist"`, or `"pkg"`. Returns 1 if present, 0 otherwise (and 0 for a NULL or
207/// unknown name). The optional surfaces (`pio_to_arrow`, the `pio_read_dir`/
208/// gridfm path, the `pio_dist_*` block, and the `pio_package_*` block) are only
209/// linked when their feature is built, so a consumer that loaded the library at
210/// runtime probes for them here instead of resolving symbols blind. Feature
211/// names are strings like format names, so a new feature never changes this
212/// signature. Infallible.
213#[unsafe(no_mangle)]
214pub unsafe extern "C" fn pio_has_feature(feature: *const c_char) -> i32 {
215    unsafe {
216        guard(0, || {
217            let Some(name) = cstr(feature) else { return 0 };
218            let features: &[(&str, bool)] = &[
219                ("arrow", cfg!(feature = "arrow")),
220                ("gridfm", cfg!(feature = "gridfm")),
221                ("dist", cfg!(feature = "dist")),
222                ("pkg", cfg!(feature = "pkg")),
223            ];
224            i32::from(features.iter().any(|&(n, on)| n == name && on))
225        })
226    }
227}
228
229/// The crate version string (a semver string), `'static` and NUL-terminated. Do
230/// NOT free it. Informational; pair it with [`pio_abi_version`] for the actual
231/// compatibility check.
232#[unsafe(no_mangle)]
233pub extern "C" fn pio_version() -> *const c_char {
234    // env! is resolved at compile time; the trailing NUL makes it a valid C
235    // string and the 'static lifetime means the pointer is always valid and
236    // never owned by the caller.
237    concat!(env!("CARGO_PKG_VERSION"), "\0")
238        .as_ptr()
239        .cast::<c_char>()
240}
241
242fn target_format_from_c(to: *const c_char) -> Result<TargetFormat, String> {
243    let to = unsafe { cstr(to) }.ok_or_else(|| "to is NULL or not UTF-8".to_string())?;
244    to.parse::<TargetFormat>().map_err(|e| e.to_string())
245}
246
247fn optional_cstr<'a>(p: *const c_char, name: &str) -> Result<Option<&'a str>, String> {
248    if p.is_null() {
249        Ok(None)
250    } else {
251        unsafe { cstr(p) }
252            .map(Some)
253            .ok_or_else(|| format!("{name} is not UTF-8"))
254    }
255}
256
257/// Like [`cstr`] but a NULL or non-UTF-8 pointer is an error naming the
258/// offending parameter. Entry points use this for required strings.
259#[cfg(any(feature = "dist", feature = "pkg"))]
260fn required_cstr<'a>(p: *const c_char, name: &str) -> Result<&'a str, String> {
261    unsafe { cstr(p) }.ok_or_else(|| format!("{name} is NULL or not UTF-8"))
262}
263
264/// Parse `path` (format from extension, or `from` if non-NULL) into a network
265/// handle. `from` accepts the [`pio_parse_str`] format names plus
266/// `pypsa-csv`/`pypsa`, `goc3-json`/`goc3`, `surge-json`/`surge`, and `pwb`;
267/// that includes `pslf`/`epc`, and `.epc` is inferred by extension. A PyPSA CSV folder is a directory, so it can only
268/// enter through this function, with `from = "pypsa-csv"` (or NULL when the
269/// directory holds a `network.csv`). Read fidelity warnings attach to the
270/// handle ([`pio_warnings`]). Returns `NULL` on error and writes the message
271/// into `errbuf`. Free the handle with [`pio_network_free`].
272#[unsafe(no_mangle)]
273pub unsafe extern "C" fn pio_parse_file(
274    path: *const c_char,
275    from: *const c_char,
276    errbuf: *mut c_char,
277    errlen: usize,
278) -> *mut PioNetwork {
279    unsafe {
280        finish_network(errbuf, errlen, "panic while parsing", || {
281            let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?;
282            let from = optional_cstr(from, "from")?;
283            powerio::parse_file(std::path::Path::new(path), from)
284                .map(|p| (p.network, p.warnings))
285                .map_err(|e| e.to_string())
286        })
287    }
288}
289
290/// Parse in-memory case `text` of the named `format` into a network handle.
291/// Unlike [`pio_parse_file`] there is no path to infer from, so `format` is
292/// required: one of `matpower`/`m`, `powermodels`/`pm`, `egret`,
293/// `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`, `powerworld`/`aux`,
294/// `pslf`/`epc`, `goc3-json`/`goc3`, `surge-json`/`surge`, or `powerio-json`/`json` (the canonical snapshot
295/// [`pio_to_format`] writes, validated on read). PyPSA CSV folders are
296/// directories, not text; parse them with [`pio_parse_file`] and
297/// `from = "pypsa-csv"`. Read fidelity warnings attach to the handle
298/// ([`pio_warnings`]). Returns `NULL` on error and writes the message into
299/// `errbuf`. Free the handle with [`pio_network_free`].
300#[unsafe(no_mangle)]
301pub unsafe extern "C" fn pio_parse_str(
302    text: *const c_char,
303    format: *const c_char,
304    errbuf: *mut c_char,
305    errlen: usize,
306) -> *mut PioNetwork {
307    unsafe {
308        finish_network(errbuf, errlen, "panic while parsing", || {
309            let text = cstr(text).ok_or_else(|| "text is NULL or not UTF-8".to_string())?;
310            let format = cstr(format).ok_or_else(|| "format is NULL or not UTF-8".to_string())?;
311            powerio::parse_str(text, format)
312                .map(|p| (p.network, p.warnings))
313                .map_err(|e| e.to_string())
314        })
315    }
316}
317
318/// Read one scenario of a dataset directory in the named `from` format into a
319/// network handle: the directory sibling of [`pio_parse_file`]. `gridfm` (the
320/// gridfm-datakit Parquet layout; `dir` resolves leniently: the `raw/` leaf,
321/// a `<case>/` directory with a `raw/` child, or a parent holding exactly one
322/// such case) is the one dataset format today. `scenario` selects within a
323/// multi-scenario dataset ([`pio_scenario_ids`] enumerates them); formats
324/// without scenarios take `0`. Read fidelity warnings attach to the handle
325/// ([`pio_warnings`]). Returns `NULL` on error and writes the message into
326/// `errbuf`. Free the handle with [`pio_network_free`]. Built
327/// `--features gridfm`.
328#[cfg(feature = "gridfm")]
329#[unsafe(no_mangle)]
330pub unsafe extern "C" fn pio_read_dir(
331    dir: *const c_char,
332    from: *const c_char,
333    scenario: i64,
334    errbuf: *mut c_char,
335    errlen: usize,
336) -> *mut PioNetwork {
337    unsafe {
338        finish_network(errbuf, errlen, "panic while reading dataset", || {
339            let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?;
340            let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?;
341            powerio_matrix::read_dataset_dir(std::path::Path::new(dir), from, scenario)
342                .map(|read| (read.network, read.warnings))
343                .map_err(|e| e.to_string())
344        })
345    }
346}
347
348/// Write the distinct scenario ids (ascending) of the dataset directory `dir`
349/// in the named `from` format into `out`, up to `cap` entries, and return the
350/// total count: the cap/count convention of [`pio_bus_ids`]. `gridfm` is the
351/// one dataset format today. Returns `-1` on error and writes the message into
352/// `errbuf` (unlike the handle extractors, this reads the filesystem and can
353/// fail). Built `--features gridfm`.
354#[cfg(feature = "gridfm")]
355#[unsafe(no_mangle)]
356pub unsafe extern "C" fn pio_scenario_ids(
357    dir: *const c_char,
358    from: *const c_char,
359    out: *mut i64,
360    cap: usize,
361    errbuf: *mut c_char,
362    errlen: usize,
363) -> isize {
364    unsafe {
365        let r = catch_unwind(AssertUnwindSafe(|| {
366            let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?;
367            let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?;
368            powerio_matrix::dataset_scenario_ids(std::path::Path::new(dir), from)
369                .map_err(|e| e.to_string())
370        }));
371        match r {
372            Ok(Ok(ids)) => {
373                let Ok(total) = isize::try_from(ids.len()) else {
374                    copy_to_buf(errbuf, errlen, "scenario count exceeds isize");
375                    return -1;
376                };
377                fill(out, cap, ids.iter().copied());
378                total
379            }
380            Ok(Err(msg)) => {
381                copy_to_buf(errbuf, errlen, &msg);
382                -1
383            }
384            Err(_) => {
385                copy_to_buf(errbuf, errlen, "panic while reading scenario ids");
386                -1
387            }
388        }
389    }
390}
391
392/// The fidelity warnings attached to the handle at construction (by whichever
393/// of [`pio_parse_file`], [`pio_parse_str`], [`pio_read_dir`], or
394/// [`pio_normalize`] built it), `\n`-joined into `warnbuf` (truncated to fit
395/// on a UTF-8 boundary; NULL/0 to skip). Returns the byte length of the full
396/// joined text, excluding the NUL; call once with `(NULL, 0)` to size, then
397/// pass a `char[len + 1]`. `0` means no warnings (or a NULL handle); readers
398/// that are total attach none.
399#[unsafe(no_mangle)]
400pub unsafe extern "C" fn pio_warnings(
401    net: *const PioNetwork,
402    warnbuf: *mut c_char,
403    warnlen: usize,
404) -> usize {
405    unsafe {
406        guard(0, || {
407            let Some(c) = network_ref(net) else { return 0 };
408            let msg = c.warnings.join("\n");
409            copy_to_buf(warnbuf, warnlen, &msg);
410            msg.len()
411        })
412    }
413}
414
415/// Free a network handle from [`pio_parse_file`], [`pio_parse_str`],
416/// [`pio_read_dir`], or [`pio_normalize`].
417#[unsafe(no_mangle)]
418pub unsafe extern "C" fn pio_network_free(net: *mut PioNetwork) {
419    unsafe {
420        // Under the same panic guard as every other entry point: the drop is
421        // pure deallocation today, but "catches panics" must not depend on that
422        // staying true.
423        guard((), || {
424            if !net.is_null() {
425                drop(Box::from_raw(net));
426            }
427        });
428    }
429}
430
431unsafe fn network_ref<'a>(net: *const PioNetwork) -> Option<&'a PioNetwork> {
432    unsafe { net.as_ref() }
433}
434
435/// View `net` through its cached [`IndexCore`] with no per-call rebuild.
436unsafe fn view<'a>(net: *const PioNetwork) -> Option<IndexedNetwork<'a>> {
437    unsafe {
438        net.as_ref()
439            .map(|c| IndexedNetwork::with_core(&c.net, &c.core))
440    }
441}
442
443/// Normalize `net` into a NEW network handle: per unit, radians, out-of-service
444/// filtered, source bus ids preserved, bus types canonicalized (see
445/// `Network::to_normalized`). A value transform, not a serialization, hence
446/// the verb, while the `to_*` family re-encodes unchanged data. The result is
447/// independent of `net`; free both with [`pio_network_free`]. Every extractor
448/// and serializer works on it unchanged (the handle is per unit, not MW).
449/// Returns `NULL` on error (no reference bus can be chosen, or a non-positive
450/// base MVA) and writes the message into `errbuf`.
451#[unsafe(no_mangle)]
452pub unsafe extern "C" fn pio_normalize(
453    net: *const PioNetwork,
454    errbuf: *mut c_char,
455    errlen: usize,
456) -> *mut PioNetwork {
457    unsafe {
458        finish_network(errbuf, errlen, "panic while normalizing", || {
459            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
460            c.net
461                .to_normalized()
462                .map(|n| (n, c.warnings.clone()))
463                .map_err(|e| e.to_string())
464        })
465    }
466}
467
468#[unsafe(no_mangle)]
469pub unsafe extern "C" fn pio_n_buses(net: *const PioNetwork) -> usize {
470    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.buses.len())) }
471}
472
473#[unsafe(no_mangle)]
474pub unsafe extern "C" fn pio_n_branches(net: *const PioNetwork) -> usize {
475    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.branches.len())) }
476}
477
478#[unsafe(no_mangle)]
479pub unsafe extern "C" fn pio_n_switches(net: *const PioNetwork) -> usize {
480    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.switches.len())) }
481}
482
483#[unsafe(no_mangle)]
484pub unsafe extern "C" fn pio_n_gens(net: *const PioNetwork) -> usize {
485    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.generators.len())) }
486}
487
488#[unsafe(no_mangle)]
489pub unsafe extern "C" fn pio_base_mva(net: *const PioNetwork) -> f64 {
490    unsafe { guard(0.0, || network_ref(net).map_or(0.0, |c| c.net.base_mva)) }
491}
492
493/// Dense `[0, n)` index of the single reference (slack) bus, or `-1` if not
494/// exactly one. An INDEX into the [`pio_bus_ids`] ordering, not a bus id;
495/// `pio_branches` from/to carry ids, so the unit is in the name. A network may
496/// carry several references (one per island, or a normalized case that kept
497/// the file's multiple `REF` buses); [`pio_ref_bus_indices`] reads them all,
498/// and its count (`NULL` out) tells zero from many.
499#[unsafe(no_mangle)]
500pub unsafe extern "C" fn pio_ref_bus_index(net: *const PioNetwork) -> i64 {
501    unsafe {
502        guard(-1, || match view(net) {
503            Some(v) => v
504                .reference_bus_index()
505                .map_or(-1, |i| i64::try_from(i).unwrap_or(-1)),
506            None => -1,
507        })
508    }
509}
510
511/// Write the dense `[0, n)` indices of the reference (slack) buses, ascending,
512/// into `out`, up to `cap` entries, and return the total count: the cap/count
513/// convention of [`pio_bus_ids`]. `0` means none; `> 1` means one reference
514/// per island or several fixed references in one island (a normalized case
515/// always reports `>= 1`).
516#[unsafe(no_mangle)]
517pub unsafe extern "C" fn pio_ref_bus_indices(
518    net: *const PioNetwork,
519    out: *mut i64,
520    cap: usize,
521) -> usize {
522    unsafe {
523        guard(0, || {
524            view(net).map_or(0, |v| {
525                fill(
526                    out,
527                    cap,
528                    v.reference_bus_indices()
529                        .into_iter()
530                        .map(|i| i64::try_from(i).unwrap_or(-1)),
531                )
532            })
533        })
534    }
535}
536
537/// Number of islands: connected components of the in-service topology.
538#[unsafe(no_mangle)]
539pub unsafe extern "C" fn pio_n_islands(net: *const PioNetwork) -> usize {
540    unsafe { guard(0, || view(net).map_or(0, |v| v.n_connected_components())) }
541}
542
543/// `1` if the in-service topology is radial (every island a tree), else `0`.
544#[unsafe(no_mangle)]
545pub unsafe extern "C" fn pio_is_radial(net: *const PioNetwork) -> i32 {
546    unsafe { guard(0, || view(net).map_or(0, |v| i32::from(v.is_radial()))) }
547}
548
549/// Serialize `net` to the named format `to`: the one text serializer; every
550/// format is named by a string. Accepts the [`pio_parse_str`] names:
551/// `matpower` is a byte-exact echo when the handle was parsed from MATPOWER,
552/// and `powerio-json` is the canonical snapshot (validated by [`pio_parse_str`]
553/// on the way back; the retained source text is the one field it omits). The
554/// snapshot is lossless except for a non-finite `f64` (`Inf`/`NaN`), which JSON
555/// cannot represent: it is written as `null`, named in a fidelity warning, and
556/// then fails to read back; pass `warnbuf` to detect it.
557///
558/// Returns the text as an owned C string (free with [`pio_string_free`]),
559/// `NULL` on error (message into `errbuf`). Fidelity warnings, if any, are
560/// written `\n`-joined into `warnbuf`; a returned string has no handle to
561/// attach them to.
562#[unsafe(no_mangle)]
563pub unsafe extern "C" fn pio_to_format(
564    net: *const PioNetwork,
565    to: *const c_char,
566    warnbuf: *mut c_char,
567    warnlen: usize,
568    errbuf: *mut c_char,
569    errlen: usize,
570) -> *mut c_char {
571    unsafe {
572        finish_conversion(warnbuf, warnlen, errbuf, errlen, || {
573            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
574            let target = target_format_from_c(to)?;
575            let conv = c.net.to_format(target).map_err(|e| e.to_string())?;
576            Ok((conv.text, conv.warnings))
577        })
578    }
579}
580
581/// Finish a text-conversion entry point: run `f` (producing the converted text
582/// with its fidelity warnings, or an error message) under the panic guard,
583/// write the warnings into `warnbuf`, and hand back the owned C string, or
584/// write the error and return NULL. The shared tail of [`pio_to_format`],
585/// [`pio_convert_file`], and [`pio_convert_str`], mirroring [`finish_network`].
586unsafe fn finish_conversion(
587    warnbuf: *mut c_char,
588    warnlen: usize,
589    errbuf: *mut c_char,
590    errlen: usize,
591    f: impl FnOnce() -> Result<(String, Vec<String>), String>,
592) -> *mut c_char {
593    unsafe {
594        match catch_unwind(AssertUnwindSafe(f)) {
595            Ok(Ok((text, warnings))) => {
596                copy_to_buf(warnbuf, warnlen, &warnings.join("\n"));
597                finish_cstring(text, errbuf, errlen)
598            }
599            Ok(Err(msg)) => {
600                copy_to_buf(errbuf, errlen, &msg);
601                std::ptr::null_mut()
602            }
603            Err(_) => {
604                copy_to_buf(errbuf, errlen, "panic while converting");
605                std::ptr::null_mut()
606            }
607        }
608    }
609}
610
611/// Convert the case file at `path` from format `from` (NULL to infer from the
612/// path, as [`pio_parse_file`]) to format `to`, without keeping a handle.
613/// Returns the converted text as an owned C string (free with
614/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, read side first,
615/// are written `\n`-joined into `warnbuf`.
616#[unsafe(no_mangle)]
617pub unsafe extern "C" fn pio_convert_file(
618    path: *const c_char,
619    from: *const c_char,
620    to: *const c_char,
621    warnbuf: *mut c_char,
622    warnlen: usize,
623    errbuf: *mut c_char,
624    errlen: usize,
625) -> *mut c_char {
626    unsafe {
627        finish_conversion(warnbuf, warnlen, errbuf, errlen, || {
628            let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?;
629            let from = optional_cstr(from, "from")?;
630            let target = target_format_from_c(to)?;
631            let conv = powerio::convert_file(std::path::Path::new(path), target, from)
632                .map_err(|e| e.to_string())?;
633            Ok((conv.text, conv.warnings))
634        })
635    }
636}
637
638/// Convert in-memory case `text` from format `from` (required; there is no
639/// path to infer from) to format `to`, without keeping a handle: the in-memory
640/// sibling of [`pio_convert_file`]. Returns the converted text as an owned C
641/// string (free with [`pio_string_free`]), `NULL` on error. Fidelity warnings,
642/// read side first, are written `\n`-joined into `warnbuf`.
643#[unsafe(no_mangle)]
644pub unsafe extern "C" fn pio_convert_str(
645    text: *const c_char,
646    from: *const c_char,
647    to: *const c_char,
648    warnbuf: *mut c_char,
649    warnlen: usize,
650    errbuf: *mut c_char,
651    errlen: usize,
652) -> *mut c_char {
653    unsafe {
654        finish_conversion(warnbuf, warnlen, errbuf, errlen, || {
655            let text = cstr(text).ok_or_else(|| "text is NULL or not UTF-8".to_string())?;
656            let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?;
657            let target = target_format_from_c(to)?;
658            let conv = powerio::convert_str(text, target, from).map_err(|e| e.to_string())?;
659            Ok((conv.text, conv.warnings))
660        })
661    }
662}
663
664/// Write `net` into the directory `out_dir` as the named directory-shaped
665/// format `to`: the directory sibling of [`pio_to_format`]. PyPSA CSV
666/// (`pypsa-csv`/`pypsa`) is the one such format today; a text format name is
667/// an error pointing back at [`pio_to_format`]. Returns `0` on success and
668/// `-1` on error (message into `errbuf`). Fidelity warnings, if any, are
669/// written `\n`-joined into `warnbuf`.
670#[unsafe(no_mangle)]
671pub unsafe extern "C" fn pio_write_dir(
672    net: *const PioNetwork,
673    to: *const c_char,
674    out_dir: *const c_char,
675    warnbuf: *mut c_char,
676    warnlen: usize,
677    errbuf: *mut c_char,
678    errlen: usize,
679) -> i32 {
680    unsafe {
681        let r = catch_unwind(AssertUnwindSafe(|| {
682            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
683            let to = cstr(to).ok_or_else(|| "to is NULL or not UTF-8".to_string())?;
684            let out_dir =
685                cstr(out_dir).ok_or_else(|| "out_dir is NULL or not UTF-8".to_string())?;
686            powerio::write_dir(&c.net, to, std::path::Path::new(out_dir)).map_err(|e| e.to_string())
687        }));
688        match r {
689            Ok(Ok(warnings)) => {
690                copy_to_buf(warnbuf, warnlen, &warnings.join("\n"));
691                0
692            }
693            Ok(Err(msg)) => {
694                copy_to_buf(errbuf, errlen, &msg);
695                -1
696            }
697            Err(_) => {
698                copy_to_buf(errbuf, errlen, "panic while writing directory");
699                -1
700            }
701        }
702    }
703}
704
705/// Free a string returned by [`pio_to_format`], [`pio_convert_file`], or
706/// [`pio_convert_str`].
707#[unsafe(no_mangle)]
708pub unsafe extern "C" fn pio_string_free(s: *mut c_char) {
709    unsafe {
710        // Same rationale as `pio_network_free`: the boundary catches panics.
711        guard((), || {
712            if !s.is_null() {
713                drop(CString::from_raw(s));
714            }
715        });
716    }
717}
718
719/// Write up to `cap` values from `vals` into `out` and return the total number
720/// available. A NULL `out` skips the write, so `(NULL, 0)` is the pure count
721/// query of the cap/count convention every array extractor shares.
722unsafe fn fill<T: Copy>(out: *mut T, cap: usize, vals: impl ExactSizeIterator<Item = T>) -> usize {
723    unsafe {
724        let total = vals.len();
725        if !out.is_null() {
726            for (i, v) in vals.take(cap).enumerate() {
727                *out.add(i) = v;
728            }
729        }
730        total
731    }
732}
733
734/// Write the 1-based external bus ids, in dense order, into `out`, up to `cap`
735/// entries, and return the total bus count. This ordering DEFINES the dense
736/// index space every other per-bus array shares. Call once with `(NULL, 0)` to
737/// size, allocate, then call again to fill. Ids are int64 in `1..2^63-1` (a v4
738/// invariant); a source id that is a string or exceeds that range is mapped to
739/// dense int64 at read, never passed through raw.
740#[unsafe(no_mangle)]
741pub unsafe extern "C" fn pio_bus_ids(net: *const PioNetwork, out: *mut i64, cap: usize) -> usize {
742    unsafe {
743        guard(0, || {
744            network_ref(net).map_or(0, |c| {
745                fill(
746                    out,
747                    cap,
748                    c.net
749                        .buses
750                        .iter()
751                        .map(|b| i64::try_from(b.id.0).unwrap_or(-1)),
752                )
753            })
754        })
755    }
756}
757
758/// Write the branch table as parallel arrays, each up to `cap` entries, and
759/// return the total branch count. A branch is any two-terminal series element
760/// lines and transformers alike (a transformer has `tap != 0`). `from`/`to`
761/// are 1-based bus IDS (the [`pio_bus_ids`] id space, not dense indices); map
762/// them to dense matrix rows with the [`pio_bus_ids`] ordering. Any output
763/// pointer may be NULL to skip that column; all NULL is the count query.
764#[unsafe(no_mangle)]
765pub unsafe extern "C" fn pio_branches(
766    net: *const PioNetwork,
767    from: *mut i64,
768    to: *mut i64,
769    r: *mut f64,
770    x: *mut f64,
771    b: *mut f64,
772    tap: *mut f64,
773    shift: *mut f64,
774    in_service: *mut u8,
775    cap: usize,
776) -> usize {
777    unsafe {
778        guard(0, || {
779            let Some(c) = network_ref(net) else { return 0 };
780            let net = &c.net;
781            fill(
782                from,
783                cap,
784                net.branches
785                    .iter()
786                    .map(|br| i64::try_from(br.from.0).unwrap_or(-1)),
787            );
788            fill(
789                to,
790                cap,
791                net.branches
792                    .iter()
793                    .map(|br| i64::try_from(br.to.0).unwrap_or(-1)),
794            );
795            fill(r, cap, net.branches.iter().map(|br| br.r));
796            fill(x, cap, net.branches.iter().map(|br| br.x));
797            fill(
798                b,
799                cap,
800                net.branches.iter().map(|br| br.legacy_total_charging_b()),
801            );
802            fill(tap, cap, net.branches.iter().map(|br| br.tap));
803            fill(shift, cap, net.branches.iter().map(|br| br.shift));
804            fill(
805                in_service,
806                cap,
807                net.branches.iter().map(|br| u8::from(br.in_service)),
808            );
809            net.branches.len()
810        })
811    }
812}
813
814/// Write the branch terminal charging table as parallel arrays, each up to
815/// `cap` entries, and return the total branch count. Columns are p.u.
816#[unsafe(no_mangle)]
817pub unsafe extern "C" fn pio_branch_charging(
818    net: *const PioNetwork,
819    g_fr: *mut f64,
820    b_fr: *mut f64,
821    g_to: *mut f64,
822    b_to: *mut f64,
823    cap: usize,
824) -> usize {
825    unsafe {
826        guard(0, || {
827            let Some(c) = network_ref(net) else { return 0 };
828            let net = &c.net;
829            fill(
830                g_fr,
831                cap,
832                net.branches.iter().map(|br| br.terminal_charging().g_fr),
833            );
834            fill(
835                b_fr,
836                cap,
837                net.branches.iter().map(|br| br.terminal_charging().b_fr),
838            );
839            fill(
840                g_to,
841                cap,
842                net.branches.iter().map(|br| br.terminal_charging().g_to),
843            );
844            fill(
845                b_to,
846                cap,
847                net.branches.iter().map(|br| br.terminal_charging().b_to),
848            );
849            net.branches.len()
850        })
851    }
852}
853
854/// Write the switch table as parallel arrays, each up to `cap` entries, and
855/// return the total switch count. `from`/`to` are external bus ids.
856#[unsafe(no_mangle)]
857pub unsafe extern "C" fn pio_switches(
858    net: *const PioNetwork,
859    from: *mut i64,
860    to: *mut i64,
861    closed: *mut u8,
862    thermal_rating: *mut f64,
863    current_rating: *mut f64,
864    pf: *mut f64,
865    qf: *mut f64,
866    pt: *mut f64,
867    qt: *mut f64,
868    cap: usize,
869) -> usize {
870    unsafe {
871        guard(0, || {
872            let Some(c) = network_ref(net) else { return 0 };
873            let net = &c.net;
874            fill(
875                from,
876                cap,
877                net.switches
878                    .iter()
879                    .map(|sw| i64::try_from(sw.from.0).unwrap_or(-1)),
880            );
881            fill(
882                to,
883                cap,
884                net.switches
885                    .iter()
886                    .map(|sw| i64::try_from(sw.to.0).unwrap_or(-1)),
887            );
888            fill(
889                closed,
890                cap,
891                net.switches.iter().map(|sw| u8::from(sw.closed)),
892            );
893            fill(
894                thermal_rating,
895                cap,
896                net.switches
897                    .iter()
898                    .map(|sw| sw.thermal_rating.unwrap_or(0.0)),
899            );
900            fill(
901                current_rating,
902                cap,
903                net.switches
904                    .iter()
905                    .map(|sw| sw.current_rating.unwrap_or(0.0)),
906            );
907            fill(pf, cap, net.switches.iter().map(|sw| sw.pf.unwrap_or(0.0)));
908            fill(qf, cap, net.switches.iter().map(|sw| sw.qf.unwrap_or(0.0)));
909            fill(pt, cap, net.switches.iter().map(|sw| sw.pt.unwrap_or(0.0)));
910            fill(qt, cap, net.switches.iter().map(|sw| sw.qt.unwrap_or(0.0)));
911            net.switches.len()
912        })
913    }
914}
915
916/// Write the generator table as parallel arrays, each up to `cap` entries, and
917/// return the total generator count. `bus` is the 1-based bus id (the
918/// [`pio_bus_ids`] id space). Any output pointer may be NULL to skip.
919#[unsafe(no_mangle)]
920pub unsafe extern "C" fn pio_gens(
921    net: *const PioNetwork,
922    bus: *mut i64,
923    pg: *mut f64,
924    pmax: *mut f64,
925    pmin: *mut f64,
926    in_service: *mut u8,
927    cap: usize,
928) -> usize {
929    unsafe {
930        guard(0, || {
931            let Some(c) = network_ref(net) else { return 0 };
932            let net = &c.net;
933            fill(
934                bus,
935                cap,
936                net.generators
937                    .iter()
938                    .map(|g| i64::try_from(g.bus.0).unwrap_or(-1)),
939            );
940            fill(pg, cap, net.generators.iter().map(|g| g.pg));
941            fill(pmax, cap, net.generators.iter().map(|g| g.pmax));
942            fill(pmin, cap, net.generators.iter().map(|g| g.pmin));
943            fill(
944                in_service,
945                cap,
946                net.generators.iter().map(|g| u8::from(g.in_service)),
947            );
948            net.generators.len()
949        })
950    }
951}
952
953/// Write the per-bus demand aggregates (active `pd`, reactive `qd`, summed
954/// over each bus's loads, dense [`pio_bus_ids`] order), each up to `cap`
955/// entries, and return the total bus count. Either pointer may be NULL.
956#[unsafe(no_mangle)]
957pub unsafe extern "C" fn pio_bus_demand(
958    net: *const PioNetwork,
959    pd: *mut f64,
960    qd: *mut f64,
961    cap: usize,
962) -> usize {
963    unsafe {
964        guard(0, || {
965            view(net).map_or(0, |v| {
966                // Return an explicit bus count, not the last fill's result, so the
967                // cap/count return is independent of which columns were requested.
968                let n = fill(pd, cap, v.pd().iter().copied());
969                fill(qd, cap, v.qd().iter().copied());
970                n
971            })
972        })
973    }
974}
975
976/// Write the per-bus shunt aggregates (conductance `gs`, susceptance `bs`,
977/// dense [`pio_bus_ids`] order), each up to `cap` entries, and return the
978/// total bus count. Either pointer may be NULL.
979#[unsafe(no_mangle)]
980pub unsafe extern "C" fn pio_bus_shunt(
981    net: *const PioNetwork,
982    gs: *mut f64,
983    bs: *mut f64,
984    cap: usize,
985) -> usize {
986    unsafe {
987        guard(0, || {
988            view(net).map_or(0, |v| {
989                // Explicit bus count, not the last fill's result (see pio_bus_demand).
990                let n = fill(gs, cap, v.gs().iter().copied());
991                fill(bs, cap, v.bs().iter().copied());
992                n
993            })
994        })
995    }
996}
997
998/// Export one network table over the Arrow C Data Interface: the `to_`
999/// conversion whose output type is Arrow structs rather than a string, and the
1000/// bulk plane this ABI evolves on. Tables 0..5 are raw network tables; tables 6
1001/// and up are normalized solver tables with per unit/radian values and dense
1002/// zero based row ids. New or richer columns arrive in the Arrow schema, leaving
1003/// the C signatures fixed.
1004///
1005/// `table` is one of the `PIO_ARROW_TABLE_*` selectors. Raw table columns use
1006/// EXTERNAL bus ids (the `pio_bus_ids` id space), not the gridfm schema. On
1007/// success (returns `0`),
1008/// `out_array` and `out_schema` are populated with owned C Data Interface
1009/// structs: ownership of the Arrow buffers transfers to the caller, both
1010/// `release` callbacks are non-NULL, and the caller MUST invoke each exactly
1011/// once when done (skipping one leaks; the structs outlive `pio_network_free`).
1012/// On error (returns `-1`) the message is written into `errbuf` and the
1013/// out-params are left untouched. Only built with the `arrow` cargo feature.
1014#[cfg(feature = "arrow")]
1015#[unsafe(no_mangle)]
1016pub unsafe extern "C" fn pio_to_arrow(
1017    net: *const PioNetwork,
1018    table: i32,
1019    out_array: *mut arrow::ffi::FFI_ArrowArray,
1020    out_schema: *mut arrow::ffi::FFI_ArrowSchema,
1021    errbuf: *mut c_char,
1022    errlen: usize,
1023) -> i32 {
1024    unsafe {
1025        let r = catch_unwind(AssertUnwindSafe(|| {
1026            if out_array.is_null() || out_schema.is_null() {
1027                return Err("out_array or out_schema is NULL".to_string());
1028            }
1029            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
1030            arrow_export::export(&c.net, table)
1031        }));
1032        match r {
1033            Ok(Ok((array, schema))) => {
1034                // Move the FFI structs into caller memory: ptr::write does not
1035                // drop the (caller-zeroed) destination and does not run Drop on
1036                // `array`/`schema`, so the producer release callbacks transfer to
1037                // the caller. Exactly one owner.
1038                std::ptr::write(out_array, array);
1039                std::ptr::write(out_schema, schema);
1040                0
1041            }
1042            Ok(Err(msg)) => {
1043                copy_to_buf(errbuf, errlen, &msg);
1044                -1
1045            }
1046            Err(_) => {
1047                copy_to_buf(errbuf, errlen, "panic while exporting Arrow");
1048                -1
1049            }
1050        }
1051    }
1052}
1053
1054// ---------------------------------------------------------------------------
1055// Package surface (`pkg` feature). `.pio.json` compiler packages sit above the
1056// balanced and multiconductor handles: a package wraps exactly one payload and
1057// carries provenance, validation, diagnostics, and lowering history.
1058// ---------------------------------------------------------------------------
1059
1060/// Opaque `.pio.json` compiler package handle. A package owns one
1061/// [`powerio_pkg::NetworkPackage`], which wraps either a balanced
1062/// [`PioNetwork`] payload or a multiconductor [`PioDistNetwork`] payload.
1063#[cfg(feature = "pkg")]
1064pub struct PioPackage {
1065    package: powerio_pkg::NetworkPackage,
1066}
1067
1068#[cfg(feature = "pkg")]
1069const _: fn() = || {
1070    fn assert_send_sync<T: Send + Sync>() {}
1071    assert_send_sync::<PioPackage>();
1072};
1073
1074#[cfg(feature = "pkg")]
1075fn lowering_options(base_mva: f64) -> powerio_pkg::MulticonductorToBalancedOptions {
1076    powerio_pkg::MulticonductorToBalancedOptions {
1077        base_mva,
1078        ..Default::default()
1079    }
1080}
1081
1082#[cfg(feature = "pkg")]
1083unsafe fn finish_package(
1084    errbuf: *mut c_char,
1085    errlen: usize,
1086    panic_msg: &str,
1087    f: impl FnOnce() -> Result<powerio_pkg::NetworkPackage, String>,
1088) -> *mut PioPackage {
1089    unsafe {
1090        match catch_unwind(AssertUnwindSafe(f)) {
1091            Ok(Ok(package)) => Box::into_raw(Box::new(PioPackage { package })),
1092            Ok(Err(msg)) => {
1093                copy_to_buf(errbuf, errlen, &msg);
1094                std::ptr::null_mut()
1095            }
1096            Err(_) => {
1097                copy_to_buf(errbuf, errlen, panic_msg);
1098                std::ptr::null_mut()
1099            }
1100        }
1101    }
1102}
1103
1104/// Parse a `.pio.json` package file into an opaque package handle. This reads
1105/// only the package envelope; case format names still enter through
1106/// [`pio_parse_file`] / [`pio_dist_parse_file`] and package constructors.
1107/// Returns `NULL` on error and writes the message into `errbuf`. Free the handle
1108/// with [`pio_package_free`].
1109#[cfg(feature = "pkg")]
1110#[unsafe(no_mangle)]
1111pub unsafe extern "C" fn pio_package_parse_file(
1112    path: *const c_char,
1113    errbuf: *mut c_char,
1114    errlen: usize,
1115) -> *mut PioPackage {
1116    unsafe {
1117        finish_package(errbuf, errlen, "panic while parsing package", || {
1118            let path = required_cstr(path, "path")?;
1119            let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1120            powerio_pkg::NetworkPackage::from_json(&text).map_err(|e| e.to_string())
1121        })
1122    }
1123}
1124
1125/// Parse in-memory `.pio.json` text into an opaque package handle. Returns
1126/// `NULL` on error and writes the message into `errbuf`. Free the handle with
1127/// [`pio_package_free`].
1128#[cfg(feature = "pkg")]
1129#[unsafe(no_mangle)]
1130pub unsafe extern "C" fn pio_package_parse_str(
1131    text: *const c_char,
1132    errbuf: *mut c_char,
1133    errlen: usize,
1134) -> *mut PioPackage {
1135    unsafe {
1136        finish_package(errbuf, errlen, "panic while parsing package", || {
1137            let text = required_cstr(text, "text")?;
1138            powerio_pkg::NetworkPackage::from_json(text).map_err(|e| e.to_string())
1139        })
1140    }
1141}
1142
1143/// Free a package handle returned by `pio_package_*`. NULL is a no-op; free
1144/// exactly once.
1145#[cfg(feature = "pkg")]
1146#[unsafe(no_mangle)]
1147pub unsafe extern "C" fn pio_package_free(pkg: *mut PioPackage) {
1148    unsafe {
1149        guard((), || {
1150            if !pkg.is_null() {
1151                drop(Box::from_raw(pkg));
1152            }
1153        });
1154    }
1155}
1156
1157/// Finish a `*mut c_char` package accessor: run `f` on the non-NULL handle
1158/// under the panic guard and hand back an owned C string, or write the error
1159/// (`panic_msg` if `f` panicked) into `errbuf` and return NULL. The shared
1160/// tail of the `pio_package_*_json` getters.
1161#[cfg(feature = "pkg")]
1162unsafe fn finish_package_json(
1163    pkg: *const PioPackage,
1164    errbuf: *mut c_char,
1165    errlen: usize,
1166    panic_msg: &str,
1167    f: impl FnOnce(&PioPackage) -> Result<String, String>,
1168) -> *mut c_char {
1169    unsafe {
1170        let result = catch_unwind(AssertUnwindSafe(|| {
1171            let pkg = pkg
1172                .as_ref()
1173                .ok_or_else(|| "package handle is NULL".to_string())?;
1174            f(pkg)
1175        }));
1176        match result {
1177            Ok(Ok(text)) => finish_cstring(text, errbuf, errlen),
1178            Ok(Err(msg)) => {
1179                copy_to_buf(errbuf, errlen, &msg);
1180                std::ptr::null_mut()
1181            }
1182            Err(_) => {
1183                copy_to_buf(errbuf, errlen, panic_msg);
1184                std::ptr::null_mut()
1185            }
1186        }
1187    }
1188}
1189
1190/// Serialize a package handle to compact `.pio.json`. Returns an owned C string
1191/// (free with [`pio_string_free`]) or `NULL` on error.
1192#[cfg(feature = "pkg")]
1193#[unsafe(no_mangle)]
1194pub unsafe extern "C" fn pio_package_to_json(
1195    pkg: *const PioPackage,
1196    errbuf: *mut c_char,
1197    errlen: usize,
1198) -> *mut c_char {
1199    unsafe {
1200        finish_package_json(
1201            pkg,
1202            errbuf,
1203            errlen,
1204            "panic while serializing package",
1205            |p| p.package.to_json().map_err(|e| e.to_string()),
1206        )
1207    }
1208}
1209
1210/// Wrap a balanced [`PioNetwork`] handle in a `.pio.json` package. The C handle
1211/// name is historical; the payload is `powerio::BalancedNetwork`.
1212/// `include_solver_metadata != 0` attaches compact normalized solver table
1213/// metadata.
1214#[cfg(feature = "pkg")]
1215#[unsafe(no_mangle)]
1216pub unsafe extern "C" fn pio_package_from_balanced_network(
1217    net: *const PioNetwork,
1218    include_solver_metadata: i32,
1219    errbuf: *mut c_char,
1220    errlen: usize,
1221) -> *mut PioPackage {
1222    unsafe {
1223        finish_package(
1224            errbuf,
1225            errlen,
1226            "panic while packaging balanced network",
1227            || {
1228                let net = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
1229                let mut package = powerio_pkg::NetworkPackage::from_balanced(net.net.clone());
1230                if include_solver_metadata != 0 {
1231                    package
1232                        .attach_normalized_solver_table_metadata()
1233                        .map_err(|e| e.to_string())?;
1234                }
1235                Ok(package)
1236            },
1237        )
1238    }
1239}
1240
1241/// Wrap a multiconductor [`PioDistNetwork`] handle in a `.pio.json` package. The
1242/// C handle name is historical; the payload is
1243/// `powerio_dist::MulticonductorNetwork`.
1244#[cfg(all(feature = "pkg", feature = "dist"))]
1245#[unsafe(no_mangle)]
1246pub unsafe extern "C" fn pio_package_from_multiconductor_network(
1247    net: *const PioDistNetwork,
1248    errbuf: *mut c_char,
1249    errlen: usize,
1250) -> *mut PioPackage {
1251    unsafe {
1252        finish_package(
1253            errbuf,
1254            errlen,
1255            "panic while packaging multiconductor network",
1256            || {
1257                let net = net
1258                    .as_ref()
1259                    .ok_or_else(|| "distribution network handle is NULL".to_string())?;
1260                Ok(powerio_pkg::NetworkPackage::from_multiconductor(
1261                    net.net.clone(),
1262                ))
1263            },
1264        )
1265    }
1266}
1267
1268/// Run the package semantic validation profile in place. Returns `0` on
1269/// success, `-1` on error.
1270///
1271/// Unlike the read-only accessors, this rewrites the handle's `diagnostics` and
1272/// `validation` (the payload is untouched), so it takes the handle non-`const`
1273/// and needs exclusive access: no other call may touch the same handle
1274/// concurrently. This is the one exception to the header's blanket
1275/// concurrent-read guarantee.
1276#[cfg(feature = "pkg")]
1277#[unsafe(no_mangle)]
1278pub unsafe extern "C" fn pio_package_validate(
1279    pkg: *mut PioPackage,
1280    errbuf: *mut c_char,
1281    errlen: usize,
1282) -> i32 {
1283    unsafe {
1284        let result = catch_unwind(AssertUnwindSafe(|| {
1285            let pkg = pkg
1286                .as_mut()
1287                .ok_or_else(|| "package handle is NULL".to_string())?;
1288            pkg.package.run_sane_validation();
1289            Ok::<_, String>(())
1290        }));
1291        match result {
1292            Ok(Ok(())) => 0,
1293            Ok(Err(msg)) => {
1294                copy_to_buf(errbuf, errlen, &msg);
1295                -1
1296            }
1297            Err(_) => {
1298                copy_to_buf(errbuf, errlen, "panic while validating package");
1299                -1
1300            }
1301        }
1302    }
1303}
1304
1305/// Return the package validation summary as JSON. The returned string is owned
1306/// by the library; free it with [`pio_string_free`].
1307#[cfg(feature = "pkg")]
1308#[unsafe(no_mangle)]
1309pub unsafe extern "C" fn pio_package_validation_json(
1310    pkg: *const PioPackage,
1311    errbuf: *mut c_char,
1312    errlen: usize,
1313) -> *mut c_char {
1314    unsafe {
1315        finish_package_json(
1316            pkg,
1317            errbuf,
1318            errlen,
1319            "panic while reading package validation",
1320            |p| serde_json::to_string(&p.package.validation).map_err(|e| e.to_string()),
1321        )
1322    }
1323}
1324
1325/// Return the package structured diagnostics array as JSON. The returned string
1326/// is owned by the library; free it with [`pio_string_free`].
1327#[cfg(feature = "pkg")]
1328#[unsafe(no_mangle)]
1329pub unsafe extern "C" fn pio_package_diagnostics_json(
1330    pkg: *const PioPackage,
1331    errbuf: *mut c_char,
1332    errlen: usize,
1333) -> *mut c_char {
1334    unsafe {
1335        finish_package_json(
1336            pkg,
1337            errbuf,
1338            errlen,
1339            "panic while reading package diagnostics",
1340            |p| serde_json::to_string(&p.package.diagnostics).map_err(|e| e.to_string()),
1341        )
1342    }
1343}
1344
1345/// Return the package operating point series as JSON, or `null` when absent.
1346/// The returned string is owned by the library; free it with
1347/// [`pio_string_free`].
1348#[cfg(feature = "pkg")]
1349#[unsafe(no_mangle)]
1350pub unsafe extern "C" fn pio_package_operating_points_json(
1351    pkg: *const PioPackage,
1352    errbuf: *mut c_char,
1353    errlen: usize,
1354) -> *mut c_char {
1355    unsafe {
1356        finish_package_json(
1357            pkg,
1358            errbuf,
1359            errlen,
1360            "panic while reading package operating points",
1361            |p| serde_json::to_string(&p.package.operating_points).map_err(|e| e.to_string()),
1362        )
1363    }
1364}
1365
1366/// Materialize one operating point into a new static package.
1367///
1368/// The returned handle owns a package with the selected updates applied and no
1369/// operating point series. Free it with [`pio_package_free`].
1370#[cfg(feature = "pkg")]
1371#[unsafe(no_mangle)]
1372pub unsafe extern "C" fn pio_package_materialize_operating_point(
1373    pkg: *const PioPackage,
1374    index: i64,
1375    errbuf: *mut c_char,
1376    errlen: usize,
1377) -> *mut PioPackage {
1378    unsafe {
1379        finish_package(
1380            errbuf,
1381            errlen,
1382            "panic while materializing package operating point",
1383            || {
1384                let pkg = pkg
1385                    .as_ref()
1386                    .ok_or_else(|| "package handle is NULL".to_string())?;
1387                let index = usize::try_from(index)
1388                    .map_err(|_| "operating point index must be non-negative".to_string())?;
1389                pkg.package
1390                    .materialize_operating_point(index)
1391                    .map_err(|e| e.to_string())
1392            },
1393        )
1394    }
1395}
1396
1397/// Return the multiconductor-to-balanced lowering preflight report as JSON.
1398/// `base_mva` is the three phase system power base used for the balanced
1399/// per-unit projection. Returns `NULL` if the package is not multiconductor.
1400#[cfg(feature = "pkg")]
1401#[unsafe(no_mangle)]
1402pub unsafe extern "C" fn pio_package_multiconductor_to_balanced_preflight_json(
1403    pkg: *const PioPackage,
1404    base_mva: f64,
1405    errbuf: *mut c_char,
1406    errlen: usize,
1407) -> *mut c_char {
1408    unsafe {
1409        let result = catch_unwind(AssertUnwindSafe(|| {
1410            let pkg = pkg
1411                .as_ref()
1412                .ok_or_else(|| "package handle is NULL".to_string())?;
1413            let net = pkg.package.as_multiconductor().ok_or_else(|| {
1414                format!(
1415                    "multiconductor preflight requires a multiconductor package, got {:?}",
1416                    pkg.package.model_kind()
1417                )
1418            })?;
1419            let report = powerio_pkg::check_multiconductor_to_balanced_lowering(
1420                net,
1421                lowering_options(base_mva),
1422            );
1423            serde_json::to_string(&report).map_err(|e| e.to_string())
1424        }));
1425        match result {
1426            Ok(Ok(text)) => finish_cstring(text, errbuf, errlen),
1427            Ok(Err(msg)) => {
1428                copy_to_buf(errbuf, errlen, &msg);
1429                std::ptr::null_mut()
1430            }
1431            Err(_) => {
1432                copy_to_buf(errbuf, errlen, "panic while preflighting package lowering");
1433                std::ptr::null_mut()
1434            }
1435        }
1436    }
1437}
1438
1439/// Lower a multiconductor package to a new balanced package. Call
1440/// [`pio_package_multiconductor_to_balanced_preflight_json`] first when the
1441/// caller needs structured blockers for unsupported inputs. `base_mva` is the
1442/// three phase system power base used for the balanced per-unit projection.
1443#[cfg(feature = "pkg")]
1444#[unsafe(no_mangle)]
1445pub unsafe extern "C" fn pio_package_lower_multiconductor_to_balanced(
1446    pkg: *const PioPackage,
1447    base_mva: f64,
1448    errbuf: *mut c_char,
1449    errlen: usize,
1450) -> *mut PioPackage {
1451    unsafe {
1452        finish_package(errbuf, errlen, "panic while lowering package", || {
1453            let pkg = pkg
1454                .as_ref()
1455                .ok_or_else(|| "package handle is NULL".to_string())?;
1456            pkg.package
1457                .lower_multiconductor_to_balanced(lowering_options(base_mva))
1458                .map_err(|e| e.to_string())
1459        })
1460    }
1461}
1462
1463// ---------------------------------------------------------------------------
1464// Distribution surface (`dist` feature). The multiconductor model behind its own
1465// opaque `PioDistNetwork` handle and the `pio_dist_*` entry points. It is gated
1466// on the `dist` feature / `PIO_DIST` define, exactly like `arrow`/`gridfm`; a
1467// runtime consumer probes it with `pio_has_feature("dist")`, then checks
1468// `pio_dist_abi_version()`. The surface is EXPERIMENTAL while the IEEE BMOPF
1469// schema is a draft: C signature changes bump `PIO_DIST_ABI_VERSION`, and the
1470// JSON payloads (bmopf-json, powerio-dist-json) carry their own meta.version.
1471// ---------------------------------------------------------------------------
1472
1473/// Finish a handle-returning dist entry point: run `f` (the handle payload or an
1474/// error message) under the panic guard and box the payload into an owned handle,
1475/// or write the error (`panic_msg` if `f` panicked) into `errbuf` and return NULL.
1476#[cfg(feature = "dist")]
1477unsafe fn finish_handle<H>(
1478    errbuf: *mut c_char,
1479    errlen: usize,
1480    panic_msg: &str,
1481    f: impl FnOnce() -> Result<H, String>,
1482) -> *mut H {
1483    unsafe {
1484        match catch_unwind(AssertUnwindSafe(f)) {
1485            Ok(Ok(h)) => Box::into_raw(Box::new(h)),
1486            Ok(Err(msg)) => {
1487                copy_to_buf(errbuf, errlen, &msg);
1488                std::ptr::null_mut()
1489            }
1490            Err(_) => {
1491                copy_to_buf(errbuf, errlen, panic_msg);
1492                std::ptr::null_mut()
1493            }
1494        }
1495    }
1496}
1497
1498/// Opaque parsed distribution network handle (the multiconductor wire-coordinate
1499/// model). Distinct from [`PioNetwork`] (the positive-sequence transmission
1500/// model); none of the `pio_n_*`/extractor functions accept it. Only built with
1501/// the `dist` cargo feature.
1502#[cfg(feature = "dist")]
1503pub struct PioDistNetwork {
1504    net: powerio_dist::DistNetwork,
1505}
1506
1507// Same cross-thread read guarantee as `PioNetwork` (see that assertion): pin
1508// `Send + Sync` so a future non-`Sync` field fails the build.
1509#[cfg(feature = "dist")]
1510const _: fn() = || {
1511    fn assert_send_sync<T: Send + Sync>() {}
1512    assert_send_sync::<PioDistNetwork>();
1513};
1514
1515/// Parse a distribution case file into a [`PioDistNetwork`] handle. The format
1516/// comes from `from` if non-NULL (`dss`, `pmd`, or `bmopf`), else from the file
1517/// itself: `.dss` is OpenDSS, and `.json` holding the ENGINEERING `data_model`
1518/// key is PMD JSON, otherwise BMOPF JSON. Returns `NULL` on error and writes the
1519/// message into `errbuf`. Free the handle with [`pio_dist_network_free`].
1520#[cfg(feature = "dist")]
1521#[unsafe(no_mangle)]
1522pub unsafe extern "C" fn pio_dist_parse_file(
1523    path: *const c_char,
1524    from: *const c_char,
1525    errbuf: *mut c_char,
1526    errlen: usize,
1527) -> *mut PioDistNetwork {
1528    unsafe {
1529        finish_handle(errbuf, errlen, "panic while parsing", || {
1530            let path = required_cstr(path, "path")?;
1531            let from = optional_cstr(from, "from")?;
1532            powerio_dist::parse_file(std::path::Path::new(path), from)
1533                .map(|net| PioDistNetwork { net })
1534                .map_err(|e| e.to_string())
1535        })
1536    }
1537}
1538
1539/// Parse in-memory distribution case `text` of the named `format` (`dss`, `pmd`,
1540/// or `bmopf`; required, since there is no path to infer from). An OpenDSS
1541/// `Redirect`/`Compile` in `text` resolves against the current working directory.
1542/// Returns `NULL` on error and writes the message into `errbuf`. Free the handle
1543/// with [`pio_dist_network_free`].
1544#[cfg(feature = "dist")]
1545#[unsafe(no_mangle)]
1546pub unsafe extern "C" fn pio_dist_parse_str(
1547    text: *const c_char,
1548    format: *const c_char,
1549    errbuf: *mut c_char,
1550    errlen: usize,
1551) -> *mut PioDistNetwork {
1552    unsafe {
1553        finish_handle(errbuf, errlen, "panic while parsing", || {
1554            let text = required_cstr(text, "text")?;
1555            let format = required_cstr(format, "format")?;
1556            powerio_dist::parse_str(text, format)
1557                .map(|net| PioDistNetwork { net })
1558                .map_err(|e| e.to_string())
1559        })
1560    }
1561}
1562
1563/// Free a distribution network handle from [`pio_dist_parse_file`] or
1564/// [`pio_dist_parse_str`]. NULL is a no-op; free exactly once.
1565#[cfg(feature = "dist")]
1566#[unsafe(no_mangle)]
1567pub unsafe extern "C" fn pio_dist_network_free(net: *mut PioDistNetwork) {
1568    unsafe {
1569        // Same rationale as `pio_network_free`: the boundary catches panics so a
1570        // Drop on the `serde_json::Value` extras can't unwind across the ABI.
1571        guard((), || {
1572            if !net.is_null() {
1573                drop(Box::from_raw(net));
1574            }
1575        });
1576    }
1577}
1578
1579/// Parse warnings retained on the handle (everything the reader could not
1580/// represent or had to assume), `\n`-joined and written into the caller `warnbuf`
1581/// (truncated to fit, always NUL-terminated). Returns the total byte length of
1582/// the joined message; call with `NULL`/0 to size first, then fill — the same
1583/// idiom as [`pio_warnings`]. Returns 0 for a NULL handle.
1584#[cfg(feature = "dist")]
1585#[unsafe(no_mangle)]
1586pub unsafe extern "C" fn pio_dist_warnings(
1587    net: *const PioDistNetwork,
1588    warnbuf: *mut c_char,
1589    warnlen: usize,
1590) -> usize {
1591    unsafe {
1592        guard(0, || {
1593            let Some(c) = net.as_ref() else { return 0 };
1594            let msg = c.net.warnings.join("\n");
1595            copy_to_buf(warnbuf, warnlen, &msg);
1596            msg.len()
1597        })
1598    }
1599}
1600
1601/// Serialize `net` to distribution format `to` (`dss`, `pmd`, or `bmopf`).
1602/// Writing back to the format the handle was parsed from echoes the source text
1603/// byte for byte; a cross-format write reports every fidelity loss in `warnbuf`
1604/// (`\n`-joined). Returns the text as an owned C string (free with
1605/// [`pio_string_free`]), `NULL` on error.
1606#[cfg(feature = "dist")]
1607#[unsafe(no_mangle)]
1608pub unsafe extern "C" fn pio_dist_to_format(
1609    net: *const PioDistNetwork,
1610    to: *const c_char,
1611    warnbuf: *mut c_char,
1612    warnlen: usize,
1613    errbuf: *mut c_char,
1614    errlen: usize,
1615) -> *mut c_char {
1616    unsafe {
1617        finish_conversion(warnbuf, warnlen, errbuf, errlen, || {
1618            let c = net
1619                .as_ref()
1620                .ok_or_else(|| "network handle is NULL".to_string())?;
1621            let target = dist_target_from_c(to)?;
1622            let conv = c.net.to_format(target);
1623            Ok((conv.text, conv.warnings))
1624        })
1625    }
1626}
1627
1628/// Convert distribution case `path` from optional source format `from` to format
1629/// `to`; see [`pio_dist_parse_file`] for the inference rules. Returns the
1630/// converted text as an owned C string (free with [`pio_string_free`]), `NULL` on
1631/// error. The warnings written `\n`-joined into `warnbuf` carry both the parse
1632/// warnings and the writer's fidelity losses (there is no handle to query them).
1633#[cfg(feature = "dist")]
1634#[unsafe(no_mangle)]
1635pub unsafe extern "C" fn pio_dist_convert_file(
1636    path: *const c_char,
1637    from: *const c_char,
1638    to: *const c_char,
1639    warnbuf: *mut c_char,
1640    warnlen: usize,
1641    errbuf: *mut c_char,
1642    errlen: usize,
1643) -> *mut c_char {
1644    unsafe {
1645        finish_conversion(warnbuf, warnlen, errbuf, errlen, || {
1646            let path = required_cstr(path, "path")?;
1647            let from = optional_cstr(from, "from")?;
1648            let to = dist_target_from_c(to)?;
1649            let conv = powerio_dist::convert_file(std::path::Path::new(path), to, from)
1650                .map_err(|e| e.to_string())?;
1651            Ok((conv.text, conv.warnings))
1652        })
1653    }
1654}
1655
1656/// Convert in-memory distribution case `text` of format `from` to format `to`
1657/// (both required; `dss`, `pmd`, or `bmopf`). The parameter order is input,
1658/// source, target, matching [`pio_dist_convert_file`]. Returns the converted text
1659/// as an owned C string (free with [`pio_string_free`]), `NULL` on error. The
1660/// warnings written `\n`-joined into `warnbuf` carry both the parse warnings and
1661/// the writer's fidelity losses (there is no handle to query them).
1662#[cfg(feature = "dist")]
1663#[unsafe(no_mangle)]
1664pub unsafe extern "C" fn pio_dist_convert_str(
1665    text: *const c_char,
1666    from: *const c_char,
1667    to: *const c_char,
1668    warnbuf: *mut c_char,
1669    warnlen: usize,
1670    errbuf: *mut c_char,
1671    errlen: usize,
1672) -> *mut c_char {
1673    unsafe {
1674        finish_conversion(warnbuf, warnlen, errbuf, errlen, || {
1675            let text = required_cstr(text, "text")?;
1676            let to = dist_target_from_c(to)?;
1677            let from = required_cstr(from, "from")?;
1678            let conv = powerio_dist::convert_str(text, to, from).map_err(|e| e.to_string())?;
1679            Ok((conv.text, conv.warnings))
1680        })
1681    }
1682}
1683
1684#[cfg(feature = "dist")]
1685fn dist_target_from_c(to: *const c_char) -> Result<powerio_dist::DistTargetFormat, String> {
1686    let to = required_cstr(to, "to")?;
1687    // The message comes from the real error so it can't drift from what the
1688    // powerio-dist dispatchers report for the same mistake.
1689    to.parse::<powerio_dist::DistTargetFormat>()
1690        .map_err(|e| e.to_string())
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::*;
1696    use std::ffi::CString;
1697
1698    fn data_path(name: &str) -> CString {
1699        CString::new(
1700            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1701                .join("../tests/data")
1702                .join(name)
1703                .to_str()
1704                .unwrap(),
1705        )
1706        .unwrap()
1707    }
1708
1709    fn close(actual: f64, expected: f64) {
1710        assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
1711    }
1712
1713    fn case9() -> *mut PioNetwork {
1714        let path = data_path("case9.m");
1715        let mut err = [0 as c_char; 256];
1716        let c =
1717            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
1718        assert!(!c.is_null(), "parse returned null");
1719        c
1720    }
1721
1722    fn terminal_projection_case() -> *mut PioNetwork {
1723        use powerio::{Branch, BranchCharging, Bus, BusId, BusType};
1724
1725        let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.1);
1726        branch.charging = Some(BranchCharging::new(0.01, 0.02, 0.03, 0.05));
1727        branch.rate_a = 100.0;
1728        let net = Network::in_memory(
1729            "terminal-projection",
1730            100.0,
1731            vec![
1732                Bus::new(BusId(1), BusType::Ref, 230.0),
1733                Bus::new(BusId(2), BusType::Pq, 230.0),
1734            ],
1735            vec![branch],
1736        );
1737        let text = CString::new(net.to_json().unwrap()).unwrap();
1738        let format = CString::new("powerio-json").unwrap();
1739        let mut err = [0 as c_char; 256];
1740        let c =
1741            unsafe { pio_parse_str(text.as_ptr(), format.as_ptr(), err.as_mut_ptr(), err.len()) };
1742        assert!(
1743            !c.is_null(),
1744            "parse returned null: {}",
1745            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
1746        );
1747        c
1748    }
1749
1750    /// `pio_to_format` with a Rust-side format name, asserting success.
1751    unsafe fn to_format(net: *const PioNetwork, to: &str) -> String {
1752        let to = CString::new(to).unwrap();
1753        let mut warn = [0 as c_char; 512];
1754        let mut err = [0 as c_char; 256];
1755        unsafe {
1756            let s = pio_to_format(
1757                net,
1758                to.as_ptr(),
1759                warn.as_mut_ptr(),
1760                warn.len(),
1761                err.as_mut_ptr(),
1762                err.len(),
1763            );
1764            assert!(
1765                !s.is_null(),
1766                "to_format failed: {}",
1767                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
1768            );
1769            let text = CStr::from_ptr(s).to_str().unwrap().to_owned();
1770            pio_string_free(s);
1771            text
1772        }
1773    }
1774
1775    #[cfg(feature = "pkg")]
1776    unsafe fn package_json_text(pkg: *const PioPackage) -> String {
1777        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
1778        unsafe {
1779            let s = pio_package_to_json(pkg, err.as_mut_ptr(), err.len());
1780            assert!(
1781                !s.is_null(),
1782                "package to json failed: {}",
1783                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
1784            );
1785            let text = CStr::from_ptr(s).to_str().unwrap().to_owned();
1786            pio_string_free(s);
1787            text
1788        }
1789    }
1790
1791    #[cfg(feature = "pkg")]
1792    unsafe fn package_json(pkg: *const PioPackage) -> serde_json::Value {
1793        unsafe { serde_json::from_str(&package_json_text(pkg)).unwrap() }
1794    }
1795
1796    #[cfg(feature = "pkg")]
1797    unsafe fn package_report_json(
1798        f: unsafe extern "C" fn(*const PioPackage, *mut c_char, usize) -> *mut c_char,
1799        pkg: *const PioPackage,
1800    ) -> serde_json::Value {
1801        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
1802        unsafe {
1803            let s = f(pkg, err.as_mut_ptr(), err.len());
1804            assert!(
1805                !s.is_null(),
1806                "package report failed: {}",
1807                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
1808            );
1809            let text = CStr::from_ptr(s).to_str().unwrap().to_owned();
1810            pio_string_free(s);
1811            serde_json::from_str(&text).unwrap()
1812        }
1813    }
1814
1815    #[test]
1816    fn version_surface() {
1817        // The ABI version is the load-time compatibility check; the version
1818        // string is static, NUL-terminated, and non-empty.
1819        assert_eq!(pio_abi_version(), PIO_ABI_VERSION);
1820        assert_eq!(PIO_ABI_VERSION, 4);
1821        let v = unsafe { CStr::from_ptr(pio_version()) }.to_str().unwrap();
1822        assert_eq!(v, env!("CARGO_PKG_VERSION"));
1823        assert!(!v.is_empty());
1824    }
1825
1826    fn strip_c_comments(input: &str) -> String {
1827        let mut out = String::with_capacity(input.len());
1828        let mut chars = input.chars().peekable();
1829        let mut in_block = false;
1830        while let Some(ch) = chars.next() {
1831            if in_block {
1832                if ch == '*' && chars.peek() == Some(&'/') {
1833                    chars.next();
1834                    in_block = false;
1835                } else if ch == '\n' {
1836                    out.push('\n');
1837                }
1838            } else if ch == '/' && chars.peek() == Some(&'*') {
1839                chars.next();
1840                in_block = true;
1841            } else if ch == '/' && chars.peek() == Some(&'/') {
1842                chars.next();
1843                for tail in chars.by_ref() {
1844                    if tail == '\n' {
1845                        out.push('\n');
1846                        break;
1847                    }
1848                }
1849            } else {
1850                out.push(ch);
1851            }
1852        }
1853        out
1854    }
1855
1856    fn collapse_ws(s: &str) -> String {
1857        s.split_whitespace().collect::<Vec<_>>().join(" ")
1858    }
1859
1860    fn c_header_abi_manifest(header: &str) -> Vec<String> {
1861        let clean = strip_c_comments(header);
1862        let mut entries = Vec::new();
1863        let mut prototype = String::new();
1864        for line in clean.lines().map(str::trim).filter(|line| !line.is_empty()) {
1865            if !prototype.is_empty() {
1866                prototype.push(' ');
1867                prototype.push_str(line);
1868                if line.ends_with(';') {
1869                    entries.push(collapse_ws(&prototype));
1870                    prototype.clear();
1871                }
1872                continue;
1873            }
1874
1875            if line.starts_with("#define PIO_") || line.starts_with("typedef struct Pio") {
1876                entries.push(collapse_ws(line));
1877            } else if line.contains("pio_") {
1878                if line.ends_with(';') {
1879                    entries.push(collapse_ws(line));
1880                } else {
1881                    prototype.push_str(line);
1882                }
1883            }
1884        }
1885        assert!(prototype.is_empty(), "unterminated prototype: {prototype}");
1886        entries
1887    }
1888
1889    fn pio_symbol_names_from_manifest(manifest: &[String]) -> Vec<String> {
1890        let mut names = std::collections::BTreeSet::new();
1891        for entry in manifest {
1892            if let Some(start) = entry.find("pio_") {
1893                let tail = &entry[start..];
1894                if let Some(end) = tail.find('(') {
1895                    names.insert(tail[..end].to_string());
1896                }
1897            }
1898        }
1899        names.into_iter().collect()
1900    }
1901
1902    fn source_exported_pio_symbols(source: &str) -> Vec<String> {
1903        let mut names = std::collections::BTreeSet::new();
1904        let mut saw_no_mangle = false;
1905        for line in source.lines() {
1906            let trimmed = line.trim();
1907            if trimmed == "#[unsafe(no_mangle)]" {
1908                saw_no_mangle = true;
1909                continue;
1910            }
1911            if !trimmed.contains("extern \"C\"") {
1912                if !trimmed.is_empty()
1913                    && !trimmed.starts_with("#[")
1914                    && !trimmed.starts_with("//")
1915                    && !trimmed.starts_with("///")
1916                {
1917                    saw_no_mangle = false;
1918                }
1919                continue;
1920            }
1921            if let Some(start) = trimmed.find("fn pio_") {
1922                let tail = &trimmed[start + "fn ".len()..];
1923                let end = tail
1924                    .find('(')
1925                    .unwrap_or_else(|| panic!("unterminated extern fn line: {trimmed}"));
1926                let name = &tail[..end];
1927                assert!(
1928                    saw_no_mangle,
1929                    "{name} is exported in Rust source without #[unsafe(no_mangle)]"
1930                );
1931                names.insert(name.to_string());
1932                saw_no_mangle = false;
1933                continue;
1934            }
1935            if !trimmed.is_empty() && !trimmed.starts_with("#[") && !trimmed.starts_with("//") {
1936                saw_no_mangle = false;
1937            }
1938        }
1939        names.into_iter().collect()
1940    }
1941
1942    #[test]
1943    fn c_header_abi_manifest_is_pinned() {
1944        let actual = c_header_abi_manifest(include_str!("../include/powerio.h"));
1945        let expected = [
1946            "#define PIO_ABI_VERSION 4",
1947            "#define PIO_DIST_ABI_VERSION 1",
1948            "#define PIO_ERRBUF_MIN 256",
1949            "#define PIO_ARROW_TABLE_BUS 0",
1950            "#define PIO_ARROW_TABLE_BRANCH 1",
1951            "#define PIO_ARROW_TABLE_GEN 2",
1952            "#define PIO_ARROW_TABLE_LOAD 3",
1953            "#define PIO_ARROW_TABLE_SHUNT 4",
1954            "#define PIO_ARROW_TABLE_SWITCH 5",
1955            "#define PIO_ARROW_TABLE_SOLVER_BUS 6",
1956            "#define PIO_ARROW_TABLE_SOLVER_LOAD 7",
1957            "#define PIO_ARROW_TABLE_SOLVER_SHUNT 8",
1958            "#define PIO_ARROW_TABLE_SOLVER_BRANCH 9",
1959            "#define PIO_ARROW_TABLE_SOLVER_SWITCH 10",
1960            "#define PIO_ARROW_TABLE_SOLVER_ARC 11",
1961            "#define PIO_ARROW_TABLE_SOLVER_GEN 12",
1962            "#define PIO_ARROW_TABLE_SOLVER_STORAGE 13",
1963            "#define PIO_ARROW_TABLE_SOLVER_HVDC 14",
1964            "typedef struct PioDistNetwork PioDistNetwork;",
1965            "typedef struct PioNetwork PioNetwork;",
1966            "typedef struct PioPackage PioPackage;",
1967            "uint32_t pio_abi_version(void);",
1968            "uint32_t pio_dist_abi_version(void);",
1969            "int32_t pio_has_feature(const char *feature);",
1970            "const char *pio_version(void);",
1971            "PioNetwork *pio_parse_file(const char *path, const char *from, char *errbuf, size_t errlen);",
1972            "PioNetwork *pio_parse_str(const char *text, const char *format, char *errbuf, size_t errlen);",
1973            "PioNetwork *pio_read_dir(const char *dir, const char *from, int64_t scenario, char *errbuf, size_t errlen);",
1974            "ptrdiff_t pio_scenario_ids(const char *dir, const char *from, int64_t *out, size_t cap, char *errbuf, size_t errlen);",
1975            "size_t pio_warnings(const PioNetwork *net, char *warnbuf, size_t warnlen);",
1976            "void pio_network_free(PioNetwork *net);",
1977            "PioNetwork *pio_normalize(const PioNetwork *net, char *errbuf, size_t errlen);",
1978            "size_t pio_n_buses(const PioNetwork *net);",
1979            "size_t pio_n_branches(const PioNetwork *net);",
1980            "size_t pio_n_switches(const PioNetwork *net);",
1981            "size_t pio_n_gens(const PioNetwork *net);",
1982            "double pio_base_mva(const PioNetwork *net);",
1983            "int64_t pio_ref_bus_index(const PioNetwork *net);",
1984            "size_t pio_ref_bus_indices(const PioNetwork *net, int64_t *out, size_t cap);",
1985            "size_t pio_n_islands(const PioNetwork *net);",
1986            "int32_t pio_is_radial(const PioNetwork *net);",
1987            "char *pio_to_format(const PioNetwork *net, const char *to, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen);",
1988            "char *pio_convert_file(const char *path, const char *from, const char *to, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen);",
1989            "char *pio_convert_str(const char *text, const char *from, const char *to, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen);",
1990            "int32_t pio_write_dir(const PioNetwork *net, const char *to, const char *out_dir, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen);",
1991            "void pio_string_free(char *s);",
1992            "size_t pio_bus_ids(const PioNetwork *net, int64_t *out, size_t cap);",
1993            "size_t pio_branches(const PioNetwork *net, int64_t *from, int64_t *to, double *r, double *x, double *b, double *tap, double *shift, uint8_t *in_service, size_t cap);",
1994            "size_t pio_branch_charging(const PioNetwork *net, double *g_fr, double *b_fr, double *g_to, double *b_to, size_t cap);",
1995            "size_t pio_switches(const PioNetwork *net, int64_t *from, int64_t *to, uint8_t *closed, double *thermal_rating, double *current_rating, double *pf, double *qf, double *pt, double *qt, size_t cap);",
1996            "size_t pio_gens(const PioNetwork *net, int64_t *bus, double *pg, double *pmax, double *pmin, uint8_t *in_service, size_t cap);",
1997            "size_t pio_bus_demand(const PioNetwork *net, double *pd, double *qd, size_t cap);",
1998            "size_t pio_bus_shunt(const PioNetwork *net, double *gs, double *bs, size_t cap);",
1999            "int32_t pio_to_arrow(const PioNetwork *net, int32_t table, struct ArrowArray *out_array, struct ArrowSchema *out_schema, char *errbuf, size_t errlen);",
2000            "PioPackage *pio_package_parse_file(const char *path, char *errbuf, size_t errlen);",
2001            "PioPackage *pio_package_parse_str(const char *text, char *errbuf, size_t errlen);",
2002            "void pio_package_free(PioPackage *pkg);",
2003            "char *pio_package_to_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
2004            "PioPackage *pio_package_from_balanced_network(const PioNetwork *net, int32_t include_solver_metadata, char *errbuf, size_t errlen);",
2005            "PioPackage *pio_package_from_multiconductor_network(const PioDistNetwork *net, char *errbuf, size_t errlen);",
2006            "int32_t pio_package_validate(PioPackage *pkg, char *errbuf, size_t errlen);",
2007            "char *pio_package_validation_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
2008            "char *pio_package_diagnostics_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
2009            "char *pio_package_operating_points_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
2010            "PioPackage *pio_package_materialize_operating_point(const PioPackage *pkg, int64_t index, char *errbuf, size_t errlen);",
2011            "char *pio_package_multiconductor_to_balanced_preflight_json(const PioPackage *pkg, double base_mva, char *errbuf, size_t errlen);",
2012            "PioPackage *pio_package_lower_multiconductor_to_balanced(const PioPackage *pkg, double base_mva, char *errbuf, size_t errlen);",
2013            "PioDistNetwork *pio_dist_parse_file(const char *path, const char *from, char *errbuf, size_t errlen);",
2014            "PioDistNetwork *pio_dist_parse_str(const char *text, const char *format, char *errbuf, size_t errlen);",
2015            "void pio_dist_network_free(PioDistNetwork *net);",
2016            "size_t pio_dist_warnings(const PioDistNetwork *net, char *warnbuf, size_t warnlen);",
2017            "char *pio_dist_to_format(const PioDistNetwork *net, const char *to, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen);",
2018            "char *pio_dist_convert_file(const char *path, const char *from, const char *to, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen);",
2019            "char *pio_dist_convert_str(const char *text, const char *from, const char *to, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen);",
2020        ]
2021        .into_iter()
2022        .map(str::to_string)
2023        .collect::<Vec<_>>();
2024        assert_eq!(actual, expected);
2025    }
2026
2027    #[test]
2028    fn c_header_and_rust_exported_symbols_match() {
2029        let manifest = c_header_abi_manifest(include_str!("../include/powerio.h"));
2030        let header_symbols = pio_symbol_names_from_manifest(&manifest);
2031        let rust_symbols = source_exported_pio_symbols(include_str!("lib.rs"));
2032        assert_eq!(rust_symbols, header_symbols);
2033    }
2034
2035    #[test]
2036    fn parse_query_free() {
2037        let c = case9();
2038        unsafe {
2039            assert_eq!(pio_n_buses(c), 9);
2040            assert_eq!(pio_n_branches(c), 9);
2041            assert_eq!(pio_n_gens(c), 3);
2042            assert_eq!(pio_base_mva(c), 100.0);
2043            assert_eq!(pio_n_islands(c), 1);
2044            assert!(pio_ref_bus_index(c) >= 0);
2045            // The MATPOWER reader is total: no warnings, zero bytes.
2046            assert_eq!(pio_warnings(c, std::ptr::null_mut(), 0), 0);
2047            pio_network_free(c);
2048        }
2049    }
2050
2051    #[test]
2052    fn warnings_size_then_fill_exactly() {
2053        // The pandapower fixture carries switches the model ignores. The byte
2054        // length returned by the NULL-out call must size a buffer that then
2055        // receives the full text untruncated.
2056        let path = data_path("pandapower/example.json");
2057        let mut err = [0 as c_char; 256];
2058        let c =
2059            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2060        assert!(
2061            !c.is_null(),
2062            "parse failed: {}",
2063            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
2064        );
2065        unsafe {
2066            let len = pio_warnings(c, std::ptr::null_mut(), 0);
2067            assert!(len > 0, "expected read warnings");
2068            let mut warn = vec![0x7f as c_char; len + 1];
2069            assert_eq!(pio_warnings(c, warn.as_mut_ptr(), warn.len()), len);
2070            let w = CStr::from_ptr(warn.as_ptr()).to_str().unwrap();
2071            assert_eq!(w.len(), len, "buffer sized from the return holds it all");
2072            assert!(w.contains("switch"), "expected a switch warning, got {w:?}");
2073            // A NULL handle reports zero bytes.
2074            assert_eq!(
2075                pio_warnings(std::ptr::null(), warn.as_mut_ptr(), warn.len()),
2076                0
2077            );
2078            pio_network_free(c);
2079        }
2080    }
2081
2082    #[test]
2083    fn matpower_write_is_byte_exact() {
2084        let src = std::fs::read_to_string(
2085            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"),
2086        )
2087        .unwrap();
2088        let c = case9();
2089        unsafe {
2090            assert_eq!(to_format(c, "matpower"), src);
2091
2092            // A NULL handle is an error message, not a crash.
2093            let to = CString::new("matpower").unwrap();
2094            let mut err = [0 as c_char; 256];
2095            let null = pio_to_format(
2096                std::ptr::null(),
2097                to.as_ptr(),
2098                std::ptr::null_mut(),
2099                0,
2100                err.as_mut_ptr(),
2101                err.len(),
2102            );
2103            assert!(null.is_null());
2104            assert_eq!(
2105                CStr::from_ptr(err.as_ptr()).to_str().unwrap(),
2106                "network handle is NULL"
2107            );
2108            pio_network_free(c);
2109        }
2110    }
2111
2112    #[test]
2113    fn extract_branch_tables() {
2114        let c = case9();
2115        unsafe {
2116            // All-NULL is the count query.
2117            let nb = pio_branches(
2118                c,
2119                std::ptr::null_mut(),
2120                std::ptr::null_mut(),
2121                std::ptr::null_mut(),
2122                std::ptr::null_mut(),
2123                std::ptr::null_mut(),
2124                std::ptr::null_mut(),
2125                std::ptr::null_mut(),
2126                std::ptr::null_mut(),
2127                0,
2128            );
2129            assert_eq!(nb, pio_n_branches(c));
2130            let mut from = vec![0i64; nb];
2131            let mut x = vec![0f64; nb];
2132            let total = pio_branches(
2133                c,
2134                from.as_mut_ptr(),
2135                std::ptr::null_mut(),
2136                std::ptr::null_mut(),
2137                x.as_mut_ptr(),
2138                std::ptr::null_mut(),
2139                std::ptr::null_mut(),
2140                std::ptr::null_mut(),
2141                std::ptr::null_mut(),
2142                nb,
2143            );
2144            assert_eq!(total, nb);
2145            // `from` carries the 1-based bus ids (case9 buses are 1..=9), the
2146            // same id space as pio_bus_ids, not dense indices.
2147            assert!(from.iter().all(|&f| f >= 1));
2148            assert!(x.iter().all(|&xx| xx > 0.0));
2149            pio_network_free(c);
2150        }
2151    }
2152
2153    #[test]
2154    fn branch_tables_project_terminal_charging_to_legacy_b() {
2155        let c = terminal_projection_case();
2156        unsafe {
2157            let mut b = [0.0];
2158            let nb = pio_branches(
2159                c,
2160                std::ptr::null_mut(),
2161                std::ptr::null_mut(),
2162                std::ptr::null_mut(),
2163                std::ptr::null_mut(),
2164                b.as_mut_ptr(),
2165                std::ptr::null_mut(),
2166                std::ptr::null_mut(),
2167                std::ptr::null_mut(),
2168                1,
2169            );
2170            assert_eq!(nb, 1);
2171            close(b[0], 0.07);
2172
2173            let mut g_fr = [0.0];
2174            let mut b_fr = [0.0];
2175            let mut g_to = [0.0];
2176            let mut b_to = [0.0];
2177            let nb = pio_branch_charging(
2178                c,
2179                g_fr.as_mut_ptr(),
2180                b_fr.as_mut_ptr(),
2181                g_to.as_mut_ptr(),
2182                b_to.as_mut_ptr(),
2183                1,
2184            );
2185            assert_eq!(nb, 1);
2186            close(g_fr[0], 0.01);
2187            close(b_fr[0], 0.02);
2188            close(g_to[0], 0.03);
2189            close(b_to[0], 0.05);
2190            pio_network_free(c);
2191        }
2192    }
2193
2194    #[test]
2195    fn cap_clamps_the_write_and_returns_the_total() {
2196        let c = case9();
2197        unsafe {
2198            let total = pio_bus_ids(c, std::ptr::null_mut(), 0);
2199            assert_eq!(total, 9);
2200            // A two-slot buffer gets exactly two ids; the total still comes back,
2201            // so a short read is detectable.
2202            let mut ids = [-1i64; 2];
2203            assert_eq!(pio_bus_ids(c, ids.as_mut_ptr(), ids.len()), 9);
2204            assert!(ids.iter().all(|&id| id >= 1));
2205            pio_network_free(c);
2206        }
2207    }
2208
2209    #[test]
2210    fn convert_matpower_echo() {
2211        let path = data_path("case14.m");
2212        let to = CString::new("matpower").unwrap();
2213        let mut warn = [0 as c_char; 256];
2214        let mut err = [0 as c_char; 256];
2215        unsafe {
2216            let s = pio_convert_file(
2217                path.as_ptr(),
2218                std::ptr::null(),
2219                to.as_ptr(),
2220                warn.as_mut_ptr(),
2221                warn.len(),
2222                err.as_mut_ptr(),
2223                err.len(),
2224            );
2225            assert!(!s.is_null());
2226            let got = CStr::from_ptr(s).to_str().unwrap();
2227            let src = std::fs::read_to_string(
2228                std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case14.m"),
2229            )
2230            .unwrap();
2231            assert_eq!(got, src);
2232            pio_string_free(s);
2233        }
2234    }
2235
2236    #[test]
2237    fn convert_file_rejects_target_before_source_order() {
2238        let path = data_path("case14.m");
2239        let old_target = CString::new("powermodels-json").unwrap();
2240        let old_source = CString::new("matpower").unwrap();
2241        let mut warn = [0 as c_char; 512];
2242        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2243        unsafe {
2244            let s = pio_convert_file(
2245                path.as_ptr(),
2246                old_target.as_ptr(),
2247                old_source.as_ptr(),
2248                warn.as_mut_ptr(),
2249                warn.len(),
2250                err.as_mut_ptr(),
2251                err.len(),
2252            );
2253            assert!(
2254                s.is_null(),
2255                "legacy target-before-source order unexpectedly succeeded"
2256            );
2257            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
2258            assert!(!msg.is_empty(), "expected an explanatory parse error");
2259        }
2260    }
2261
2262    #[test]
2263    fn convert_str_round_trips_in_memory() {
2264        // The in-memory converter is parse_str + to_format fused: matpower in,
2265        // powermodels out, no filesystem.
2266        let src = std::fs::read_to_string(
2267            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"),
2268        )
2269        .unwrap();
2270        let text = CString::new(src).unwrap();
2271        let from = CString::new("matpower").unwrap();
2272        let to = CString::new("powermodels-json").unwrap();
2273        let mut warn = [0 as c_char; 512];
2274        let mut err = [0 as c_char; 256];
2275        unsafe {
2276            let s = pio_convert_str(
2277                text.as_ptr(),
2278                from.as_ptr(),
2279                to.as_ptr(),
2280                warn.as_mut_ptr(),
2281                warn.len(),
2282                err.as_mut_ptr(),
2283                err.len(),
2284            );
2285            assert!(
2286                !s.is_null(),
2287                "convert_str failed: {}",
2288                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2289            );
2290            let out = CStr::from_ptr(s).to_str().unwrap();
2291            assert!(out.contains("\"bus\""));
2292            pio_string_free(s);
2293        }
2294    }
2295
2296    #[test]
2297    fn convert_str_rejects_target_before_source_order() {
2298        let src = std::fs::read_to_string(
2299            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"),
2300        )
2301        .unwrap();
2302        let text = CString::new(src).unwrap();
2303        let old_target = CString::new("powermodels-json").unwrap();
2304        let old_source = CString::new("matpower").unwrap();
2305        let mut warn = [0 as c_char; 512];
2306        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2307        unsafe {
2308            let s = pio_convert_str(
2309                text.as_ptr(),
2310                old_target.as_ptr(),
2311                old_source.as_ptr(),
2312                warn.as_mut_ptr(),
2313                warn.len(),
2314                err.as_mut_ptr(),
2315                err.len(),
2316            );
2317            assert!(
2318                s.is_null(),
2319                "legacy target-before-source order unexpectedly succeeded"
2320            );
2321            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
2322            assert!(!msg.is_empty(), "expected an explanatory parse error");
2323        }
2324    }
2325
2326    #[test]
2327    fn to_format_converts_live_handle() {
2328        let c = case9();
2329        unsafe {
2330            let text = to_format(c, "powermodels-json");
2331            assert!(text.contains("\"bus\""));
2332            pio_network_free(c);
2333        }
2334    }
2335
2336    #[test]
2337    fn parse_error_sets_message_not_null_handle() {
2338        let path = CString::new("/no/such/case.m").unwrap();
2339        let mut err = [0 as c_char; 256];
2340        let c =
2341            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2342        assert!(c.is_null());
2343        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
2344        assert!(!msg.is_empty(), "expected an error message");
2345    }
2346
2347    #[test]
2348    fn non_utf8_from_hint_errors_instead_of_falling_back() {
2349        let path = data_path("case9.m");
2350        let to = CString::new("matpower").unwrap();
2351        let bad_from = [0xff_u8, 0];
2352        let mut err = [0 as c_char; 256];
2353        let c = unsafe {
2354            pio_parse_file(
2355                path.as_ptr(),
2356                bad_from.as_ptr().cast::<c_char>(),
2357                err.as_mut_ptr(),
2358                err.len(),
2359            )
2360        };
2361        assert!(c.is_null());
2362        assert_eq!(
2363            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
2364            "from is not UTF-8"
2365        );
2366
2367        let mut warn = [0 as c_char; 256];
2368        err.fill(0);
2369        let s = unsafe {
2370            pio_convert_file(
2371                path.as_ptr(),
2372                bad_from.as_ptr().cast::<c_char>(),
2373                to.as_ptr(),
2374                warn.as_mut_ptr(),
2375                warn.len(),
2376                err.as_mut_ptr(),
2377                err.len(),
2378            )
2379        };
2380        assert!(s.is_null());
2381        assert_eq!(
2382            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
2383            "from is not UTF-8"
2384        );
2385    }
2386
2387    #[test]
2388    fn extract_gen_and_bus_aggregate_tables() {
2389        // case30 carries generators, loads, and shunts: cross-check the table
2390        // extractors against known counts and aggregate signs (a column swap in
2391        // pio_gens/pio_bus_* would otherwise ship silently).
2392        let path = data_path("case30.m");
2393        let mut err = [0 as c_char; 256];
2394        let c =
2395            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2396        assert!(!c.is_null());
2397        unsafe {
2398            let nb = pio_n_buses(c);
2399            let ng = pio_n_gens(c);
2400            assert_eq!(nb, 30);
2401            assert!(ng > 0);
2402
2403            let mut gbus = vec![-9i64; ng];
2404            let mut pmax = vec![0f64; ng];
2405            let total = pio_gens(
2406                c,
2407                gbus.as_mut_ptr(),
2408                std::ptr::null_mut(),
2409                pmax.as_mut_ptr(),
2410                std::ptr::null_mut(),
2411                std::ptr::null_mut(),
2412                ng,
2413            );
2414            assert_eq!(total, ng);
2415            // Generator buses are 1-based ids within the case's id range.
2416            assert!(gbus.iter().all(|&b| (1..=nb as i64).contains(&b)));
2417            assert!(pmax.iter().any(|&p| p > 0.0));
2418
2419            let mut ids = vec![0i64; nb];
2420            assert_eq!(pio_bus_ids(c, ids.as_mut_ptr(), nb), nb);
2421            assert!(ids.iter().all(|&id| id >= 1)); // MATPOWER bus ids are 1-based
2422
2423            let mut pd = vec![0f64; nb];
2424            let mut qd = vec![0f64; nb];
2425            assert_eq!(pio_bus_demand(c, pd.as_mut_ptr(), qd.as_mut_ptr(), nb), nb);
2426            assert!(pd.iter().sum::<f64>() > 0.0, "case30 has active demand");
2427
2428            let mut gs = vec![0f64; nb];
2429            let mut bs = vec![0f64; nb];
2430            assert_eq!(pio_bus_shunt(c, gs.as_mut_ptr(), bs.as_mut_ptr(), nb), nb);
2431            assert!(gs.iter().chain(bs.iter()).all(|x| x.is_finite()));
2432
2433            pio_network_free(c);
2434        }
2435    }
2436
2437    #[test]
2438    fn null_handle_and_null_out_are_safe() {
2439        // Every query tolerates a NULL handle (the documented safe default), and
2440        // a NULL output pointer on a valid case is a count query, not a deref.
2441        unsafe {
2442            let nil: *const PioNetwork = std::ptr::null();
2443            assert_eq!(pio_n_buses(nil), 0);
2444            assert_eq!(pio_n_branches(nil), 0);
2445            assert_eq!(pio_n_gens(nil), 0);
2446            assert_eq!(pio_base_mva(nil), 0.0);
2447            assert_eq!(pio_ref_bus_index(nil), -1);
2448            assert_eq!(pio_ref_bus_indices(nil, std::ptr::null_mut(), 0), 0);
2449            assert_eq!(pio_is_radial(nil), 0);
2450            assert_eq!(pio_n_islands(nil), 0);
2451
2452            // The two FFI constructors reject a NULL input rather than crash.
2453            let mut err = [0 as c_char; 128];
2454            assert!(pio_normalize(nil, err.as_mut_ptr(), err.len()).is_null());
2455            let fmt = CString::new("matpower").unwrap();
2456            assert!(
2457                pio_parse_str(std::ptr::null(), fmt.as_ptr(), err.as_mut_ptr(), err.len())
2458                    .is_null()
2459            );
2460
2461            let c = case9();
2462            assert_eq!(pio_bus_ids(c, std::ptr::null_mut(), 0), 9);
2463            pio_ref_bus_indices(c, std::ptr::null_mut(), 0);
2464            pio_bus_demand(c, std::ptr::null_mut(), std::ptr::null_mut(), 0);
2465            pio_gens(
2466                c,
2467                std::ptr::null_mut(),
2468                std::ptr::null_mut(),
2469                std::ptr::null_mut(),
2470                std::ptr::null_mut(),
2471                std::ptr::null_mut(),
2472                0,
2473            );
2474            pio_network_free(c);
2475        }
2476    }
2477
2478    #[test]
2479    fn normalized_multi_ref_is_legible() {
2480        // A two-slack case (both gen-backed file REF buses) normalizes to a
2481        // handle that keeps both references. `pio_ref_bus_index` can't name a
2482        // single slack (returns -1), but the reference-set extractor does, so a
2483        // C consumer can tell "two slacks, you pick" from "no slack, broken".
2484        let src = "\
2485function mpc = tworef
2486mpc.version = '2';
2487mpc.baseMVA = 100;
2488mpc.bus = [
2489\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2490\t2\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2491\t3\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2492];
2493mpc.gen = [
2494\t1\t0\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
2495\t2\t0\t0\t100\t-100\t1\t100\t1\t300\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
2496];
2497mpc.branch = [
2498\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2499\t2\t3\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2500];
2501";
2502        let text = CString::new(src).unwrap();
2503        let fmt = CString::new("matpower").unwrap();
2504        let mut err = [0 as c_char; 256];
2505        unsafe {
2506            let cs = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
2507            assert!(!cs.is_null(), "parse_str returned null");
2508            let cn = pio_normalize(cs, err.as_mut_ptr(), err.len());
2509            assert!(!cn.is_null(), "normalize returned null");
2510
2511            // Count via NULL out, then fill.
2512            assert_eq!(pio_ref_bus_indices(cn, std::ptr::null_mut(), 0), 2);
2513            // Multiple references: the single-slack query reports -1, by design.
2514            assert_eq!(pio_ref_bus_index(cn), -1);
2515            let mut refs = [-1i64; 2];
2516            assert_eq!(pio_ref_bus_indices(cn, refs.as_mut_ptr(), refs.len()), 2);
2517            assert_eq!(refs, [0, 1]);
2518
2519            pio_network_free(cn);
2520            pio_network_free(cs);
2521        }
2522    }
2523
2524    #[test]
2525    fn normalized_preserves_source_bus_ids() {
2526        let src = "\
2527function mpc = sparseids
2528mpc.version = '2';
2529mpc.baseMVA = 100;
2530mpc.bus = [
2531\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2532\t2\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2533\t3\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2534\t4\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2535\t10\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
2536];
2537mpc.gen = [
2538\t1\t0\t0\t100\t-100\t1\t100\t1\t200\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
2539];
2540mpc.branch = [
2541\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2542\t2\t3\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2543\t3\t4\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2544\t4\t10\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
2545];
2546";
2547        let text = CString::new(src).unwrap();
2548        let fmt = CString::new("matpower").unwrap();
2549        let mut err = [0 as c_char; 256];
2550        unsafe {
2551            let cs = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
2552            assert!(!cs.is_null(), "parse_str returned null");
2553            let cn = pio_normalize(cs, err.as_mut_ptr(), err.len());
2554            assert!(!cn.is_null(), "normalize returned null");
2555
2556            let mut ids = vec![0i64; pio_n_buses(cn)];
2557            pio_bus_ids(cn, ids.as_mut_ptr(), ids.len());
2558            assert_eq!(ids, vec![1, 2, 3, 4, 10]);
2559
2560            let mut from = vec![0i64; pio_n_branches(cn)];
2561            let mut to = vec![0i64; pio_n_branches(cn)];
2562            pio_branches(
2563                cn,
2564                from.as_mut_ptr(),
2565                to.as_mut_ptr(),
2566                std::ptr::null_mut(),
2567                std::ptr::null_mut(),
2568                std::ptr::null_mut(),
2569                std::ptr::null_mut(),
2570                std::ptr::null_mut(),
2571                std::ptr::null_mut(),
2572                from.len(),
2573            );
2574            assert_eq!((from[3], to[3]), (4, 10));
2575
2576            pio_network_free(cn);
2577            pio_network_free(cs);
2578        }
2579    }
2580
2581    #[test]
2582    fn convert_emits_warning_into_buffer() {
2583        // t_case9_dcline carries an HVDC dcline. PSS/E writes it as two-terminal DC
2584        // but defaults the converter detail; that fidelity note must reach the
2585        // caller's warning buffer, not vanish.
2586        let path = data_path("t_case9_dcline.m");
2587        let to = CString::new("psse").unwrap();
2588        let mut warn = [0 as c_char; 512];
2589        let mut err = [0 as c_char; 256];
2590        unsafe {
2591            let s = pio_convert_file(
2592                path.as_ptr(),
2593                std::ptr::null(),
2594                to.as_ptr(),
2595                warn.as_mut_ptr(),
2596                warn.len(),
2597                err.as_mut_ptr(),
2598                err.len(),
2599            );
2600            assert!(!s.is_null());
2601            let w = CStr::from_ptr(warn.as_ptr()).to_str().unwrap();
2602            assert!(
2603                w.contains("converter detail"),
2604                "expected an HVDC converter-detail warning, got {w:?}"
2605            );
2606            pio_string_free(s);
2607        }
2608    }
2609
2610    #[test]
2611    fn snapshot_round_trip_preserves_structure() {
2612        // to_format("powerio-json") -> parse_str("powerio-json") must reproduce
2613        // the structured tables. case30 carries loads, shunts, and gen costs,
2614        // so a dropped field shows up.
2615        let path = data_path("case30.m");
2616        let mut err = [0 as c_char; 256];
2617        let c =
2618            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2619        assert!(!c.is_null());
2620        unsafe {
2621            let json = to_format(c, "powerio-json");
2622            assert!(json.contains("\"buses\""));
2623
2624            let text = CString::new(json).unwrap();
2625            let fmt = CString::new("powerio-json").unwrap();
2626            let back = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
2627            assert!(
2628                !back.is_null(),
2629                "snapshot parse failed: {}",
2630                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2631            );
2632            // The snapshot is lossless: no fidelity warnings on the way back.
2633            assert_eq!(pio_warnings(back, std::ptr::null_mut(), 0), 0);
2634            // Counts and base survive the round trip.
2635            assert_eq!(pio_n_buses(back), pio_n_buses(c));
2636            assert_eq!(pio_n_branches(back), pio_n_branches(c));
2637            assert_eq!(pio_n_gens(back), pio_n_gens(c));
2638            assert_eq!(pio_base_mva(back), pio_base_mva(c));
2639            assert_eq!(pio_ref_bus_index(back), pio_ref_bus_index(c));
2640
2641            // The bare "json" alias means the same snapshot format.
2642            let alias = CString::new("json").unwrap();
2643            let again = pio_parse_str(text.as_ptr(), alias.as_ptr(), err.as_mut_ptr(), err.len());
2644            assert!(!again.is_null());
2645            assert_eq!(pio_n_buses(again), pio_n_buses(c));
2646
2647            pio_network_free(again);
2648            pio_network_free(back);
2649            pio_network_free(c);
2650        }
2651    }
2652
2653    #[test]
2654    fn snapshot_rejects_garbage() {
2655        let bad = CString::new("{ not json").unwrap();
2656        let fmt = CString::new("powerio-json").unwrap();
2657        let mut err = [0 as c_char; 256];
2658        let h = unsafe { pio_parse_str(bad.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len()) };
2659        assert!(h.is_null());
2660        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
2661        assert!(!msg.is_empty(), "expected a JSON parse error message");
2662    }
2663
2664    #[test]
2665    fn error_buffer_truncates_and_nul_terminates() {
2666        // copy_to_buf must truncate an oversized message to fit and keep the
2667        // trailing NUL (the one piece of pointer arithmetic in the file).
2668        let path = CString::new("/no/such/directory/deeply/nested/missing/case.m").unwrap();
2669        let mut err = [0x7f as c_char; 16]; // prefill nonzero so the NUL is visible
2670        let c =
2671            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2672        assert!(c.is_null());
2673        let nul = err
2674            .iter()
2675            .position(|&b| b == 0)
2676            .expect("buffer must be NUL-terminated");
2677        assert!(nul <= 15);
2678    }
2679
2680    #[test]
2681    fn truncation_lands_on_a_utf8_char_boundary() {
2682        // "aé€" is 1+2+3 bytes; a 6-byte buffer fits 5 message bytes, which
2683        // would split '€'. The copy must back up to "aé" instead of emitting a
2684        // dangling partial codepoint.
2685        let mut buf = [0x7f as c_char; 6];
2686        unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") };
2687        let s = unsafe { CStr::from_ptr(buf.as_ptr()) }
2688            .to_str()
2689            .expect("truncated message must be valid UTF-8");
2690        assert_eq!(s, "aé");
2691
2692        // A message that fits is copied whole.
2693        let mut buf = [0x7f as c_char; 8];
2694        unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") };
2695        let s = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap();
2696        assert_eq!(s, "aé€");
2697    }
2698
2699    #[cfg(feature = "pkg")]
2700    #[test]
2701    fn package_feature_is_reported() {
2702        let pkg = CString::new("pkg").unwrap();
2703        let nope = CString::new("nope").unwrap();
2704        unsafe {
2705            assert_eq!(pio_has_feature(pkg.as_ptr()), 1);
2706            assert_eq!(pio_has_feature(nope.as_ptr()), 0);
2707        }
2708    }
2709
2710    #[cfg(feature = "pkg")]
2711    #[test]
2712    fn package_materialize_reports_unknown_identity() {
2713        use powerio_pkg::{
2714            ElementRef, ElementUpdate, NetworkPackage, OperatingPoint, OperatingPointSeries,
2715            TimeAxis,
2716        };
2717
2718        let case = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2719            .join("../tests/data")
2720            .join("case9.m");
2721        let net = powerio::parse_str(&std::fs::read_to_string(case).unwrap(), "matpower")
2722            .unwrap()
2723            .network;
2724        let mut point = OperatingPoint::new(0);
2725        point.updates.push(ElementUpdate::new(
2726            ElementRef::by_source_uid("generators", "no-such-uid"),
2727            std::collections::BTreeMap::from([("pg".to_owned(), serde_json::json!(1.0))]),
2728        ));
2729        let package = NetworkPackage::from_balanced(net).with_operating_points(
2730            OperatingPointSeries::new(TimeAxis::new(1).with_duration_hours(vec![1.0]), vec![point]),
2731        );
2732        let json = CString::new(package.to_json().unwrap()).unwrap();
2733
2734        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2735        unsafe {
2736            let pkg = pio_package_parse_str(json.as_ptr(), err.as_mut_ptr(), err.len());
2737            assert!(
2738                !pkg.is_null(),
2739                "package parse_str failed: {}",
2740                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2741            );
2742            let materialized =
2743                pio_package_materialize_operating_point(pkg, 0, err.as_mut_ptr(), err.len());
2744            assert!(materialized.is_null(), "unknown identity must fail");
2745            let message = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
2746            assert!(
2747                message.contains("unknown identity"),
2748                "unexpected error: {message}"
2749            );
2750            pio_package_free(pkg);
2751        }
2752    }
2753
2754    #[cfg(feature = "pkg")]
2755    #[test]
2756    fn package_parse_free_to_json_and_reports() {
2757        let net = case9();
2758        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2759        unsafe {
2760            let pkg = pio_package_from_balanced_network(net, 1, err.as_mut_ptr(), err.len());
2761            assert!(
2762                !pkg.is_null(),
2763                "package constructor failed: {}",
2764                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2765            );
2766            let v = package_json(pkg);
2767            assert_eq!(v["schema_version"], serde_json::json!("0.1.1"));
2768            assert_eq!(v["model_kind"], serde_json::json!("balanced"));
2769            assert_eq!(v["model"]["kind"], serde_json::json!("balanced"));
2770            assert_eq!(
2771                v["payload_schema"],
2772                serde_json::json!(powerio_pkg::PIO_PAYLOAD_BALANCED_SCHEMA_URL)
2773            );
2774            assert_eq!(
2775                v["payload_schema_version"],
2776                serde_json::json!(powerio_pkg::PIO_PAYLOAD_BALANCED_SCHEMA_VERSION)
2777            );
2778            assert_eq!(
2779                v["derived"]["normalized_solver_tables"]["row_counts"]["buses"],
2780                serde_json::json!(9)
2781            );
2782
2783            let json = CString::new(package_json_text(pkg)).unwrap();
2784            let parsed = pio_package_parse_str(json.as_ptr(), err.as_mut_ptr(), err.len());
2785            assert!(
2786                !parsed.is_null(),
2787                "package parse_str failed: {}",
2788                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2789            );
2790
2791            let tmp = tempfile::tempdir().unwrap();
2792            let path = tmp.path().join("case9.pio.json");
2793            std::fs::write(&path, CStr::from_ptr(json.as_ptr()).to_bytes()).unwrap();
2794            let path = CString::new(path.to_str().unwrap()).unwrap();
2795            let parsed_file = pio_package_parse_file(path.as_ptr(), err.as_mut_ptr(), err.len());
2796            assert!(
2797                !parsed_file.is_null(),
2798                "package parse_file failed: {}",
2799                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2800            );
2801
2802            assert_eq!(
2803                pio_package_validate(parsed_file, err.as_mut_ptr(), err.len()),
2804                0
2805            );
2806            let validation = package_report_json(pio_package_validation_json, parsed_file);
2807            assert_eq!(validation["status"], serde_json::json!("ok"));
2808            assert!(
2809                validation["passes"]
2810                    .as_array()
2811                    .unwrap()
2812                    .iter()
2813                    .any(|p| p["name"] == "balanced.structure")
2814            );
2815            let diagnostics = package_report_json(pio_package_diagnostics_json, parsed_file);
2816            assert!(diagnostics.as_array().unwrap().is_empty());
2817
2818            pio_package_free(parsed_file);
2819            pio_package_free(parsed);
2820            pio_package_free(pkg);
2821            pio_network_free(net);
2822        }
2823    }
2824
2825    #[cfg(feature = "pkg")]
2826    #[test]
2827    fn package_balanced_constructor_omits_solver_metadata_by_default() {
2828        let net = case9();
2829        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2830        unsafe {
2831            let pkg = pio_package_from_balanced_network(net, 0, err.as_mut_ptr(), err.len());
2832            assert!(
2833                !pkg.is_null(),
2834                "package constructor failed: {}",
2835                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2836            );
2837            let v = package_json(pkg);
2838            assert!(v["derived"].get("normalized_solver_tables").is_none());
2839            pio_package_free(pkg);
2840            pio_network_free(net);
2841        }
2842    }
2843
2844    #[cfg(all(feature = "pkg", feature = "dist"))]
2845    mod package_dist {
2846        use super::*;
2847
2848        fn strings(values: &[&str]) -> Vec<String> {
2849            values.iter().map(|v| (*v).to_owned()).collect()
2850        }
2851
2852        fn zero_matrix(n: usize) -> powerio_dist::Mat {
2853            vec![vec![0.0; n]; n]
2854        }
2855
2856        fn diagonal_matrix(n: usize, value: f64) -> powerio_dist::Mat {
2857            let mut matrix = zero_matrix(n);
2858            for (idx, row) in matrix.iter_mut().enumerate() {
2859                row[idx] = value;
2860            }
2861            matrix
2862        }
2863
2864        fn phase_reference(terminals: &[&str], grounded: &[&str]) -> (Vec<f64>, Vec<f64>) {
2865            let phase_angles = [
2866                0.0,
2867                -2.0 * std::f64::consts::PI / 3.0,
2868                2.0 * std::f64::consts::PI / 3.0,
2869            ];
2870            let mut magnitudes = vec![0.0; terminals.len()];
2871            let mut angles = vec![0.0; terminals.len()];
2872            let mut active = 0;
2873            for (idx, terminal) in terminals.iter().enumerate() {
2874                if grounded.contains(terminal) || *terminal == "0" {
2875                    continue;
2876                }
2877                magnitudes[idx] = 240.0;
2878                if active < phase_angles.len() {
2879                    angles[idx] = phase_angles[active];
2880                }
2881                active += 1;
2882            }
2883            (magnitudes, angles)
2884        }
2885
2886        fn preflight_network(terminals: &[&str], grounded: &[&str]) -> powerio_dist::DistNetwork {
2887            use powerio_dist::{DistBus, DistLine, DistLineCode, DistNetwork, VoltageSource};
2888
2889            let n = terminals.len();
2890            let terminal_map = strings(terminals);
2891            let (v_magnitude, v_angle) = phase_reference(terminals, grounded);
2892            let mut net = DistNetwork::default();
2893            for id in ["sourcebus", "loadbus"] {
2894                let mut bus = DistBus::new(id, terminal_map.clone());
2895                bus.grounded = strings(grounded);
2896                net.buses.push(bus);
2897            }
2898            let mut linecode =
2899                DistLineCode::new("lc", diagonal_matrix(n, 0.01), diagonal_matrix(n, 0.10));
2900            linecode.g_from = zero_matrix(n);
2901            linecode.b_from = zero_matrix(n);
2902            linecode.g_to = zero_matrix(n);
2903            linecode.b_to = zero_matrix(n);
2904            net.linecodes.push(linecode);
2905            net.lines.push(DistLine::new(
2906                "l1",
2907                "sourcebus",
2908                "loadbus",
2909                terminal_map.clone(),
2910                terminal_map.clone(),
2911                "lc",
2912                1.0,
2913            ));
2914            net.sources.push(VoltageSource::new(
2915                "source",
2916                "sourcebus",
2917                terminal_map,
2918                v_magnitude,
2919                v_angle,
2920            ));
2921            net
2922        }
2923
2924        #[test]
2925        fn multiconductor_package_preflight_and_lowering() {
2926            let dist = PioDistNetwork {
2927                net: preflight_network(&["1", "2", "3"], &[]),
2928            };
2929            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2930            unsafe {
2931                let pkg =
2932                    pio_package_from_multiconductor_network(&dist, err.as_mut_ptr(), err.len());
2933                assert!(
2934                    !pkg.is_null(),
2935                    "multiconductor package constructor failed: {}",
2936                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2937                );
2938                let v = package_json(pkg);
2939                assert_eq!(v["model_kind"], serde_json::json!("multiconductor"));
2940
2941                let report = pio_package_multiconductor_to_balanced_preflight_json(
2942                    pkg,
2943                    50.0,
2944                    err.as_mut_ptr(),
2945                    err.len(),
2946                );
2947                assert!(
2948                    !report.is_null(),
2949                    "preflight failed: {}",
2950                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2951                );
2952                let report_json: serde_json::Value =
2953                    serde_json::from_str(CStr::from_ptr(report).to_str().unwrap()).unwrap();
2954                assert_eq!(report_json["status"], serde_json::json!("ok"));
2955                assert_eq!(report_json["base_mva"], serde_json::json!(50.0));
2956                pio_string_free(report);
2957
2958                let lowered = pio_package_lower_multiconductor_to_balanced(
2959                    pkg,
2960                    75.0,
2961                    err.as_mut_ptr(),
2962                    err.len(),
2963                );
2964                assert!(
2965                    !lowered.is_null(),
2966                    "lowering failed: {}",
2967                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2968                );
2969                let lowered_json = package_json(lowered);
2970                assert_eq!(lowered_json["model_kind"], serde_json::json!("balanced"));
2971                assert_eq!(
2972                    lowered_json["model"]["balanced_network"]["base_mva"],
2973                    serde_json::json!(75.0)
2974                );
2975                assert_eq!(
2976                    lowered_json["lowering_history"][0]["pass"],
2977                    serde_json::json!("multiconductor-to-balanced")
2978                );
2979
2980                let invalid_report = pio_package_multiconductor_to_balanced_preflight_json(
2981                    pkg,
2982                    0.0,
2983                    err.as_mut_ptr(),
2984                    err.len(),
2985                );
2986                assert!(
2987                    !invalid_report.is_null(),
2988                    "invalid-base preflight failed: {}",
2989                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2990                );
2991                let invalid_report_json: serde_json::Value =
2992                    serde_json::from_str(CStr::from_ptr(invalid_report).to_str().unwrap()).unwrap();
2993                assert_eq!(invalid_report_json["status"], serde_json::json!("error"));
2994                assert!(
2995                    invalid_report_json["diagnostics"]
2996                        .as_array()
2997                        .unwrap()
2998                        .iter()
2999                        .any(|d| d["code"] == "LOWER.MULTI_TO_BALANCED.INVALID_BASE_MVA")
3000                );
3001                pio_string_free(invalid_report);
3002
3003                let invalid_lowered = pio_package_lower_multiconductor_to_balanced(
3004                    pkg,
3005                    0.0,
3006                    err.as_mut_ptr(),
3007                    err.len(),
3008                );
3009                assert!(invalid_lowered.is_null());
3010                let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
3011                assert!(msg.contains("base_mva must be positive"), "got: {msg}");
3012
3013                pio_package_free(lowered);
3014                pio_package_free(pkg);
3015            }
3016        }
3017    }
3018
3019    #[cfg(feature = "arrow")]
3020    #[test]
3021    fn to_arrow_null_out_params_return_error() {
3022        // A NULL out_array/out_schema must be reported (-1), not dereferenced.
3023        let c = case9();
3024        let mut err = [0 as c_char; 256];
3025        let rc = unsafe {
3026            pio_to_arrow(
3027                c,
3028                PIO_ARROW_TABLE_BUS,
3029                std::ptr::null_mut(),
3030                std::ptr::null_mut(),
3031                err.as_mut_ptr(),
3032                err.len(),
3033            )
3034        };
3035        assert_eq!(rc, -1);
3036        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
3037        assert!(!msg.is_empty(), "expected an error message");
3038        unsafe { pio_network_free(c) };
3039    }
3040
3041    #[cfg(feature = "gridfm")]
3042    #[test]
3043    fn read_dir_round_trips_and_enumerates_scenarios() {
3044        use powerio_matrix::{GridfmOptions, write_gridfm_dataset};
3045        // Write a one-scenario dataset, then read it back over the C ABI.
3046        let net = powerio::parse_file(
3047            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case14.m"),
3048            None,
3049        )
3050        .unwrap()
3051        .network;
3052        let tmp = tempfile::tempdir().unwrap();
3053        let out = write_gridfm_dataset(&net, 0, tmp.path(), &GridfmOptions::default()).unwrap();
3054        let dir = CString::new(out.dir.to_str().unwrap()).unwrap();
3055        let from = CString::new("gridfm").unwrap();
3056
3057        let mut err = [0 as c_char; 256];
3058        unsafe {
3059            let h = pio_read_dir(dir.as_ptr(), from.as_ptr(), 0, err.as_mut_ptr(), err.len());
3060            assert!(
3061                !h.is_null(),
3062                "read failed: {}",
3063                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
3064            );
3065            assert_eq!(pio_n_buses(h), 14);
3066            // The lossy read's fidelity warnings attach to the handle, like
3067            // every other constructor's.
3068            assert!(
3069                pio_warnings(h, std::ptr::null_mut(), 0) > 0,
3070                "expected fidelity warnings on the handle"
3071            );
3072            pio_network_free(h);
3073
3074            // Scenario ids: size with a NULL out, then fill. One scenario -> [0].
3075            let count = pio_scenario_ids(
3076                dir.as_ptr(),
3077                from.as_ptr(),
3078                std::ptr::null_mut(),
3079                0,
3080                err.as_mut_ptr(),
3081                err.len(),
3082            );
3083            assert_eq!(count, 1);
3084            let mut ids = [-1i64; 4];
3085            let n = pio_scenario_ids(
3086                dir.as_ptr(),
3087                from.as_ptr(),
3088                ids.as_mut_ptr(),
3089                ids.len(),
3090                err.as_mut_ptr(),
3091                err.len(),
3092            );
3093            assert_eq!(n, 1);
3094            assert_eq!(ids[0], 0);
3095
3096            // An unknown dataset format is a loud error naming the known ones.
3097            let bad = CString::new("pypsa").unwrap();
3098            let h = pio_read_dir(dir.as_ptr(), bad.as_ptr(), 0, err.as_mut_ptr(), err.len());
3099            assert!(h.is_null());
3100            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
3101            assert!(msg.contains("gridfm"), "got: {msg}");
3102
3103            // A missing dataset directory errors (NULL handle + message), not a panic.
3104            let missing = CString::new(tmp.path().join("nope").to_str().unwrap()).unwrap();
3105            let bad = pio_read_dir(
3106                missing.as_ptr(),
3107                from.as_ptr(),
3108                0,
3109                err.as_mut_ptr(),
3110                err.len(),
3111            );
3112            assert!(bad.is_null());
3113            assert!(!CStr::from_ptr(err.as_ptr()).to_str().unwrap().is_empty());
3114        }
3115    }
3116
3117    #[test]
3118    fn write_dir_rejects_text_formats_by_name() {
3119        let c = case9();
3120        let to = CString::new("matpower").unwrap();
3121        let dir = CString::new("/tmp/unused").unwrap();
3122        let mut err = [0 as c_char; 256];
3123        unsafe {
3124            let rc = pio_write_dir(
3125                c,
3126                to.as_ptr(),
3127                dir.as_ptr(),
3128                std::ptr::null_mut(),
3129                0,
3130                err.as_mut_ptr(),
3131                err.len(),
3132            );
3133            assert_eq!(rc, -1);
3134            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
3135            assert!(msg.contains("pypsa"), "got: {msg}");
3136            pio_network_free(c);
3137        }
3138    }
3139
3140    #[cfg(feature = "dist")]
3141    mod dist {
3142        use super::*;
3143        use std::ffi::CStr;
3144
3145        fn fourwire() -> std::path::PathBuf {
3146            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3147                .join("../tests/data/dist/micro/fourwire_linecode.dss")
3148        }
3149
3150        fn fourwire_cstr() -> CString {
3151            CString::new(fourwire().to_str().unwrap()).unwrap()
3152        }
3153
3154        #[test]
3155        fn dist_abi_version_is_separate() {
3156            assert_eq!(pio_abi_version(), PIO_ABI_VERSION);
3157            assert_eq!(PIO_ABI_VERSION, 4);
3158            assert_eq!(pio_dist_abi_version(), PIO_DIST_ABI_VERSION);
3159            assert_eq!(PIO_DIST_ABI_VERSION, 1);
3160            let feature = CString::new("dist").unwrap();
3161            assert_eq!(unsafe { pio_has_feature(feature.as_ptr()) }, 1);
3162        }
3163
3164        #[test]
3165        fn parse_file_convert_and_echo() {
3166            let path = fourwire_cstr();
3167            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3168            let net = unsafe {
3169                pio_dist_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len())
3170            };
3171            assert!(
3172                !net.is_null(),
3173                "{}",
3174                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
3175            );
3176
3177            // Cross format write: schema-shaped BMOPF JSON out.
3178            let to = CString::new("bmopf").unwrap();
3179            let mut warn = [0 as c_char; 4096];
3180            let s = unsafe {
3181                pio_dist_to_format(
3182                    net,
3183                    to.as_ptr(),
3184                    warn.as_mut_ptr(),
3185                    warn.len(),
3186                    err.as_mut_ptr(),
3187                    err.len(),
3188                )
3189            };
3190            assert!(!s.is_null());
3191            let text = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
3192            assert!(text.contains("\"bus\""));
3193            unsafe { pio_string_free(s) };
3194
3195            // Same format write echoes the retained source byte for byte.
3196            let to = CString::new("dss").unwrap();
3197            let s = unsafe {
3198                pio_dist_to_format(
3199                    net,
3200                    to.as_ptr(),
3201                    warn.as_mut_ptr(),
3202                    warn.len(),
3203                    err.as_mut_ptr(),
3204                    err.len(),
3205                )
3206            };
3207            assert!(!s.is_null());
3208            let echoed = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
3209            let source = std::fs::read_to_string(fourwire()).unwrap();
3210            assert_eq!(echoed, source);
3211            assert_eq!(
3212                unsafe { CStr::from_ptr(warn.as_ptr()) }.to_str().unwrap(),
3213                ""
3214            );
3215            unsafe { pio_string_free(s) };
3216
3217            unsafe { pio_dist_network_free(net) };
3218        }
3219
3220        #[test]
3221        fn convert_str_round_trips_through_pmd() {
3222            let source = std::fs::read_to_string(fourwire()).unwrap();
3223            let text = CString::new(source).unwrap();
3224            let from = CString::new("dss").unwrap();
3225            let to = CString::new("pmd").unwrap();
3226            let mut warn = [0 as c_char; 4096];
3227            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3228            let s = unsafe {
3229                pio_dist_convert_str(
3230                    text.as_ptr(),
3231                    from.as_ptr(),
3232                    to.as_ptr(),
3233                    warn.as_mut_ptr(),
3234                    warn.len(),
3235                    err.as_mut_ptr(),
3236                    err.len(),
3237                )
3238            };
3239            assert!(
3240                !s.is_null(),
3241                "{}",
3242                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
3243            );
3244            let pmd = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
3245            assert!(pmd.contains("\"data_model\": \"ENGINEERING\""));
3246            unsafe { pio_string_free(s) };
3247        }
3248
3249        #[test]
3250        fn convert_str_rejects_target_before_source_order() {
3251            let source = std::fs::read_to_string(fourwire()).unwrap();
3252            let text = CString::new(source).unwrap();
3253            let old_target = CString::new("pmd").unwrap();
3254            let old_source = CString::new("dss").unwrap();
3255            let mut warn = [0 as c_char; 4096];
3256            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3257            let s = unsafe {
3258                pio_dist_convert_str(
3259                    text.as_ptr(),
3260                    old_target.as_ptr(),
3261                    old_source.as_ptr(),
3262                    warn.as_mut_ptr(),
3263                    warn.len(),
3264                    err.as_mut_ptr(),
3265                    err.len(),
3266                )
3267            };
3268            assert!(
3269                s.is_null(),
3270                "legacy target-before-source order unexpectedly succeeded"
3271            );
3272            let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
3273            assert!(!msg.is_empty(), "expected an explanatory parse error");
3274        }
3275
3276        #[test]
3277        fn warnings_report_count_and_text() {
3278            // An unknown length unit draws a parse warning; the handle must
3279            // surface it. Warnings use the size-then-fill idiom of `pio_warnings`.
3280            let text = CString::new(
3281                "clear\nnew circuit.w basekv=12.47 bus1=src\nnew line.l1 bus1=src bus2=b2 length=1 units=furlong\n",
3282            )
3283            .unwrap();
3284            let fmt = CString::new("dss").unwrap();
3285            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3286            let net = unsafe {
3287                pio_dist_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len())
3288            };
3289            assert!(!net.is_null());
3290            let mut warn = [0 as c_char; 4096];
3291            let n = unsafe { pio_dist_warnings(net, warn.as_mut_ptr(), warn.len()) };
3292            assert!(n > 0, "expected a nonzero warning length");
3293            let msg = unsafe { CStr::from_ptr(warn.as_ptr()) }.to_str().unwrap();
3294            assert!(
3295                msg.lines().any(|w| w.contains("furlong")),
3296                "expected the units warning, got: {msg}"
3297            );
3298            // NULL handle is a 0-length count, not a crash.
3299            assert_eq!(
3300                unsafe { pio_dist_warnings(std::ptr::null(), warn.as_mut_ptr(), warn.len()) },
3301                0
3302            );
3303            unsafe { pio_dist_network_free(net) };
3304        }
3305
3306        #[test]
3307        fn convert_file_round_trips_through_bmopf() {
3308            let path = fourwire_cstr();
3309            let to = CString::new("bmopf-json").unwrap();
3310            let mut warn = [0 as c_char; 4096];
3311            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3312            let s = unsafe {
3313                pio_dist_convert_file(
3314                    path.as_ptr(),
3315                    std::ptr::null(),
3316                    to.as_ptr(),
3317                    warn.as_mut_ptr(),
3318                    warn.len(),
3319                    err.as_mut_ptr(),
3320                    err.len(),
3321                )
3322            };
3323            assert!(
3324                !s.is_null(),
3325                "{}",
3326                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
3327            );
3328            let text = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
3329            assert!(text.contains("\"bus\""));
3330            unsafe { pio_string_free(s) };
3331        }
3332
3333        #[test]
3334        fn convert_file_rejects_target_before_source_order() {
3335            let path = fourwire_cstr();
3336            let old_target = CString::new("pmd").unwrap();
3337            let old_source = CString::new("dss").unwrap();
3338            let mut warn = [0 as c_char; 4096];
3339            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3340            let s = unsafe {
3341                pio_dist_convert_file(
3342                    path.as_ptr(),
3343                    old_target.as_ptr(),
3344                    old_source.as_ptr(),
3345                    warn.as_mut_ptr(),
3346                    warn.len(),
3347                    err.as_mut_ptr(),
3348                    err.len(),
3349                )
3350            };
3351            assert!(
3352                s.is_null(),
3353                "legacy target-before-source order unexpectedly succeeded"
3354            );
3355            let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
3356            assert!(!msg.is_empty(), "expected an explanatory parse error");
3357        }
3358
3359        #[test]
3360        fn unknown_format_is_an_error_not_a_crash() {
3361            let text = CString::new("clear\n").unwrap();
3362            let fmt = CString::new("matpower").unwrap();
3363            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3364            let net = unsafe {
3365                pio_dist_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len())
3366            };
3367            assert!(net.is_null());
3368            let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
3369            assert!(msg.contains("unknown distribution format"));
3370        }
3371
3372        #[test]
3373        fn has_feature_reports_dist() {
3374            let dist = CString::new("dist").unwrap();
3375            assert_eq!(unsafe { pio_has_feature(dist.as_ptr()) }, 1);
3376            let nope = CString::new("nope").unwrap();
3377            assert_eq!(unsafe { pio_has_feature(nope.as_ptr()) }, 0);
3378        }
3379    }
3380}