Skip to main content

powerio_capi/
lib.rs

1//! C ABI for `powerio`: ABI v5.
2//!
3//! Functions parse, query, and convert networks through opaque handles. Feature
4//! gates add Arrow tables, directory datasets, distribution networks,
5//! `.pio.json` packages, and SCOPF problem instances. Each entry point is
6//! `extern "C"` and writes errors to a caller provided buffer.
7//!
8//! The C API follows a fixed grammar, written out in the header preamble
9//! (`include/powerio.h`, generated by cbindgen, never hand-edit):
10//!
11//! - Verb-led names are operations and the verb fixes the return family:
12//!   `parse`/`read`/`normalize` return a new handle, `write` has a filesystem
13//!   effect, `convert` transcodes without keeping a handle, `free` destroys.
14//! - `to_` marks a representation change of the same network; the target is a
15//!   format string (`pio_to_format`) unless the output type differs
16//!   (`pio_to_arrow` fills Arrow C Data Interface structs).
17//! - Format names never appear in symbols: formats are strings, so a new
18//!   format never changes this ABI. Model JSON uses `pio_to_json` and
19//!   `pio_from_json`.
20//! - Array extractors share the cap/count convention: write up to `cap`
21//!   values, return the total available, `NULL` out is a pure count query.
22//! - Vocabulary: a *bus* is a named connection point (this API is bus
23//!   granular); a *node* is one conductor's point at a bus, reserved for the
24//!   multiconductor API; a *branch* is any two-terminal series element,
25//!   lines and transformers alike.
26
27#![allow(clippy::missing_safety_doc)]
28
29use std::ffi::{CStr, CString, c_char};
30use std::panic::{AssertUnwindSafe, catch_unwind};
31
32use powerio::{BalancedNetwork, IndexCore, IndexedNetwork, NormalizeOptions, TargetFormat};
33
34#[cfg(feature = "arrow")]
35mod arrow_export;
36#[cfg(feature = "arrow")]
37pub use arrow_export::{
38    PIO_ARROW_TABLE_BDOUBLEPRIME, PIO_ARROW_TABLE_BPRIME, PIO_ARROW_TABLE_BRANCH,
39    PIO_ARROW_TABLE_BUS, PIO_ARROW_TABLE_GEN, PIO_ARROW_TABLE_INCIDENCE, PIO_ARROW_TABLE_LOAD,
40    PIO_ARROW_TABLE_MATRIX_BRANCH, PIO_ARROW_TABLE_MATRIX_BUS, PIO_ARROW_TABLE_SHUNT,
41    PIO_ARROW_TABLE_SOLVER_ARC, PIO_ARROW_TABLE_SOLVER_BRANCH, PIO_ARROW_TABLE_SOLVER_BUS,
42    PIO_ARROW_TABLE_SOLVER_GEN, PIO_ARROW_TABLE_SOLVER_HVDC, PIO_ARROW_TABLE_SOLVER_LOAD,
43    PIO_ARROW_TABLE_SOLVER_SHUNT, PIO_ARROW_TABLE_SOLVER_STORAGE, PIO_ARROW_TABLE_SOLVER_SWITCH,
44    PIO_ARROW_TABLE_SWITCH, PIO_ARROW_TABLE_YBUS,
45};
46
47/// Opaque parsed network handle. Carries the parsed [`BalancedNetwork`], the
48/// [`IndexCore`] derived from it once at parse time (so every indexed query
49/// reuses the same bus-id map and per-bus aggregates instead of rebuilding
50/// them), and the reader's fidelity warnings ([`pio_warnings`]).
51pub struct PioNetwork {
52    net: BalancedNetwork,
53    core: IndexCore,
54    warnings: Vec<String>,
55}
56
57// The handle is immutable after construction and the C ABI documents concurrent
58// reads from any number of threads as safe (see the cbindgen header preamble).
59// That guarantee requires `PioNetwork: Send + Sync`; pin it at compile time so
60// a future field that is not `Sync` fails the build instead of weakening it.
61const _: fn() = || {
62    fn assert_send_sync<T: Send + Sync>() {}
63    assert_send_sync::<PioNetwork>();
64};
65
66/// Copy `msg` (truncated to fit) into a caller `char[len]` buffer, always
67/// NUL-terminated. Truncation backs up to a UTF-8 character boundary so a
68/// clipped message is still valid UTF-8. Shared by the error and warning
69/// outputs.
70///
71/// # Safety
72/// A non-NULL `buf` must point to at least `len` writable bytes; the write
73/// stays within `len` (at most `len - 1` message bytes plus the terminating
74/// NUL). NULL or `len == 0` is a no-op.
75unsafe fn copy_to_buf(buf: *mut c_char, len: usize, msg: &str) {
76    unsafe {
77        if buf.is_null() || len == 0 {
78            return;
79        }
80        let bytes = msg.as_bytes();
81        let mut n = bytes.len().min(len - 1);
82        while n > 0 && !msg.is_char_boundary(n) {
83            n -= 1;
84        }
85        std::ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), buf, n);
86        *buf.add(n) = 0;
87    }
88}
89
90unsafe fn cstr<'a>(p: *const c_char) -> Option<&'a str> {
91    unsafe {
92        if p.is_null() {
93            return None;
94        }
95        CStr::from_ptr(p).to_str().ok()
96    }
97}
98
99/// Move `s` into an owned C string, or `None` if it holds an interior NUL byte
100/// (which can't cross as a C string). Callers return the `None` as a real error
101/// rather than silently handing back an empty string.
102fn into_cstring(s: String) -> Option<*mut c_char> {
103    CString::new(s).ok().map(CString::into_raw)
104}
105
106/// Finish a `*mut c_char` entry point: hand back the owned C string, or on an
107/// interior NUL write the error into `errbuf` (NULL/0 to skip) and return NULL.
108/// The shared tail of the string-returning functions.
109fn finish_cstring(s: String, errbuf: *mut c_char, errlen: usize) -> *mut c_char {
110    match into_cstring(s) {
111        Some(p) => p,
112        None => {
113            unsafe { copy_to_buf(errbuf, errlen, "output contained an interior NUL byte") };
114            std::ptr::null_mut()
115        }
116    }
117}
118
119/// Finish a `*mut c_char` entry point: run `f` (the string payload or an error
120/// message) under the panic guard and hand back an owned C string, or write
121/// the error (`panic_msg` if `f` panicked) into `errbuf` and return NULL. The
122/// shared tail of the string-returning functions that carry no warning buffer.
123/// Used by the always built `pio_to_json` as well as the dist and pkg
124/// entry points, so it carries no feature gate.
125unsafe fn finish_string(
126    errbuf: *mut c_char,
127    errlen: usize,
128    panic_msg: &str,
129    f: impl FnOnce() -> Result<String, String>,
130) -> *mut c_char {
131    unsafe {
132        match catch_unwind(AssertUnwindSafe(f)) {
133            Ok(Ok(text)) => finish_cstring(text, errbuf, errlen),
134            Ok(Err(msg)) => {
135                copy_to_buf(errbuf, errlen, &msg);
136                std::ptr::null_mut()
137            }
138            Err(_) => {
139                copy_to_buf(errbuf, errlen, panic_msg);
140                std::ptr::null_mut()
141            }
142        }
143    }
144}
145
146/// Run `f` at the FFI boundary, catching any panic so it can't unwind across
147/// `extern "C"` (UB). Returns `fallback` if `f` panics.
148unsafe fn guard<R>(fallback: R, f: impl FnOnce() -> R) -> R {
149    catch_unwind(AssertUnwindSafe(f)).unwrap_or(fallback)
150}
151
152/// Box a `BalancedNetwork` into an owned network handle, building its [`IndexCore`] once so
153/// every indexed query reuses it. The one constructor for `*mut PioNetwork`.
154fn make_network(net: BalancedNetwork, warnings: Vec<String>) -> *mut PioNetwork {
155    let core = IndexCore::build(&net);
156    Box::into_raw(Box::new(PioNetwork {
157        net,
158        core,
159        warnings,
160    }))
161}
162
163/// Finish a `*mut PioNetwork` entry point: run `f` (producing a `BalancedNetwork` with
164/// its read warnings, or an error message) under the panic guard, hand back an
165/// owned handle, or write the error, `panic_msg` if `f` panicked, into `errbuf`
166/// and return NULL. The shared tail of every handle-returning function
167/// (`pio_parse_file`, `pio_parse_str`, `pio_read_dir`, `pio_normalize`).
168unsafe fn finish_network(
169    errbuf: *mut c_char,
170    errlen: usize,
171    panic_msg: &str,
172    f: impl FnOnce() -> Result<(BalancedNetwork, Vec<String>), String>,
173) -> *mut PioNetwork {
174    unsafe {
175        // make_network runs inside the guard: IndexCore::build is part of the
176        // entry point's work and the header promises panics never cross the
177        // boundary.
178        match catch_unwind(AssertUnwindSafe(|| {
179            f().map(|(net, warnings)| make_network(net, warnings))
180        })) {
181            Ok(Ok(handle)) => handle,
182            Ok(Err(msg)) => {
183                copy_to_buf(errbuf, errlen, &msg);
184                std::ptr::null_mut()
185            }
186            Err(_) => {
187                copy_to_buf(errbuf, errlen, panic_msg);
188                std::ptr::null_mut()
189            }
190        }
191    }
192}
193
194/// ABI version of this C interface. Bump on any breaking change to an existing
195/// `pio_*` signature or documented behavior, including removing a supported
196/// format token from the C API. New additive symbols do not require a bump.
197/// A consumer compares [`pio_abi_version`] against the value it was built
198/// against (the `PIO_ABI_VERSION` macro in `powerio.h`) and refuses a
199/// mismatched library before calling another function.
200///
201/// New data uses new symbols or versioned Arrow, `.pio.json`, or format
202/// specific JSON schemas. Existing signatures do not change without an ABI
203/// version increment.
204///
205/// 5 is the current version. It bumped because every ABI visible JSON document
206/// changed shape: `pio_schema_versions_json` dropped four keys,
207/// `pio_dist_capabilities_json` renamed `schema_version` to `powerio_version`,
208/// and the Arrow metadata key became `powerio.version`. A binding built against
209/// 4 would pass a handshake it should fail and read `null` for keys it mirrors.
210pub const PIO_ABI_VERSION: u32 = 5;
211
212/// Frozen at 1 and no longer meaningful. It existed to absorb distribution
213/// volatility, but that volatility lives in the BMOPF schema, which changes a
214/// reader, a writer and an emitted token, and no C signature. One shared object
215/// carrying two compatibility promises is a thing no mature C library does.
216///
217/// The symbol stays because PowerIO.jl gates thirteen distribution call sites on
218/// resolving it, and removing it would break every distribution call on a
219/// library that fully supports distribution. Foreign schema drift is reported at
220/// runtime by [`pio_build_info`] instead, which can express "BMOPF 0.2 but not
221/// 0.3"; an integer checked once at load cannot.
222#[cfg(feature = "dist")]
223pub const PIO_DIST_ABI_VERSION: u32 = 1;
224
225/// Recommended error buffer size: pass a `char[PIO_ERRBUF_MIN]` to any
226/// `errbuf`/`warnbuf` parameter and a message always fits without truncation.
227pub const PIO_ERRBUF_MIN: usize = 256;
228
229/// The ABI version the library was built with (see [`PIO_ABI_VERSION`]). Lets a
230/// consumer detect a stale or incompatible library at load time. Infallible.
231#[unsafe(no_mangle)]
232pub extern "C" fn pio_abi_version() -> u32 {
233    PIO_ABI_VERSION
234}
235
236/// The ABI version of the optional `pio_dist_*` C API. Only linked when the
237/// `dist` feature is compiled in; probe that first with `pio_has_feature("dist")`
238/// if loading dynamically.
239#[cfg(feature = "dist")]
240#[unsafe(no_mangle)]
241pub extern "C" fn pio_dist_abi_version() -> u32 {
242    PIO_DIST_ABI_VERSION
243}
244
245#[cfg(feature = "dist")]
246fn dist_capabilities_json() -> String {
247    serde_json::json!({
248        "dist": true,
249        // This document is powerio's own, so it states the release that wrote
250        // it. The BMOPF schema it reports below belongs to the task force, and
251        // its version is theirs to set.
252        powerio::version::VERSION_KEY: powerio::VERSION,
253        "bmopf_fixed_taps": true,
254        "bmopf_center_tap_leakage": true,
255        "bmopf_delta_wye_leakage": true,
256        "bmopf_delta_roll": true,
257        "bmopf_voltage_source_merge": true,
258        "bmopf_transformer_diagnostics": true,
259        // Not a fetch location. Use it together with `bmopf_schema_version`.
260        "bmopf_schema_id": powerio_dist::BMOPF_SCHEMA_ID,
261        "bmopf_schema_version": powerio_dist::BMOPF_SCHEMA_VERSION,
262        "typed_capacitors": true,
263        "line_and_generator_ratings": true,
264        "per_sequence_bus_bounds": true,
265        "transformer_extras_relocation": true,
266    })
267    .to_string()
268}
269
270/// Return distribution capability flags as owned JSON. Free the returned string
271/// with [`pio_string_free`]. Only linked when the `dist` feature is compiled in;
272/// runtime loaders can either check `pio_has_feature("dist")` or probe for this
273/// symbol directly. The JSON schema is versioned separately from
274/// [`PIO_DIST_ABI_VERSION`] so new additive flags do not force a C signature
275/// change.
276#[cfg(feature = "dist")]
277#[unsafe(no_mangle)]
278pub extern "C" fn pio_dist_capabilities_json() -> *mut c_char {
279    // Guarded like every other allocating entry point: `serde_json::json!`
280    // expands to `to_value(..).unwrap()`, so the panic-free property would
281    // otherwise rest on the current field types alone.
282    unsafe {
283        guard(std::ptr::null_mut(), || {
284            into_cstring(dist_capabilities_json()).unwrap_or(std::ptr::null_mut())
285        })
286    }
287}
288
289/// Report the schema version of each document format in this library, as
290/// owned JSON. Free the returned string with [`pio_string_free`]. Infallible.
291///
292/// [`PIO_ABI_VERSION`] does not cover these versions. A binding that
293/// mirrors one of them must read it from here and refuse a library it does
294/// not agree with. A key is `null` when the owning feature is not compiled
295/// in. Keys are only added over time. `powerio_version` covers every
296/// document powerio authors; `bmopf_schema` is the foreign schema this build
297/// speaks, whose version belongs to whoever owns it.
298#[unsafe(no_mangle)]
299pub extern "C" fn pio_schema_versions_json() -> *mut c_char {
300    unsafe { guard(std::ptr::null_mut(), schema_versions_json_ptr) }
301}
302
303/// The body of [`pio_schema_versions_json`], called inside the panic guard.
304fn schema_versions_json_ptr() -> *mut c_char {
305    // Every document powerio authors states one version, the release that
306    // wrote it, so this report needs one key for all of them. What stays
307    // separate is the C handshake integer and any foreign schema this build
308    // speaks, whose version belongs to whoever owns that schema.
309    #[cfg(feature = "dist")]
310    let bmopf_schema = Some(powerio_dist::BMOPF_SCHEMA_VERSION);
311    // `None` serializes to `null`: the build cannot speak that format.
312    #[cfg(not(feature = "dist"))]
313    let bmopf_schema: Option<&str> = None;
314
315    let doc = serde_json::json!({
316        powerio::version::VERSION_KEY: powerio::VERSION,
317        "abi": PIO_ABI_VERSION,
318        "bmopf_schema": bmopf_schema,
319    });
320    into_cstring(doc.to_string()).unwrap_or(std::ptr::null_mut())
321}
322
323/// Everything a loader needs to decide what this library can do, as one owned
324/// JSON document. Free the returned string with [`pio_string_free`]. Infallible.
325///
326/// `curl_version_info` is the shape: one call, one report, and new keys arrive
327/// without a new symbol. Keys are only added. A caller with no JSON parser
328/// keeps using [`pio_has_feature`] and [`pio_abi_version`], which say the same
329/// things one answer at a time.
330///
331/// `error_categories` lists the tokens that prefix an `errbuf` message. The ABI
332/// reports errors as text, so a consumer that wants to branch on the kind of
333/// failure matches these rather than parsing prose. They are stable; a new
334/// category may be added.
335#[unsafe(no_mangle)]
336pub extern "C" fn pio_build_info() -> *mut c_char {
337    unsafe { guard(std::ptr::null_mut(), build_info_ptr) }
338}
339
340/// The body of [`pio_build_info`], called inside the panic guard.
341fn build_info_ptr() -> *mut c_char {
342    #[cfg(feature = "dist")]
343    let bmopf_schema = Some(powerio_dist::BMOPF_SCHEMA_VERSION);
344    #[cfg(not(feature = "dist"))]
345    let bmopf_schema: Option<&str> = None;
346
347    let doc = serde_json::json!({
348        powerio::version::VERSION_KEY: powerio::VERSION,
349        "abi": PIO_ABI_VERSION,
350        "features": {
351            "arrow": cfg!(feature = "arrow"),
352            "matrix": cfg!(feature = "matrix"),
353            "gridfm": cfg!(feature = "gridfm"),
354            "dist": cfg!(feature = "dist"),
355            "pkg": cfg!(feature = "pkg"),
356            "prob": cfg!(feature = "prob"),
357        },
358        // Foreign schemas this build speaks. The version belongs to whoever
359        // owns the schema, which is why an integer checked once at load cannot
360        // express it.
361        "foreign_schemas": { "bmopf": bmopf_schema },
362        "error_categories": powerio::ErrorCategory::TOKENS,
363    });
364    into_cstring(doc.to_string()).unwrap_or(std::ptr::null_mut())
365}
366
367/// Whether the matrix Arrow table API is usable in this build. Returns 1
368/// only when both `arrow` and `matrix` are compiled in. Matrix tables use
369/// `pio_to_arrow`. Infallible.
370#[unsafe(no_mangle)]
371pub extern "C" fn pio_matrix_available() -> i32 {
372    i32::from(cfg!(all(feature = "arrow", feature = "matrix")))
373}
374
375/// Whether an optional build feature is compiled in: pass `"arrow"`, `"matrix"`,
376/// `"gridfm"`, `"dist"`, `"pkg"`, or `"prob"`. Returns 1 if present, 0 otherwise (and 0
377/// for a NULL or unknown name). The optional entry points (`pio_to_arrow`, the
378/// matrix Arrow tables, the `pio_read_dir`/gridfm path, the `pio_dist_*` block,
379/// and the `pio_package_*` block) are only linked when their feature is built,
380/// so a consumer that loaded the library at runtime probes for them here
381/// instead of resolving symbols blind. Feature names are strings like format
382/// names, so a new feature never changes this signature. Infallible.
383#[unsafe(no_mangle)]
384pub unsafe extern "C" fn pio_has_feature(feature: *const c_char) -> i32 {
385    unsafe {
386        guard(0, || {
387            let Some(name) = cstr(feature) else { return 0 };
388            let features: &[(&str, bool)] = &[
389                ("arrow", cfg!(feature = "arrow")),
390                ("matrix", cfg!(feature = "matrix")),
391                ("gridfm", cfg!(feature = "gridfm")),
392                ("dist", cfg!(feature = "dist")),
393                ("pkg", cfg!(feature = "pkg")),
394                ("prob", cfg!(feature = "prob")),
395            ];
396            i32::from(features.iter().any(|&(n, on)| n == name && on))
397        })
398    }
399}
400
401/// The crate version string (a semver string), `'static` and NUL-terminated. Do
402/// NOT free it. Informational; pair it with [`pio_abi_version`] for the actual
403/// compatibility check.
404#[unsafe(no_mangle)]
405pub extern "C" fn pio_version() -> *const c_char {
406    // env! is resolved at compile time; the trailing NUL makes it a valid C
407    // string and the 'static lifetime means the pointer is always valid and
408    // never owned by the caller.
409    concat!(env!("CARGO_PKG_VERSION"), "\0")
410        .as_ptr()
411        .cast::<c_char>()
412}
413
414fn target_format_from_c(to: *const c_char) -> Result<TargetFormat, String> {
415    let to = unsafe { cstr(to) }.ok_or_else(|| "to is NULL or not UTF-8".to_string())?;
416    to.parse::<TargetFormat>().map_err(|e| e.to_string())
417}
418
419fn optional_cstr<'a>(p: *const c_char, name: &str) -> Result<Option<&'a str>, String> {
420    if p.is_null() {
421        Ok(None)
422    } else {
423        unsafe { cstr(p) }
424            .map(Some)
425            .ok_or_else(|| format!("{name} is not UTF-8"))
426    }
427}
428
429/// Like [`cstr`] but a NULL or non-UTF-8 pointer is an error naming the
430/// offending parameter. Entry points use this for required strings.
431/// Used by the always-built `pio_classify_str` as well as the dist and pkg
432/// entry points, so it carries no feature gate.
433fn required_cstr<'a>(p: *const c_char, name: &str) -> Result<&'a str, String> {
434    unsafe { cstr(p) }.ok_or_else(|| format!("{name} is NULL or not UTF-8"))
435}
436
437/// Parse `path` (format from extension, or `from` if non-NULL) into a network
438/// handle. `from` accepts the [`pio_parse_str`] format names plus
439/// `pypsa-csv`/`pypsa`, `goc3-json`/`goc3`, `surge-json`/`surge`, and `pwb`;
440/// that includes `pslf`/`epc`, and `.epc` is inferred by extension. A PyPSA CSV folder is a directory, so it can only
441/// enter through this function, with `from = "pypsa-csv"` (or NULL when the
442/// directory holds a `network.csv`). Read fidelity warnings attach to the
443/// handle ([`pio_warnings`]). Returns `NULL` on error and writes the message
444/// into `errbuf`. Free the handle with [`pio_network_free`].
445#[unsafe(no_mangle)]
446pub unsafe extern "C" fn pio_parse_file(
447    path: *const c_char,
448    from: *const c_char,
449    errbuf: *mut c_char,
450    errlen: usize,
451) -> *mut PioNetwork {
452    unsafe {
453        finish_network(errbuf, errlen, "panic while parsing", || {
454            let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?;
455            let from = optional_cstr(from, "from")?;
456            powerio::parse_file(std::path::Path::new(path), from)
457                .map(|p| (p.network, p.warnings))
458                .map_err(|e| e.to_string())
459        })
460    }
461}
462
463/// Parse in-memory case `text` of the named `format` into a network handle.
464/// Unlike [`pio_parse_file`] there is no path to infer from, so `format` is
465/// required: one of `matpower`/`m`, `powermodels`/`pm`, `egret`,
466/// `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`, `powerworld`/`aux`,
467/// `pslf`/`epc`, `goc3-json`/`goc3`, or `surge-json`/`surge`. PyPSA CSV folders are
468/// directories, not text; parse them with [`pio_parse_file`] and
469/// `from = "pypsa-csv"`. Read fidelity warnings attach to the handle
470/// ([`pio_warnings`]). Returns `NULL` on error and writes the message into
471/// `errbuf`. Free the handle with [`pio_network_free`]. Also accepts
472/// `powerio-json`/`json` as aliases for [`pio_from_json`].
473#[unsafe(no_mangle)]
474pub unsafe extern "C" fn pio_parse_str(
475    text: *const c_char,
476    format: *const c_char,
477    errbuf: *mut c_char,
478    errlen: usize,
479) -> *mut PioNetwork {
480    unsafe {
481        finish_network(errbuf, errlen, "panic while parsing", || {
482            let text = cstr(text).ok_or_else(|| "text is NULL or not UTF-8".to_string())?;
483            let format = cstr(format).ok_or_else(|| "format is NULL or not UTF-8".to_string())?;
484            powerio::parse_str(text, format)
485                .map(|p| (p.network, p.warnings))
486                .map_err(|e| e.to_string())
487        })
488    }
489}
490
491/// Parse `len` bytes of in-memory case data of the named `format` into a
492/// network handle. Accepts every [`pio_parse_str`] format name plus `pwb`:
493/// PowerWorld binary has no text form, so before this call the only way to
494/// reach that reader was [`pio_parse_file`], which means staging a temporary
495/// file. `bytes` need not be NUL-terminated and may contain interior NULs;
496/// text formats are decoded as UTF-8 and fail with a message if they are not.
497///
498/// Read fidelity warnings attach to the handle ([`pio_warnings`]). Returns
499/// `NULL` on error and writes the message into `errbuf`. Free the handle with
500/// [`pio_network_free`].
501#[unsafe(no_mangle)]
502pub unsafe extern "C" fn pio_parse_bytes(
503    bytes: *const u8,
504    len: usize,
505    format: *const c_char,
506    errbuf: *mut c_char,
507    errlen: usize,
508) -> *mut PioNetwork {
509    unsafe {
510        finish_network(errbuf, errlen, "panic while parsing", || {
511            let format = required_cstr(format, "format")?;
512            // A zero length read is an empty case, and every reader rejects
513            // one with its own message; a NULL pointer is a caller bug.
514            let slice = if len == 0 {
515                &[][..]
516            } else if bytes.is_null() {
517                return Err("bytes is NULL".to_string());
518            } else {
519                std::slice::from_raw_parts(bytes, len)
520            };
521            powerio::parse_bytes(slice, format)
522                .map(|p| (p.network, p.warnings))
523                .map_err(|e| e.to_string())
524        })
525    }
526}
527
528/// Classify in-memory JSON case `text` by its top level markers, without
529/// parsing the case. Writes one of
530///
531/// - `transmission:<format>` (e.g. `transmission:powermodels-json`)
532/// - `distribution:<format>` (e.g. `distribution:pmd-json`)
533/// - `package` (a `.pio.json` package; read it with the package entry points)
534/// - `ambiguous` (strong markers from both domains; pass an explicit format)
535/// - `unknown` (no recognized marker, or not a JSON object)
536///
537/// into the caller `outbuf` (truncated to fit, always NUL-terminated) and
538/// returns the total byte length of the classification string (the
539/// size-then-fill idiom of [`pio_warnings`]). Returns 0 for NULL `text`. The
540/// markers are the same ones the transmission parser's `.json` sniffing uses,
541/// so a binding can route a bare `.json` before choosing a parser.
542#[unsafe(no_mangle)]
543pub unsafe extern "C" fn pio_classify_str(
544    text: *const c_char,
545    outbuf: *mut c_char,
546    outlen: usize,
547) -> usize {
548    unsafe {
549        // Terminate up front so every early return, including a panic inside
550        // the guard, leaves outbuf a valid empty string.
551        copy_to_buf(outbuf, outlen, "");
552        guard(0, || {
553            let Ok(text) = required_cstr(text, "text") else {
554                return 0;
555            };
556            let label = classify_label(text);
557            copy_to_buf(outbuf, outlen, &label);
558            label.len()
559        })
560    }
561}
562
563fn classify_label(text: &str) -> String {
564    use powerio::format::routing::{self, Detection, Domain, JsonClass};
565    match routing::classify_json_text(text) {
566        JsonClass::Package => "package".to_string(),
567        JsonClass::Case(Detection::Known(format)) => {
568            let domain = match format.domain() {
569                Domain::Transmission => "transmission",
570                Domain::Distribution => "distribution",
571                _ => return "unknown".to_string(),
572            };
573            format!("{domain}:{}", format.name())
574        }
575        JsonClass::Case(Detection::Ambiguous) => "ambiguous".to_string(),
576        JsonClass::Case(Detection::Unknown) => "unknown".to_string(),
577    }
578}
579
580/// Serialize `net` to its model JSON: the same object a `.pio.json` package
581/// carries under `model.balanced_network`, without the surrounding document,
582/// and the same text the `powerio-json` format token writes. This is the
583/// bindings' data transport; the token remains as a compatibility alias for
584/// file based workflows. Returns an owned C string (free with
585/// [`pio_string_free`]), `NULL` on error.
586#[unsafe(no_mangle)]
587pub unsafe extern "C" fn pio_to_json(
588    net: *const PioNetwork,
589    errbuf: *mut c_char,
590    errlen: usize,
591) -> *mut c_char {
592    unsafe {
593        finish_string(errbuf, errlen, "panic while serializing model JSON", || {
594            let net = net
595                .as_ref()
596                .ok_or_else(|| "network handle is NULL".to_string())?;
597            net.net.to_json().map_err(|e| e.to_string())
598        })
599    }
600}
601
602/// Parse model JSON produced by [`pio_to_json`] (or lifted from a `.pio.json`
603/// document's `model.balanced_network`) back into an owned handle, the
604/// inverse of [`pio_to_json`] and the function form of parsing under the
605/// `powerio-json` token. Returns `NULL` on error. Free with
606/// [`pio_network_free`].
607#[unsafe(no_mangle)]
608pub unsafe extern "C" fn pio_from_json(
609    text: *const c_char,
610    errbuf: *mut c_char,
611    errlen: usize,
612) -> *mut PioNetwork {
613    unsafe {
614        finish_network(errbuf, errlen, "panic while parsing model JSON", || {
615            let text = required_cstr(text, "text")?;
616            BalancedNetwork::from_json(text)
617                .map(|net| (net, Vec::new()))
618                .map_err(|e| e.to_string())
619        })
620    }
621}
622
623/// Read one scenario of a dataset directory in the named `from` format into a
624/// network handle. `gridfm` (the
625/// gridfm-datakit Parquet layout; `dir` resolves leniently: the `raw/` leaf,
626/// a `<case>/` directory with a `raw/` child, or a parent holding exactly one
627/// such case) is the currently supported dataset format. `scenario` selects within a
628/// multi-scenario dataset ([`pio_scenario_ids`] enumerates them); formats
629/// without scenarios take `0`. Read fidelity warnings attach to the handle
630/// ([`pio_warnings`]). Returns `NULL` on error and writes the message into
631/// `errbuf`. Free the handle with [`pio_network_free`]. Built
632/// `--features gridfm`.
633#[cfg(feature = "gridfm")]
634#[unsafe(no_mangle)]
635pub unsafe extern "C" fn pio_read_dir(
636    dir: *const c_char,
637    from: *const c_char,
638    scenario: i64,
639    errbuf: *mut c_char,
640    errlen: usize,
641) -> *mut PioNetwork {
642    unsafe {
643        finish_network(errbuf, errlen, "panic while reading dataset", || {
644            let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?;
645            let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?;
646            powerio_matrix::read_dataset_dir(std::path::Path::new(dir), from, scenario)
647                .map(|read| (read.network, read.warnings))
648                .map_err(|e| e.to_string())
649        })
650    }
651}
652
653/// Write the distinct scenario ids (ascending) of the dataset directory `dir`
654/// in the named `from` format into `out`, up to `cap` entries, and return the
655/// total count: the cap/count convention of [`pio_bus_ids`]. `gridfm` is the
656/// currently supported dataset format. Returns `-1` on error and writes the message into
657/// `errbuf` (unlike the handle extractors, this reads the filesystem and can
658/// fail). Built `--features gridfm`.
659#[cfg(feature = "gridfm")]
660#[unsafe(no_mangle)]
661pub unsafe extern "C" fn pio_scenario_ids(
662    dir: *const c_char,
663    from: *const c_char,
664    out: *mut i64,
665    cap: usize,
666    errbuf: *mut c_char,
667    errlen: usize,
668) -> isize {
669    unsafe {
670        let r = catch_unwind(AssertUnwindSafe(|| {
671            let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?;
672            let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?;
673            powerio_matrix::dataset_scenario_ids(std::path::Path::new(dir), from)
674                .map_err(|e| e.to_string())
675        }));
676        match r {
677            Ok(Ok(ids)) => {
678                let Ok(total) = isize::try_from(ids.len()) else {
679                    copy_to_buf(errbuf, errlen, "scenario count exceeds isize");
680                    return -1;
681                };
682                fill(out, cap, ids.iter().copied());
683                total
684            }
685            Ok(Err(msg)) => {
686                copy_to_buf(errbuf, errlen, &msg);
687                -1
688            }
689            Err(_) => {
690                copy_to_buf(errbuf, errlen, "panic while reading scenario ids");
691                -1
692            }
693        }
694    }
695}
696
697/// The fidelity warnings attached to the handle at construction (by whichever
698/// of [`pio_parse_file`], [`pio_parse_str`], `pio_read_dir`, or
699/// [`pio_normalize`] built it), `\n`-joined into `warnbuf` (truncated to fit
700/// on a UTF-8 boundary; NULL/0 to skip). Returns the byte length of the full
701/// joined text, excluding the NUL; call once with `(NULL, 0)` to size, then
702/// pass a `char[len + 1]`. `0` means no warnings (or a NULL handle); readers
703/// that are total attach none.
704#[unsafe(no_mangle)]
705pub unsafe extern "C" fn pio_warnings(
706    net: *const PioNetwork,
707    warnbuf: *mut c_char,
708    warnlen: usize,
709) -> usize {
710    unsafe {
711        guard(0, || {
712            let Some(c) = network_ref(net) else { return 0 };
713            let msg = c.warnings.join("\n");
714            copy_to_buf(warnbuf, warnlen, &msg);
715            msg.len()
716        })
717    }
718}
719
720/// Free a network handle from [`pio_parse_file`], [`pio_parse_str`],
721/// `pio_read_dir`, [`pio_normalize`], or [`pio_normalize_with_options`].
722#[unsafe(no_mangle)]
723pub unsafe extern "C" fn pio_network_free(net: *mut PioNetwork) {
724    unsafe {
725        // Under the same panic guard as every other entry point: the drop is
726        // pure deallocation today, but "catches panics" must not depend on that
727        // staying true.
728        guard((), || {
729            if !net.is_null() {
730                drop(Box::from_raw(net));
731            }
732        });
733    }
734}
735
736unsafe fn network_ref<'a>(net: *const PioNetwork) -> Option<&'a PioNetwork> {
737    unsafe { net.as_ref() }
738}
739
740/// View `net` through its cached [`IndexCore`] with no per-call rebuild.
741unsafe fn view<'a>(net: *const PioNetwork) -> Option<IndexedNetwork<'a>> {
742    unsafe {
743        net.as_ref()
744            .map(|c| IndexedNetwork::with_core(&c.net, &c.core))
745    }
746}
747
748/// Normalize `net` into a NEW network handle: per unit, radians, out of service
749/// filtered, source bus ids preserved, bus types canonicalized (see
750/// `BalancedNetwork::to_normalized`). A value transform, not a serialization, hence
751/// the verb, while the `to_*` family re-encodes unchanged data. The result is
752/// independent of `net`; free both with [`pio_network_free`]. Every extractor
753/// and serializer works on it unchanged (the handle is per unit, not MW).
754/// Returns `NULL` on error (no reference bus can be chosen, or a non-positive
755/// base MVA) and writes the message into `errbuf`.
756#[unsafe(no_mangle)]
757pub unsafe extern "C" fn pio_normalize(
758    net: *const PioNetwork,
759    errbuf: *mut c_char,
760    errlen: usize,
761) -> *mut PioNetwork {
762    unsafe {
763        finish_network(errbuf, errlen, "panic while normalizing", || {
764            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
765            c.net
766                .to_normalized()
767                .map(|n| (n, c.warnings.clone()))
768                .map_err(|e| e.to_string())
769        })
770    }
771}
772
773/// Normalize `net` into a NEW network handle, with opt in solver preparation
774/// repairs.
775/// `clamp_angle_bounds != 0` applies the same branch angle difference bound
776/// repair as PowerModels (`angmin <= -pi/2`, `angmax >= pi/2`, and zero/zero
777/// bounds replaced by `[-angle_bound_pad, angle_bound_pad]`). A repair that
778/// would invert the interval widens to that same window. The default pad is
779/// 1.0472 radians.
780/// Existing read warnings and repair warnings are attached to the returned
781/// handle and can be read with [`pio_warnings`].
782#[unsafe(no_mangle)]
783pub unsafe extern "C" fn pio_normalize_with_options(
784    net: *const PioNetwork,
785    clamp_angle_bounds: i32,
786    angle_bound_pad: f64,
787    errbuf: *mut c_char,
788    errlen: usize,
789) -> *mut PioNetwork {
790    unsafe {
791        finish_network(
792            errbuf,
793            errlen,
794            "panic while normalizing with options",
795            || {
796                let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
797                let options = NormalizeOptions {
798                    clamp_angle_bounds: clamp_angle_bounds != 0,
799                    angle_bound_pad,
800                };
801                let out = c
802                    .net
803                    .to_normalized_with_options(&options)
804                    .map_err(|e| e.to_string())?;
805                let mut warnings = c.warnings.clone();
806                warnings.extend(out.warnings);
807                Ok((out.network, warnings))
808            },
809        )
810    }
811}
812
813#[unsafe(no_mangle)]
814pub unsafe extern "C" fn pio_n_buses(net: *const PioNetwork) -> usize {
815    // The star-lowered space, which is what every other per-bus extractor
816    // reports. Through v4 this counted the unexpanded table while
817    // pio_bus_demand, pio_bus_shunt and pio_n_islands counted the expansion, so
818    // a caller sizing a per-bus buffer from here read short by one entry per
819    // in-service 3-winding transformer.
820    unsafe { guard(0, || view(net).map_or(0, |v| v.n())) }
821}
822
823#[unsafe(no_mangle)]
824pub unsafe extern "C" fn pio_n_branches(net: *const PioNetwork) -> usize {
825    // The star-lowered space, matching pio_n_buses: a 3-winding transformer
826    // becomes a star bus plus three branches, and a caller building a matrix
827    // from these tables needs both halves of that or the star bus is an
828    // isolated row.
829    unsafe { guard(0, || view(net).map_or(0, |v| v.branches().len())) }
830}
831
832#[unsafe(no_mangle)]
833pub unsafe extern "C" fn pio_n_switches(net: *const PioNetwork) -> usize {
834    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.switches.len())) }
835}
836
837#[unsafe(no_mangle)]
838pub unsafe extern "C" fn pio_n_gens(net: *const PioNetwork) -> usize {
839    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.generators.len())) }
840}
841
842#[unsafe(no_mangle)]
843pub unsafe extern "C" fn pio_base_mva(net: *const PioNetwork) -> f64 {
844    unsafe { guard(0.0, || network_ref(net).map_or(0.0, |c| c.net.base_mva)) }
845}
846
847/// Case name. Writes UTF-8 bytes into `out`, up to `cap`, NUL-terminates when
848/// possible, and returns the byte length needed excluding the NUL. `NULL` or
849/// `cap == 0` is a size query.
850#[unsafe(no_mangle)]
851pub unsafe extern "C" fn pio_network_name(
852    net: *const PioNetwork,
853    out: *mut c_char,
854    cap: usize,
855) -> usize {
856    unsafe {
857        guard(0, || {
858            let Some(c) = network_ref(net) else { return 0 };
859            copy_to_buf(out, cap, &c.net.name);
860            c.net.name.len()
861        })
862    }
863}
864
865/// Source format enum spelling used by the JSON snapshot, for example
866/// `Matpower`, `PowerModelsJson`, or `Normalized`. Uses the same cap/count
867/// string convention as [`pio_network_name`].
868#[unsafe(no_mangle)]
869pub unsafe extern "C" fn pio_source_format(
870    net: *const PioNetwork,
871    out: *mut c_char,
872    cap: usize,
873) -> usize {
874    unsafe {
875        guard(0, || {
876            let Some(c) = network_ref(net) else { return 0 };
877            let name = format!("{:?}", c.net.source_format);
878            copy_to_buf(out, cap, &name);
879            name.len()
880        })
881    }
882}
883
884/// Serialize a compact balanced network summary as JSON for display and scalar
885/// queries without serializing [`pio_to_json`]'s full payload.
886///
887/// `counts` is the case file's own inventory, so it counts a 3-winding
888/// transformer once under `transformers_3w` rather than as the star bus and
889/// three branches it lowers to. `topology.n_buses` and `topology.n_branches`
890/// are that lowered space, the one [`pio_n_buses`] and [`pio_branches`]
891/// report and the one the rest of `topology` is computed over. The two differ
892/// only for a case with an in-service 3-winding transformer.
893#[unsafe(no_mangle)]
894pub unsafe extern "C" fn pio_summary_json(
895    net: *const PioNetwork,
896    errbuf: *mut c_char,
897    errlen: usize,
898) -> *mut c_char {
899    unsafe {
900        finish_string(
901            errbuf,
902            errlen,
903            "panic while serializing summary JSON",
904            || {
905                let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
906                let v = IndexedNetwork::with_core(&c.net, &c.core);
907                let reference_bus_indices = v.reference_bus_indices();
908                let reference_bus_ids: Vec<usize> = reference_bus_indices
909                    .iter()
910                    .map(|&idx| v.bus_id(idx).0)
911                    .collect();
912                let summary = serde_json::json!({
913                    powerio::version::VERSION_KEY: powerio::VERSION,
914                    "name": c.net.name,
915                    "source_format": format!("{:?}", c.net.source_format),
916                    "base_mva": c.net.base_mva,
917                    "base_frequency": c.net.base_frequency,
918                    "counts": {
919                        "buses": c.net.buses.len(),
920                        "loads": c.net.loads.len(),
921                        "shunts": c.net.shunts.len(),
922                        "branches": c.net.branches.len(),
923                        "switches": c.net.switches.len(),
924                        "generators": c.net.generators.len(),
925                        "storage": c.net.storage.len(),
926                        "hvdc": c.net.hvdc.len(),
927                        "transformers_3w": c.net.transformers_3w.len(),
928                        "areas": c.net.areas.len(),
929                        "warnings": c.warnings.len(),
930                    },
931                    "topology": {
932                        "n_buses": v.n(),
933                        "n_branches": v.branches().len(),
934                        "reference_bus_ids": reference_bus_ids,
935                        "reference_bus_indices": reference_bus_indices,
936                        "n_components": v.n_connected_components(),
937                        "is_radial": v.is_radial(),
938                    },
939                });
940                serde_json::to_string(&summary).map_err(|e| e.to_string())
941            },
942        )
943    }
944}
945
946/// Dense `[0, n)` index of the single reference (slack) bus, or `-1` if not
947/// exactly one. An INDEX into the [`pio_bus_ids`] ordering, not a bus id;
948/// `pio_branches` from/to carry ids, so the unit is in the name. A network may
949/// carry several references (one per island, or a normalized case that kept
950/// the file's multiple `REF` buses); [`pio_ref_bus_indices`] reads them all,
951/// and its count (`NULL` out) tells zero from many.
952#[unsafe(no_mangle)]
953pub unsafe extern "C" fn pio_ref_bus_index(net: *const PioNetwork) -> i64 {
954    unsafe {
955        guard(-1, || match view(net) {
956            Some(v) => v
957                .reference_bus_index()
958                .map_or(-1, |i| i64::try_from(i).unwrap_or(-1)),
959            None => -1,
960        })
961    }
962}
963
964/// Write the dense `[0, n)` indices of the reference (slack) buses, ascending,
965/// into `out`, up to `cap` entries, and return the total count: the cap/count
966/// convention of [`pio_bus_ids`]. `0` means none; `> 1` means one reference
967/// per island or several fixed references in one island (a normalized case
968/// always reports `>= 1`).
969#[unsafe(no_mangle)]
970pub unsafe extern "C" fn pio_ref_bus_indices(
971    net: *const PioNetwork,
972    out: *mut i64,
973    cap: usize,
974) -> usize {
975    unsafe {
976        guard(0, || {
977            view(net).map_or(0, |v| {
978                fill(
979                    out,
980                    cap,
981                    v.reference_bus_indices()
982                        .into_iter()
983                        .map(|i| i64::try_from(i).unwrap_or(-1)),
984                )
985            })
986        })
987    }
988}
989
990/// Number of islands: connected components of the in-service topology.
991#[unsafe(no_mangle)]
992pub unsafe extern "C" fn pio_n_islands(net: *const PioNetwork) -> usize {
993    unsafe { guard(0, || view(net).map_or(0, |v| v.n_connected_components())) }
994}
995
996/// `1` if the in-service topology is radial (every island a tree), else `0`.
997#[unsafe(no_mangle)]
998pub unsafe extern "C" fn pio_is_radial(net: *const PioNetwork) -> i32 {
999    unsafe { guard(0, || view(net).map_or(0, |v| i32::from(v.is_radial()))) }
1000}
1001
1002/// Serialize `net` to the named format `to`: the one text serializer; every
1003/// format is named by a string. Accepts the [`pio_parse_str`] names:
1004/// `matpower` is a byte-exact echo when the handle was parsed from MATPOWER.
1005/// Also accepts `powerio-json` as an alias for
1006/// [`pio_to_json`]. Model JSON cannot represent a non-finite `f64` (`Inf`/`NaN`):
1007/// it writes `null`, records the field in `out_warnings`, and fails validation when
1008/// read back.
1009///
1010/// Returns the text as an owned C string (free with [`pio_string_free`]),
1011/// `NULL` on error (message into `errbuf`). Fidelity warnings, if any, are
1012/// published through `out_warnings` as one owned C string (free it with
1013/// [`pio_string_free`]), or NULL when there are none; a returned string has no
1014/// handle to attach them to. Pass NULL to discard them.
1015#[unsafe(no_mangle)]
1016pub unsafe extern "C" fn pio_to_format(
1017    net: *const PioNetwork,
1018    to: *const c_char,
1019    out_warnings: *mut *mut c_char,
1020    errbuf: *mut c_char,
1021    errlen: usize,
1022) -> *mut c_char {
1023    unsafe {
1024        finish_conversion(out_warnings, errbuf, errlen, || {
1025            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
1026            let target = target_format_from_c(to)?;
1027            let conv = c.net.to_format(target).map_err(|e| e.to_string())?;
1028            Ok((conv.text, conv.warnings))
1029        })
1030    }
1031}
1032
1033/// Write `warnings` to `out_warnings` as one owned, `\n`-joined C string, or
1034/// NULL when there are none. NULL `out_warnings` discards them.
1035///
1036/// A caller buffer cannot work here. Warnings are unbounded in a way an error
1037/// message is not: one per lossy element, so a large case produces more than
1038/// any fixed size a caller can guess, and guessing is what PowerIO.jl was doing
1039/// with a 64 KiB buffer. The errbuf idiom stays for errors, which are one
1040/// message.
1041unsafe fn set_out_warnings(out_warnings: *mut *mut c_char, warnings: &[String]) {
1042    unsafe {
1043        if out_warnings.is_null() {
1044            return;
1045        }
1046        *out_warnings = if warnings.is_empty() {
1047            std::ptr::null_mut()
1048        } else {
1049            // An interior NUL cannot reach here: warnings are library-authored
1050            // text. If one ever did, reporting no warnings beats truncating at
1051            // the NUL and reporting a prefix as the whole set.
1052            into_cstring(warnings.join("\n")).unwrap_or(std::ptr::null_mut())
1053        };
1054    }
1055}
1056
1057/// Finish a text-conversion entry point: run `f` (producing the converted text
1058/// with its fidelity warnings, or an error message) under the panic guard,
1059/// publish the warnings through `out_warnings`, and hand back the owned C
1060/// string, or write the error and return NULL. The shared tail of
1061/// [`pio_to_format`], [`pio_convert_file`], and [`pio_convert_str`], mirroring
1062/// [`finish_network`].
1063unsafe fn finish_conversion(
1064    out_warnings: *mut *mut c_char,
1065    errbuf: *mut c_char,
1066    errlen: usize,
1067    f: impl FnOnce() -> Result<(String, Vec<String>), String>,
1068) -> *mut c_char {
1069    unsafe {
1070        // Set before running f: a caller reads it on every return path, and a
1071        // stale value from an earlier call must not be mistaken for this one's.
1072        set_out_warnings(out_warnings, &[]);
1073        match catch_unwind(AssertUnwindSafe(f)) {
1074            Ok(Ok((text, warnings))) => {
1075                set_out_warnings(out_warnings, &warnings);
1076                finish_cstring(text, errbuf, errlen)
1077            }
1078            Ok(Err(msg)) => {
1079                copy_to_buf(errbuf, errlen, &msg);
1080                std::ptr::null_mut()
1081            }
1082            Err(_) => {
1083                copy_to_buf(errbuf, errlen, "panic while converting");
1084                std::ptr::null_mut()
1085            }
1086        }
1087    }
1088}
1089
1090/// Convert the case file at `path` from format `from` (NULL to infer from the
1091/// path, as [`pio_parse_file`]) to format `to`, without keeping a handle.
1092/// Returns the converted text as an owned C string (free with
1093/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, read side first,
1094/// are published through `out_warnings` as one owned C string (free it with
1095/// [`pio_string_free`]), NULL when there are none. Pass NULL to discard them.
1096#[unsafe(no_mangle)]
1097pub unsafe extern "C" fn pio_convert_file(
1098    path: *const c_char,
1099    from: *const c_char,
1100    to: *const c_char,
1101    out_warnings: *mut *mut c_char,
1102    errbuf: *mut c_char,
1103    errlen: usize,
1104) -> *mut c_char {
1105    unsafe {
1106        finish_conversion(out_warnings, errbuf, errlen, || {
1107            let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?;
1108            let from = optional_cstr(from, "from")?;
1109            let target = target_format_from_c(to)?;
1110            let conv = powerio::convert_file(std::path::Path::new(path), target, from)
1111                .map_err(|e| e.to_string())?;
1112            Ok((conv.text, conv.warnings))
1113        })
1114    }
1115}
1116
1117/// Convert in-memory case `text` from format `from` (required; there is no
1118/// path to infer from) to format `to` without keeping a handle. Returns the
1119/// converted text as an owned C
1120/// string (free with [`pio_string_free`]), `NULL` on error. Fidelity warnings,
1121/// read side first, are written `\n`-joined into `warnbuf`.
1122#[unsafe(no_mangle)]
1123pub unsafe extern "C" fn pio_convert_str(
1124    text: *const c_char,
1125    from: *const c_char,
1126    to: *const c_char,
1127    out_warnings: *mut *mut c_char,
1128    errbuf: *mut c_char,
1129    errlen: usize,
1130) -> *mut c_char {
1131    unsafe {
1132        finish_conversion(out_warnings, errbuf, errlen, || {
1133            let text = cstr(text).ok_or_else(|| "text is NULL or not UTF-8".to_string())?;
1134            let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?;
1135            let target = target_format_from_c(to)?;
1136            let conv = powerio::convert_str(text, target, from).map_err(|e| e.to_string())?;
1137            Ok((conv.text, conv.warnings))
1138        })
1139    }
1140}
1141
1142/// Write `net` into `out_dir` as the named directory format `to`. PyPSA CSV
1143/// (`pypsa-csv`/`pypsa`) is the currently supported directory format; a text format name is
1144/// an error pointing back at [`pio_to_format`]. Returns `0` on success and
1145/// `-1` on error (message into `errbuf`). Fidelity warnings, if any, are
1146/// published through `out_warnings` as one owned C string (free it with
1147/// [`pio_string_free`]), NULL when there are none. Pass NULL to discard them.
1148#[unsafe(no_mangle)]
1149pub unsafe extern "C" fn pio_write_dir(
1150    net: *const PioNetwork,
1151    to: *const c_char,
1152    out_dir: *const c_char,
1153    out_warnings: *mut *mut c_char,
1154    errbuf: *mut c_char,
1155    errlen: usize,
1156) -> i32 {
1157    unsafe {
1158        set_out_warnings(out_warnings, &[]);
1159        let r = catch_unwind(AssertUnwindSafe(|| {
1160            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
1161            let to = cstr(to).ok_or_else(|| "to is NULL or not UTF-8".to_string())?;
1162            let out_dir =
1163                cstr(out_dir).ok_or_else(|| "out_dir is NULL or not UTF-8".to_string())?;
1164            powerio::write_dir(&c.net, to, std::path::Path::new(out_dir)).map_err(|e| e.to_string())
1165        }));
1166        match r {
1167            Ok(Ok(warnings)) => {
1168                set_out_warnings(out_warnings, &warnings);
1169                0
1170            }
1171            Ok(Err(msg)) => {
1172                copy_to_buf(errbuf, errlen, &msg);
1173                -1
1174            }
1175            Err(_) => {
1176                copy_to_buf(errbuf, errlen, "panic while writing directory");
1177                -1
1178            }
1179        }
1180    }
1181}
1182
1183/// Free any owned C string returned by this API.
1184#[unsafe(no_mangle)]
1185pub unsafe extern "C" fn pio_string_free(s: *mut c_char) {
1186    unsafe {
1187        // Same rationale as `pio_network_free`: the boundary catches panics.
1188        guard((), || {
1189            if !s.is_null() {
1190                drop(CString::from_raw(s));
1191            }
1192        });
1193    }
1194}
1195
1196/// Write up to `cap` values from `vals` into `out` and return the total number
1197/// available. A NULL `out` skips the write, so `(NULL, 0)` is the pure count
1198/// query of the cap/count convention every array extractor shares.
1199unsafe fn fill<T: Copy>(out: *mut T, cap: usize, vals: impl ExactSizeIterator<Item = T>) -> usize {
1200    unsafe {
1201        let total = vals.len();
1202        if !out.is_null() {
1203            for (i, v) in vals.take(cap).enumerate() {
1204                *out.add(i) = v;
1205            }
1206        }
1207        total
1208    }
1209}
1210
1211/// Write the 1-based external bus ids, in dense order, into `out`, up to `cap`
1212/// entries, and return the total bus count. This ordering DEFINES the dense
1213/// index space every other per-bus array shares. Call once with `(NULL, 0)` to
1214/// size, allocate, then call again to fill. Ids are int64 in `1..2^63-1` (a v4
1215/// invariant); a source id that is a string or exceeds that range is mapped to
1216/// dense int64 at read, never passed through raw.
1217#[unsafe(no_mangle)]
1218pub unsafe extern "C" fn pio_bus_ids(net: *const PioNetwork, out: *mut i64, cap: usize) -> usize {
1219    unsafe {
1220        guard(0, || {
1221            // The star-lowered space, so every per-bus column keyed to this
1222            // ordering has an id at every index. A star bus carries the
1223            // synthesized id the expansion assigned it.
1224            view(net).map_or(0, |v| {
1225                fill(
1226                    out,
1227                    cap,
1228                    (0..v.n()).map(|i| i64::try_from(v.bus_id(i).0).unwrap_or(-1)),
1229                )
1230            })
1231        })
1232    }
1233}
1234
1235/// Write the branch table as parallel arrays, each up to `cap` entries, and
1236/// return the total branch count. A branch is any two-terminal series element
1237/// lines and transformers alike (a transformer has `tap != 0`). `from`/`to`
1238/// are 1-based bus IDS (the [`pio_bus_ids`] id space, not dense indices); map
1239/// them to dense matrix rows with the [`pio_bus_ids`] ordering. Any output
1240/// pointer may be NULL to skip that column; all NULL is the count query.
1241#[unsafe(no_mangle)]
1242pub unsafe extern "C" fn pio_branches(
1243    net: *const PioNetwork,
1244    from: *mut i64,
1245    to: *mut i64,
1246    r: *mut f64,
1247    x: *mut f64,
1248    b: *mut f64,
1249    tap: *mut f64,
1250    shift: *mut f64,
1251    in_service: *mut u8,
1252    cap: usize,
1253) -> usize {
1254    unsafe {
1255        guard(0, || {
1256            let Some(v) = view(net) else { return 0 };
1257            let branches = v.branches();
1258            fill(
1259                from,
1260                cap,
1261                branches
1262                    .iter()
1263                    .map(|br| i64::try_from(br.from.0).unwrap_or(-1)),
1264            );
1265            fill(
1266                to,
1267                cap,
1268                branches
1269                    .iter()
1270                    .map(|br| i64::try_from(br.to.0).unwrap_or(-1)),
1271            );
1272            fill(r, cap, branches.iter().map(|br| br.r));
1273            fill(x, cap, branches.iter().map(|br| br.x));
1274            fill(b, cap, branches.iter().map(|br| br.total_charging_b()));
1275            fill(tap, cap, branches.iter().map(|br| br.tap));
1276            fill(shift, cap, branches.iter().map(|br| br.shift));
1277            fill(
1278                in_service,
1279                cap,
1280                branches.iter().map(|br| u8::from(br.in_service)),
1281            );
1282            branches.len()
1283        })
1284    }
1285}
1286
1287/// Write the branch terminal charging table as parallel arrays, each up to
1288/// `cap` entries, and return the total branch count. Columns are p.u.
1289#[unsafe(no_mangle)]
1290pub unsafe extern "C" fn pio_branch_charging(
1291    net: *const PioNetwork,
1292    g_fr: *mut f64,
1293    b_fr: *mut f64,
1294    g_to: *mut f64,
1295    b_to: *mut f64,
1296    cap: usize,
1297) -> usize {
1298    unsafe {
1299        guard(0, || {
1300            let Some(v) = view(net) else { return 0 };
1301            let branches = v.branches();
1302            fill(
1303                g_fr,
1304                cap,
1305                branches.iter().map(|br| br.terminal_charging().g_fr),
1306            );
1307            fill(
1308                b_fr,
1309                cap,
1310                branches.iter().map(|br| br.terminal_charging().b_fr),
1311            );
1312            fill(
1313                g_to,
1314                cap,
1315                branches.iter().map(|br| br.terminal_charging().g_to),
1316            );
1317            fill(
1318                b_to,
1319                cap,
1320                branches.iter().map(|br| br.terminal_charging().b_to),
1321            );
1322            branches.len()
1323        })
1324    }
1325}
1326
1327/// Write the switch table as parallel arrays, each up to `cap` entries, and
1328/// return the total switch count. `from`/`to` are external bus ids.
1329#[unsafe(no_mangle)]
1330pub unsafe extern "C" fn pio_switches(
1331    net: *const PioNetwork,
1332    from: *mut i64,
1333    to: *mut i64,
1334    closed: *mut u8,
1335    thermal_rating: *mut f64,
1336    current_rating: *mut f64,
1337    pf: *mut f64,
1338    qf: *mut f64,
1339    pt: *mut f64,
1340    qt: *mut f64,
1341    cap: usize,
1342) -> usize {
1343    unsafe {
1344        guard(0, || {
1345            let Some(c) = network_ref(net) else { return 0 };
1346            let net = &c.net;
1347            fill(
1348                from,
1349                cap,
1350                net.switches
1351                    .iter()
1352                    .map(|sw| i64::try_from(sw.from.0).unwrap_or(-1)),
1353            );
1354            fill(
1355                to,
1356                cap,
1357                net.switches
1358                    .iter()
1359                    .map(|sw| i64::try_from(sw.to.0).unwrap_or(-1)),
1360            );
1361            fill(
1362                closed,
1363                cap,
1364                net.switches.iter().map(|sw| u8::from(sw.closed)),
1365            );
1366            fill(
1367                thermal_rating,
1368                cap,
1369                net.switches
1370                    .iter()
1371                    .map(|sw| sw.thermal_rating.unwrap_or(0.0)),
1372            );
1373            fill(
1374                current_rating,
1375                cap,
1376                net.switches
1377                    .iter()
1378                    .map(|sw| sw.current_rating.unwrap_or(0.0)),
1379            );
1380            fill(pf, cap, net.switches.iter().map(|sw| sw.pf.unwrap_or(0.0)));
1381            fill(qf, cap, net.switches.iter().map(|sw| sw.qf.unwrap_or(0.0)));
1382            fill(pt, cap, net.switches.iter().map(|sw| sw.pt.unwrap_or(0.0)));
1383            fill(qt, cap, net.switches.iter().map(|sw| sw.qt.unwrap_or(0.0)));
1384            net.switches.len()
1385        })
1386    }
1387}
1388
1389/// Write the generator table as parallel arrays, each up to `cap` entries, and
1390/// return the total generator count. `bus` is the 1-based bus id (the
1391/// [`pio_bus_ids`] id space). Any output pointer may be NULL to skip.
1392#[unsafe(no_mangle)]
1393pub unsafe extern "C" fn pio_gens(
1394    net: *const PioNetwork,
1395    bus: *mut i64,
1396    pg: *mut f64,
1397    pmax: *mut f64,
1398    pmin: *mut f64,
1399    in_service: *mut u8,
1400    cap: usize,
1401) -> usize {
1402    unsafe {
1403        guard(0, || {
1404            let Some(c) = network_ref(net) else { return 0 };
1405            let net = &c.net;
1406            fill(
1407                bus,
1408                cap,
1409                net.generators
1410                    .iter()
1411                    .map(|g| i64::try_from(g.bus.0).unwrap_or(-1)),
1412            );
1413            fill(pg, cap, net.generators.iter().map(|g| g.pg));
1414            fill(pmax, cap, net.generators.iter().map(|g| g.pmax));
1415            fill(pmin, cap, net.generators.iter().map(|g| g.pmin));
1416            fill(
1417                in_service,
1418                cap,
1419                net.generators.iter().map(|g| u8::from(g.in_service)),
1420            );
1421            net.generators.len()
1422        })
1423    }
1424}
1425
1426/// Write the per-bus demand aggregates (active `pd`, reactive `qd`, summed
1427/// over each bus's loads, dense [`pio_bus_ids`] order), each up to `cap`
1428/// entries, and return the total bus count. Either pointer may be NULL.
1429#[unsafe(no_mangle)]
1430pub unsafe extern "C" fn pio_bus_demand(
1431    net: *const PioNetwork,
1432    pd: *mut f64,
1433    qd: *mut f64,
1434    cap: usize,
1435) -> usize {
1436    unsafe {
1437        guard(0, || {
1438            view(net).map_or(0, |v| {
1439                // Return an explicit bus count, not the last fill's result, so the
1440                // cap/count return is independent of which columns were requested.
1441                let n = fill(pd, cap, v.pd().iter().copied());
1442                fill(qd, cap, v.qd().iter().copied());
1443                n
1444            })
1445        })
1446    }
1447}
1448
1449/// Write the per-bus shunt aggregates (conductance `gs`, susceptance `bs`,
1450/// dense [`pio_bus_ids`] order), each up to `cap` entries, and return the
1451/// total bus count. Either pointer may be NULL.
1452#[unsafe(no_mangle)]
1453pub unsafe extern "C" fn pio_bus_shunt(
1454    net: *const PioNetwork,
1455    gs: *mut f64,
1456    bs: *mut f64,
1457    cap: usize,
1458) -> usize {
1459    unsafe {
1460        guard(0, || {
1461            view(net).map_or(0, |v| {
1462                // Explicit bus count, not the last fill's result (see pio_bus_demand).
1463                let n = fill(gs, cap, v.gs().iter().copied());
1464                fill(bs, cap, v.bs().iter().copied());
1465                n
1466            })
1467        })
1468    }
1469}
1470
1471/// Export one network table over the Arrow C Data Interface: the `to_`
1472/// conversion whose output type is Arrow structs rather than a string, and the
1473/// bulk table surface of this ABI. Tables 0..5 are raw network tables; tables
1474/// 6..14 are normalized solver tables with per unit/radian values and dense
1475/// zero based row ids; the matrix tables carry COO triplets in that dense index
1476/// space with dimensions in schema metadata. New columns extend the Arrow
1477/// schema without changing an existing C signature.
1478///
1479/// `table` is one of the `PIO_ARROW_TABLE_*` selectors. Raw table columns use
1480/// EXTERNAL bus ids (the `pio_bus_ids` id space), not the gridfm schema. On
1481/// success (returns `0`),
1482/// `out_array` and `out_schema` are populated with owned C Data Interface
1483/// structs: ownership of the Arrow buffers transfers to the caller, both
1484/// `release` callbacks are non-NULL, and the caller MUST invoke each exactly
1485/// once when done (skipping one leaks; the structs outlive `pio_network_free`).
1486/// On error (returns `-1`) the message is written into `errbuf` and the
1487/// out-params are left untouched. Only built with the `arrow` cargo feature.
1488#[cfg(feature = "arrow")]
1489#[unsafe(no_mangle)]
1490pub unsafe extern "C" fn pio_to_arrow(
1491    net: *const PioNetwork,
1492    table: i32,
1493    out_array: *mut arrow::ffi::FFI_ArrowArray,
1494    out_schema: *mut arrow::ffi::FFI_ArrowSchema,
1495    errbuf: *mut c_char,
1496    errlen: usize,
1497) -> i32 {
1498    unsafe {
1499        let r = catch_unwind(AssertUnwindSafe(|| {
1500            if out_array.is_null() || out_schema.is_null() {
1501                return Err("out_array or out_schema is NULL".to_string());
1502            }
1503            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
1504            arrow_export::export(&c.net, &c.core, table)
1505        }));
1506        match r {
1507            Ok(Ok((array, schema))) => {
1508                // Move the FFI structs into caller memory: ptr::write does not
1509                // drop the (caller-zeroed) destination and does not run Drop on
1510                // `array`/`schema`, so the producer release callbacks transfer to
1511                // the caller. Exactly one owner.
1512                std::ptr::write(out_array, array);
1513                std::ptr::write(out_schema, schema);
1514                0
1515            }
1516            Ok(Err(msg)) => {
1517                copy_to_buf(errbuf, errlen, &msg);
1518                -1
1519            }
1520            Err(_) => {
1521                copy_to_buf(errbuf, errlen, "panic while exporting Arrow");
1522                -1
1523            }
1524        }
1525    }
1526}
1527
1528/// Return the Arrow table catalog as owned compact JSON.
1529///
1530/// The catalog is feature based rather than handle based: it describes what
1531/// this library build can export, not what a particular network contains. Top
1532/// level fields are `powerio_version`, `producer`, and `tables`. Each table
1533/// entry includes `id`, `name`, `format`, `feature_requirements`, `available`,
1534/// `row_axis`, `col_axis`, `units`, and `columns`. Each column entry includes
1535/// `name`, `type`, and `nullable`. Through v4 both levels carried a
1536/// `schema_version`; one release version now covers every document powerio
1537/// authors, so the top level names it and the per-table copy is gone.
1538///
1539/// Free the returned string with [`pio_string_free`]. On error this returns
1540/// NULL and writes the message into `errbuf`. Only built with the `arrow` cargo
1541/// feature.
1542#[cfg(feature = "arrow")]
1543#[unsafe(no_mangle)]
1544pub unsafe extern "C" fn pio_arrow_catalog_json(errbuf: *mut c_char, errlen: usize) -> *mut c_char {
1545    unsafe {
1546        finish_string(errbuf, errlen, "panic while building Arrow catalog", || {
1547            Ok(arrow_export::catalog_json())
1548        })
1549    }
1550}
1551
1552// ---------------------------------------------------------------------------
1553// Package API (`pkg` feature). `.pio.json` compiler packages sit above the
1554// balanced and multiconductor handles: a package wraps exactly one payload and
1555// carries provenance, validation, diagnostics, and lowering history.
1556// ---------------------------------------------------------------------------
1557
1558/// Opaque `.pio.json` compiler package handle. A package owns one
1559/// [`powerio_pkg::NetworkPackage`], which wraps either a balanced
1560/// [`PioNetwork`] payload or a multiconductor [`PioDistNetwork`] payload.
1561#[cfg(feature = "pkg")]
1562pub struct PioPackage {
1563    package: powerio_pkg::NetworkPackage,
1564}
1565
1566#[cfg(feature = "pkg")]
1567const _: fn() = || {
1568    fn assert_send_sync<T: Send + Sync>() {}
1569    assert_send_sync::<PioPackage>();
1570};
1571
1572#[cfg(feature = "pkg")]
1573fn lowering_options(base_mva: f64) -> powerio_pkg::MulticonductorToBalancedOptions {
1574    powerio_pkg::MulticonductorToBalancedOptions {
1575        base_mva,
1576        ..Default::default()
1577    }
1578}
1579
1580#[cfg(feature = "pkg")]
1581unsafe fn finish_package(
1582    errbuf: *mut c_char,
1583    errlen: usize,
1584    panic_msg: &str,
1585    f: impl FnOnce() -> Result<powerio_pkg::NetworkPackage, String>,
1586) -> *mut PioPackage {
1587    unsafe {
1588        match catch_unwind(AssertUnwindSafe(f)) {
1589            Ok(Ok(package)) => Box::into_raw(Box::new(PioPackage { package })),
1590            Ok(Err(msg)) => {
1591                copy_to_buf(errbuf, errlen, &msg);
1592                std::ptr::null_mut()
1593            }
1594            Err(_) => {
1595                copy_to_buf(errbuf, errlen, panic_msg);
1596                std::ptr::null_mut()
1597            }
1598        }
1599    }
1600}
1601
1602/// Parse a `.pio.json` package file into an opaque package handle. This reads
1603/// only the package; case format names still enter through
1604/// [`pio_parse_file`] / [`pio_dist_parse_file`] and package constructors.
1605/// Returns `NULL` on error and writes the message into `errbuf`. Free the handle
1606/// with [`pio_package_free`].
1607#[cfg(feature = "pkg")]
1608#[unsafe(no_mangle)]
1609pub unsafe extern "C" fn pio_package_parse_file(
1610    path: *const c_char,
1611    errbuf: *mut c_char,
1612    errlen: usize,
1613) -> *mut PioPackage {
1614    unsafe {
1615        finish_package(errbuf, errlen, "panic while parsing package", || {
1616            let path = required_cstr(path, "path")?;
1617            let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
1618            powerio_pkg::NetworkPackage::from_json(&text).map_err(|e| e.to_string())
1619        })
1620    }
1621}
1622
1623/// Parse in-memory `.pio.json` text into an opaque package handle. Returns
1624/// `NULL` on error and writes the message into `errbuf`. Free the handle with
1625/// [`pio_package_free`].
1626#[cfg(feature = "pkg")]
1627#[unsafe(no_mangle)]
1628pub unsafe extern "C" fn pio_package_parse_str(
1629    text: *const c_char,
1630    errbuf: *mut c_char,
1631    errlen: usize,
1632) -> *mut PioPackage {
1633    unsafe {
1634        finish_package(errbuf, errlen, "panic while parsing package", || {
1635            let text = required_cstr(text, "text")?;
1636            powerio_pkg::NetworkPackage::from_json(text).map_err(|e| e.to_string())
1637        })
1638    }
1639}
1640
1641/// Free a package handle returned by `pio_package_*`. NULL is a no-op; free
1642/// exactly once.
1643#[cfg(feature = "pkg")]
1644#[unsafe(no_mangle)]
1645pub unsafe extern "C" fn pio_package_free(pkg: *mut PioPackage) {
1646    unsafe {
1647        guard((), || {
1648            if !pkg.is_null() {
1649                drop(Box::from_raw(pkg));
1650            }
1651        });
1652    }
1653}
1654
1655/// Finish a `*mut c_char` package accessor: run `f` on the non-NULL handle
1656/// under the panic guard and hand back an owned C string, or write the error
1657/// (`panic_msg` if `f` panicked) into `errbuf` and return NULL. The shared
1658/// tail of the `pio_package_*_json` getters.
1659#[cfg(feature = "pkg")]
1660unsafe fn finish_package_json(
1661    pkg: *const PioPackage,
1662    errbuf: *mut c_char,
1663    errlen: usize,
1664    panic_msg: &str,
1665    f: impl FnOnce(&PioPackage) -> Result<String, String>,
1666) -> *mut c_char {
1667    unsafe {
1668        finish_string(errbuf, errlen, panic_msg, || {
1669            let pkg = pkg
1670                .as_ref()
1671                .ok_or_else(|| "package handle is NULL".to_string())?;
1672            f(pkg)
1673        })
1674    }
1675}
1676
1677/// Serialize a package handle to compact `.pio.json`. Returns an owned C string
1678/// (free with [`pio_string_free`]) or `NULL` on error.
1679#[cfg(feature = "pkg")]
1680#[unsafe(no_mangle)]
1681pub unsafe extern "C" fn pio_package_to_json(
1682    pkg: *const PioPackage,
1683    errbuf: *mut c_char,
1684    errlen: usize,
1685) -> *mut c_char {
1686    unsafe {
1687        finish_package_json(
1688            pkg,
1689            errbuf,
1690            errlen,
1691            "panic while serializing package",
1692            |p| p.package.to_json().map_err(|e| e.to_string()),
1693        )
1694    }
1695}
1696
1697/// Wrap a balanced [`PioNetwork`] handle in a `.pio.json` package. The C handle
1698/// name is historical; the payload is `powerio::BalancedNetwork`.
1699/// `include_solver_metadata != 0` attaches compact normalized solver table
1700/// metadata.
1701#[cfg(feature = "pkg")]
1702#[unsafe(no_mangle)]
1703pub unsafe extern "C" fn pio_package_from_balanced_network(
1704    net: *const PioNetwork,
1705    include_solver_metadata: i32,
1706    errbuf: *mut c_char,
1707    errlen: usize,
1708) -> *mut PioPackage {
1709    unsafe {
1710        finish_package(
1711            errbuf,
1712            errlen,
1713            "panic while packaging balanced network",
1714            || {
1715                let net = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
1716                let mut package = powerio_pkg::NetworkPackage::from_balanced_with_read_warnings(
1717                    net.net.clone(),
1718                    powerio_pkg::READ_TRANSMISSION_PARSE_WARNING,
1719                    net.warnings.clone(),
1720                );
1721                if include_solver_metadata != 0 {
1722                    package
1723                        .attach_normalized_solver_table_metadata()
1724                        .map_err(|e| e.to_string())?;
1725                }
1726                Ok(package)
1727            },
1728        )
1729    }
1730}
1731
1732/// Wrap a multiconductor [`PioDistNetwork`] handle in a `.pio.json` package. The
1733/// C handle name is historical; the payload is
1734/// `powerio_dist::MulticonductorNetwork`.
1735#[cfg(all(feature = "pkg", feature = "dist"))]
1736#[unsafe(no_mangle)]
1737pub unsafe extern "C" fn pio_package_from_multiconductor_network(
1738    net: *const PioDistNetwork,
1739    errbuf: *mut c_char,
1740    errlen: usize,
1741) -> *mut PioPackage {
1742    unsafe {
1743        finish_package(
1744            errbuf,
1745            errlen,
1746            "panic while packaging multiconductor network",
1747            || {
1748                let net = net
1749                    .as_ref()
1750                    .ok_or_else(|| "distribution network handle is NULL".to_string())?;
1751                Ok(powerio_pkg::NetworkPackage::from_multiconductor(
1752                    net.net.clone(),
1753                ))
1754            },
1755        )
1756    }
1757}
1758
1759/// Materialize the balanced payload of a package handle as an owned network
1760/// handle: the inverse of [`pio_package_from_balanced_network`]. Errors when
1761/// the package holds a different model kind. The handle is built from the
1762/// payload alone: it retains no source text, so a same format write is a fresh
1763/// serialization rather than a byte-exact echo, and it carries no parse
1764/// warnings. Free with [`pio_network_free`].
1765#[cfg(feature = "pkg")]
1766#[unsafe(no_mangle)]
1767pub unsafe extern "C" fn pio_package_to_balanced_network(
1768    pkg: *const PioPackage,
1769    errbuf: *mut c_char,
1770    errlen: usize,
1771) -> *mut PioNetwork {
1772    unsafe {
1773        finish_network(
1774            errbuf,
1775            errlen,
1776            "panic while extracting balanced network",
1777            || {
1778                let pkg = pkg
1779                    .as_ref()
1780                    .ok_or_else(|| "package handle is NULL".to_string())?;
1781                let net = pkg.package.model.as_balanced().ok_or_else(|| {
1782                    if cfg!(feature = "dist") {
1783                        "package holds a multiconductor model, not balanced; use \
1784                         pio_package_to_multiconductor_network"
1785                            .to_string()
1786                    } else {
1787                        "package holds a multiconductor model, not balanced; extracting \
1788                         it needs a build with the `dist` feature \
1789                         (pio_package_to_multiconductor_network)"
1790                            .to_string()
1791                    }
1792                })?;
1793                let mut net = net.clone();
1794                // An in-memory package still holds the payload's #[serde(skip)]
1795                // source text; drop it so extraction behaves the same whether
1796                // or not the package crossed JSON, and a same-format write is
1797                // the promised fresh serialization.
1798                net.source = None;
1799                Ok((net, Vec::new()))
1800            },
1801        )
1802    }
1803}
1804
1805/// Materialize the multiconductor payload of a package handle as an owned
1806/// distribution network handle: the inverse of
1807/// [`pio_package_from_multiconductor_network`]. Errors when the package holds
1808/// a different model kind. The handle retains no source text, so a
1809/// same format write is a fresh serialization. The handle retains the payload's
1810/// parse warnings, readable through [`pio_dist_warnings`]. Free with
1811/// [`pio_dist_network_free`].
1812#[cfg(all(feature = "pkg", feature = "dist"))]
1813#[unsafe(no_mangle)]
1814pub unsafe extern "C" fn pio_package_to_multiconductor_network(
1815    pkg: *const PioPackage,
1816    errbuf: *mut c_char,
1817    errlen: usize,
1818) -> *mut PioDistNetwork {
1819    unsafe {
1820        finish_handle(
1821            errbuf,
1822            errlen,
1823            "panic while extracting multiconductor network",
1824            || {
1825                let pkg = pkg
1826                    .as_ref()
1827                    .ok_or_else(|| "package handle is NULL".to_string())?;
1828                let net = pkg.package.model.as_multiconductor().ok_or_else(|| {
1829                    "package holds a balanced model, not multiconductor; use \
1830                     pio_package_to_balanced_network"
1831                        .to_string()
1832                })?;
1833                let mut net = net.clone();
1834                // Same in-memory strip as the balanced inverse: source and the
1835                // defaulted provenance are #[serde(skip)], so dropping them
1836                // here matches what a JSON crossing produces.
1837                net.source = None;
1838                net.defaulted = Default::default();
1839                Ok(PioDistNetwork { net })
1840            },
1841        )
1842    }
1843}
1844
1845/// Run the package semantic validation profile in place. Returns `0` on
1846/// success, `-1` on error.
1847///
1848/// Unlike the read-only accessors, this rewrites the handle's `diagnostics` and
1849/// `validation` (the payload is untouched), so it takes the handle non-`const`
1850/// and needs exclusive access: no other call may touch the same handle
1851/// concurrently. [`pio_package_set_operating_points`] is the other such
1852/// entry point; every other call takes the handle `const` and shares it.
1853#[cfg(feature = "pkg")]
1854#[unsafe(no_mangle)]
1855pub unsafe extern "C" fn pio_package_validate(
1856    pkg: *mut PioPackage,
1857    errbuf: *mut c_char,
1858    errlen: usize,
1859) -> i32 {
1860    unsafe {
1861        let result = catch_unwind(AssertUnwindSafe(|| {
1862            let pkg = pkg
1863                .as_mut()
1864                .ok_or_else(|| "package handle is NULL".to_string())?;
1865            pkg.package.run_sane_validation();
1866            Ok::<_, String>(())
1867        }));
1868        match result {
1869            Ok(Ok(())) => 0,
1870            Ok(Err(msg)) => {
1871                copy_to_buf(errbuf, errlen, &msg);
1872                -1
1873            }
1874            Err(_) => {
1875                copy_to_buf(errbuf, errlen, "panic while validating package");
1876                -1
1877            }
1878        }
1879    }
1880}
1881
1882/// Return the package validation summary as JSON. The returned string is owned
1883/// by the library; free it with [`pio_string_free`].
1884#[cfg(feature = "pkg")]
1885#[unsafe(no_mangle)]
1886pub unsafe extern "C" fn pio_package_validation_json(
1887    pkg: *const PioPackage,
1888    errbuf: *mut c_char,
1889    errlen: usize,
1890) -> *mut c_char {
1891    unsafe {
1892        finish_package_json(
1893            pkg,
1894            errbuf,
1895            errlen,
1896            "panic while reading package validation",
1897            |p| serde_json::to_string(&p.package.validation).map_err(|e| e.to_string()),
1898        )
1899    }
1900}
1901
1902/// Return the package structured diagnostics array as JSON. The returned string
1903/// is owned by the library; free it with [`pio_string_free`].
1904#[cfg(feature = "pkg")]
1905#[unsafe(no_mangle)]
1906pub unsafe extern "C" fn pio_package_diagnostics_json(
1907    pkg: *const PioPackage,
1908    errbuf: *mut c_char,
1909    errlen: usize,
1910) -> *mut c_char {
1911    unsafe {
1912        finish_package_json(
1913            pkg,
1914            errbuf,
1915            errlen,
1916            "panic while reading package diagnostics",
1917            |p| serde_json::to_string(&p.package.diagnostics).map_err(|e| e.to_string()),
1918        )
1919    }
1920}
1921
1922/// Return the package operating point series as JSON, or `null` when absent.
1923/// The returned string is owned by the library; free it with
1924/// [`pio_string_free`].
1925#[cfg(feature = "pkg")]
1926#[unsafe(no_mangle)]
1927pub unsafe extern "C" fn pio_package_operating_points_json(
1928    pkg: *const PioPackage,
1929    errbuf: *mut c_char,
1930    errlen: usize,
1931) -> *mut c_char {
1932    unsafe {
1933        finish_package_json(
1934            pkg,
1935            errbuf,
1936            errlen,
1937            "panic while reading package operating points",
1938            |p| serde_json::to_string(&p.package.operating_points).map_err(|e| e.to_string()),
1939        )
1940    }
1941}
1942
1943/// Replace the package's operating point series from `json`. `null` or an
1944/// empty series clears it. Validation is recomputed before this function
1945/// returns. Returns `0` on success and `-1` on error.
1946///
1947/// This rewrites the handle, so it takes it non-`const` and needs exclusive
1948/// access: no other call may touch the same handle concurrently. See
1949/// [`pio_package_validate`], the other such entry point.
1950#[cfg(feature = "pkg")]
1951#[unsafe(no_mangle)]
1952pub unsafe extern "C" fn pio_package_set_operating_points(
1953    pkg: *mut PioPackage,
1954    json: *const c_char,
1955    errbuf: *mut c_char,
1956    errlen: usize,
1957) -> i32 {
1958    unsafe {
1959        let result = catch_unwind(AssertUnwindSafe(|| {
1960            let pkg = pkg
1961                .as_mut()
1962                .ok_or_else(|| "package handle is NULL".to_string())?;
1963            let json = required_cstr(json, "json")?;
1964            let series: Option<powerio_pkg::OperatingPointSeries> =
1965                serde_json::from_str(json).map_err(|e| e.to_string())?;
1966            match series {
1967                Some(series) => pkg.package.set_operating_points(series),
1968                None => pkg.package.clear_operating_points(),
1969            }
1970            pkg.package.run_sane_validation();
1971            Ok::<_, String>(())
1972        }));
1973        match result {
1974            Ok(Ok(())) => 0,
1975            Ok(Err(msg)) => {
1976                copy_to_buf(errbuf, errlen, &msg);
1977                -1
1978            }
1979            Err(_) => {
1980                copy_to_buf(
1981                    errbuf,
1982                    errlen,
1983                    "panic while setting package operating points",
1984                );
1985                -1
1986            }
1987        }
1988    }
1989}
1990
1991/// Return the package study block as JSON, or `null` when absent. The returned
1992/// string is owned by the library; free it with [`pio_string_free`].
1993#[cfg(feature = "pkg")]
1994#[unsafe(no_mangle)]
1995pub unsafe extern "C" fn pio_package_study_json(
1996    pkg: *const PioPackage,
1997    errbuf: *mut c_char,
1998    errlen: usize,
1999) -> *mut c_char {
2000    unsafe {
2001        finish_package_json(
2002            pkg,
2003            errbuf,
2004            errlen,
2005            "panic while reading package study block",
2006            |p| serde_json::to_string(&p.package.study).map_err(|e| e.to_string()),
2007        )
2008    }
2009}
2010
2011/// Materialize one operating point into a new static package.
2012///
2013/// The returned handle owns a package with the selected updates applied and no
2014/// operating point series. Free it with [`pio_package_free`].
2015#[cfg(feature = "pkg")]
2016#[unsafe(no_mangle)]
2017pub unsafe extern "C" fn pio_package_materialize_operating_point(
2018    pkg: *const PioPackage,
2019    index: i64,
2020    errbuf: *mut c_char,
2021    errlen: usize,
2022) -> *mut PioPackage {
2023    unsafe {
2024        finish_package(
2025            errbuf,
2026            errlen,
2027            "panic while materializing package operating point",
2028            || {
2029                let pkg = pkg
2030                    .as_ref()
2031                    .ok_or_else(|| "package handle is NULL".to_string())?;
2032                let index = usize::try_from(index)
2033                    .map_err(|_| "operating point index must be non-negative".to_string())?;
2034                pkg.package
2035                    .materialize_operating_point(index)
2036                    .map_err(|e| e.to_string())
2037            },
2038        )
2039    }
2040}
2041
2042/// Materialize one study commit into a new static package.
2043///
2044/// The returned handle owns a package with commits `0..=index` applied and no
2045/// operating point series or study block. Free it with [`pio_package_free`].
2046#[cfg(feature = "pkg")]
2047#[unsafe(no_mangle)]
2048pub unsafe extern "C" fn pio_package_materialize_study_commit(
2049    pkg: *const PioPackage,
2050    index: i64,
2051    errbuf: *mut c_char,
2052    errlen: usize,
2053) -> *mut PioPackage {
2054    unsafe {
2055        finish_package(
2056            errbuf,
2057            errlen,
2058            "panic while materializing package study commit",
2059            || {
2060                let pkg = pkg
2061                    .as_ref()
2062                    .ok_or_else(|| "package handle is NULL".to_string())?;
2063                let index = usize::try_from(index)
2064                    .map_err(|_| "study commit index must be non-negative".to_string())?;
2065                pkg.package
2066                    .materialize_study_commit(index)
2067                    .map_err(|e| e.to_string())
2068            },
2069        )
2070    }
2071}
2072
2073/// Return the multiconductor-to-balanced lowering preflight report as JSON.
2074/// `base_mva` is the three phase system power base used for the balanced
2075/// per-unit projection. Returns `NULL` if the package is not multiconductor.
2076#[cfg(feature = "pkg")]
2077#[unsafe(no_mangle)]
2078pub unsafe extern "C" fn pio_package_multiconductor_to_balanced_preflight_json(
2079    pkg: *const PioPackage,
2080    base_mva: f64,
2081    errbuf: *mut c_char,
2082    errlen: usize,
2083) -> *mut c_char {
2084    unsafe {
2085        finish_string(
2086            errbuf,
2087            errlen,
2088            "panic while preflighting package lowering",
2089            || {
2090                let pkg = pkg
2091                    .as_ref()
2092                    .ok_or_else(|| "package handle is NULL".to_string())?;
2093                let net = pkg.package.as_multiconductor().ok_or_else(|| {
2094                    format!(
2095                        "multiconductor preflight requires a multiconductor package, got {:?}",
2096                        pkg.package.model_kind()
2097                    )
2098                })?;
2099                let report = powerio_pkg::check_multiconductor_to_balanced_lowering(
2100                    net,
2101                    lowering_options(base_mva),
2102                );
2103                serde_json::to_string(&report).map_err(|e| e.to_string())
2104            },
2105        )
2106    }
2107}
2108
2109/// Lower a multiconductor package to a new balanced package. Call
2110/// [`pio_package_multiconductor_to_balanced_preflight_json`] first when the
2111/// caller needs structured blockers for unsupported inputs. `base_mva` is the
2112/// three phase system power base used for the balanced per-unit projection.
2113#[cfg(feature = "pkg")]
2114#[unsafe(no_mangle)]
2115pub unsafe extern "C" fn pio_package_lower_multiconductor_to_balanced(
2116    pkg: *const PioPackage,
2117    base_mva: f64,
2118    errbuf: *mut c_char,
2119    errlen: usize,
2120) -> *mut PioPackage {
2121    unsafe {
2122        finish_package(errbuf, errlen, "panic while lowering package", || {
2123            let pkg = pkg
2124                .as_ref()
2125                .ok_or_else(|| "package handle is NULL".to_string())?;
2126            pkg.package
2127                .lower_multiconductor_to_balanced(lowering_options(base_mva))
2128                .map_err(|e| e.to_string())
2129        })
2130    }
2131}
2132
2133// ---------------------------------------------------------------------------
2134// Geographic layer API. String in and string out, no new object lifetimes:
2135// the canonical form is a GeoJSON FeatureCollection with the
2136// `powerio_geo` foreign member (see the geo chapter of the guide).
2137// ---------------------------------------------------------------------------
2138
2139/// Normalize a tolerant geographic sidecar (headerless buscoords CSV, aliased
2140/// CSV/JSON records, GeoJSON Point/LineString) to the canonical GeoJSON form.
2141/// `name_hint` (a file name, nullable) picks CSV against JSON when given;
2142/// otherwise the content is sniffed. The tolerant reader's notes are not
2143/// returned here; parse through the Rust or Python surface to see them. Free
2144/// the returned string with `pio_string_free`. Returns `NULL` on input that
2145/// carries no usable coordinates and writes the message into `errbuf`.
2146#[unsafe(no_mangle)]
2147pub unsafe extern "C" fn pio_geo_parse(
2148    text: *const c_char,
2149    name_hint: *const c_char,
2150    errbuf: *mut c_char,
2151    errlen: usize,
2152) -> *mut c_char {
2153    unsafe {
2154        finish_string(errbuf, errlen, "panic while parsing geo layer", || {
2155            let text = required_cstr(text, "text")?;
2156            let name_hint = optional_cstr(name_hint, "name_hint")?;
2157            let parsed = powerio::GeoLayer::parse_bytes(text.as_bytes(), name_hint)
2158                .map_err(|e| e.to_string())?;
2159            Ok(parsed.layer.to_geojson())
2160        })
2161    }
2162}
2163
2164/// Extract a network's coordinates as the canonical GeoJSON layer: one point
2165/// per located bus, one route per routed branch. Free the returned string
2166/// with `pio_string_free`. Returns `NULL` (with a message) when the network
2167/// carries no coordinates.
2168#[unsafe(no_mangle)]
2169pub unsafe extern "C" fn pio_geo_extract(
2170    net: *const PioNetwork,
2171    errbuf: *mut c_char,
2172    errlen: usize,
2173) -> *mut c_char {
2174    unsafe {
2175        finish_string(errbuf, errlen, "panic while extracting geo layer", || {
2176            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
2177            c.net
2178                .geo_layer()
2179                .extracted_geojson()
2180                .map_err(|e| e.to_string())
2181        })
2182    }
2183}
2184
2185/// Apply a geographic sidecar (any form [`pio_geo_parse`] accepts) onto a NEW
2186/// network handle; the input handle is unchanged and both are freed with
2187/// `pio_network_free`. `name_hint` (a file name, nullable) picks CSV against
2188/// JSON as in [`pio_geo_parse`]. Matched bus points land in `Bus.location`,
2189/// matched branch routes in `Branch.route`. The returned handle drops the
2190/// retained source text, so a same-format write re-serializes the placed case
2191/// instead of echoing the original. The reader's notes and an apply summary
2192/// (`geo apply: N bus point(s), ...`) are appended to the handle's warnings
2193/// ([`pio_warnings`]). Returns `NULL` on error.
2194#[unsafe(no_mangle)]
2195pub unsafe extern "C" fn pio_geo_apply(
2196    net: *const PioNetwork,
2197    layer: *const c_char,
2198    name_hint: *const c_char,
2199    errbuf: *mut c_char,
2200    errlen: usize,
2201) -> *mut PioNetwork {
2202    unsafe {
2203        finish_network(errbuf, errlen, "panic while applying geo layer", || {
2204            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
2205            let layer = required_cstr(layer, "layer")?;
2206            let name_hint = optional_cstr(name_hint, "name_hint")?;
2207            let parsed = powerio::GeoLayer::parse_bytes(layer.as_bytes(), name_hint)
2208                .map_err(|e| e.to_string())?;
2209            let mut out = c.net.clone();
2210            let report = out.apply_geo_layer(&parsed.layer);
2211            out.source = None;
2212            let mut warnings = c.warnings.clone();
2213            warnings.extend(parsed.warnings);
2214            warnings.push(geo_apply_summary(&report));
2215            warnings.extend(report.notes);
2216            Ok((out, warnings))
2217        })
2218    }
2219}
2220
2221/// One-line apply summary lifted into the returned handle's warnings.
2222fn geo_apply_summary(report: &powerio::GeoApplyReport) -> String {
2223    format!(
2224        "geo apply: {} bus point(s), {} branch route(s), {} unmatched feature(s), \
2225         {} bus(es) with no location, {} branch(es) with no route",
2226        report.matched_buses,
2227        report.matched_branches,
2228        report.unmatched_features,
2229        report.unlocated_buses,
2230        report.unlocated_branches
2231    )
2232}
2233
2234// ---------------------------------------------------------------------------
2235// Problem instance API (`prob` feature).
2236// ---------------------------------------------------------------------------
2237
2238/// Opaque matrix free SCOPF instance.
2239#[cfg(feature = "prob")]
2240pub struct PioScopfInstance {
2241    instance: powerio_prob::ScopfInstance,
2242}
2243
2244#[cfg(feature = "prob")]
2245const _: fn() = || {
2246    fn assert_send_sync<T: Send + Sync>() {}
2247    assert_send_sync::<PioScopfInstance>();
2248};
2249
2250/// Parse SCOPF source text into an owned problem instance. `from` currently
2251/// accepts `"goc3-json"`. Returns `NULL` on error and writes the message into
2252/// `errbuf`. Free the handle with `pio_scopf_instance_free`.
2253#[cfg(feature = "prob")]
2254#[unsafe(no_mangle)]
2255pub unsafe extern "C" fn pio_scopf_parse_str(
2256    text: *const c_char,
2257    from: *const c_char,
2258    errbuf: *mut c_char,
2259    errlen: usize,
2260) -> *mut PioScopfInstance {
2261    unsafe {
2262        finish_handle(errbuf, errlen, "panic while parsing SCOPF instance", || {
2263            let text = required_cstr(text, "text")?;
2264            let from = required_cstr(from, "from")?;
2265            let instance =
2266                powerio_prob::parse_scopf_str(text, from).map_err(|error| error.to_string())?;
2267            Ok(PioScopfInstance { instance })
2268        })
2269    }
2270}
2271
2272/// Serialize a SCOPF instance as its Julia compatibility document. The JSON records
2273/// its schema version and index base. Free the returned string with
2274/// `pio_string_free`. Returns `NULL` for a null handle or serialization error.
2275#[cfg(feature = "prob")]
2276#[unsafe(no_mangle)]
2277pub unsafe extern "C" fn pio_scopf_to_json(
2278    instance: *const PioScopfInstance,
2279    errbuf: *mut c_char,
2280    errlen: usize,
2281) -> *mut c_char {
2282    unsafe {
2283        finish_string(
2284            errbuf,
2285            errlen,
2286            "panic while serializing SCOPF instance",
2287            || {
2288                let instance = instance
2289                    .as_ref()
2290                    .ok_or_else(|| "SCOPF instance handle is NULL".to_string())?;
2291                powerio_prob::scopf::json::to_json(&instance.instance)
2292                    .map_err(|error| error.to_string())
2293            },
2294        )
2295    }
2296}
2297
2298/// Free a SCOPF instance handle. `NULL` is a no-op; free each handle once.
2299#[cfg(feature = "prob")]
2300#[unsafe(no_mangle)]
2301pub unsafe extern "C" fn pio_scopf_instance_free(instance: *mut PioScopfInstance) {
2302    unsafe {
2303        guard((), || {
2304            if !instance.is_null() {
2305                drop(Box::from_raw(instance));
2306            }
2307        });
2308    }
2309}
2310
2311// ---------------------------------------------------------------------------
2312// Distribution API (`dist` feature). The multiconductor model behind its own
2313// opaque `PioDistNetwork` handle and the `pio_dist_*` entry points. It is gated
2314// on the `dist` feature / `PIO_DIST` define, exactly like `arrow`/`gridfm`; a
2315// runtime consumer probes it with `pio_has_feature("dist")`, then checks
2316// `pio_dist_abi_version()`. The API is EXPERIMENTAL while the IEEE BMOPF
2317// schema is a draft: C signature changes bump `PIO_DIST_ABI_VERSION`. BMOPF
2318// JSON carries its own meta.version; the model JSON of `pio_dist_to_json` is
2319// covered by the `.pio.json` `schema_version` in powerio-pkg.
2320// ---------------------------------------------------------------------------
2321
2322/// Finish a handle-returning entry point: run `f` (the handle payload or an
2323/// error message) under the panic guard and box the payload into an owned handle,
2324/// or write the error (`panic_msg` if `f` panicked) into `errbuf` and return NULL.
2325#[cfg(any(feature = "dist", feature = "prob"))]
2326unsafe fn finish_handle<H>(
2327    errbuf: *mut c_char,
2328    errlen: usize,
2329    panic_msg: &str,
2330    f: impl FnOnce() -> Result<H, String>,
2331) -> *mut H {
2332    unsafe {
2333        match catch_unwind(AssertUnwindSafe(f)) {
2334            Ok(Ok(h)) => Box::into_raw(Box::new(h)),
2335            Ok(Err(msg)) => {
2336                copy_to_buf(errbuf, errlen, &msg);
2337                std::ptr::null_mut()
2338            }
2339            Err(_) => {
2340                copy_to_buf(errbuf, errlen, panic_msg);
2341                std::ptr::null_mut()
2342            }
2343        }
2344    }
2345}
2346
2347/// Opaque parsed distribution network handle (the multiconductor wire coordinate
2348/// model). Distinct from [`PioNetwork`] (the positive sequence transmission
2349/// model); none of the `pio_n_*`/extractor functions accept it. Only built with
2350/// the `dist` cargo feature.
2351#[cfg(feature = "dist")]
2352pub struct PioDistNetwork {
2353    net: powerio_dist::MulticonductorNetwork,
2354}
2355
2356// Same cross-thread read guarantee as `PioNetwork` (see that assertion): pin
2357// `Send + Sync` so a future non-`Sync` field fails the build.
2358#[cfg(feature = "dist")]
2359const _: fn() = || {
2360    fn assert_send_sync<T: Send + Sync>() {}
2361    assert_send_sync::<PioDistNetwork>();
2362};
2363
2364/// Parse a distribution case file into a [`PioDistNetwork`] handle. The format
2365/// comes from `from` if non-NULL (`dss`, `pmd`, or `bmopf`), else from the file
2366/// itself: `.dss` is OpenDSS, and `.json` holding the ENGINEERING `data_model`
2367/// key is PMD JSON, otherwise BMOPF JSON. Returns `NULL` on error and writes the
2368/// message into `errbuf`. Free the handle with [`pio_dist_network_free`].
2369#[cfg(feature = "dist")]
2370#[unsafe(no_mangle)]
2371pub unsafe extern "C" fn pio_dist_parse_file(
2372    path: *const c_char,
2373    from: *const c_char,
2374    errbuf: *mut c_char,
2375    errlen: usize,
2376) -> *mut PioDistNetwork {
2377    unsafe {
2378        finish_handle(errbuf, errlen, "panic while parsing", || {
2379            let path = required_cstr(path, "path")?;
2380            let from = optional_cstr(from, "from")?;
2381            powerio_dist::parse_file(std::path::Path::new(path), from)
2382                .map(|net| PioDistNetwork { net })
2383                .map_err(|e| e.to_string())
2384        })
2385    }
2386}
2387
2388/// Parse in-memory distribution case `text` of the named `format` (`dss`, `pmd`,
2389/// or `bmopf`; required, since there is no path to infer from). An OpenDSS
2390/// `Redirect`/`Compile` in `text` resolves against the current working directory.
2391/// Returns `NULL` on error and writes the message into `errbuf`. Free the handle
2392/// with [`pio_dist_network_free`].
2393#[cfg(feature = "dist")]
2394#[unsafe(no_mangle)]
2395pub unsafe extern "C" fn pio_dist_parse_str(
2396    text: *const c_char,
2397    format: *const c_char,
2398    errbuf: *mut c_char,
2399    errlen: usize,
2400) -> *mut PioDistNetwork {
2401    unsafe {
2402        finish_handle(errbuf, errlen, "panic while parsing", || {
2403            let text = required_cstr(text, "text")?;
2404            let format = required_cstr(format, "format")?;
2405            powerio_dist::parse_str(text, format)
2406                .map(|net| PioDistNetwork { net })
2407                .map_err(|e| e.to_string())
2408        })
2409    }
2410}
2411
2412/// Free a distribution network handle from [`pio_dist_parse_file`] or
2413/// [`pio_dist_parse_str`]. NULL is a no-op; free exactly once.
2414#[cfg(feature = "dist")]
2415#[unsafe(no_mangle)]
2416pub unsafe extern "C" fn pio_dist_network_free(net: *mut PioDistNetwork) {
2417    unsafe {
2418        // Same rationale as `pio_network_free`: the boundary catches panics so a
2419        // Drop on the `serde_json::Value` extras can't unwind across the ABI.
2420        guard((), || {
2421            if !net.is_null() {
2422                drop(Box::from_raw(net));
2423            }
2424        });
2425    }
2426}
2427
2428/// Parse warnings retained on the handle (everything the reader could not
2429/// represent or had to assume), `\n`-joined and written into the caller `warnbuf`
2430/// (truncated to fit, always NUL-terminated). Returns the total byte length of
2431/// the joined message; call with `NULL`/0 to size first, then fill — the same
2432/// idiom as [`pio_warnings`]. Returns 0 for a NULL handle.
2433#[cfg(feature = "dist")]
2434#[unsafe(no_mangle)]
2435pub unsafe extern "C" fn pio_dist_warnings(
2436    net: *const PioDistNetwork,
2437    warnbuf: *mut c_char,
2438    warnlen: usize,
2439) -> usize {
2440    unsafe {
2441        guard(0, || {
2442            let Some(c) = net.as_ref() else { return 0 };
2443            let msg = c.net.warnings.join("\n");
2444            copy_to_buf(warnbuf, warnlen, &msg);
2445            msg.len()
2446        })
2447    }
2448}
2449
2450/// Serialize a compact summary of a distribution handle as JSON. This lets
2451/// bindings answer display and scalar queries without forcing
2452/// [`pio_dist_to_json`]'s full model payload.
2453#[cfg(feature = "dist")]
2454#[unsafe(no_mangle)]
2455pub unsafe extern "C" fn pio_dist_summary_json(
2456    net: *const PioDistNetwork,
2457    errbuf: *mut c_char,
2458    errlen: usize,
2459) -> *mut c_char {
2460    unsafe {
2461        finish_string(
2462            errbuf,
2463            errlen,
2464            "panic while serializing summary JSON",
2465            || {
2466                let net = net
2467                    .as_ref()
2468                    .ok_or_else(|| "distribution network handle is NULL".to_string())?;
2469                let summary = serde_json::json!({
2470                    powerio::version::VERSION_KEY: powerio::VERSION,
2471                    "name": net.net.name.as_deref(),
2472                    "source_format": net.net.source_format.map(|f| f.name()),
2473                    "base_frequency": net.net.base_frequency,
2474                    "counts": {
2475                        "buses": net.net.buses.len(),
2476                        "linecodes": net.net.linecodes.len(),
2477                        "lines": net.net.lines.len(),
2478                        "switches": net.net.switches.len(),
2479                        "transformers": net.net.transformers.len(),
2480                        "loads": net.net.loads.len(),
2481                        "generators": net.net.generators.len(),
2482                        "ibrs": net.net.ibrs.len(),
2483                        "control_profiles": net.net.control_profiles.len(),
2484                        "shunts": net.net.shunts.len(),
2485                        "capacitors": net.net.capacitors.len(),
2486                        "sources": net.net.sources.len(),
2487                        "untyped": net.net.untyped.len(),
2488                        "warnings": net.net.warnings.len(),
2489                    },
2490                });
2491                serde_json::to_string(&summary).map_err(|e| e.to_string())
2492            },
2493        )
2494    }
2495}
2496
2497/// Serialize `net` to its model JSON: the same object a `.pio.json` package
2498/// carries under `model.multiconductor_network`, without the surrounding
2499/// document. This is the bindings' data transport, not a case format: the
2500/// converter, CLI, and format inference do not know it; distribution cases
2501/// exchanged with other tools are BMOPF JSON ([`pio_dist_to_format`]).
2502/// Returns an owned C string (free with [`pio_string_free`]), `NULL` on error.
2503#[cfg(feature = "dist")]
2504#[unsafe(no_mangle)]
2505pub unsafe extern "C" fn pio_dist_to_json(
2506    net: *const PioDistNetwork,
2507    errbuf: *mut c_char,
2508    errlen: usize,
2509) -> *mut c_char {
2510    unsafe {
2511        finish_string(errbuf, errlen, "panic while serializing model JSON", || {
2512            let net = net
2513                .as_ref()
2514                .ok_or_else(|| "distribution network handle is NULL".to_string())?;
2515            serde_json::to_string(&net.net).map_err(|e| e.to_string())
2516        })
2517    }
2518}
2519
2520/// Serialize the collapsed bus and terminal graph projection for `net` as JSON.
2521/// The returned string is owned by the library; free it with
2522/// [`pio_string_free`].
2523#[cfg(feature = "dist")]
2524#[unsafe(no_mangle)]
2525pub unsafe extern "C" fn pio_dist_graph_json(
2526    net: *const PioDistNetwork,
2527    errbuf: *mut c_char,
2528    errlen: usize,
2529) -> *mut c_char {
2530    unsafe {
2531        finish_string(errbuf, errlen, "panic while serializing graph JSON", || {
2532            let net = net
2533                .as_ref()
2534                .ok_or_else(|| "distribution network handle is NULL".to_string())?;
2535            serde_json::to_string(&net.net.graph()).map_err(|e| e.to_string())
2536        })
2537    }
2538}
2539
2540/// Parse model JSON produced by [`pio_dist_to_json`] (or lifted from a
2541/// `.pio.json` document's `model.multiconductor_network`) back into an owned
2542/// handle: the inverse of [`pio_dist_to_json`]. The rebuilt handle retains
2543/// no source text, so a same format write is a fresh serialization. The handle
2544/// retains the model JSON `warnings`. Returns `NULL` on error. Free with
2545/// [`pio_dist_network_free`].
2546#[cfg(feature = "dist")]
2547#[unsafe(no_mangle)]
2548pub unsafe extern "C" fn pio_dist_from_json(
2549    text: *const c_char,
2550    errbuf: *mut c_char,
2551    errlen: usize,
2552) -> *mut PioDistNetwork {
2553    unsafe {
2554        finish_handle(errbuf, errlen, "panic while parsing model JSON", || {
2555            let text = required_cstr(text, "text")?;
2556            serde_json::from_str::<powerio_dist::MulticonductorNetwork>(text)
2557                .map(|net| PioDistNetwork { net })
2558                .map_err(|e| format!("model JSON: {e}"))
2559        })
2560    }
2561}
2562
2563/// Append a fidelity warning for each companion file the writer produced.
2564/// The text-only C entry points cannot return these files, so the case text
2565/// can refer to a file the caller does not have. The warning names it.
2566#[cfg(feature = "dist")]
2567fn warn_dropped_sidecars(
2568    mut warnings: Vec<String>,
2569    sidecars: &[powerio_dist::ConversionSidecar],
2570) -> Vec<String> {
2571    for sidecar in sidecars {
2572        warnings.push(sidecar.dropped_warning(
2573            "this entry point returns the case text only, and that text refers to the file; \
2574             write it beside the case before loading",
2575        ));
2576    }
2577    warnings
2578}
2579
2580/// Serialize `net` to distribution format `to` (`dss`, `pmd`, or `bmopf`).
2581/// Writing back to the format the handle was parsed from echoes the source text
2582/// byte for byte; a cross format write reports every fidelity loss in `warnbuf`
2583/// (`\n`-joined). Returns the text as an owned C string (free with
2584/// [`pio_string_free`]), `NULL` on error.
2585#[cfg(feature = "dist")]
2586#[unsafe(no_mangle)]
2587pub unsafe extern "C" fn pio_dist_to_format(
2588    net: *const PioDistNetwork,
2589    to: *const c_char,
2590    out_warnings: *mut *mut c_char,
2591    errbuf: *mut c_char,
2592    errlen: usize,
2593) -> *mut c_char {
2594    unsafe {
2595        finish_conversion(out_warnings, errbuf, errlen, || {
2596            let c = net
2597                .as_ref()
2598                .ok_or_else(|| "network handle is NULL".to_string())?;
2599            let target = dist_target_from_c(to)?;
2600            let conv = c.net.to_format(target);
2601            Ok((
2602                conv.text,
2603                warn_dropped_sidecars(conv.warnings, &conv.sidecars),
2604            ))
2605        })
2606    }
2607}
2608
2609/// Convert distribution case `path` from optional source format `from` to format
2610/// `to`; see [`pio_dist_parse_file`] for the inference rules. Returns the
2611/// converted text as an owned C string (free with [`pio_string_free`]), `NULL` on
2612/// error. The warnings written `\n`-joined into `warnbuf` carry both the parse
2613/// warnings and the writer's fidelity losses (there is no handle to query them).
2614#[cfg(feature = "dist")]
2615#[unsafe(no_mangle)]
2616pub unsafe extern "C" fn pio_dist_convert_file(
2617    path: *const c_char,
2618    from: *const c_char,
2619    to: *const c_char,
2620    out_warnings: *mut *mut c_char,
2621    errbuf: *mut c_char,
2622    errlen: usize,
2623) -> *mut c_char {
2624    unsafe {
2625        finish_conversion(out_warnings, errbuf, errlen, || {
2626            let path = required_cstr(path, "path")?;
2627            let from = optional_cstr(from, "from")?;
2628            let to = dist_target_from_c(to)?;
2629            let conv = powerio_dist::convert_file(std::path::Path::new(path), to, from)
2630                .map_err(|e| e.to_string())?;
2631            Ok((
2632                conv.text,
2633                warn_dropped_sidecars(conv.warnings, &conv.sidecars),
2634            ))
2635        })
2636    }
2637}
2638
2639/// Convert in-memory distribution case `text` of format `from` to format `to`
2640/// (both required; `dss`, `pmd`, or `bmopf`). The parameter order is input,
2641/// source, target, matching [`pio_dist_convert_file`]. Returns the converted text
2642/// as an owned C string (free with [`pio_string_free`]), `NULL` on error. The
2643/// warnings written `\n`-joined into `warnbuf` carry both the parse warnings and
2644/// the writer's fidelity losses (there is no handle to query them).
2645#[cfg(feature = "dist")]
2646#[unsafe(no_mangle)]
2647pub unsafe extern "C" fn pio_dist_convert_str(
2648    text: *const c_char,
2649    from: *const c_char,
2650    to: *const c_char,
2651    out_warnings: *mut *mut c_char,
2652    errbuf: *mut c_char,
2653    errlen: usize,
2654) -> *mut c_char {
2655    unsafe {
2656        finish_conversion(out_warnings, errbuf, errlen, || {
2657            let text = required_cstr(text, "text")?;
2658            let to = dist_target_from_c(to)?;
2659            let from = required_cstr(from, "from")?;
2660            let conv = powerio_dist::convert_str(text, to, from).map_err(|e| e.to_string())?;
2661            Ok((
2662                conv.text,
2663                warn_dropped_sidecars(conv.warnings, &conv.sidecars),
2664            ))
2665        })
2666    }
2667}
2668
2669#[cfg(feature = "dist")]
2670fn dist_target_from_c(to: *const c_char) -> Result<powerio_dist::DistTargetFormat, String> {
2671    let to = required_cstr(to, "to")?;
2672    // The message comes from the real error so it can't drift from what the
2673    // powerio-dist dispatchers report for the same mistake.
2674    to.parse::<powerio_dist::DistTargetFormat>()
2675        .map_err(|e| e.to_string())
2676}
2677
2678/// Extract a multiconductor network's coordinates as the canonical GeoJSON
2679/// layer, keyed by the string bus and line names. Free the returned string
2680/// with `pio_string_free`. Returns `NULL` (with a message) when the network
2681/// carries no coordinates.
2682#[cfg(all(feature = "dist", feature = "pkg"))]
2683#[unsafe(no_mangle)]
2684pub unsafe extern "C" fn pio_dist_geo_extract(
2685    net: *const PioDistNetwork,
2686    errbuf: *mut c_char,
2687    errlen: usize,
2688) -> *mut c_char {
2689    unsafe {
2690        finish_string(errbuf, errlen, "panic while extracting geo layer", || {
2691            let c = net
2692                .as_ref()
2693                .ok_or_else(|| "distribution network handle is NULL".to_string())?;
2694            powerio_pkg::dist_geo_layer(&c.net)
2695                .extracted_geojson()
2696                .map_err(|e| e.to_string())
2697        })
2698    }
2699}
2700
2701/// Apply a geographic sidecar (any form [`pio_geo_parse`] accepts) onto a NEW
2702/// distribution network handle; the input handle is unchanged and both are
2703/// freed with `pio_dist_network_free`. `name_hint` (a file name, nullable)
2704/// picks CSV against JSON as in [`pio_geo_parse`]. The returned handle drops
2705/// the retained source text, so a same-format write re-serializes the placed
2706/// case. The reader's notes and an apply summary are appended to the handle's
2707/// warnings ([`pio_dist_warnings`]). Returns `NULL` on error.
2708#[cfg(all(feature = "dist", feature = "pkg"))]
2709#[unsafe(no_mangle)]
2710pub unsafe extern "C" fn pio_dist_geo_apply(
2711    net: *const PioDistNetwork,
2712    layer: *const c_char,
2713    name_hint: *const c_char,
2714    errbuf: *mut c_char,
2715    errlen: usize,
2716) -> *mut PioDistNetwork {
2717    unsafe {
2718        finish_handle(errbuf, errlen, "panic while applying geo layer", || {
2719            let c = net
2720                .as_ref()
2721                .ok_or_else(|| "distribution network handle is NULL".to_string())?;
2722            let layer = required_cstr(layer, "layer")?;
2723            let name_hint = optional_cstr(name_hint, "name_hint")?;
2724            let parsed = powerio::GeoLayer::parse_bytes(layer.as_bytes(), name_hint)
2725                .map_err(|e| e.to_string())?;
2726            let mut out = c.net.clone();
2727            let report = powerio_pkg::apply_dist_geo_layer(&mut out, &parsed.layer);
2728            out.source = None;
2729            out.source_format = None;
2730            out.warnings.extend(parsed.warnings);
2731            out.warnings.push(geo_apply_summary(&report));
2732            out.warnings.extend(report.notes);
2733            Ok(PioDistNetwork { net: out })
2734        })
2735    }
2736}
2737
2738#[cfg(test)]
2739mod tests {
2740    use super::*;
2741    use powerio::POWER_MODELS_ANGLE_BOUND_PAD;
2742    use std::ffi::CString;
2743
2744    fn data_path(name: &str) -> CString {
2745        CString::new(
2746            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2747                .join("../tests/data")
2748                .join(name)
2749                .to_str()
2750                .unwrap(),
2751        )
2752        .unwrap()
2753    }
2754
2755    fn close(actual: f64, expected: f64) {
2756        assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
2757    }
2758
2759    #[test]
2760    fn parse_bytes_reads_binary_and_text_without_a_file() {
2761        let read = |name: &str| {
2762            std::fs::read(
2763                std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2764                    .join("../tests/data")
2765                    .join(name),
2766            )
2767            .unwrap()
2768        };
2769        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2770        unsafe {
2771            // The reason the symbol exists: PowerWorld binary has no text form,
2772            // so pio_parse_str cannot reach this reader at all.
2773            let pwb = read("powerworld/ACTIVSg200.pwb");
2774            let fmt = CString::new("pwb").unwrap();
2775            let net = pio_parse_bytes(
2776                pwb.as_ptr(),
2777                pwb.len(),
2778                fmt.as_ptr(),
2779                err.as_mut_ptr(),
2780                err.len(),
2781            );
2782            assert!(
2783                !net.is_null(),
2784                "pwb bytes: {}",
2785                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2786            );
2787            assert_eq!(pio_n_buses(net), 200);
2788            pio_network_free(net);
2789
2790            // A text format agrees with the path parse of the same file.
2791            let m = read("case9.m");
2792            let fmt = CString::new("matpower").unwrap();
2793            let from_bytes = pio_parse_bytes(
2794                m.as_ptr(),
2795                m.len(),
2796                fmt.as_ptr(),
2797                err.as_mut_ptr(),
2798                err.len(),
2799            );
2800            assert!(!from_bytes.is_null());
2801            let from_path = case9();
2802            assert_eq!(pio_n_buses(from_bytes), pio_n_buses(from_path));
2803            assert_eq!(pio_n_branches(from_bytes), pio_n_branches(from_path));
2804            pio_network_free(from_bytes);
2805            pio_network_free(from_path);
2806
2807            // Bytes that are not UTF-8 fail with a message, not a panic.
2808            let junk = [0xffu8, 0xfe, 0x00, 0x01];
2809            let bad = pio_parse_bytes(
2810                junk.as_ptr(),
2811                junk.len(),
2812                fmt.as_ptr(),
2813                err.as_mut_ptr(),
2814                err.len(),
2815            );
2816            assert!(bad.is_null());
2817            assert!(
2818                CStr::from_ptr(err.as_ptr())
2819                    .to_str()
2820                    .unwrap()
2821                    .contains("UTF-8"),
2822                "expected a UTF-8 message, got {}",
2823                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2824            );
2825
2826            // A NULL buffer with a nonzero length is a caller bug, reported
2827            // rather than dereferenced.
2828            let null_bytes = pio_parse_bytes(
2829                std::ptr::null(),
2830                8,
2831                fmt.as_ptr(),
2832                err.as_mut_ptr(),
2833                err.len(),
2834            );
2835            assert!(null_bytes.is_null());
2836        }
2837    }
2838
2839    fn case9() -> *mut PioNetwork {
2840        let path = data_path("case9.m");
2841        let mut err = [0 as c_char; 256];
2842        let c =
2843            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2844        assert!(!c.is_null(), "parse returned null");
2845        c
2846    }
2847
2848    fn angle_bounds_case() -> *mut PioNetwork {
2849        let path = data_path("angle_bounds_clamp.m");
2850        let mut err = [0 as c_char; 256];
2851        let c =
2852            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2853        assert!(!c.is_null(), "parse returned null");
2854        c
2855    }
2856
2857    fn terminal_projection_case() -> *mut PioNetwork {
2858        use powerio::{Branch, BranchCharging, Bus, BusId, BusType};
2859
2860        let mut branch = Branch::new(BusId(1), BusId(2), 0.01, 0.1);
2861        branch.charging = Some(BranchCharging::new(0.01, 0.02, 0.03, 0.05));
2862        branch.rate_a = 100.0;
2863        let net = BalancedNetwork::in_memory(
2864            "terminal-projection",
2865            100.0,
2866            vec![
2867                Bus::new(BusId(1), BusType::Ref, 230.0),
2868                Bus::new(BusId(2), BusType::Pq, 230.0),
2869            ],
2870            vec![branch],
2871        );
2872        let text = CString::new(net.to_json().unwrap()).unwrap();
2873        let format = CString::new("powerio-json").unwrap();
2874        let mut err = [0 as c_char; 256];
2875        let c =
2876            unsafe { pio_parse_str(text.as_ptr(), format.as_ptr(), err.as_mut_ptr(), err.len()) };
2877        assert!(
2878            !c.is_null(),
2879            "parse returned null: {}",
2880            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
2881        );
2882        c
2883    }
2884
2885    /// `pio_to_format` with a Rust-side format name, asserting success.
2886    unsafe fn to_format(net: *const PioNetwork, to: &str) -> String {
2887        let to = CString::new(to).unwrap();
2888        let mut warn_out: *mut c_char = std::ptr::null_mut();
2889        let mut err = [0 as c_char; 256];
2890        unsafe {
2891            let s = pio_to_format(net, to.as_ptr(), &mut warn_out, err.as_mut_ptr(), err.len());
2892            assert!(
2893                !s.is_null(),
2894                "to_format failed: {}",
2895                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2896            );
2897            let text = CStr::from_ptr(s).to_str().unwrap().to_owned();
2898            pio_string_free(s);
2899            text
2900        }
2901    }
2902
2903    unsafe fn network_json(net: *const PioNetwork) -> serde_json::Value {
2904        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2905        unsafe {
2906            let s = pio_to_json(net, err.as_mut_ptr(), err.len());
2907            assert!(
2908                !s.is_null(),
2909                "to_json failed: {}",
2910                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2911            );
2912            let text = CStr::from_ptr(s).to_str().unwrap().to_owned();
2913            pio_string_free(s);
2914            serde_json::from_str(&text).unwrap()
2915        }
2916    }
2917
2918    unsafe fn warning_text(net: *const PioNetwork) -> String {
2919        let n = unsafe { pio_warnings(net, std::ptr::null_mut(), 0) };
2920        let mut buf = vec![0 as c_char; n + 1];
2921        unsafe {
2922            pio_warnings(net, buf.as_mut_ptr(), buf.len());
2923            CStr::from_ptr(buf.as_ptr()).to_str().unwrap().to_owned()
2924        }
2925    }
2926
2927    #[test]
2928    /// Confirms that the generic C API can detect a DeepMind OPFData file,
2929    /// expose its basic network data and warnings, and convert it to MATPOWER.
2930    fn deepmind_opfdata_uses_shared_c_api() {
2931        let path = data_path("opfdataset/example_0.json");
2932        let mut err = [0 as c_char; 512];
2933        let net =
2934            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
2935        assert!(
2936            !net.is_null(),
2937            "parse returned null: {}",
2938            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
2939        );
2940
2941        unsafe {
2942            assert_eq!(pio_n_buses(net), 14);
2943            assert_eq!(pio_n_branches(net), 20);
2944            assert_eq!(pio_n_gens(net), 5);
2945            close(pio_base_mva(net), 100.0);
2946
2947            let mut source_format = [0 as c_char; 64];
2948            let len = pio_source_format(net, source_format.as_mut_ptr(), source_format.len());
2949            assert_eq!(
2950                CStr::from_ptr(source_format.as_ptr()).to_str().unwrap(),
2951                "DeepMindOpfDataJson"
2952            );
2953            assert_eq!(len, "DeepMindOpfDataJson".len());
2954            assert!(warning_text(net).contains("solver initial values"));
2955            assert!(to_format(net, "matpower").contains("mpc.bus"));
2956            pio_network_free(net);
2957        }
2958    }
2959
2960    #[cfg(feature = "pkg")]
2961    unsafe fn package_json_text(pkg: *const PioPackage) -> String {
2962        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2963        unsafe {
2964            let s = pio_package_to_json(pkg, err.as_mut_ptr(), err.len());
2965            assert!(
2966                !s.is_null(),
2967                "package to json failed: {}",
2968                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2969            );
2970            let text = CStr::from_ptr(s).to_str().unwrap().to_owned();
2971            pio_string_free(s);
2972            text
2973        }
2974    }
2975
2976    #[cfg(feature = "pkg")]
2977    unsafe fn package_json(pkg: *const PioPackage) -> serde_json::Value {
2978        unsafe { serde_json::from_str(&package_json_text(pkg)).unwrap() }
2979    }
2980
2981    #[cfg(feature = "pkg")]
2982    unsafe fn package_report_json(
2983        f: unsafe extern "C" fn(*const PioPackage, *mut c_char, usize) -> *mut c_char,
2984        pkg: *const PioPackage,
2985    ) -> serde_json::Value {
2986        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
2987        unsafe {
2988            let s = f(pkg, err.as_mut_ptr(), err.len());
2989            assert!(
2990                !s.is_null(),
2991                "package report failed: {}",
2992                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
2993            );
2994            let text = CStr::from_ptr(s).to_str().unwrap().to_owned();
2995            pio_string_free(s);
2996            serde_json::from_str(&text).unwrap()
2997        }
2998    }
2999
3000    #[test]
3001    fn every_extractor_reports_the_star_lowered_space() {
3002        // A 3-winding transformer star-lowers into one bus plus three branches
3003        // before the dense extractors run. Through v4 the bus and branch
3004        // tables reported the unexpanded network while pio_bus_demand and
3005        // pio_n_islands reported the expansion, so a caller sizing from them
3006        // read short. Both halves have to move together: 10 buses against 9
3007        // branches leaves the star point an isolated row and the transformer
3008        // contributing nothing.
3009        let case = case9_json_with_a_3w_transformer();
3010        let text = CString::new(case).unwrap();
3011        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3012        unsafe {
3013            let net = pio_from_json(text.as_ptr(), err.as_mut_ptr(), err.len());
3014            assert!(
3015                !net.is_null(),
3016                "from_json failed: {}",
3017                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
3018            );
3019
3020            let n = pio_n_buses(net);
3021            let demand = pio_bus_demand(net, std::ptr::null_mut(), std::ptr::null_mut(), 0);
3022            let shunt = pio_bus_shunt(net, std::ptr::null_mut(), std::ptr::null_mut(), 0);
3023            let ids = pio_bus_ids(net, std::ptr::null_mut(), 0);
3024
3025            assert_eq!(n, 10, "9 buses plus one star point");
3026            assert_eq!(demand, n, "pio_bus_demand must agree with pio_n_buses");
3027            assert_eq!(shunt, n, "pio_bus_shunt must agree with pio_n_buses");
3028            assert_eq!(ids, n, "pio_bus_ids must agree with pio_n_buses");
3029
3030            let m = pio_n_branches(net);
3031            let (ni, nf, nb) = (
3032                std::ptr::null_mut::<i64>(),
3033                std::ptr::null_mut::<f64>(),
3034                std::ptr::null_mut::<u8>(),
3035            );
3036            let rows = pio_branches(net, ni, ni, nf, nf, nf, nf, nf, nb, 0);
3037            let charging = pio_branch_charging(net, nf, nf, nf, nf, 0);
3038            assert_eq!(m, 12, "9 branches plus three star legs");
3039            assert_eq!(rows, m, "pio_branches must agree with pio_n_branches");
3040            assert_eq!(charging, m, "pio_branch_charging must agree too");
3041
3042            // Every index a per-bus column addresses has an id, the star point
3043            // included: sizing from pio_n_buses is now sufficient.
3044            let mut buf = vec![-1i64; n];
3045            assert_eq!(pio_bus_ids(net, buf.as_mut_ptr(), buf.len()), n);
3046            assert!(
3047                buf.iter().all(|&id| id > 0),
3048                "every dense index carries an id, got {buf:?}"
3049            );
3050
3051            // The two tables close over each other: every branch endpoint is a
3052            // bus this API reports, and every bus is reachable, so an incidence
3053            // matrix built from these arrays has no isolated row.
3054            let mut from = vec![-1i64; m];
3055            let mut to = vec![-1i64; m];
3056            assert_eq!(
3057                pio_branches(
3058                    net,
3059                    from.as_mut_ptr(),
3060                    to.as_mut_ptr(),
3061                    nf,
3062                    nf,
3063                    nf,
3064                    nf,
3065                    nf,
3066                    nb,
3067                    m
3068                ),
3069                m
3070            );
3071            for id in from.iter().chain(&to) {
3072                assert!(
3073                    buf.contains(id),
3074                    "branch endpoint {id} is not in pio_bus_ids"
3075                );
3076            }
3077            for id in &buf {
3078                assert!(
3079                    from.contains(id) || to.contains(id),
3080                    "bus {id} has no incident branch"
3081                );
3082            }
3083
3084            // The summary carries both spaces and says which is which: counts
3085            // is the case file's inventory, so the transformer is one row
3086            // there, and topology is the space the extractors report.
3087            let raw = pio_summary_json(net, err.as_mut_ptr(), err.len());
3088            assert!(!raw.is_null());
3089            let summary: serde_json::Value =
3090                serde_json::from_str(CStr::from_ptr(raw).to_str().unwrap()).unwrap();
3091            pio_string_free(raw);
3092            assert_eq!(summary["counts"]["buses"], 9);
3093            assert_eq!(summary["counts"]["branches"], 9);
3094            assert_eq!(summary["counts"]["transformers_3w"], 1);
3095            assert_eq!(summary["topology"]["n_buses"], n);
3096            assert_eq!(summary["topology"]["n_branches"], m);
3097
3098            pio_network_free(net);
3099        }
3100    }
3101
3102    /// case9 with one in-service 3-winding transformer spliced in, as model
3103    /// JSON. Built here rather than vendored: the fixtures are real MATPOWER
3104    /// cases and none of them carries a 3-winding transformer.
3105    fn case9_json_with_a_3w_transformer() -> String {
3106        let net = case9();
3107        let mut doc: serde_json::Value = unsafe {
3108            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3109            let raw = pio_to_json(net, err.as_mut_ptr(), err.len());
3110            assert!(!raw.is_null());
3111            let text = CStr::from_ptr(raw).to_str().unwrap().to_owned();
3112            pio_string_free(raw);
3113            pio_network_free(net);
3114            serde_json::from_str(&text).unwrap()
3115        };
3116        let winding = |bus: i64| {
3117            serde_json::json!({
3118                "bus": bus, "tap": 1.0, "shift": 0.0, "nominal_kv": 0.0,
3119                "rate_a": 0.0, "rate_b": 0.0, "rate_c": 0.0
3120            })
3121        };
3122        let z = serde_json::json!({ "r": 0.0, "x": 0.05, "base_mva": 100.0 });
3123        doc["transformers_3w"] = serde_json::json!([{
3124            "windings": [winding(4), winding(5), winding(6)],
3125            "z": [z, z, z],
3126            "star_vm": 1.0, "star_va": 0.0, "mag_g": 0.0, "mag_b": 0.0,
3127            "in_service": true, "name": "t3", "extras": {}
3128        }]);
3129        doc.to_string()
3130    }
3131
3132    #[test]
3133    fn build_info_reports_the_build_in_one_document() {
3134        let raw = pio_build_info();
3135        assert!(!raw.is_null());
3136        let doc: serde_json::Value =
3137            serde_json::from_str(unsafe { CStr::from_ptr(raw) }.to_str().unwrap()).unwrap();
3138        unsafe { pio_string_free(raw) };
3139
3140        assert_eq!(doc["abi"], serde_json::json!(PIO_ABI_VERSION));
3141        assert_eq!(
3142            doc[powerio::version::VERSION_KEY],
3143            serde_json::json!(powerio::VERSION)
3144        );
3145        // Every feature this build knows about answers here and through
3146        // pio_has_feature; the two must not disagree.
3147        for name in ["arrow", "matrix", "gridfm", "dist", "pkg", "prob"] {
3148            let c = CString::new(name).unwrap();
3149            let probed = unsafe { pio_has_feature(c.as_ptr()) } == 1;
3150            assert_eq!(
3151                doc["features"][name],
3152                serde_json::json!(probed),
3153                "{name} disagrees between pio_build_info and pio_has_feature"
3154            );
3155        }
3156        assert_eq!(
3157            doc["error_categories"],
3158            serde_json::json!(powerio::ErrorCategory::TOKENS)
3159        );
3160    }
3161
3162    #[test]
3163    fn version_api() {
3164        // The ABI version is the load-time compatibility check; the version
3165        // string is static, NUL-terminated, and non-empty.
3166        assert_eq!(pio_abi_version(), PIO_ABI_VERSION);
3167        assert_eq!(PIO_ABI_VERSION, 5);
3168        let v = unsafe { CStr::from_ptr(pio_version()) }.to_str().unwrap();
3169        assert_eq!(v, env!("CARGO_PKG_VERSION"));
3170        assert!(!v.is_empty());
3171    }
3172
3173    #[test]
3174    fn matrix_probe_matches_build_features() {
3175        let matrix = CString::new("matrix").unwrap();
3176        unsafe {
3177            assert_eq!(
3178                pio_has_feature(matrix.as_ptr()),
3179                i32::from(cfg!(feature = "matrix"))
3180            );
3181        }
3182        assert_eq!(
3183            pio_matrix_available(),
3184            i32::from(cfg!(all(feature = "arrow", feature = "matrix")))
3185        );
3186    }
3187
3188    #[test]
3189    fn prob_probe_matches_build_features() {
3190        let prob = CString::new("prob").unwrap();
3191        unsafe {
3192            assert_eq!(
3193                pio_has_feature(prob.as_ptr()),
3194                i32::from(cfg!(feature = "prob"))
3195            );
3196        }
3197    }
3198
3199    fn strip_c_comments(input: &str) -> String {
3200        let mut out = String::with_capacity(input.len());
3201        let mut chars = input.chars().peekable();
3202        let mut in_block = false;
3203        while let Some(ch) = chars.next() {
3204            if in_block {
3205                if ch == '*' && chars.peek() == Some(&'/') {
3206                    chars.next();
3207                    in_block = false;
3208                } else if ch == '\n' {
3209                    out.push('\n');
3210                }
3211            } else if ch == '/' && chars.peek() == Some(&'*') {
3212                chars.next();
3213                in_block = true;
3214            } else if ch == '/' && chars.peek() == Some(&'/') {
3215                chars.next();
3216                for tail in chars.by_ref() {
3217                    if tail == '\n' {
3218                        out.push('\n');
3219                        break;
3220                    }
3221                }
3222            } else {
3223                out.push(ch);
3224            }
3225        }
3226        out
3227    }
3228
3229    fn collapse_ws(s: &str) -> String {
3230        s.split_whitespace().collect::<Vec<_>>().join(" ")
3231    }
3232
3233    fn c_header_abi_manifest(header: &str) -> Vec<String> {
3234        let clean = strip_c_comments(header);
3235        let mut entries = Vec::new();
3236        let mut prototype = String::new();
3237        for line in clean.lines().map(str::trim).filter(|line| !line.is_empty()) {
3238            if !prototype.is_empty() {
3239                prototype.push(' ');
3240                prototype.push_str(line);
3241                if line.ends_with(';') {
3242                    entries.push(collapse_ws(&prototype));
3243                    prototype.clear();
3244                }
3245                continue;
3246            }
3247
3248            if line.starts_with("#define PIO_") || line.starts_with("typedef struct Pio") {
3249                entries.push(collapse_ws(line));
3250            } else if line.contains("pio_") {
3251                if line.ends_with(';') {
3252                    entries.push(collapse_ws(line));
3253                } else {
3254                    prototype.push_str(line);
3255                }
3256            }
3257        }
3258        assert!(prototype.is_empty(), "unterminated prototype: {prototype}");
3259        entries
3260    }
3261
3262    fn pio_symbol_names_from_manifest(manifest: &[String]) -> Vec<String> {
3263        let mut names = std::collections::BTreeSet::new();
3264        for entry in manifest {
3265            if let Some(start) = entry.find("pio_") {
3266                let tail = &entry[start..];
3267                if let Some(end) = tail.find('(') {
3268                    names.insert(tail[..end].to_string());
3269                }
3270            }
3271        }
3272        names.into_iter().collect()
3273    }
3274
3275    fn source_exported_pio_symbols(source: &str) -> Vec<String> {
3276        let mut names = std::collections::BTreeSet::new();
3277        let mut saw_no_mangle = false;
3278        for line in source.lines() {
3279            let trimmed = line.trim();
3280            if trimmed == "#[unsafe(no_mangle)]" {
3281                saw_no_mangle = true;
3282                continue;
3283            }
3284            if !trimmed.contains("extern \"C\"") {
3285                if !trimmed.is_empty()
3286                    && !trimmed.starts_with("#[")
3287                    && !trimmed.starts_with("//")
3288                    && !trimmed.starts_with("///")
3289                {
3290                    saw_no_mangle = false;
3291                }
3292                continue;
3293            }
3294            if let Some(start) = trimmed.find("fn pio_") {
3295                let tail = &trimmed[start + "fn ".len()..];
3296                let end = tail
3297                    .find('(')
3298                    .unwrap_or_else(|| panic!("unterminated extern fn line: {trimmed}"));
3299                let name = &tail[..end];
3300                assert!(
3301                    saw_no_mangle,
3302                    "{name} is exported in Rust source without #[unsafe(no_mangle)]"
3303                );
3304                names.insert(name.to_string());
3305                saw_no_mangle = false;
3306                continue;
3307            }
3308            if !trimmed.is_empty() && !trimmed.starts_with("#[") && !trimmed.starts_with("//") {
3309                saw_no_mangle = false;
3310            }
3311        }
3312        names.into_iter().collect()
3313    }
3314
3315    #[test]
3316    fn c_header_abi_manifest_is_pinned() {
3317        let actual = c_header_abi_manifest(include_str!("../include/powerio.h"));
3318        let expected = [
3319            "#define PIO_ABI_VERSION 5",
3320            "#define PIO_DIST_ABI_VERSION 1",
3321            "#define PIO_ERRBUF_MIN 256",
3322            "#define PIO_ARROW_TABLE_BUS 0",
3323            "#define PIO_ARROW_TABLE_BRANCH 1",
3324            "#define PIO_ARROW_TABLE_GEN 2",
3325            "#define PIO_ARROW_TABLE_LOAD 3",
3326            "#define PIO_ARROW_TABLE_SHUNT 4",
3327            "#define PIO_ARROW_TABLE_SWITCH 5",
3328            "#define PIO_ARROW_TABLE_SOLVER_BUS 6",
3329            "#define PIO_ARROW_TABLE_SOLVER_LOAD 7",
3330            "#define PIO_ARROW_TABLE_SOLVER_SHUNT 8",
3331            "#define PIO_ARROW_TABLE_SOLVER_BRANCH 9",
3332            "#define PIO_ARROW_TABLE_SOLVER_SWITCH 10",
3333            "#define PIO_ARROW_TABLE_SOLVER_ARC 11",
3334            "#define PIO_ARROW_TABLE_SOLVER_GEN 12",
3335            "#define PIO_ARROW_TABLE_SOLVER_STORAGE 13",
3336            "#define PIO_ARROW_TABLE_SOLVER_HVDC 14",
3337            "#define PIO_ARROW_TABLE_YBUS 15",
3338            "#define PIO_ARROW_TABLE_INCIDENCE 16",
3339            "#define PIO_ARROW_TABLE_BPRIME 17",
3340            "#define PIO_ARROW_TABLE_BDOUBLEPRIME 18",
3341            "#define PIO_ARROW_TABLE_MATRIX_BUS 19",
3342            "#define PIO_ARROW_TABLE_MATRIX_BRANCH 20",
3343            "typedef struct PioDistNetwork PioDistNetwork;",
3344            "typedef struct PioNetwork PioNetwork;",
3345            "typedef struct PioPackage PioPackage;",
3346            "typedef struct PioScopfInstance PioScopfInstance;",
3347            "uint32_t pio_abi_version(void);",
3348            "uint32_t pio_dist_abi_version(void);",
3349            "char *pio_dist_capabilities_json(void);",
3350            "char *pio_schema_versions_json(void);",
3351            "char *pio_build_info(void);",
3352            "int32_t pio_matrix_available(void);",
3353            "int32_t pio_has_feature(const char *feature);",
3354            "const char *pio_version(void);",
3355            "PioNetwork *pio_parse_file(const char *path, const char *from, char *errbuf, size_t errlen);",
3356            "PioNetwork *pio_parse_str(const char *text, const char *format, char *errbuf, size_t errlen);",
3357            "PioNetwork *pio_parse_bytes(const uint8_t *bytes, size_t len, const char *format, char *errbuf, size_t errlen);",
3358            "size_t pio_classify_str(const char *text, char *outbuf, size_t outlen);",
3359            "char *pio_to_json(const PioNetwork *net, char *errbuf, size_t errlen);",
3360            "PioNetwork *pio_from_json(const char *text, char *errbuf, size_t errlen);",
3361            "PioNetwork *pio_read_dir(const char *dir, const char *from, int64_t scenario, char *errbuf, size_t errlen);",
3362            "ptrdiff_t pio_scenario_ids(const char *dir, const char *from, int64_t *out, size_t cap, char *errbuf, size_t errlen);",
3363            "size_t pio_warnings(const PioNetwork *net, char *warnbuf, size_t warnlen);",
3364            "void pio_network_free(PioNetwork *net);",
3365            "PioNetwork *pio_normalize(const PioNetwork *net, char *errbuf, size_t errlen);",
3366            "PioNetwork *pio_normalize_with_options(const PioNetwork *net, int32_t clamp_angle_bounds, double angle_bound_pad, char *errbuf, size_t errlen);",
3367            "size_t pio_n_buses(const PioNetwork *net);",
3368            "size_t pio_n_branches(const PioNetwork *net);",
3369            "size_t pio_n_switches(const PioNetwork *net);",
3370            "size_t pio_n_gens(const PioNetwork *net);",
3371            "double pio_base_mva(const PioNetwork *net);",
3372            "size_t pio_network_name(const PioNetwork *net, char *out, size_t cap);",
3373            "size_t pio_source_format(const PioNetwork *net, char *out, size_t cap);",
3374            "char *pio_summary_json(const PioNetwork *net, char *errbuf, size_t errlen);",
3375            "int64_t pio_ref_bus_index(const PioNetwork *net);",
3376            "size_t pio_ref_bus_indices(const PioNetwork *net, int64_t *out, size_t cap);",
3377            "size_t pio_n_islands(const PioNetwork *net);",
3378            "int32_t pio_is_radial(const PioNetwork *net);",
3379            "char *pio_to_format(const PioNetwork *net, const char *to, char **out_warnings, char *errbuf, size_t errlen);",
3380            "char *pio_convert_file(const char *path, const char *from, const char *to, char **out_warnings, char *errbuf, size_t errlen);",
3381            "char *pio_convert_str(const char *text, const char *from, const char *to, char **out_warnings, char *errbuf, size_t errlen);",
3382            "int32_t pio_write_dir(const PioNetwork *net, const char *to, const char *out_dir, char **out_warnings, char *errbuf, size_t errlen);",
3383            "void pio_string_free(char *s);",
3384            "size_t pio_bus_ids(const PioNetwork *net, int64_t *out, size_t cap);",
3385            "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);",
3386            "size_t pio_branch_charging(const PioNetwork *net, double *g_fr, double *b_fr, double *g_to, double *b_to, size_t cap);",
3387            "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);",
3388            "size_t pio_gens(const PioNetwork *net, int64_t *bus, double *pg, double *pmax, double *pmin, uint8_t *in_service, size_t cap);",
3389            "size_t pio_bus_demand(const PioNetwork *net, double *pd, double *qd, size_t cap);",
3390            "size_t pio_bus_shunt(const PioNetwork *net, double *gs, double *bs, size_t cap);",
3391            "int32_t pio_to_arrow(const PioNetwork *net, int32_t table, struct ArrowArray *out_array, struct ArrowSchema *out_schema, char *errbuf, size_t errlen);",
3392            "char *pio_arrow_catalog_json(char *errbuf, size_t errlen);",
3393            "PioPackage *pio_package_parse_file(const char *path, char *errbuf, size_t errlen);",
3394            "PioPackage *pio_package_parse_str(const char *text, char *errbuf, size_t errlen);",
3395            "void pio_package_free(PioPackage *pkg);",
3396            "char *pio_package_to_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
3397            "PioPackage *pio_package_from_balanced_network(const PioNetwork *net, int32_t include_solver_metadata, char *errbuf, size_t errlen);",
3398            "PioPackage *pio_package_from_multiconductor_network(const PioDistNetwork *net, char *errbuf, size_t errlen);",
3399            "PioNetwork *pio_package_to_balanced_network(const PioPackage *pkg, char *errbuf, size_t errlen);",
3400            "PioDistNetwork *pio_package_to_multiconductor_network(const PioPackage *pkg, char *errbuf, size_t errlen);",
3401            "int32_t pio_package_validate(PioPackage *pkg, char *errbuf, size_t errlen);",
3402            "char *pio_package_validation_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
3403            "char *pio_package_diagnostics_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
3404            "char *pio_package_operating_points_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
3405            "int32_t pio_package_set_operating_points(PioPackage *pkg, const char *json, char *errbuf, size_t errlen);",
3406            "char *pio_package_study_json(const PioPackage *pkg, char *errbuf, size_t errlen);",
3407            "PioPackage *pio_package_materialize_operating_point(const PioPackage *pkg, int64_t index, char *errbuf, size_t errlen);",
3408            "PioPackage *pio_package_materialize_study_commit(const PioPackage *pkg, int64_t index, char *errbuf, size_t errlen);",
3409            "char *pio_package_multiconductor_to_balanced_preflight_json(const PioPackage *pkg, double base_mva, char *errbuf, size_t errlen);",
3410            "PioPackage *pio_package_lower_multiconductor_to_balanced(const PioPackage *pkg, double base_mva, char *errbuf, size_t errlen);",
3411            "char *pio_geo_parse(const char *text, const char *name_hint, char *errbuf, size_t errlen);",
3412            "char *pio_geo_extract(const PioNetwork *net, char *errbuf, size_t errlen);",
3413            "PioNetwork *pio_geo_apply(const PioNetwork *net, const char *layer, const char *name_hint, char *errbuf, size_t errlen);",
3414            "PioScopfInstance *pio_scopf_parse_str(const char *text, const char *from, char *errbuf, size_t errlen);",
3415            "char *pio_scopf_to_json(const PioScopfInstance *instance, char *errbuf, size_t errlen);",
3416            "void pio_scopf_instance_free(PioScopfInstance *instance);",
3417            "PioDistNetwork *pio_dist_parse_file(const char *path, const char *from, char *errbuf, size_t errlen);",
3418            "PioDistNetwork *pio_dist_parse_str(const char *text, const char *format, char *errbuf, size_t errlen);",
3419            "void pio_dist_network_free(PioDistNetwork *net);",
3420            "size_t pio_dist_warnings(const PioDistNetwork *net, char *warnbuf, size_t warnlen);",
3421            "char *pio_dist_summary_json(const PioDistNetwork *net, char *errbuf, size_t errlen);",
3422            "char *pio_dist_to_json(const PioDistNetwork *net, char *errbuf, size_t errlen);",
3423            "char *pio_dist_graph_json(const PioDistNetwork *net, char *errbuf, size_t errlen);",
3424            "PioDistNetwork *pio_dist_from_json(const char *text, char *errbuf, size_t errlen);",
3425            "char *pio_dist_to_format(const PioDistNetwork *net, const char *to, char **out_warnings, char *errbuf, size_t errlen);",
3426            "char *pio_dist_convert_file(const char *path, const char *from, const char *to, char **out_warnings, char *errbuf, size_t errlen);",
3427            "char *pio_dist_convert_str(const char *text, const char *from, const char *to, char **out_warnings, char *errbuf, size_t errlen);",
3428            "char *pio_dist_geo_extract(const PioDistNetwork *net, char *errbuf, size_t errlen);",
3429            "PioDistNetwork *pio_dist_geo_apply(const PioDistNetwork *net, const char *layer, const char *name_hint, char *errbuf, size_t errlen);",
3430        ]
3431        .into_iter()
3432        .map(str::to_string)
3433        .collect::<Vec<_>>();
3434        assert_eq!(actual, expected);
3435    }
3436
3437    #[test]
3438    fn c_header_and_rust_exported_symbols_match() {
3439        let manifest = c_header_abi_manifest(include_str!("../include/powerio.h"));
3440        let header_symbols = pio_symbol_names_from_manifest(&manifest);
3441        let rust_symbols = source_exported_pio_symbols(include_str!("lib.rs"));
3442        assert_eq!(rust_symbols, header_symbols);
3443    }
3444
3445    #[test]
3446    fn parse_query_free() {
3447        let c = case9();
3448        unsafe {
3449            assert_eq!(pio_n_buses(c), 9);
3450            assert_eq!(pio_n_branches(c), 9);
3451            assert_eq!(pio_n_gens(c), 3);
3452            assert_eq!(pio_base_mva(c), 100.0);
3453            let mut name = [0 as c_char; 64];
3454            let name_len = pio_network_name(c, name.as_mut_ptr(), name.len());
3455            assert_eq!(CStr::from_ptr(name.as_ptr()).to_str().unwrap(), "case9");
3456            assert_eq!(name_len, 5);
3457            let mut source_format = [0 as c_char; 64];
3458            let fmt_len = pio_source_format(c, source_format.as_mut_ptr(), source_format.len());
3459            assert_eq!(
3460                CStr::from_ptr(source_format.as_ptr()).to_str().unwrap(),
3461                "Matpower"
3462            );
3463            assert_eq!(fmt_len, 8);
3464            let mut err = [0 as c_char; 256];
3465            let summary = pio_summary_json(c, err.as_mut_ptr(), err.len());
3466            assert!(
3467                !summary.is_null(),
3468                "summary json failed: {}",
3469                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
3470            );
3471            let summary_value: serde_json::Value =
3472                serde_json::from_str(CStr::from_ptr(summary).to_str().unwrap()).unwrap();
3473            assert_eq!(summary_value["name"], "case9");
3474            assert_eq!(summary_value["source_format"], "Matpower");
3475            assert_eq!(summary_value["base_mva"], 100.0);
3476            assert_eq!(summary_value["counts"]["buses"], 9);
3477            assert_eq!(summary_value["counts"]["branches"], 9);
3478            assert_eq!(summary_value["counts"]["generators"], 3);
3479            assert_eq!(
3480                summary_value["topology"]["reference_bus_ids"],
3481                serde_json::json!([1])
3482            );
3483            assert_eq!(summary_value["topology"]["n_components"], 1);
3484            assert_eq!(summary_value["topology"]["is_radial"], false);
3485            pio_string_free(summary);
3486            assert_eq!(pio_n_islands(c), 1);
3487            assert!(pio_ref_bus_index(c) >= 0);
3488            // The MATPOWER reader is total: no warnings, zero bytes.
3489            assert_eq!(pio_warnings(c, std::ptr::null_mut(), 0), 0);
3490            pio_network_free(c);
3491        }
3492    }
3493
3494    #[test]
3495    fn warnings_size_then_fill_exactly() {
3496        // The pandapower fixture carries switches the model ignores. The byte
3497        // length returned by the NULL-out call must size a buffer that then
3498        // receives the full text untruncated.
3499        let path = data_path("pandapower/example.json");
3500        let mut err = [0 as c_char; 256];
3501        let c =
3502            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
3503        assert!(
3504            !c.is_null(),
3505            "parse failed: {}",
3506            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
3507        );
3508        unsafe {
3509            let len = pio_warnings(c, std::ptr::null_mut(), 0);
3510            assert!(len > 0, "expected read warnings");
3511            let mut warn = vec![0x7f as c_char; len + 1];
3512            assert_eq!(pio_warnings(c, warn.as_mut_ptr(), warn.len()), len);
3513            let w = CStr::from_ptr(warn.as_ptr()).to_str().unwrap();
3514            assert_eq!(w.len(), len, "buffer sized from the return holds it all");
3515            assert!(w.contains("switch"), "expected a switch warning, got {w:?}");
3516            // A NULL handle reports zero bytes.
3517            assert_eq!(
3518                pio_warnings(std::ptr::null(), warn.as_mut_ptr(), warn.len()),
3519                0
3520            );
3521            pio_network_free(c);
3522        }
3523    }
3524
3525    #[test]
3526    fn matpower_write_is_byte_exact() {
3527        let src = std::fs::read_to_string(
3528            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"),
3529        )
3530        .unwrap();
3531        let c = case9();
3532        unsafe {
3533            assert_eq!(to_format(c, "matpower"), src);
3534
3535            // A NULL handle is an error message, not a crash.
3536            let to = CString::new("matpower").unwrap();
3537            let mut err = [0 as c_char; 256];
3538            let null = pio_to_format(
3539                std::ptr::null(),
3540                to.as_ptr(),
3541                std::ptr::null_mut(),
3542                err.as_mut_ptr(),
3543                err.len(),
3544            );
3545            assert!(null.is_null());
3546            assert_eq!(
3547                CStr::from_ptr(err.as_ptr()).to_str().unwrap(),
3548                "network handle is NULL"
3549            );
3550            pio_network_free(c);
3551        }
3552    }
3553
3554    #[test]
3555    fn extract_branch_tables() {
3556        let c = case9();
3557        unsafe {
3558            // All-NULL is the count query.
3559            let nb = pio_branches(
3560                c,
3561                std::ptr::null_mut(),
3562                std::ptr::null_mut(),
3563                std::ptr::null_mut(),
3564                std::ptr::null_mut(),
3565                std::ptr::null_mut(),
3566                std::ptr::null_mut(),
3567                std::ptr::null_mut(),
3568                std::ptr::null_mut(),
3569                0,
3570            );
3571            assert_eq!(nb, pio_n_branches(c));
3572            let mut from = vec![0i64; nb];
3573            let mut x = vec![0f64; nb];
3574            let total = pio_branches(
3575                c,
3576                from.as_mut_ptr(),
3577                std::ptr::null_mut(),
3578                std::ptr::null_mut(),
3579                x.as_mut_ptr(),
3580                std::ptr::null_mut(),
3581                std::ptr::null_mut(),
3582                std::ptr::null_mut(),
3583                std::ptr::null_mut(),
3584                nb,
3585            );
3586            assert_eq!(total, nb);
3587            // `from` carries the 1-based bus ids (case9 buses are 1..=9), the
3588            // same id space as pio_bus_ids, not dense indices.
3589            assert!(from.iter().all(|&f| f >= 1));
3590            assert!(x.iter().all(|&xx| xx > 0.0));
3591            pio_network_free(c);
3592        }
3593    }
3594
3595    #[test]
3596    fn branch_tables_project_terminal_charging_to_legacy_b() {
3597        let c = terminal_projection_case();
3598        unsafe {
3599            let mut b = [0.0];
3600            let nb = pio_branches(
3601                c,
3602                std::ptr::null_mut(),
3603                std::ptr::null_mut(),
3604                std::ptr::null_mut(),
3605                std::ptr::null_mut(),
3606                b.as_mut_ptr(),
3607                std::ptr::null_mut(),
3608                std::ptr::null_mut(),
3609                std::ptr::null_mut(),
3610                1,
3611            );
3612            assert_eq!(nb, 1);
3613            close(b[0], 0.07);
3614
3615            let mut g_fr = [0.0];
3616            let mut b_fr = [0.0];
3617            let mut g_to = [0.0];
3618            let mut b_to = [0.0];
3619            let nb = pio_branch_charging(
3620                c,
3621                g_fr.as_mut_ptr(),
3622                b_fr.as_mut_ptr(),
3623                g_to.as_mut_ptr(),
3624                b_to.as_mut_ptr(),
3625                1,
3626            );
3627            assert_eq!(nb, 1);
3628            close(g_fr[0], 0.01);
3629            close(b_fr[0], 0.02);
3630            close(g_to[0], 0.03);
3631            close(b_to[0], 0.05);
3632            pio_network_free(c);
3633        }
3634    }
3635
3636    #[test]
3637    fn cap_clamps_the_write_and_returns_the_total() {
3638        let c = case9();
3639        unsafe {
3640            let total = pio_bus_ids(c, std::ptr::null_mut(), 0);
3641            assert_eq!(total, 9);
3642            // A two-slot buffer gets exactly two ids; the total still comes back,
3643            // so a short read is detectable.
3644            let mut ids = [-1i64; 2];
3645            assert_eq!(pio_bus_ids(c, ids.as_mut_ptr(), ids.len()), 9);
3646            assert!(ids.iter().all(|&id| id >= 1));
3647            pio_network_free(c);
3648        }
3649    }
3650
3651    #[test]
3652    fn convert_matpower_echo() {
3653        let path = data_path("case14.m");
3654        let to = CString::new("matpower").unwrap();
3655        let mut warn_out: *mut c_char = std::ptr::null_mut();
3656        let mut err = [0 as c_char; 256];
3657        unsafe {
3658            let s = pio_convert_file(
3659                path.as_ptr(),
3660                std::ptr::null(),
3661                to.as_ptr(),
3662                &mut warn_out,
3663                err.as_mut_ptr(),
3664                err.len(),
3665            );
3666            assert!(!s.is_null());
3667            let got = CStr::from_ptr(s).to_str().unwrap();
3668            let src = std::fs::read_to_string(
3669                std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case14.m"),
3670            )
3671            .unwrap();
3672            assert_eq!(got, src);
3673            pio_string_free(s);
3674        }
3675    }
3676
3677    #[test]
3678    fn convert_file_rejects_target_before_source_order() {
3679        let path = data_path("case14.m");
3680        let old_target = CString::new("powermodels-json").unwrap();
3681        let old_source = CString::new("matpower").unwrap();
3682        let mut warn_out: *mut c_char = std::ptr::null_mut();
3683        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3684        unsafe {
3685            let s = pio_convert_file(
3686                path.as_ptr(),
3687                old_target.as_ptr(),
3688                old_source.as_ptr(),
3689                &mut warn_out,
3690                err.as_mut_ptr(),
3691                err.len(),
3692            );
3693            assert!(
3694                s.is_null(),
3695                "legacy target-before-source order unexpectedly succeeded"
3696            );
3697            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
3698            assert!(!msg.is_empty(), "expected an explanatory parse error");
3699        }
3700    }
3701
3702    #[test]
3703    fn convert_str_round_trips_in_memory() {
3704        // The in-memory converter is parse_str + to_format fused: matpower in,
3705        // powermodels out, no filesystem.
3706        let src = std::fs::read_to_string(
3707            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"),
3708        )
3709        .unwrap();
3710        let text = CString::new(src).unwrap();
3711        let from = CString::new("matpower").unwrap();
3712        let to = CString::new("powermodels-json").unwrap();
3713        let mut warn_out: *mut c_char = std::ptr::null_mut();
3714        let mut err = [0 as c_char; 256];
3715        unsafe {
3716            let s = pio_convert_str(
3717                text.as_ptr(),
3718                from.as_ptr(),
3719                to.as_ptr(),
3720                &mut warn_out,
3721                err.as_mut_ptr(),
3722                err.len(),
3723            );
3724            assert!(
3725                !s.is_null(),
3726                "convert_str failed: {}",
3727                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
3728            );
3729            let out = CStr::from_ptr(s).to_str().unwrap();
3730            assert!(out.contains("\"bus\""));
3731            pio_string_free(s);
3732        }
3733    }
3734
3735    #[test]
3736    fn convert_str_rejects_target_before_source_order() {
3737        let src = std::fs::read_to_string(
3738            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"),
3739        )
3740        .unwrap();
3741        let text = CString::new(src).unwrap();
3742        let old_target = CString::new("powermodels-json").unwrap();
3743        let old_source = CString::new("matpower").unwrap();
3744        let mut warn_out: *mut c_char = std::ptr::null_mut();
3745        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
3746        unsafe {
3747            let s = pio_convert_str(
3748                text.as_ptr(),
3749                old_target.as_ptr(),
3750                old_source.as_ptr(),
3751                &mut warn_out,
3752                err.as_mut_ptr(),
3753                err.len(),
3754            );
3755            assert!(
3756                s.is_null(),
3757                "legacy target-before-source order unexpectedly succeeded"
3758            );
3759            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
3760            assert!(!msg.is_empty(), "expected an explanatory parse error");
3761        }
3762    }
3763
3764    #[test]
3765    fn to_format_converts_live_handle() {
3766        let c = case9();
3767        unsafe {
3768            let text = to_format(c, "powermodels-json");
3769            assert!(text.contains("\"bus\""));
3770            pio_network_free(c);
3771        }
3772    }
3773
3774    #[test]
3775    fn parse_error_sets_message_not_null_handle() {
3776        let path = CString::new("/no/such/case.m").unwrap();
3777        let mut err = [0 as c_char; 256];
3778        let c =
3779            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
3780        assert!(c.is_null());
3781        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
3782        assert!(!msg.is_empty(), "expected an error message");
3783    }
3784
3785    #[test]
3786    fn non_utf8_from_hint_errors_instead_of_falling_back() {
3787        let path = data_path("case9.m");
3788        let to = CString::new("matpower").unwrap();
3789        let bad_from = [0xff_u8, 0];
3790        let mut err = [0 as c_char; 256];
3791        let c = unsafe {
3792            pio_parse_file(
3793                path.as_ptr(),
3794                bad_from.as_ptr().cast::<c_char>(),
3795                err.as_mut_ptr(),
3796                err.len(),
3797            )
3798        };
3799        assert!(c.is_null());
3800        assert_eq!(
3801            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
3802            "from is not UTF-8"
3803        );
3804
3805        let mut warn_out: *mut c_char = std::ptr::null_mut();
3806        err.fill(0);
3807        let s = unsafe {
3808            pio_convert_file(
3809                path.as_ptr(),
3810                bad_from.as_ptr().cast::<c_char>(),
3811                to.as_ptr(),
3812                &mut warn_out,
3813                err.as_mut_ptr(),
3814                err.len(),
3815            )
3816        };
3817        assert!(s.is_null());
3818        assert_eq!(
3819            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
3820            "from is not UTF-8"
3821        );
3822    }
3823
3824    #[test]
3825    fn extract_gen_and_bus_aggregate_tables() {
3826        // case30 carries generators, loads, and shunts: cross-check the table
3827        // extractors against known counts and aggregate signs (a column swap in
3828        // pio_gens/pio_bus_* would otherwise ship silently).
3829        let path = data_path("case30.m");
3830        let mut err = [0 as c_char; 256];
3831        let c =
3832            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
3833        assert!(!c.is_null());
3834        unsafe {
3835            let nb = pio_n_buses(c);
3836            let ng = pio_n_gens(c);
3837            assert_eq!(nb, 30);
3838            assert!(ng > 0);
3839
3840            let mut gbus = vec![-9i64; ng];
3841            let mut pmax = vec![0f64; ng];
3842            let total = pio_gens(
3843                c,
3844                gbus.as_mut_ptr(),
3845                std::ptr::null_mut(),
3846                pmax.as_mut_ptr(),
3847                std::ptr::null_mut(),
3848                std::ptr::null_mut(),
3849                ng,
3850            );
3851            assert_eq!(total, ng);
3852            // Generator buses are 1-based ids within the case's id range.
3853            assert!(gbus.iter().all(|&b| (1..=nb as i64).contains(&b)));
3854            assert!(pmax.iter().any(|&p| p > 0.0));
3855
3856            let mut ids = vec![0i64; nb];
3857            assert_eq!(pio_bus_ids(c, ids.as_mut_ptr(), nb), nb);
3858            assert!(ids.iter().all(|&id| id >= 1)); // MATPOWER bus ids are 1-based
3859
3860            let mut pd = vec![0f64; nb];
3861            let mut qd = vec![0f64; nb];
3862            assert_eq!(pio_bus_demand(c, pd.as_mut_ptr(), qd.as_mut_ptr(), nb), nb);
3863            assert!(pd.iter().sum::<f64>() > 0.0, "case30 has active demand");
3864
3865            let mut gs = vec![0f64; nb];
3866            let mut bs = vec![0f64; nb];
3867            assert_eq!(pio_bus_shunt(c, gs.as_mut_ptr(), bs.as_mut_ptr(), nb), nb);
3868            assert!(gs.iter().chain(bs.iter()).all(|x| x.is_finite()));
3869
3870            pio_network_free(c);
3871        }
3872    }
3873
3874    #[test]
3875    fn null_handle_and_null_out_are_safe() {
3876        // Every query tolerates a NULL handle (the documented safe default), and
3877        // a NULL output pointer on a valid case is a count query, not a deref.
3878        unsafe {
3879            let nil: *const PioNetwork = std::ptr::null();
3880            assert_eq!(pio_n_buses(nil), 0);
3881            assert_eq!(pio_n_branches(nil), 0);
3882            assert_eq!(pio_n_gens(nil), 0);
3883            assert_eq!(pio_base_mva(nil), 0.0);
3884            assert_eq!(pio_ref_bus_index(nil), -1);
3885            assert_eq!(pio_ref_bus_indices(nil, std::ptr::null_mut(), 0), 0);
3886            assert_eq!(pio_is_radial(nil), 0);
3887            assert_eq!(pio_n_islands(nil), 0);
3888
3889            // The two FFI constructors reject a NULL input rather than crash.
3890            let mut err = [0 as c_char; 128];
3891            assert!(pio_normalize(nil, err.as_mut_ptr(), err.len()).is_null());
3892            assert!(
3893                pio_normalize_with_options(
3894                    nil,
3895                    1,
3896                    POWER_MODELS_ANGLE_BOUND_PAD,
3897                    err.as_mut_ptr(),
3898                    err.len()
3899                )
3900                .is_null()
3901            );
3902            let fmt = CString::new("matpower").unwrap();
3903            assert!(
3904                pio_parse_str(std::ptr::null(), fmt.as_ptr(), err.as_mut_ptr(), err.len())
3905                    .is_null()
3906            );
3907
3908            let c = case9();
3909            assert_eq!(pio_bus_ids(c, std::ptr::null_mut(), 0), 9);
3910            pio_ref_bus_indices(c, std::ptr::null_mut(), 0);
3911            pio_bus_demand(c, std::ptr::null_mut(), std::ptr::null_mut(), 0);
3912            pio_gens(
3913                c,
3914                std::ptr::null_mut(),
3915                std::ptr::null_mut(),
3916                std::ptr::null_mut(),
3917                std::ptr::null_mut(),
3918                std::ptr::null_mut(),
3919                0,
3920            );
3921            pio_network_free(c);
3922        }
3923    }
3924
3925    #[test]
3926    fn normalized_multi_ref_is_legible() {
3927        // A two-slack case (both gen-backed file REF buses) normalizes to a
3928        // handle that keeps both references. `pio_ref_bus_index` can't name a
3929        // single slack (returns -1), but the reference-set extractor does, so a
3930        // C consumer can tell "two slacks, you pick" from "no slack, broken".
3931        let src = "\
3932function mpc = tworef
3933mpc.version = '2';
3934mpc.baseMVA = 100;
3935mpc.bus = [
3936\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3937\t2\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3938\t3\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3939];
3940mpc.gen = [
3941\t1\t0\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
3942\t2\t0\t0\t100\t-100\t1\t100\t1\t300\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
3943];
3944mpc.branch = [
3945\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
3946\t2\t3\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
3947];
3948";
3949        let text = CString::new(src).unwrap();
3950        let fmt = CString::new("matpower").unwrap();
3951        let mut err = [0 as c_char; 256];
3952        unsafe {
3953            let cs = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
3954            assert!(!cs.is_null(), "parse_str returned null");
3955            let cn = pio_normalize(cs, err.as_mut_ptr(), err.len());
3956            assert!(!cn.is_null(), "normalize returned null");
3957
3958            // Count via NULL out, then fill.
3959            assert_eq!(pio_ref_bus_indices(cn, std::ptr::null_mut(), 0), 2);
3960            // Multiple references: the single-slack query reports -1, by design.
3961            assert_eq!(pio_ref_bus_index(cn), -1);
3962            let mut refs = [-1i64; 2];
3963            assert_eq!(pio_ref_bus_indices(cn, refs.as_mut_ptr(), refs.len()), 2);
3964            assert_eq!(refs, [0, 1]);
3965
3966            pio_network_free(cn);
3967            pio_network_free(cs);
3968        }
3969    }
3970
3971    #[test]
3972    fn normalized_preserves_source_bus_ids() {
3973        let src = "\
3974function mpc = sparseids
3975mpc.version = '2';
3976mpc.baseMVA = 100;
3977mpc.bus = [
3978\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3979\t2\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3980\t3\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3981\t4\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3982\t10\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
3983];
3984mpc.gen = [
3985\t1\t0\t0\t100\t-100\t1\t100\t1\t200\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
3986];
3987mpc.branch = [
3988\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
3989\t2\t3\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
3990\t3\t4\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
3991\t4\t10\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
3992];
3993";
3994        let text = CString::new(src).unwrap();
3995        let fmt = CString::new("matpower").unwrap();
3996        let mut err = [0 as c_char; 256];
3997        unsafe {
3998            let cs = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
3999            assert!(!cs.is_null(), "parse_str returned null");
4000            let cn = pio_normalize(cs, err.as_mut_ptr(), err.len());
4001            assert!(!cn.is_null(), "normalize returned null");
4002
4003            let mut ids = vec![0i64; pio_n_buses(cn)];
4004            pio_bus_ids(cn, ids.as_mut_ptr(), ids.len());
4005            assert_eq!(ids, vec![1, 2, 3, 4, 10]);
4006
4007            let mut from = vec![0i64; pio_n_branches(cn)];
4008            let mut to = vec![0i64; pio_n_branches(cn)];
4009            pio_branches(
4010                cn,
4011                from.as_mut_ptr(),
4012                to.as_mut_ptr(),
4013                std::ptr::null_mut(),
4014                std::ptr::null_mut(),
4015                std::ptr::null_mut(),
4016                std::ptr::null_mut(),
4017                std::ptr::null_mut(),
4018                std::ptr::null_mut(),
4019                from.len(),
4020            );
4021            assert_eq!((from[3], to[3]), (4, 10));
4022
4023            pio_network_free(cn);
4024            pio_network_free(cs);
4025        }
4026    }
4027
4028    #[test]
4029    fn normalize_with_options_clamps_angle_bounds_and_warns() {
4030        let c = angle_bounds_case();
4031        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4032        unsafe {
4033            let cn = pio_normalize_with_options(
4034                c,
4035                1,
4036                POWER_MODELS_ANGLE_BOUND_PAD,
4037                err.as_mut_ptr(),
4038                err.len(),
4039            );
4040            assert!(
4041                !cn.is_null(),
4042                "normalize with options returned null: {}",
4043                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4044            );
4045            let v = network_json(cn);
4046            close(
4047                v["branches"][0]["angmin"].as_f64().unwrap(),
4048                -POWER_MODELS_ANGLE_BOUND_PAD,
4049            );
4050            close(
4051                v["branches"][0]["angmax"].as_f64().unwrap(),
4052                POWER_MODELS_ANGLE_BOUND_PAD,
4053            );
4054            close(
4055                v["branches"][1]["angmin"].as_f64().unwrap(),
4056                -POWER_MODELS_ANGLE_BOUND_PAD,
4057            );
4058            close(
4059                v["branches"][1]["angmax"].as_f64().unwrap(),
4060                POWER_MODELS_ANGLE_BOUND_PAD,
4061            );
4062            close(
4063                v["branches"][2]["angmin"].as_f64().unwrap(),
4064                -std::f64::consts::PI / 6.0,
4065            );
4066            close(
4067                v["branches"][2]["angmax"].as_f64().unwrap(),
4068                std::f64::consts::PI / 6.0,
4069            );
4070            close(
4071                v["branches"][3]["angmin"].as_f64().unwrap(),
4072                -POWER_MODELS_ANGLE_BOUND_PAD,
4073            );
4074            close(
4075                v["branches"][3]["angmax"].as_f64().unwrap(),
4076                POWER_MODELS_ANGLE_BOUND_PAD,
4077            );
4078            close(
4079                v["branches"][4]["angmin"].as_f64().unwrap(),
4080                -POWER_MODELS_ANGLE_BOUND_PAD,
4081            );
4082            close(
4083                v["branches"][4]["angmax"].as_f64().unwrap(),
4084                POWER_MODELS_ANGLE_BOUND_PAD,
4085            );
4086            for branch in v["branches"].as_array().unwrap() {
4087                assert!(branch["angmin"].as_f64().unwrap() <= branch["angmax"].as_f64().unwrap());
4088            }
4089
4090            let warnings = warning_text(cn);
4091            assert!(warnings.contains("branch 0 angle difference bounds clamped"));
4092            assert!(warnings.contains("branch 1 angle difference bounds clamped"));
4093            assert!(warnings.contains("branch 3 angle difference bounds clamped"));
4094            assert!(warnings.contains("branch 4 angle difference bounds clamped"));
4095
4096            pio_network_free(cn);
4097            pio_network_free(c);
4098        }
4099    }
4100
4101    #[test]
4102    fn normalize_with_options_rejects_invalid_angle_pad() {
4103        let c = angle_bounds_case();
4104        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4105        unsafe {
4106            let cn = pio_normalize_with_options(
4107                c,
4108                1,
4109                std::f64::consts::FRAC_PI_2,
4110                err.as_mut_ptr(),
4111                err.len(),
4112            );
4113            assert!(cn.is_null());
4114            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
4115            assert!(msg.contains("angle_bound_pad"), "{msg}");
4116            pio_network_free(c);
4117        }
4118    }
4119
4120    #[test]
4121    fn convert_emits_warning_into_buffer() {
4122        // t_case9_dcline carries an HVDC dcline. PSS/E writes it as two-terminal DC
4123        // but defaults the converter detail; that fidelity note must reach the
4124        // caller's warning buffer, not vanish.
4125        let path = data_path("t_case9_dcline.m");
4126        let to = CString::new("psse").unwrap();
4127        let mut warn_out: *mut c_char = std::ptr::null_mut();
4128        let mut err = [0 as c_char; 256];
4129        unsafe {
4130            let s = pio_convert_file(
4131                path.as_ptr(),
4132                std::ptr::null(),
4133                to.as_ptr(),
4134                &mut warn_out,
4135                err.as_mut_ptr(),
4136                err.len(),
4137            );
4138            assert!(!s.is_null());
4139            assert!(!warn_out.is_null(), "expected fidelity warnings");
4140            let w = CStr::from_ptr(warn_out).to_str().unwrap().to_owned();
4141            assert!(
4142                w.contains("converter detail"),
4143                "expected an HVDC converter-detail warning, got {w:?}"
4144            );
4145            pio_string_free(warn_out);
4146            pio_string_free(s);
4147        }
4148    }
4149
4150    #[test]
4151    fn snapshot_round_trip_preserves_structure() {
4152        // to_format("powerio-json") -> parse_str("powerio-json") must reproduce
4153        // the structured tables. case30 carries loads, shunts, and gen costs,
4154        // so a dropped field shows up.
4155        let path = data_path("case30.m");
4156        let mut err = [0 as c_char; 256];
4157        let c =
4158            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
4159        assert!(!c.is_null());
4160        unsafe {
4161            let json = to_format(c, "powerio-json");
4162            assert!(json.contains("\"buses\""));
4163
4164            let text = CString::new(json).unwrap();
4165            let fmt = CString::new("powerio-json").unwrap();
4166            let back = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
4167            assert!(
4168                !back.is_null(),
4169                "snapshot parse failed: {}",
4170                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4171            );
4172            // The snapshot is lossless: no fidelity warnings on the way back.
4173            assert_eq!(pio_warnings(back, std::ptr::null_mut(), 0), 0);
4174            // Counts and base survive the round trip.
4175            assert_eq!(pio_n_buses(back), pio_n_buses(c));
4176            assert_eq!(pio_n_branches(back), pio_n_branches(c));
4177            assert_eq!(pio_n_gens(back), pio_n_gens(c));
4178            assert_eq!(pio_base_mva(back), pio_base_mva(c));
4179            assert_eq!(pio_ref_bus_index(back), pio_ref_bus_index(c));
4180
4181            // The bare "json" alias means the same snapshot format.
4182            let alias = CString::new("json").unwrap();
4183            let again = pio_parse_str(text.as_ptr(), alias.as_ptr(), err.as_mut_ptr(), err.len());
4184            assert!(!again.is_null());
4185            assert_eq!(pio_n_buses(again), pio_n_buses(c));
4186
4187            pio_network_free(again);
4188            pio_network_free(back);
4189            pio_network_free(c);
4190        }
4191    }
4192
4193    #[test]
4194    fn snapshot_rejects_garbage() {
4195        let bad = CString::new("{ not json").unwrap();
4196        let fmt = CString::new("powerio-json").unwrap();
4197        let mut err = [0 as c_char; 256];
4198        let h = unsafe { pio_parse_str(bad.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len()) };
4199        assert!(h.is_null());
4200        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
4201        assert!(!msg.is_empty(), "expected a JSON parse error message");
4202    }
4203
4204    #[test]
4205    fn error_buffer_truncates_and_nul_terminates() {
4206        // copy_to_buf must truncate an oversized message to fit and keep the
4207        // trailing NUL (the one piece of pointer arithmetic in the file).
4208        let path = CString::new("/no/such/directory/deeply/nested/missing/case.m").unwrap();
4209        let mut err = [0x7f as c_char; 16]; // prefill nonzero so the NUL is visible
4210        let c =
4211            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
4212        assert!(c.is_null());
4213        let nul = err
4214            .iter()
4215            .position(|&b| b == 0)
4216            .expect("buffer must be NUL-terminated");
4217        assert!(nul <= 15);
4218    }
4219
4220    #[test]
4221    fn truncation_lands_on_a_utf8_char_boundary() {
4222        // "aé€" is 1+2+3 bytes; a 6-byte buffer fits 5 message bytes, which
4223        // would split '€'. The copy must back up to "aé" instead of emitting a
4224        // dangling partial codepoint.
4225        let mut buf = [0x7f as c_char; 6];
4226        unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") };
4227        let s = unsafe { CStr::from_ptr(buf.as_ptr()) }
4228            .to_str()
4229            .expect("truncated message must be valid UTF-8");
4230        assert_eq!(s, "aé");
4231
4232        // A message that fits is copied whole.
4233        let mut buf = [0x7f as c_char; 8];
4234        unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") };
4235        let s = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap();
4236        assert_eq!(s, "aé€");
4237    }
4238
4239    #[cfg(feature = "pkg")]
4240    #[test]
4241    fn package_feature_is_reported() {
4242        let pkg = CString::new("pkg").unwrap();
4243        let nope = CString::new("nope").unwrap();
4244        unsafe {
4245            assert_eq!(pio_has_feature(pkg.as_ptr()), 1);
4246            assert_eq!(pio_has_feature(nope.as_ptr()), 0);
4247        }
4248    }
4249
4250    #[cfg(feature = "pkg")]
4251    #[test]
4252    fn package_materialize_reports_unknown_identity() {
4253        use powerio_pkg::{
4254            ElementRef, ElementUpdate, NetworkPackage, OperatingPoint, OperatingPointSeries,
4255            TimeAxis,
4256        };
4257
4258        let case = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4259            .join("../tests/data")
4260            .join("case9.m");
4261        let net = powerio::parse_str(&std::fs::read_to_string(case).unwrap(), "matpower")
4262            .unwrap()
4263            .network;
4264        let mut point = OperatingPoint::new(0);
4265        point.updates.push(ElementUpdate::new(
4266            ElementRef::by_source_uid("generators", "no-such-uid"),
4267            std::collections::BTreeMap::from([("pg".to_owned(), serde_json::json!(1.0))]),
4268        ));
4269        let package = NetworkPackage::from_balanced(net).with_operating_points(
4270            OperatingPointSeries::new(TimeAxis::new(1).with_duration_hours(vec![1.0]), vec![point]),
4271        );
4272        let json = CString::new(package.to_json().unwrap()).unwrap();
4273
4274        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4275        unsafe {
4276            let pkg = pio_package_parse_str(json.as_ptr(), err.as_mut_ptr(), err.len());
4277            assert!(
4278                !pkg.is_null(),
4279                "package parse_str failed: {}",
4280                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4281            );
4282            let materialized =
4283                pio_package_materialize_operating_point(pkg, 0, err.as_mut_ptr(), err.len());
4284            assert!(materialized.is_null(), "unknown identity must fail");
4285            let message = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
4286            assert!(
4287                message.contains("unknown identity"),
4288                "unexpected error: {message}"
4289            );
4290            pio_package_free(pkg);
4291        }
4292    }
4293
4294    #[cfg(feature = "pkg")]
4295    #[test]
4296    fn package_set_operating_points_round_trips() {
4297        use powerio_pkg::NetworkPackage;
4298
4299        let case = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4300            .join("../tests/data")
4301            .join("case9.m");
4302        let net = powerio::parse_str(&std::fs::read_to_string(case).unwrap(), "matpower")
4303            .unwrap()
4304            .network;
4305        let json = CString::new(NetworkPackage::from_balanced(net).to_json().unwrap()).unwrap();
4306        let series_text = r#"{
4307            "time_axis": {"periods": 1, "duration_hours": [1.0]},
4308            "points": [
4309                {
4310                    "index": 0,
4311                    "updates": [
4312                        {
4313                            "element": {"table": "generators", "source_uid": "generators:0"},
4314                            "fields": {"pg": 1.5}
4315                        }
4316                    ]
4317                }
4318            ]
4319        }"#;
4320        let series = CString::new(series_text).unwrap();
4321
4322        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4323        unsafe {
4324            let pkg = pio_package_parse_str(json.as_ptr(), err.as_mut_ptr(), err.len());
4325            assert!(
4326                !pkg.is_null(),
4327                "package parse_str failed: {}",
4328                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4329            );
4330            assert!(
4331                package_report_json(pio_package_operating_points_json, pkg).is_null(),
4332                "freshly parsed package must start with no operating points"
4333            );
4334
4335            let status =
4336                pio_package_set_operating_points(pkg, series.as_ptr(), err.as_mut_ptr(), err.len());
4337            assert_eq!(
4338                status,
4339                0,
4340                "set_operating_points failed: {}",
4341                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4342            );
4343
4344            let expected: serde_json::Value = serde_json::from_str(series_text).unwrap();
4345            let echoed = package_report_json(pio_package_operating_points_json, pkg);
4346            assert_eq!(echoed, expected, "attached series did not echo back");
4347
4348            let invalid = CString::new("not json").unwrap();
4349            let status = pio_package_set_operating_points(
4350                pkg,
4351                invalid.as_ptr(),
4352                err.as_mut_ptr(),
4353                err.len(),
4354            );
4355            assert_eq!(status, -1);
4356            assert_eq!(
4357                package_report_json(pio_package_operating_points_json, pkg),
4358                expected,
4359                "a parse error must not replace the existing series"
4360            );
4361
4362            let materialized =
4363                pio_package_materialize_operating_point(pkg, 0, err.as_mut_ptr(), err.len());
4364            assert!(
4365                !materialized.is_null(),
4366                "materialize failed: {}",
4367                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4368            );
4369            let materialized_json = package_json(materialized);
4370            assert_eq!(
4371                materialized_json["model"]["balanced_network"]["generators"][0]["pg"],
4372                serde_json::json!(1.5)
4373            );
4374            pio_package_free(materialized);
4375
4376            // Attaching `null` clears the series back out.
4377            let clear = CString::new("null").unwrap();
4378            let status =
4379                pio_package_set_operating_points(pkg, clear.as_ptr(), err.as_mut_ptr(), err.len());
4380            assert_eq!(status, 0);
4381            assert!(package_report_json(pio_package_operating_points_json, pkg).is_null());
4382            assert_eq!(
4383                package_report_json(pio_package_validation_json, pkg)["status"],
4384                serde_json::json!("ok")
4385            );
4386
4387            pio_package_free(pkg);
4388        }
4389    }
4390
4391    #[cfg(feature = "pkg")]
4392    #[test]
4393    fn package_study_json_and_materialize_commit() {
4394        use powerio_pkg::{ElementRef, NetworkPackage, StudyBlock, StudyCommit, StudyEdit};
4395
4396        let case = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4397            .join("../tests/data")
4398            .join("case9.m");
4399        let net = powerio::parse_str(&std::fs::read_to_string(case).unwrap(), "matpower")
4400            .unwrap()
4401            .network;
4402        let mut commit = StudyCommit::default();
4403        commit.label = Some("load step".to_owned());
4404        commit.edits.push(StudyEdit::DemandDelta {
4405            bus: ElementRef::by_source_uid("buses", "buses:0"),
4406            p_mw: 7.0,
4407            q_mvar: Some(3.0),
4408        });
4409        let mut study = StudyBlock::default();
4410        study.label = Some("binding study".to_owned());
4411        study.commits.push(commit);
4412        let package = NetworkPackage::from_balanced(net).with_study(study);
4413        let json = CString::new(package.to_json().unwrap()).unwrap();
4414
4415        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4416        unsafe {
4417            let pkg = pio_package_parse_str(json.as_ptr(), err.as_mut_ptr(), err.len());
4418            assert!(
4419                !pkg.is_null(),
4420                "package parse_str failed: {}",
4421                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4422            );
4423
4424            let study = package_report_json(pio_package_study_json, pkg);
4425            assert_eq!(study["label"], serde_json::json!("binding study"));
4426            assert_eq!(
4427                study["commits"][0]["edits"][0]["kind"],
4428                serde_json::json!("demand_delta")
4429            );
4430
4431            let materialized =
4432                pio_package_materialize_study_commit(pkg, 0, err.as_mut_ptr(), err.len());
4433            assert!(
4434                !materialized.is_null(),
4435                "study materialization failed: {}",
4436                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4437            );
4438            let materialized_json = package_json(materialized);
4439            assert!(materialized_json.get("study").is_none());
4440            assert!(materialized_json.get("operating_points").is_none());
4441            let loads = materialized_json["model"]["balanced_network"]["loads"]
4442                .as_array()
4443                .unwrap();
4444            assert!(loads.iter().any(|load| {
4445                load["uid"] == serde_json::json!("study:load:buses:0")
4446                    && load["p"] == serde_json::json!(7.0)
4447                    && load["q"] == serde_json::json!(3.0)
4448            }));
4449
4450            let negative =
4451                pio_package_materialize_study_commit(pkg, -1, err.as_mut_ptr(), err.len());
4452            assert!(negative.is_null());
4453            let message = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
4454            assert!(
4455                message.contains("study commit index must be non-negative"),
4456                "unexpected error: {message}"
4457            );
4458
4459            pio_package_free(materialized);
4460            pio_package_free(pkg);
4461        }
4462    }
4463
4464    #[cfg(feature = "pkg")]
4465    #[test]
4466    fn package_parse_free_to_json_and_reports() {
4467        let net = case9();
4468        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4469        unsafe {
4470            let pkg = pio_package_from_balanced_network(net, 1, err.as_mut_ptr(), err.len());
4471            assert!(
4472                !pkg.is_null(),
4473                "package constructor failed: {}",
4474                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4475            );
4476            let v = package_json(pkg);
4477            assert_eq!(
4478                v[powerio::version::VERSION_KEY],
4479                serde_json::json!(powerio::VERSION)
4480            );
4481            assert_eq!(v["model_kind"], serde_json::json!("balanced"));
4482            assert_eq!(v["model"]["kind"], serde_json::json!("balanced"));
4483            assert_eq!(
4484                v["derived"]["normalized_solver_tables"]["row_counts"]["buses"],
4485                serde_json::json!(9)
4486            );
4487
4488            let json = CString::new(package_json_text(pkg)).unwrap();
4489            let parsed = pio_package_parse_str(json.as_ptr(), err.as_mut_ptr(), err.len());
4490            assert!(
4491                !parsed.is_null(),
4492                "package parse_str failed: {}",
4493                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4494            );
4495
4496            let tmp = tempfile::tempdir().unwrap();
4497            let path = tmp.path().join("case9.pio.json");
4498            std::fs::write(&path, CStr::from_ptr(json.as_ptr()).to_bytes()).unwrap();
4499            let path = CString::new(path.to_str().unwrap()).unwrap();
4500            let parsed_file = pio_package_parse_file(path.as_ptr(), err.as_mut_ptr(), err.len());
4501            assert!(
4502                !parsed_file.is_null(),
4503                "package parse_file failed: {}",
4504                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4505            );
4506
4507            assert_eq!(
4508                pio_package_validate(parsed_file, err.as_mut_ptr(), err.len()),
4509                0
4510            );
4511            let validation = package_report_json(pio_package_validation_json, parsed_file);
4512            assert_eq!(validation["status"], serde_json::json!("ok"));
4513            assert!(
4514                validation["passes"]
4515                    .as_array()
4516                    .unwrap()
4517                    .iter()
4518                    .any(|p| p["name"] == "balanced.structure")
4519            );
4520            let diagnostics = package_report_json(pio_package_diagnostics_json, parsed_file);
4521            assert!(diagnostics.as_array().unwrap().is_empty());
4522
4523            pio_package_free(parsed_file);
4524            pio_package_free(parsed);
4525            pio_package_free(pkg);
4526            pio_network_free(net);
4527        }
4528    }
4529
4530    #[cfg(feature = "pkg")]
4531    #[test]
4532    fn package_balanced_constructor_omits_solver_metadata_by_default() {
4533        let net = case9();
4534        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4535        unsafe {
4536            let pkg = pio_package_from_balanced_network(net, 0, err.as_mut_ptr(), err.len());
4537            assert!(
4538                !pkg.is_null(),
4539                "package constructor failed: {}",
4540                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4541            );
4542            let v = package_json(pkg);
4543            assert!(v["derived"].get("normalized_solver_tables").is_none());
4544            pio_package_free(pkg);
4545            pio_network_free(net);
4546        }
4547    }
4548
4549    #[cfg(feature = "prob")]
4550    const GOC3_SMALL_FIXTURE: &str = include_str!("../../powerio-prob/tests/data/goc3_small.json");
4551
4552    #[cfg(feature = "prob")]
4553    #[test]
4554    fn scopf_handle_serializes_its_julia_document() {
4555        let text = CString::new(GOC3_SMALL_FIXTURE).unwrap();
4556        let from = CString::new("goc3-json").unwrap();
4557        let feature = CString::new("prob").unwrap();
4558        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4559        unsafe {
4560            assert_eq!(pio_has_feature(feature.as_ptr()), 1);
4561            let instance =
4562                pio_scopf_parse_str(text.as_ptr(), from.as_ptr(), err.as_mut_ptr(), err.len());
4563            assert!(
4564                !instance.is_null(),
4565                "pio_scopf_parse_str failed: {}",
4566                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4567            );
4568            let json = pio_scopf_to_json(instance, err.as_mut_ptr(), err.len());
4569            assert!(!json.is_null());
4570            let text = CStr::from_ptr(json).to_str().unwrap().to_owned();
4571            let v: serde_json::Value = serde_json::from_str(&text).unwrap();
4572
4573            assert_eq!(v["schema"], "powerio.scopf.julia");
4574            assert_eq!(v[powerio::version::VERSION_KEY], powerio::VERSION);
4575            assert_eq!(v["index_base"], 1);
4576            assert_eq!(v["instance"]["lengths"]["I"], 2);
4577            assert_eq!(v["instance"]["static"]["acl_branch"][0]["j_ln"], 1);
4578
4579            pio_string_free(json);
4580            pio_scopf_instance_free(instance);
4581            pio_scopf_instance_free(std::ptr::null_mut());
4582        }
4583    }
4584
4585    #[cfg(feature = "prob")]
4586    #[test]
4587    fn scopf_handle_reports_format_parse_and_null_errors() {
4588        let valid = CString::new(GOC3_SMALL_FIXTURE).unwrap();
4589        let text = CString::new("not json").unwrap();
4590        let from = CString::new("goc3-json").unwrap();
4591        let unsupported = CString::new("matpower").unwrap();
4592        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4593        unsafe {
4594            let instance =
4595                pio_scopf_parse_str(text.as_ptr(), from.as_ptr(), err.as_mut_ptr(), err.len());
4596            assert!(instance.is_null());
4597            assert!(!CStr::from_ptr(err.as_ptr()).to_bytes().is_empty());
4598
4599            let instance = pio_scopf_parse_str(
4600                valid.as_ptr(),
4601                unsupported.as_ptr(),
4602                err.as_mut_ptr(),
4603                err.len(),
4604            );
4605            assert!(instance.is_null());
4606            assert!(
4607                CStr::from_ptr(err.as_ptr())
4608                    .to_str()
4609                    .unwrap()
4610                    .contains("unsupported SCOPF source format")
4611            );
4612
4613            let json = pio_scopf_to_json(std::ptr::null(), err.as_mut_ptr(), err.len());
4614            assert!(json.is_null());
4615            assert!(
4616                CStr::from_ptr(err.as_ptr())
4617                    .to_str()
4618                    .unwrap()
4619                    .contains("handle is NULL")
4620            );
4621        }
4622    }
4623
4624    #[test]
4625    fn geo_parse_normalizes_and_apply_returns_a_placed_handle() {
4626        let net = case9();
4627        let coords = CString::new("1, -89.6, 40.6\n2, -89.2, 39.8\n").unwrap();
4628        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4629        unsafe {
4630            // No coordinates yet: extract refuses.
4631            let empty = pio_geo_extract(net, err.as_mut_ptr(), err.len());
4632            assert!(empty.is_null());
4633
4634            let canonical = pio_geo_parse(
4635                coords.as_ptr(),
4636                std::ptr::null(),
4637                err.as_mut_ptr(),
4638                err.len(),
4639            );
4640            assert!(
4641                !canonical.is_null(),
4642                "pio_geo_parse failed: {}",
4643                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4644            );
4645            let v: serde_json::Value =
4646                serde_json::from_str(CStr::from_ptr(canonical).to_str().unwrap()).unwrap();
4647            assert_eq!(v["type"], "FeatureCollection");
4648            assert_eq!(v["powerio_geo"]["space"], "geographic");
4649
4650            let placed = pio_geo_apply(
4651                net,
4652                coords.as_ptr(),
4653                std::ptr::null(),
4654                err.as_mut_ptr(),
4655                err.len(),
4656            );
4657            assert!(
4658                !placed.is_null(),
4659                "pio_geo_apply failed: {}",
4660                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4661            );
4662            let layer = pio_geo_extract(placed, err.as_mut_ptr(), err.len());
4663            assert!(!layer.is_null());
4664            let v: serde_json::Value =
4665                serde_json::from_str(CStr::from_ptr(layer).to_str().unwrap()).unwrap();
4666            assert_eq!(v["features"].as_array().unwrap().len(), 2);
4667
4668            // The apply summary rides the new handle's warnings.
4669            let count = pio_warnings(placed, std::ptr::null_mut(), 0);
4670            assert!(count > 0);
4671
4672            pio_string_free(layer);
4673            pio_string_free(canonical);
4674            pio_network_free(placed);
4675            pio_network_free(net);
4676
4677            let garbage = CString::new("not a geo file").unwrap();
4678            let out = pio_geo_parse(
4679                garbage.as_ptr(),
4680                std::ptr::null(),
4681                err.as_mut_ptr(),
4682                err.len(),
4683            );
4684            assert!(out.is_null());
4685            assert!(!CStr::from_ptr(err.as_ptr()).to_bytes().is_empty());
4686        }
4687    }
4688
4689    #[cfg(all(feature = "pkg", feature = "dist"))]
4690    mod package_dist {
4691        use super::*;
4692
4693        fn strings(values: &[&str]) -> Vec<String> {
4694            values.iter().map(|v| (*v).to_owned()).collect()
4695        }
4696
4697        fn zero_matrix(n: usize) -> powerio_dist::Mat {
4698            vec![vec![0.0; n]; n]
4699        }
4700
4701        fn diagonal_matrix(n: usize, value: f64) -> powerio_dist::Mat {
4702            let mut matrix = zero_matrix(n);
4703            for (idx, row) in matrix.iter_mut().enumerate() {
4704                row[idx] = value;
4705            }
4706            matrix
4707        }
4708
4709        fn phase_reference(terminals: &[&str], grounded: &[&str]) -> (Vec<f64>, Vec<f64>) {
4710            let phase_angles = [
4711                0.0,
4712                -2.0 * std::f64::consts::PI / 3.0,
4713                2.0 * std::f64::consts::PI / 3.0,
4714            ];
4715            let mut magnitudes = vec![0.0; terminals.len()];
4716            let mut angles = vec![0.0; terminals.len()];
4717            let mut active = 0;
4718            for (idx, terminal) in terminals.iter().enumerate() {
4719                if grounded.contains(terminal) || *terminal == "0" {
4720                    continue;
4721                }
4722                magnitudes[idx] = 240.0;
4723                if active < phase_angles.len() {
4724                    angles[idx] = phase_angles[active];
4725                }
4726                active += 1;
4727            }
4728            (magnitudes, angles)
4729        }
4730
4731        fn preflight_network(
4732            terminals: &[&str],
4733            grounded: &[&str],
4734        ) -> powerio_dist::MulticonductorNetwork {
4735            use powerio_dist::{
4736                DistBus, DistLine, DistLineCode, MulticonductorNetwork, VoltageSource,
4737            };
4738
4739            let n = terminals.len();
4740            let terminal_map = strings(terminals);
4741            let (v_magnitude, v_angle) = phase_reference(terminals, grounded);
4742            let mut net = MulticonductorNetwork::default();
4743            for id in ["sourcebus", "loadbus"] {
4744                let mut bus = DistBus::new(id, terminal_map.clone());
4745                bus.grounded = strings(grounded);
4746                net.buses.push(bus);
4747            }
4748            let mut linecode =
4749                DistLineCode::new("lc", diagonal_matrix(n, 0.01), diagonal_matrix(n, 0.10));
4750            linecode.g_from = zero_matrix(n);
4751            linecode.b_from = zero_matrix(n);
4752            linecode.g_to = zero_matrix(n);
4753            linecode.b_to = zero_matrix(n);
4754            net.linecodes.push(linecode);
4755            net.lines.push(DistLine::new(
4756                "l1",
4757                "sourcebus",
4758                "loadbus",
4759                terminal_map.clone(),
4760                terminal_map.clone(),
4761                "lc",
4762                1.0,
4763            ));
4764            net.sources.push(VoltageSource::new(
4765                "source",
4766                "sourcebus",
4767                terminal_map,
4768                v_magnitude,
4769                v_angle,
4770            ));
4771            net
4772        }
4773
4774        #[test]
4775        fn multiconductor_package_preflight_and_lowering() {
4776            let dist = PioDistNetwork {
4777                net: preflight_network(&["1", "2", "3"], &[]),
4778            };
4779            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4780            unsafe {
4781                let pkg =
4782                    pio_package_from_multiconductor_network(&dist, err.as_mut_ptr(), err.len());
4783                assert!(
4784                    !pkg.is_null(),
4785                    "multiconductor package constructor failed: {}",
4786                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4787                );
4788                let v = package_json(pkg);
4789                assert_eq!(v["model_kind"], serde_json::json!("multiconductor"));
4790
4791                let report = pio_package_multiconductor_to_balanced_preflight_json(
4792                    pkg,
4793                    50.0,
4794                    err.as_mut_ptr(),
4795                    err.len(),
4796                );
4797                assert!(
4798                    !report.is_null(),
4799                    "preflight failed: {}",
4800                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4801                );
4802                let report_json: serde_json::Value =
4803                    serde_json::from_str(CStr::from_ptr(report).to_str().unwrap()).unwrap();
4804                assert_eq!(report_json["status"], serde_json::json!("ok"));
4805                assert_eq!(report_json["base_mva"], serde_json::json!(50.0));
4806                pio_string_free(report);
4807
4808                let lowered = pio_package_lower_multiconductor_to_balanced(
4809                    pkg,
4810                    75.0,
4811                    err.as_mut_ptr(),
4812                    err.len(),
4813                );
4814                assert!(
4815                    !lowered.is_null(),
4816                    "lowering failed: {}",
4817                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4818                );
4819                let lowered_json = package_json(lowered);
4820                assert_eq!(lowered_json["model_kind"], serde_json::json!("balanced"));
4821                assert_eq!(
4822                    lowered_json["model"]["balanced_network"]["base_mva"],
4823                    serde_json::json!(75.0)
4824                );
4825                assert_eq!(
4826                    lowered_json["lowering_history"][0]["pass"],
4827                    serde_json::json!("multiconductor-to-balanced")
4828                );
4829
4830                let invalid_report = pio_package_multiconductor_to_balanced_preflight_json(
4831                    pkg,
4832                    0.0,
4833                    err.as_mut_ptr(),
4834                    err.len(),
4835                );
4836                assert!(
4837                    !invalid_report.is_null(),
4838                    "invalid-base preflight failed: {}",
4839                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4840                );
4841                let invalid_report_json: serde_json::Value =
4842                    serde_json::from_str(CStr::from_ptr(invalid_report).to_str().unwrap()).unwrap();
4843                assert_eq!(invalid_report_json["status"], serde_json::json!("error"));
4844                assert!(
4845                    invalid_report_json["diagnostics"]
4846                        .as_array()
4847                        .unwrap()
4848                        .iter()
4849                        .any(|d| d["code"] == "LOWER.MULTI_TO_BALANCED.INVALID_BASE_MVA")
4850                );
4851                pio_string_free(invalid_report);
4852
4853                let invalid_lowered = pio_package_lower_multiconductor_to_balanced(
4854                    pkg,
4855                    0.0,
4856                    err.as_mut_ptr(),
4857                    err.len(),
4858                );
4859                assert!(invalid_lowered.is_null());
4860                let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
4861                assert!(msg.contains("base_mva must be positive"), "got: {msg}");
4862
4863                pio_package_free(lowered);
4864                pio_package_free(pkg);
4865            }
4866        }
4867    }
4868
4869    #[cfg(feature = "arrow")]
4870    #[test]
4871    fn to_arrow_null_out_params_return_error() {
4872        // A NULL out_array/out_schema must be reported (-1), not dereferenced.
4873        let c = case9();
4874        let mut err = [0 as c_char; 256];
4875        let rc = unsafe {
4876            pio_to_arrow(
4877                c,
4878                PIO_ARROW_TABLE_BUS,
4879                std::ptr::null_mut(),
4880                std::ptr::null_mut(),
4881                err.as_mut_ptr(),
4882                err.len(),
4883            )
4884        };
4885        assert_eq!(rc, -1);
4886        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
4887        assert!(!msg.is_empty(), "expected an error message");
4888        unsafe { pio_network_free(c) };
4889    }
4890
4891    #[cfg(feature = "arrow")]
4892    #[test]
4893    fn arrow_catalog_json_symbol_returns_table_catalog() {
4894        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
4895        let ptr = unsafe { pio_arrow_catalog_json(err.as_mut_ptr(), err.len()) };
4896        assert!(
4897            !ptr.is_null(),
4898            "{}",
4899            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
4900        );
4901        let text = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned();
4902        unsafe { pio_string_free(ptr) };
4903        let catalog: serde_json::Value = serde_json::from_str(&text).unwrap();
4904        assert_eq!(catalog[powerio::version::VERSION_KEY], powerio::VERSION);
4905        assert!(catalog["tables"].as_array().unwrap().iter().any(|table| {
4906            table["id"] == serde_json::json!(PIO_ARROW_TABLE_MATRIX_BUS)
4907                && table["name"] == serde_json::json!("matrix_bus")
4908        }));
4909    }
4910
4911    #[cfg(feature = "gridfm")]
4912    #[test]
4913    fn read_dir_round_trips_and_enumerates_scenarios() {
4914        use powerio_matrix::{GridfmOptions, write_gridfm_dataset};
4915        // Write a one-scenario dataset, then read it back over the C ABI.
4916        let net = powerio::parse_file(
4917            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case14.m"),
4918            None,
4919        )
4920        .unwrap()
4921        .network;
4922        let tmp = tempfile::tempdir().unwrap();
4923        let out = write_gridfm_dataset(&net, 0, tmp.path(), &GridfmOptions::default()).unwrap();
4924        let dir = CString::new(out.dir.to_str().unwrap()).unwrap();
4925        let from = CString::new("gridfm").unwrap();
4926
4927        let mut err = [0 as c_char; 256];
4928        unsafe {
4929            let h = pio_read_dir(dir.as_ptr(), from.as_ptr(), 0, err.as_mut_ptr(), err.len());
4930            assert!(
4931                !h.is_null(),
4932                "read failed: {}",
4933                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
4934            );
4935            assert_eq!(pio_n_buses(h), 14);
4936            // The lossy read's fidelity warnings attach to the handle, like
4937            // every other constructor's.
4938            assert!(
4939                pio_warnings(h, std::ptr::null_mut(), 0) > 0,
4940                "expected fidelity warnings on the handle"
4941            );
4942            pio_network_free(h);
4943
4944            // Scenario ids: size with a NULL out, then fill. One scenario -> [0].
4945            let count = pio_scenario_ids(
4946                dir.as_ptr(),
4947                from.as_ptr(),
4948                std::ptr::null_mut(),
4949                0,
4950                err.as_mut_ptr(),
4951                err.len(),
4952            );
4953            assert_eq!(count, 1);
4954            let mut ids = [-1i64; 4];
4955            let n = pio_scenario_ids(
4956                dir.as_ptr(),
4957                from.as_ptr(),
4958                ids.as_mut_ptr(),
4959                ids.len(),
4960                err.as_mut_ptr(),
4961                err.len(),
4962            );
4963            assert_eq!(n, 1);
4964            assert_eq!(ids[0], 0);
4965
4966            // An unknown dataset format is a loud error naming the known ones.
4967            let bad = CString::new("pypsa").unwrap();
4968            let h = pio_read_dir(dir.as_ptr(), bad.as_ptr(), 0, err.as_mut_ptr(), err.len());
4969            assert!(h.is_null());
4970            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
4971            assert!(msg.contains("gridfm"), "got: {msg}");
4972
4973            // A missing dataset directory errors (NULL handle + message), not a panic.
4974            let missing = CString::new(tmp.path().join("nope").to_str().unwrap()).unwrap();
4975            let bad = pio_read_dir(
4976                missing.as_ptr(),
4977                from.as_ptr(),
4978                0,
4979                err.as_mut_ptr(),
4980                err.len(),
4981            );
4982            assert!(bad.is_null());
4983            assert!(!CStr::from_ptr(err.as_ptr()).to_str().unwrap().is_empty());
4984        }
4985    }
4986
4987    #[test]
4988    fn write_dir_rejects_text_formats_by_name() {
4989        let c = case9();
4990        let to = CString::new("matpower").unwrap();
4991        let dir = CString::new("/tmp/unused").unwrap();
4992        let mut err = [0 as c_char; 256];
4993        unsafe {
4994            let rc = pio_write_dir(
4995                c,
4996                to.as_ptr(),
4997                dir.as_ptr(),
4998                std::ptr::null_mut(),
4999                err.as_mut_ptr(),
5000                err.len(),
5001            );
5002            assert_eq!(rc, -1);
5003            let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
5004            assert!(msg.contains("pypsa"), "got: {msg}");
5005            pio_network_free(c);
5006        }
5007    }
5008
5009    #[cfg(feature = "dist")]
5010    fn fourwire() -> std::path::PathBuf {
5011        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5012            .join("../tests/data/dist/micro/fourwire_linecode.dss")
5013    }
5014
5015    #[cfg(feature = "dist")]
5016    fn fourwire_cstr() -> CString {
5017        CString::new(fourwire().to_str().unwrap()).unwrap()
5018    }
5019
5020    /// Write `net` as BMOPF JSON. Shared by the round trip tests that compare
5021    /// a handle's output before and after crossing the package boundary.
5022    #[cfg(feature = "dist")]
5023    unsafe fn bmopf(net: *const PioDistNetwork) -> String {
5024        let to = CString::new("bmopf").unwrap();
5025        let mut warn_out: *mut c_char = std::ptr::null_mut();
5026        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5027        let s = unsafe {
5028            pio_dist_to_format(net, to.as_ptr(), &mut warn_out, err.as_mut_ptr(), err.len())
5029        };
5030        assert!(!s.is_null());
5031        let text = unsafe { std::ffi::CStr::from_ptr(s) }
5032            .to_str()
5033            .unwrap()
5034            .to_owned();
5035        unsafe { pio_string_free(s) };
5036        text
5037    }
5038
5039    #[cfg(feature = "dist")]
5040    mod dist {
5041        use super::*;
5042        use std::ffi::CStr;
5043
5044        #[cfg(feature = "pkg")]
5045        #[test]
5046        fn dist_geo_apply_returns_a_placed_handle() {
5047            let master = CString::new(
5048                "New Circuit.c1 bus1=sourcebus basekv=12.47\n\
5049                 New Line.l1 bus1=sourcebus bus2=loadbus length=1 units=km\n",
5050            )
5051            .unwrap();
5052            let format = CString::new("dss").unwrap();
5053            let coords = CString::new("sourcebus, -89.6, 40.6\nloadbus, -89.2, 39.8\n").unwrap();
5054            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5055            unsafe {
5056                let net = pio_dist_parse_str(
5057                    master.as_ptr(),
5058                    format.as_ptr(),
5059                    err.as_mut_ptr(),
5060                    err.len(),
5061                );
5062                assert!(!net.is_null());
5063                let empty = pio_dist_geo_extract(net, err.as_mut_ptr(), err.len());
5064                assert!(empty.is_null());
5065
5066                let placed = pio_dist_geo_apply(
5067                    net,
5068                    coords.as_ptr(),
5069                    std::ptr::null(),
5070                    err.as_mut_ptr(),
5071                    err.len(),
5072                );
5073                assert!(
5074                    !placed.is_null(),
5075                    "pio_dist_geo_apply failed: {}",
5076                    CStr::from_ptr(err.as_ptr()).to_str().unwrap()
5077                );
5078                let layer = pio_dist_geo_extract(placed, err.as_mut_ptr(), err.len());
5079                assert!(!layer.is_null());
5080                let v: serde_json::Value =
5081                    serde_json::from_str(CStr::from_ptr(layer).to_str().unwrap()).unwrap();
5082                assert_eq!(v["features"].as_array().unwrap().len(), 2);
5083                pio_string_free(layer);
5084                pio_dist_network_free(placed);
5085                pio_dist_network_free(net);
5086            }
5087        }
5088
5089        #[test]
5090        fn dist_abi_version_is_frozen_at_one() {
5091            assert_eq!(pio_abi_version(), PIO_ABI_VERSION);
5092            assert_eq!(PIO_ABI_VERSION, 5);
5093            assert_eq!(pio_dist_abi_version(), PIO_DIST_ABI_VERSION);
5094            assert_eq!(PIO_DIST_ABI_VERSION, 1);
5095            let feature = CString::new("dist").unwrap();
5096            assert_eq!(unsafe { pio_has_feature(feature.as_ptr()) }, 1);
5097        }
5098
5099        #[test]
5100        fn version_report_states_one_powerio_version_and_the_foreign_ones() {
5101            let raw = pio_schema_versions_json();
5102            assert!(!raw.is_null());
5103            let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
5104            unsafe { pio_string_free(raw) };
5105            let doc: serde_json::Value = serde_json::from_str(&text).unwrap();
5106
5107            // One key for every document powerio authors, and the C handshake
5108            // integer, which is a different mechanism.
5109            assert_eq!(
5110                doc[powerio::version::VERSION_KEY],
5111                serde_json::json!(powerio::VERSION)
5112            );
5113            assert_eq!(doc["abi"], serde_json::json!(PIO_ABI_VERSION));
5114
5115            // The per document numbers this report used to carry are gone,
5116            // not renamed: a binding that still reads one must fail loudly.
5117            for retired in ["schema_version", "package", "arrow", "dist_capabilities"] {
5118                assert_eq!(doc[retired], serde_json::Value::Null, "{retired}");
5119            }
5120
5121            // A foreign schema keeps its owner's version, which is the whole
5122            // reason this report exists.
5123            #[cfg(feature = "dist")]
5124            assert_eq!(
5125                doc["bmopf_schema"],
5126                serde_json::json!(powerio_dist::BMOPF_SCHEMA_VERSION)
5127            );
5128            #[cfg(not(feature = "dist"))]
5129            assert_eq!(doc["bmopf_schema"], serde_json::Value::Null);
5130        }
5131
5132        #[test]
5133        fn dist_capabilities_json_reports_bmopf_fidelity_flags() {
5134            let ptr = pio_dist_capabilities_json();
5135            assert!(!ptr.is_null(), "dist capabilities JSON returned NULL");
5136            let text = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned();
5137            unsafe { pio_string_free(ptr) };
5138
5139            let caps: serde_json::Value = serde_json::from_str(&text).unwrap();
5140            // Whole-document equality: each addition must be a deliberate
5141            // edit here.
5142            assert_eq!(
5143                caps,
5144                serde_json::json!({
5145                    "dist": true,
5146                    powerio::version::VERSION_KEY: powerio::VERSION,
5147                    "bmopf_fixed_taps": true,
5148                    "bmopf_center_tap_leakage": true,
5149                    "bmopf_delta_wye_leakage": true,
5150                    "bmopf_delta_roll": true,
5151                    "bmopf_voltage_source_merge": true,
5152                    "bmopf_transformer_diagnostics": true,
5153                    "bmopf_schema_id": powerio_dist::BMOPF_SCHEMA_ID,
5154                    "bmopf_schema_version": powerio_dist::BMOPF_SCHEMA_VERSION,
5155                    "typed_capacitors": true,
5156                    "line_and_generator_ratings": true,
5157                    "per_sequence_bus_bounds": true,
5158                    "transformer_extras_relocation": true,
5159                })
5160            );
5161        }
5162
5163        #[test]
5164        fn parse_file_convert_and_echo() {
5165            let path = fourwire_cstr();
5166            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5167            let net = unsafe {
5168                pio_dist_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len())
5169            };
5170            assert!(
5171                !net.is_null(),
5172                "{}",
5173                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5174            );
5175
5176            // Cross format write: schema compatible BMOPF JSON out.
5177            let to = CString::new("bmopf").unwrap();
5178            let mut warn_out: *mut c_char = std::ptr::null_mut();
5179            let s = unsafe {
5180                pio_dist_to_format(net, to.as_ptr(), &mut warn_out, err.as_mut_ptr(), err.len())
5181            };
5182            assert!(!s.is_null());
5183            let text = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
5184            assert!(text.contains("\"bus\""));
5185            unsafe { pio_string_free(s) };
5186
5187            // Same format write echoes the retained source byte for byte.
5188            let to = CString::new("dss").unwrap();
5189            let s = unsafe {
5190                pio_dist_to_format(net, to.as_ptr(), &mut warn_out, err.as_mut_ptr(), err.len())
5191            };
5192            assert!(!s.is_null());
5193            let echoed = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
5194            let source = std::fs::read_to_string(fourwire()).unwrap();
5195            assert_eq!(echoed, source);
5196            assert!(warn_out.is_null(), "a byte exact echo loses nothing");
5197            unsafe { pio_string_free(s) };
5198
5199            unsafe { pio_dist_network_free(net) };
5200        }
5201
5202        #[test]
5203        fn graph_json_reports_bus_edge_projection() {
5204            let path = fourwire_cstr();
5205            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5206            let net = unsafe {
5207                pio_dist_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len())
5208            };
5209            assert!(
5210                !net.is_null(),
5211                "{}",
5212                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5213            );
5214
5215            let graph = unsafe { pio_dist_graph_json(net, err.as_mut_ptr(), err.len()) };
5216            assert!(
5217                !graph.is_null(),
5218                "graph json failed: {}",
5219                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5220            );
5221            let graph_json: serde_json::Value =
5222                serde_json::from_str(unsafe { CStr::from_ptr(graph) }.to_str().unwrap()).unwrap();
5223            let buses = graph_json["buses"].as_array().unwrap();
5224            assert_eq!(buses.len(), 2);
5225            assert!(buses.iter().any(|bus| {
5226                bus["id"] == serde_json::json!("sourcebus")
5227                    && bus["has_source"] == serde_json::json!(true)
5228            }));
5229            let edges = graph_json["edges"].as_array().unwrap();
5230            assert!(edges.iter().any(|edge| {
5231                edge["kind"] == serde_json::json!("line")
5232                    && edge["id"] == serde_json::json!("l1")
5233                    && edge["from"] == serde_json::json!("sourcebus")
5234                    && edge["to"] == serde_json::json!("loadbus")
5235                    && edge["n_phases"] == serde_json::json!(4)
5236                    && edge["conductors"].as_array().unwrap().len() == 4
5237            }));
5238
5239            unsafe {
5240                pio_string_free(graph);
5241                pio_dist_network_free(net);
5242            }
5243        }
5244
5245        #[test]
5246        fn summary_json_reports_counts_without_model_payload() {
5247            let path = fourwire_cstr();
5248            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5249            let net = unsafe {
5250                pio_dist_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len())
5251            };
5252            assert!(
5253                !net.is_null(),
5254                "{}",
5255                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5256            );
5257
5258            let summary = unsafe { pio_dist_summary_json(net, err.as_mut_ptr(), err.len()) };
5259            assert!(
5260                !summary.is_null(),
5261                "summary json failed: {}",
5262                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5263            );
5264            let value: serde_json::Value =
5265                serde_json::from_str(unsafe { CStr::from_ptr(summary) }.to_str().unwrap()).unwrap();
5266            assert_eq!(
5267                value[powerio::version::VERSION_KEY],
5268                serde_json::json!(powerio::VERSION)
5269            );
5270            assert_eq!(value["source_format"], serde_json::json!("dss"));
5271            assert_eq!(value["base_frequency"], serde_json::json!(60.0));
5272            assert_eq!(value["counts"]["buses"], serde_json::json!(2));
5273            assert_eq!(value["counts"]["lines"], serde_json::json!(1));
5274            assert_eq!(value["counts"]["loads"], serde_json::json!(3));
5275
5276            unsafe {
5277                pio_string_free(summary);
5278                pio_dist_network_free(net);
5279            }
5280        }
5281
5282        #[test]
5283        fn convert_str_round_trips_through_pmd() {
5284            let source = std::fs::read_to_string(fourwire()).unwrap();
5285            let text = CString::new(source).unwrap();
5286            let from = CString::new("dss").unwrap();
5287            let to = CString::new("pmd").unwrap();
5288            let mut warn_out: *mut c_char = std::ptr::null_mut();
5289            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5290            let s = unsafe {
5291                pio_dist_convert_str(
5292                    text.as_ptr(),
5293                    from.as_ptr(),
5294                    to.as_ptr(),
5295                    &mut warn_out,
5296                    err.as_mut_ptr(),
5297                    err.len(),
5298                )
5299            };
5300            assert!(
5301                !s.is_null(),
5302                "{}",
5303                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5304            );
5305            let pmd = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
5306            assert!(pmd.contains("\"data_model\": \"ENGINEERING\""));
5307            unsafe { pio_string_free(s) };
5308        }
5309
5310        #[test]
5311        fn capabilities_json_reports_the_bmopf_vintage_the_writer_targets() {
5312            let raw = pio_dist_capabilities_json();
5313            assert!(!raw.is_null());
5314            let text = unsafe { CStr::from_ptr(raw) }.to_str().unwrap().to_owned();
5315            unsafe { pio_string_free(raw) };
5316            let caps: serde_json::Value = serde_json::from_str(&text).unwrap();
5317
5318            // The vendored schema file's own `version` field must agree
5319            // with BMOPF_SCHEMA_VERSION.
5320            let vendored: serde_json::Value = serde_json::from_str(
5321                &std::fs::read_to_string("../tests/data/dist/bmopf/draft_bmopf_schema.json")
5322                    .unwrap(),
5323            )
5324            .unwrap();
5325            assert_eq!(vendored["version"], caps["bmopf_schema_version"]);
5326
5327            assert_eq!(
5328                caps[powerio::version::VERSION_KEY],
5329                serde_json::json!(powerio::VERSION)
5330            );
5331        }
5332
5333        #[test]
5334        fn convert_str_warns_that_the_buscoords_sidecar_was_dropped() {
5335            // Bus coordinates make the dss writer emit a `Buscoords`
5336            // directive plus the CSV it names. This surface returns text
5337            // only, so the CSV is dropped.
5338            let source = "\
5339New Circuit.c basekv=12.47
5340New Line.l1 bus1=a bus2=b phases=3
5341";
5342            let text = CString::new(source).unwrap();
5343            let from = CString::new("dss").unwrap();
5344            let to = CString::new("dss").unwrap();
5345            let mut warn_out: *mut c_char = std::ptr::null_mut();
5346            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5347            // Convert through bmopf so the dss writer runs instead of an
5348            // echo of the source text.
5349            let bmopf_target = CString::new("bmopf").unwrap();
5350            let as_bmopf = unsafe {
5351                pio_dist_convert_str(
5352                    text.as_ptr(),
5353                    from.as_ptr(),
5354                    bmopf_target.as_ptr(),
5355                    &mut warn_out,
5356                    err.as_mut_ptr(),
5357                    err.len(),
5358                )
5359            };
5360            assert!(!as_bmopf.is_null());
5361            let bmopf_text = unsafe { CStr::from_ptr(as_bmopf) }
5362                .to_str()
5363                .unwrap()
5364                .to_owned();
5365            unsafe { pio_string_free(as_bmopf) };
5366
5367            // Give the document coordinates so the dss writer produces a sidecar.
5368            let mut doc: serde_json::Value = serde_json::from_str(&bmopf_text).unwrap();
5369            if let Some(buses) = doc["bus"].as_object_mut() {
5370                for (i, (_, bus)) in buses.iter_mut().enumerate() {
5371                    bus["longitude"] = serde_json::json!(i as f64);
5372                    bus["latitude"] = serde_json::json!(i as f64);
5373                }
5374            }
5375            let with_coords = CString::new(doc.to_string()).unwrap();
5376            let bmopf_from = CString::new("bmopf").unwrap();
5377            let mut warn2_out: *mut c_char = std::ptr::null_mut();
5378            let s = unsafe {
5379                pio_dist_convert_str(
5380                    with_coords.as_ptr(),
5381                    bmopf_from.as_ptr(),
5382                    to.as_ptr(),
5383                    &mut warn2_out,
5384                    err.as_mut_ptr(),
5385                    err.len(),
5386                )
5387            };
5388            assert!(
5389                !s.is_null(),
5390                "{}",
5391                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5392            );
5393            let dss = unsafe { CStr::from_ptr(s) }.to_str().unwrap().to_owned();
5394            unsafe { pio_string_free(s) };
5395            assert!(!warn2_out.is_null(), "expected a dropped sidecar warning");
5396            let warnings = unsafe { CStr::from_ptr(warn2_out) }
5397                .to_str()
5398                .unwrap()
5399                .to_owned();
5400            unsafe { pio_string_free(warn2_out) };
5401
5402            // Guard the premise: the writer must emit the directive, or the
5403            // warning assertion below proves nothing.
5404            assert!(
5405                dss.to_lowercase().contains("buscoords"),
5406                "expected the dss writer to reference a buscoords file; output was: {dss}"
5407            );
5408            // The text names a companion file, so a warning must name it too.
5409            assert!(
5410                warnings.contains("was not written"),
5411                "dss output references a buscoords file but no warning reported the drop; \
5412                 warnings were: {warnings}"
5413            );
5414        }
5415
5416        #[test]
5417        fn convert_str_rejects_target_before_source_order() {
5418            let source = std::fs::read_to_string(fourwire()).unwrap();
5419            let text = CString::new(source).unwrap();
5420            let old_target = CString::new("pmd").unwrap();
5421            let old_source = CString::new("dss").unwrap();
5422            let mut warn_out: *mut c_char = std::ptr::null_mut();
5423            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5424            let s = unsafe {
5425                pio_dist_convert_str(
5426                    text.as_ptr(),
5427                    old_target.as_ptr(),
5428                    old_source.as_ptr(),
5429                    &mut warn_out,
5430                    err.as_mut_ptr(),
5431                    err.len(),
5432                )
5433            };
5434            assert!(
5435                s.is_null(),
5436                "legacy target-before-source order unexpectedly succeeded"
5437            );
5438            let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
5439            assert!(!msg.is_empty(), "expected an explanatory parse error");
5440        }
5441
5442        #[test]
5443        fn warnings_report_count_and_text() {
5444            // An unknown length unit draws a parse warning; the handle must
5445            // report it. Warnings use the size-then-fill idiom of `pio_warnings`.
5446            let text = CString::new(
5447                "clear\nnew circuit.w basekv=12.47 bus1=src\nnew line.l1 bus1=src bus2=b2 length=1 units=furlong\n",
5448            )
5449            .unwrap();
5450            let fmt = CString::new("dss").unwrap();
5451            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5452            let net = unsafe {
5453                pio_dist_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len())
5454            };
5455            assert!(!net.is_null());
5456            let mut warn = [0 as c_char; 4096];
5457            let n = unsafe { pio_dist_warnings(net, warn.as_mut_ptr(), warn.len()) };
5458            assert!(n > 0, "expected a nonzero warning length");
5459            let msg = unsafe { CStr::from_ptr(warn.as_ptr()) }.to_str().unwrap();
5460            assert!(
5461                msg.lines().any(|w| w.contains("furlong")),
5462                "expected the units warning, got: {msg}"
5463            );
5464            // NULL handle is a 0-length count, not a crash.
5465            assert_eq!(
5466                unsafe { pio_dist_warnings(std::ptr::null(), warn.as_mut_ptr(), warn.len()) },
5467                0
5468            );
5469            unsafe { pio_dist_network_free(net) };
5470        }
5471
5472        #[test]
5473        fn convert_file_round_trips_through_bmopf() {
5474            let path = fourwire_cstr();
5475            let to = CString::new("bmopf-json").unwrap();
5476            let mut warn_out: *mut c_char = std::ptr::null_mut();
5477            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5478            let s = unsafe {
5479                pio_dist_convert_file(
5480                    path.as_ptr(),
5481                    std::ptr::null(),
5482                    to.as_ptr(),
5483                    &mut warn_out,
5484                    err.as_mut_ptr(),
5485                    err.len(),
5486                )
5487            };
5488            assert!(
5489                !s.is_null(),
5490                "{}",
5491                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5492            );
5493            let text = unsafe { CStr::from_ptr(s) }.to_str().unwrap();
5494            assert!(text.contains("\"bus\""));
5495            unsafe { pio_string_free(s) };
5496        }
5497
5498        #[test]
5499        fn convert_file_rejects_target_before_source_order() {
5500            let path = fourwire_cstr();
5501            let old_target = CString::new("pmd").unwrap();
5502            let old_source = CString::new("dss").unwrap();
5503            let mut warn_out: *mut c_char = std::ptr::null_mut();
5504            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5505            let s = unsafe {
5506                pio_dist_convert_file(
5507                    path.as_ptr(),
5508                    old_target.as_ptr(),
5509                    old_source.as_ptr(),
5510                    &mut warn_out,
5511                    err.as_mut_ptr(),
5512                    err.len(),
5513                )
5514            };
5515            assert!(
5516                s.is_null(),
5517                "legacy target-before-source order unexpectedly succeeded"
5518            );
5519            let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
5520            assert!(!msg.is_empty(), "expected an explanatory parse error");
5521        }
5522
5523        #[test]
5524        fn unknown_format_is_an_error_not_a_crash() {
5525            let text = CString::new("clear\n").unwrap();
5526            let fmt = CString::new("matpower").unwrap();
5527            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5528            let net = unsafe {
5529                pio_dist_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len())
5530            };
5531            assert!(net.is_null());
5532            let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
5533            assert!(msg.contains("unknown distribution format"));
5534        }
5535
5536        #[test]
5537        fn has_feature_reports_dist() {
5538            let dist = CString::new("dist").unwrap();
5539            assert_eq!(unsafe { pio_has_feature(dist.as_ptr()) }, 1);
5540            let nope = CString::new("nope").unwrap();
5541            assert_eq!(unsafe { pio_has_feature(nope.as_ptr()) }, 0);
5542        }
5543    }
5544
5545    /// The balanced model JSON pair: one call out, one call back, byte
5546    /// identical to the `powerio-json` token writer.
5547    #[test]
5548    fn balanced_model_json_round_trip_matches_token_writer() {
5549        let net = case9();
5550        let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5551        let json = unsafe { pio_to_json(net, err.as_mut_ptr(), err.len()) };
5552        assert!(!json.is_null());
5553        let text = unsafe { CStr::from_ptr(json) }.to_str().unwrap().to_owned();
5554        unsafe { pio_string_free(json) };
5555        assert_eq!(text, unsafe { to_format(net, "powerio-json") });
5556
5557        let c = CString::new(text).unwrap();
5558        let back = unsafe { pio_from_json(c.as_ptr(), err.as_mut_ptr(), err.len()) };
5559        assert!(!back.is_null());
5560        unsafe {
5561            assert_eq!(pio_n_buses(back), pio_n_buses(net));
5562            close(pio_base_mva(back), pio_base_mva(net));
5563            pio_network_free(back);
5564            pio_network_free(net);
5565        }
5566    }
5567
5568    /// `pio_classify_str` labels: same markers as the `.json` sniffing.
5569    #[test]
5570    fn classify_str_labels() {
5571        fn classify(text: &str) -> String {
5572            let c = CString::new(text).unwrap();
5573            let mut out = [0 as c_char; 64];
5574            let n = unsafe { pio_classify_str(c.as_ptr(), out.as_mut_ptr(), out.len()) };
5575            let label = unsafe { CStr::from_ptr(out.as_ptr()) }.to_str().unwrap();
5576            assert_eq!(n, label.len(), "size-then-fill length disagrees");
5577            label.to_string()
5578        }
5579        assert_eq!(
5580            classify(r#"{"baseMVA": 100.0, "bus": {}}"#),
5581            "transmission:powermodels-json"
5582        );
5583        assert_eq!(
5584            classify(r#"{"data_model": "ENGINEERING"}"#),
5585            "distribution:pmd-json"
5586        );
5587        assert_eq!(
5588            classify(r#"{"line": {}, "bus": {}}"#),
5589            "distribution:bmopf-json"
5590        );
5591        assert_eq!(
5592            classify(r#"{"model_kind": "balanced", "model": {}}"#),
5593            "package"
5594        );
5595        assert_eq!(classify("not json"), "unknown");
5596        assert_eq!(classify(r#"{"nothing": 1}"#), "unknown");
5597        assert_eq!(
5598            unsafe { pio_classify_str(std::ptr::null(), std::ptr::null_mut(), 0) },
5599            0
5600        );
5601    }
5602
5603    /// The package inverse pair: wrap a handle, cross the JSON document, and
5604    /// extract an owned handle again, the binding materialization path.
5605    #[cfg(feature = "pkg")]
5606    mod package_inverse {
5607        use super::*;
5608        use std::ffi::CStr;
5609
5610        unsafe fn package_round_trip(pkg: *mut PioPackage) -> *mut PioPackage {
5611            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5612            let json = unsafe { pio_package_to_json(pkg, err.as_mut_ptr(), err.len()) };
5613            assert!(!json.is_null());
5614            let text = unsafe { CStr::from_ptr(json) }.to_str().unwrap().to_owned();
5615            unsafe { pio_string_free(json) };
5616            let c = CString::new(text).unwrap();
5617            let reread = unsafe { pio_package_parse_str(c.as_ptr(), err.as_mut_ptr(), err.len()) };
5618            assert!(!reread.is_null());
5619            unsafe { pio_package_free(pkg) };
5620            reread
5621        }
5622
5623        #[test]
5624        fn balanced_wrap_extract_across_json() {
5625            let net = case9();
5626            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5627            let pkg =
5628                unsafe { pio_package_from_balanced_network(net, 0, err.as_mut_ptr(), err.len()) };
5629            assert!(!pkg.is_null());
5630
5631            // An in-memory extraction (no JSON crossing) sheds the retained
5632            // source text too: same-format writes are fresh serializations,
5633            // never byte echoes of the wrapped handle's source.
5634            unsafe {
5635                let mem = pio_package_to_balanced_network(pkg, err.as_mut_ptr(), err.len());
5636                assert!(!mem.is_null());
5637                assert!((*mem).net.source.is_none());
5638                pio_network_free(mem);
5639            }
5640
5641            let pkg = unsafe { package_round_trip(pkg) };
5642
5643            // Wrong-kind extraction refuses with a directed message.
5644            #[cfg(feature = "dist")]
5645            unsafe {
5646                let wrong = pio_package_to_multiconductor_network(pkg, err.as_mut_ptr(), err.len());
5647                assert!(wrong.is_null());
5648                let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
5649                assert!(msg.contains("balanced"), "got: {msg}");
5650            }
5651
5652            let back = unsafe { pio_package_to_balanced_network(pkg, err.as_mut_ptr(), err.len()) };
5653            assert!(
5654                !back.is_null(),
5655                "{}",
5656                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5657            );
5658            unsafe {
5659                assert_eq!(pio_n_buses(back), pio_n_buses(net));
5660                assert_eq!(pio_n_gens(back), pio_n_gens(net));
5661                close(pio_base_mva(back), pio_base_mva(net));
5662                pio_package_free(pkg);
5663                pio_network_free(back);
5664                pio_network_free(net);
5665            }
5666        }
5667
5668        #[cfg(feature = "dist")]
5669        #[test]
5670        fn dist_model_json_round_trip_matches_package_field() {
5671            let path = fourwire_cstr();
5672            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5673            let net = unsafe {
5674                pio_dist_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len())
5675            };
5676            assert!(!net.is_null());
5677
5678            // One call out, one call back: bmopf output survives unchanged.
5679            let json = unsafe { pio_dist_to_json(net, err.as_mut_ptr(), err.len()) };
5680            assert!(!json.is_null());
5681            let text = unsafe { CStr::from_ptr(json) }.to_str().unwrap().to_owned();
5682            unsafe { pio_string_free(json) };
5683            let c = CString::new(text.clone()).unwrap();
5684            let back = unsafe { pio_dist_from_json(c.as_ptr(), err.as_mut_ptr(), err.len()) };
5685            assert!(!back.is_null());
5686
5687            unsafe { assert_eq!(bmopf(back), bmopf(net)) };
5688
5689            // The model JSON is the same object the .pio.json document carries
5690            // under model.multiconductor_network.
5691            let pkg = unsafe {
5692                pio_package_from_multiconductor_network(net, err.as_mut_ptr(), err.len())
5693            };
5694            assert!(!pkg.is_null());
5695            let pkg_json = unsafe { pio_package_to_json(pkg, err.as_mut_ptr(), err.len()) };
5696            assert!(!pkg_json.is_null());
5697            let doc: serde_json::Value =
5698                serde_json::from_str(unsafe { CStr::from_ptr(pkg_json) }.to_str().unwrap())
5699                    .unwrap();
5700            let direct: serde_json::Value = serde_json::from_str(&text).unwrap();
5701            assert_eq!(doc["model"]["multiconductor_network"], direct);
5702
5703            unsafe {
5704                pio_string_free(pkg_json);
5705                pio_package_free(pkg);
5706                pio_dist_network_free(back);
5707                pio_dist_network_free(net);
5708            }
5709        }
5710
5711        #[cfg(feature = "dist")]
5712        #[test]
5713        fn multiconductor_wrap_extract_across_json() {
5714            let path = fourwire_cstr();
5715            let mut err = [0 as c_char; PIO_ERRBUF_MIN];
5716            let net = unsafe {
5717                pio_dist_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len())
5718            };
5719            assert!(!net.is_null());
5720            let pkg = unsafe {
5721                pio_package_from_multiconductor_network(net, err.as_mut_ptr(), err.len())
5722            };
5723            assert!(!pkg.is_null());
5724
5725            // Same in-memory strip check as the balanced side: source and the
5726            // defaulted provenance never survive extraction.
5727            unsafe {
5728                let mem = pio_package_to_multiconductor_network(pkg, err.as_mut_ptr(), err.len());
5729                assert!(!mem.is_null());
5730                assert!((*mem).net.source.is_none());
5731                assert!((*mem).net.defaulted.is_empty());
5732                pio_dist_network_free(mem);
5733            }
5734
5735            let pkg = unsafe { package_round_trip(pkg) };
5736            let back =
5737                unsafe { pio_package_to_multiconductor_network(pkg, err.as_mut_ptr(), err.len()) };
5738            assert!(
5739                !back.is_null(),
5740                "{}",
5741                unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
5742            );
5743
5744            // The extracted model writes the same BMOPF text as the original:
5745            // nothing the model represents is lost crossing the package.
5746            unsafe {
5747                assert_eq!(bmopf(back), bmopf(net));
5748                let wrong = pio_package_to_balanced_network(pkg, err.as_mut_ptr(), err.len());
5749                assert!(wrong.is_null());
5750                let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap();
5751                assert!(msg.contains("multiconductor"), "got: {msg}");
5752                pio_package_free(pkg);
5753                pio_dist_network_free(back);
5754                pio_dist_network_free(net);
5755            }
5756        }
5757    }
5758}