Skip to main content

powerio_capi/
lib.rs

1//! C ABI for `powerio`.
2//!
3//! Parse any supported power system case format into an opaque handle, query it,
4//! convert it to another format, and pull out the numeric tables a
5//! downstream solver needs to assemble matrices. Every entry point is `extern
6//! "C"`, catches panics at the boundary, and returns error text into a
7//! caller-provided buffer. Strings handed back are owned by the library; free
8//! them with [`pio_string_free`]. Array extractors fill caller-allocated
9//! buffers (length = the matching `pio_n_*` count); pass `NULL` to skip one.
10//!
11//! Naming: every symbol is prefixed `pio_`. The header is `include/powerio.h`.
12
13#![allow(clippy::missing_safety_doc)]
14
15use std::ffi::{CStr, CString, c_char};
16use std::panic::{AssertUnwindSafe, catch_unwind};
17
18use powerio::{IndexCore, IndexedNetwork, Network, TargetFormat};
19
20#[cfg(feature = "arrow")]
21mod arrow_export;
22#[cfg(feature = "arrow")]
23pub use arrow_export::{
24    PIO_ARROW_TABLE_BRANCH, PIO_ARROW_TABLE_BUS, PIO_ARROW_TABLE_GEN, PIO_ARROW_TABLE_LOAD,
25    PIO_ARROW_TABLE_SHUNT,
26};
27
28/// Opaque parsed network handle. Carries the parsed [`Network`], the
29/// [`IndexCore`] derived from it once at parse time (so every indexed query
30/// reuses the same bus-id map and nodal aggregates instead of rebuilding
31/// them), and the reader's fidelity warnings ([`pio_parse_warnings`]).
32pub struct PioNetwork {
33    net: Network,
34    core: IndexCore,
35    warnings: Vec<String>,
36}
37
38/// Copy `msg` (truncated to fit) into a caller `char[len]` buffer, always
39/// NUL-terminated. Truncation backs up to a UTF-8 character boundary so a
40/// clipped message is still valid UTF-8. Shared by the error and warning
41/// outputs.
42///
43/// # Safety
44/// A non-NULL `buf` must point to at least `len` writable bytes; the write
45/// stays within `len` (at most `len - 1` message bytes plus the terminating
46/// NUL). NULL or `len == 0` is a no-op.
47unsafe fn copy_to_buf(buf: *mut c_char, len: usize, msg: &str) {
48    unsafe {
49        if buf.is_null() || len == 0 {
50            return;
51        }
52        let bytes = msg.as_bytes();
53        let mut n = bytes.len().min(len - 1);
54        while n > 0 && !msg.is_char_boundary(n) {
55            n -= 1;
56        }
57        std::ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), buf, n);
58        *buf.add(n) = 0;
59    }
60}
61
62unsafe fn cstr<'a>(p: *const c_char) -> Option<&'a str> {
63    unsafe {
64        if p.is_null() {
65            return None;
66        }
67        CStr::from_ptr(p).to_str().ok()
68    }
69}
70
71/// Move `s` into an owned C string, or `None` if it holds an interior NUL byte
72/// (which can't cross as a C string). Callers surface the `None` as a real error
73/// rather than silently handing back an empty string.
74fn into_cstring(s: String) -> Option<*mut c_char> {
75    CString::new(s).ok().map(CString::into_raw)
76}
77
78/// Finish a `*mut c_char` entry point: hand back the owned C string, or on an
79/// interior NUL write the error into `errbuf` (NULL/0 to skip) and return NULL.
80/// The shared tail of the string-returning functions.
81fn finish_cstring(s: String, errbuf: *mut c_char, errlen: usize) -> *mut c_char {
82    match into_cstring(s) {
83        Some(p) => p,
84        None => {
85            unsafe { copy_to_buf(errbuf, errlen, "output contained an interior NUL byte") };
86            std::ptr::null_mut()
87        }
88    }
89}
90
91/// Run `f` at the FFI boundary, catching any panic so it can't unwind across
92/// `extern "C"` (UB). Returns `fallback` if `f` panics.
93unsafe fn guard<R>(fallback: R, f: impl FnOnce() -> R) -> R {
94    catch_unwind(AssertUnwindSafe(f)).unwrap_or(fallback)
95}
96
97/// Box a `Network` into an owned network handle, building its [`IndexCore`] once so
98/// every indexed query reuses it. The one constructor for `*mut PioNetwork`.
99fn make_network(net: Network, warnings: Vec<String>) -> *mut PioNetwork {
100    let core = IndexCore::build(&net);
101    Box::into_raw(Box::new(PioNetwork {
102        net,
103        core,
104        warnings,
105    }))
106}
107
108/// Finish a `*mut PioNetwork` entry point: run `f` (producing a `Network` with
109/// its read warnings, or an error message) under the panic guard, hand back an
110/// owned handle, or write the error, `panic_msg` if `f` panicked, into `errbuf`
111/// and return NULL. The shared tail of every handle-returning function
112/// (`pio_parse_file`, `pio_parse_str`, `pio_to_normalized`, `pio_from_json`).
113unsafe fn finish_network(
114    errbuf: *mut c_char,
115    errlen: usize,
116    panic_msg: &str,
117    f: impl FnOnce() -> Result<(Network, Vec<String>), String>,
118) -> *mut PioNetwork {
119    unsafe {
120        match catch_unwind(AssertUnwindSafe(f)) {
121            Ok(Ok((net, warnings))) => make_network(net, warnings),
122            Ok(Err(msg)) => {
123                copy_to_buf(errbuf, errlen, &msg);
124                std::ptr::null_mut()
125            }
126            Err(_) => {
127                copy_to_buf(errbuf, errlen, panic_msg);
128                std::ptr::null_mut()
129            }
130        }
131    }
132}
133
134/// ABI version of this C interface. Bump on any breaking change to an existing
135/// `pio_*` signature or to the JSON transport schema (new additive symbols don't
136/// require a bump). A consumer compares [`pio_abi_version`] against the value it
137/// was built against (the `PIO_ABI_VERSION` macro in `powerio.h`) and refuses a
138/// mismatched library instead of calling in blind.
139pub const PIO_ABI_VERSION: u32 = 3;
140
141/// A comfortable error-buffer size: pass a `char[PIO_ERRBUF_MIN]` to any
142/// `errbuf`/`warnbuf` parameter and a message always fits without truncation.
143pub const PIO_ERRBUF_MIN: usize = 256;
144
145/// The ABI version the library was built with (see [`PIO_ABI_VERSION`]). Lets a
146/// consumer detect a stale or incompatible library at load time. Infallible.
147#[unsafe(no_mangle)]
148pub extern "C" fn pio_abi_version() -> u32 {
149    PIO_ABI_VERSION
150}
151
152/// The crate version string (e.g. `"0.0.1"`), `'static` and NUL-terminated. Do
153/// NOT free it. Informational; pair it with [`pio_abi_version`] for the actual
154/// compatibility check.
155#[unsafe(no_mangle)]
156pub extern "C" fn pio_version() -> *const c_char {
157    // env! is resolved at compile time; the trailing NUL makes it a valid C
158    // string and the 'static lifetime means the pointer is always valid and
159    // never owned by the caller.
160    concat!(env!("CARGO_PKG_VERSION"), "\0")
161        .as_ptr()
162        .cast::<c_char>()
163}
164
165fn target_format_from_c(to: *const c_char) -> Result<TargetFormat, String> {
166    let to = unsafe { cstr(to) }.ok_or_else(|| "to is NULL or not UTF-8".to_string())?;
167    to.parse::<TargetFormat>().map_err(|e| e.to_string())
168}
169
170fn optional_cstr<'a>(p: *const c_char, name: &str) -> Result<Option<&'a str>, String> {
171    if p.is_null() {
172        Ok(None)
173    } else {
174        unsafe { cstr(p) }
175            .map(Some)
176            .ok_or_else(|| format!("{name} is not UTF-8"))
177    }
178}
179
180/// Parse `path` (format from extension, or `from` if non-NULL) into a case
181/// handle. `from` accepts the [`pio_parse_str`] format names plus
182/// `pypsa-csv`/`pypsa`; a PyPSA CSV folder is a directory, so it can only enter
183/// through this function, with `from = "pypsa-csv"` (or NULL when the directory
184/// holds a `network.csv`). Read fidelity warnings attach to the handle
185/// ([`pio_parse_warnings`]). Returns `NULL` on error and writes the message
186/// into `errbuf`.
187#[unsafe(no_mangle)]
188pub unsafe extern "C" fn pio_parse_file(
189    path: *const c_char,
190    from: *const c_char,
191    errbuf: *mut c_char,
192    errlen: usize,
193) -> *mut PioNetwork {
194    unsafe {
195        finish_network(errbuf, errlen, "panic while parsing", || {
196            let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?;
197            let from = optional_cstr(from, "from")?;
198            powerio::parse_file(std::path::Path::new(path), from)
199                .map(|p| (p.network, p.warnings))
200                .map_err(|e| e.to_string())
201        })
202    }
203}
204
205/// Parse in-memory case `text` of the named `format` into a network handle. Unlike
206/// [`pio_parse_file`] there is no path to infer from, so `format` is required: one of
207/// `matpower`/`m`, `powermodels`/`pm`, `egret`, `pandapower-json`/`pandapower`/`pp`,
208/// `psse`/`raw`, `powerworld`/`aux` (see `TargetFormat::from_str`). PyPSA CSV
209/// folders are directories, not text; parse them with [`pio_parse_file`] and
210/// `from = "pypsa-csv"`. Read fidelity warnings attach to the handle
211/// ([`pio_parse_warnings`]). Returns `NULL` on error and writes the message
212/// into `errbuf`. Free the handle with [`pio_network_free`].
213#[unsafe(no_mangle)]
214pub unsafe extern "C" fn pio_parse_str(
215    text: *const c_char,
216    format: *const c_char,
217    errbuf: *mut c_char,
218    errlen: usize,
219) -> *mut PioNetwork {
220    unsafe {
221        finish_network(errbuf, errlen, "panic while parsing", || {
222            let text = cstr(text).ok_or_else(|| "text is NULL or not UTF-8".to_string())?;
223            let format = cstr(format).ok_or_else(|| "format is NULL or not UTF-8".to_string())?;
224            powerio::parse_str(text, format)
225                .map(|p| (p.network, p.warnings))
226                .map_err(|e| e.to_string())
227        })
228    }
229}
230
231/// Read fidelity warnings attached at parse time, `\n`-joined into `warnbuf`
232/// (truncated to fit; NULL/0 to skip). Returns the warning count; 0 for a
233/// NULL handle. Empty for formats whose readers are total.
234#[unsafe(no_mangle)]
235pub unsafe extern "C" fn pio_parse_warnings(
236    net: *const PioNetwork,
237    warnbuf: *mut c_char,
238    warnlen: usize,
239) -> usize {
240    unsafe {
241        guard(0, || {
242            let Some(c) = network_ref(net) else { return 0 };
243            copy_to_buf(warnbuf, warnlen, &c.warnings.join("\n"));
244            c.warnings.len()
245        })
246    }
247}
248
249/// Free a network handle from [`pio_parse_file`], [`pio_parse_str`],
250/// [`pio_to_normalized`], or [`pio_from_json`].
251#[unsafe(no_mangle)]
252pub unsafe extern "C" fn pio_network_free(net: *mut PioNetwork) {
253    unsafe {
254        if !net.is_null() {
255            drop(Box::from_raw(net));
256        }
257    }
258}
259
260unsafe fn network_ref<'a>(net: *const PioNetwork) -> Option<&'a PioNetwork> {
261    unsafe { net.as_ref() }
262}
263
264/// View `net` through its cached [`IndexCore`] with no per-call rebuild.
265unsafe fn view<'a>(net: *const PioNetwork) -> Option<IndexedNetwork<'a>> {
266    unsafe {
267        net.as_ref()
268            .map(|c| IndexedNetwork::with_core(&c.net, &c.core))
269    }
270}
271
272/// Normalize `net` into a NEW per-unit network handle: per unit, radians,
273/// out-of-service filtered, source bus ids preserved, bus types canonicalized
274/// (see `Network::to_normalized`). The result is independent of `net`; free
275/// both with [`pio_network_free`]. Every extractor and [`pio_to_json`] works on
276/// it unchanged (the handle is per unit, not MW). Returns `NULL` on error (no
277/// reference bus can be chosen, or a non-positive base MVA) and writes the
278/// message into `errbuf`.
279#[unsafe(no_mangle)]
280pub unsafe extern "C" fn pio_to_normalized(
281    net: *const PioNetwork,
282    errbuf: *mut c_char,
283    errlen: usize,
284) -> *mut PioNetwork {
285    unsafe {
286        finish_network(errbuf, errlen, "panic while normalizing", || {
287            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
288            c.net
289                .to_normalized()
290                .map(|n| (n, c.warnings.clone()))
291                .map_err(|e| e.to_string())
292        })
293    }
294}
295
296#[unsafe(no_mangle)]
297pub unsafe extern "C" fn pio_n_buses(net: *const PioNetwork) -> usize {
298    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.buses.len())) }
299}
300
301#[unsafe(no_mangle)]
302pub unsafe extern "C" fn pio_n_branches(net: *const PioNetwork) -> usize {
303    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.branches.len())) }
304}
305
306#[unsafe(no_mangle)]
307pub unsafe extern "C" fn pio_n_gens(net: *const PioNetwork) -> usize {
308    unsafe { guard(0, || network_ref(net).map_or(0, |c| c.net.generators.len())) }
309}
310
311#[unsafe(no_mangle)]
312pub unsafe extern "C" fn pio_base_mva(net: *const PioNetwork) -> f64 {
313    unsafe { guard(0.0, || network_ref(net).map_or(0.0, |c| c.net.base_mva)) }
314}
315
316/// Dense `[0, n)` index of the single reference bus, or `-1` if not exactly one
317/// (also `-1` if the index is too large for `isize`). A network may carry
318/// several references (one per island, or a normalized case that kept the file's
319/// multiple `REF` buses); use [`pio_n_reference_buses`] to tell zero from many,
320/// and [`pio_reference_buses`] to read them all.
321#[unsafe(no_mangle)]
322pub unsafe extern "C" fn pio_reference_bus(net: *const PioNetwork) -> isize {
323    unsafe {
324        guard(-1, || match view(net) {
325            Some(v) => v
326                .reference_bus_index()
327                .map_or(-1, |i| isize::try_from(i).unwrap_or(-1)),
328            None => -1,
329        })
330    }
331}
332
333/// Number of reference (slack) buses. `0` means none; `> 1` means one reference
334/// per island or several fixed reference buses in one island. A normalized case
335/// always reports `>= 1`.
336#[unsafe(no_mangle)]
337pub unsafe extern "C" fn pio_n_reference_buses(net: *const PioNetwork) -> usize {
338    unsafe {
339        guard(0, || {
340            view(net).map_or(0, |v| v.reference_bus_indices().len())
341        })
342    }
343}
344
345/// Fill `out` (length [`pio_n_reference_buses`]) with the dense `[0, n)` indices
346/// of the reference buses, ascending.
347#[unsafe(no_mangle)]
348pub unsafe extern "C" fn pio_reference_buses(net: *const PioNetwork, out: *mut i64) {
349    unsafe {
350        guard((), || {
351            if let Some(v) = view(net) {
352                fill(
353                    out,
354                    v.reference_bus_indices()
355                        .into_iter()
356                        .map(|i| i64::try_from(i).unwrap_or(-1)),
357                );
358            }
359        })
360    }
361}
362
363#[unsafe(no_mangle)]
364pub unsafe extern "C" fn pio_n_components(net: *const PioNetwork) -> usize {
365    unsafe { guard(0, || view(net).map_or(0, |v| v.n_connected_components())) }
366}
367
368/// `1` if the in-service topology is a forest, else `0`.
369#[unsafe(no_mangle)]
370pub unsafe extern "C" fn pio_is_radial(net: *const PioNetwork) -> i32 {
371    unsafe { guard(0, || view(net).map_or(0, |v| i32::from(v.is_radial()))) }
372}
373
374/// Serialize `net` to MATPOWER `.m` text (byte-exact echo when parsed from
375/// MATPOWER). Returns an owned C string; free with [`pio_string_free`]. Returns
376/// `NULL` on error and writes the message into `errbuf`.
377#[unsafe(no_mangle)]
378pub unsafe extern "C" fn pio_to_matpower(
379    net: *const PioNetwork,
380    errbuf: *mut c_char,
381    errlen: usize,
382) -> *mut c_char {
383    unsafe {
384        let r = catch_unwind(AssertUnwindSafe(|| {
385            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
386            Ok::<_, String>(c.net.to_matpower())
387        }));
388        match r {
389            Ok(Ok(text)) => finish_cstring(text, errbuf, errlen),
390            Ok(Err(msg)) => {
391                copy_to_buf(errbuf, errlen, &msg);
392                std::ptr::null_mut()
393            }
394            Err(_) => {
395                copy_to_buf(errbuf, errlen, "panic while serializing to MATPOWER");
396                std::ptr::null_mut()
397            }
398        }
399    }
400}
401
402/// Serialize `net` to format `to`.
403///
404/// Returns the converted text as an owned C string (free with
405/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written
406/// `\n`-joined into `warnbuf`.
407#[unsafe(no_mangle)]
408pub unsafe extern "C" fn pio_to_format(
409    net: *const PioNetwork,
410    to: *const c_char,
411    warnbuf: *mut c_char,
412    warnlen: usize,
413    errbuf: *mut c_char,
414    errlen: usize,
415) -> *mut c_char {
416    unsafe {
417        let r = catch_unwind(AssertUnwindSafe(|| {
418            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
419            let target = target_format_from_c(to)?;
420            let conv = c.net.to_format(target);
421            Ok::<_, String>((conv.text, conv.warnings))
422        }));
423        match r {
424            Ok(Ok((text, warnings))) => {
425                copy_to_buf(warnbuf, warnlen, &warnings.join("\n"));
426                finish_cstring(text, errbuf, errlen)
427            }
428            Ok(Err(msg)) => {
429                copy_to_buf(errbuf, errlen, &msg);
430                std::ptr::null_mut()
431            }
432            Err(_) => {
433                copy_to_buf(errbuf, errlen, "panic while converting");
434                std::ptr::null_mut()
435            }
436        }
437    }
438}
439
440/// Convert `path` to format `to` (optionally forcing the source via `from`).
441/// Returns the converted text as an owned C string (free with
442/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written
443/// `\n`-joined into `warnbuf`.
444#[unsafe(no_mangle)]
445pub unsafe extern "C" fn pio_convert_file(
446    path: *const c_char,
447    to: *const c_char,
448    from: *const c_char,
449    warnbuf: *mut c_char,
450    warnlen: usize,
451    errbuf: *mut c_char,
452    errlen: usize,
453) -> *mut c_char {
454    unsafe {
455        let r = catch_unwind(AssertUnwindSafe(|| {
456            let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?;
457            let from = optional_cstr(from, "from")?;
458            let target = target_format_from_c(to)?;
459            let conv = powerio::convert_file(std::path::Path::new(path), target, from)
460                .map_err(|e| e.to_string())?;
461            Ok::<_, String>((conv.text, conv.warnings))
462        }));
463        match r {
464            Ok(Ok((text, warnings))) => {
465                copy_to_buf(warnbuf, warnlen, &warnings.join("\n"));
466                finish_cstring(text, errbuf, errlen)
467            }
468            Ok(Err(msg)) => {
469                copy_to_buf(errbuf, errlen, &msg);
470                std::ptr::null_mut()
471            }
472            Err(_) => {
473                copy_to_buf(errbuf, errlen, "panic while converting");
474                std::ptr::null_mut()
475            }
476        }
477    }
478}
479
480/// Write `net` as a PyPSA CSV folder at `out_dir`. Returns `0` on success and
481/// `-1` on error (the message is written into `errbuf`), the same convention as
482/// the other fallible `int` returns in this ABI. Fidelity warnings, if any, are
483/// written `\n`-joined into `warnbuf`.
484#[unsafe(no_mangle)]
485pub unsafe extern "C" fn pio_write_pypsa_csv_folder(
486    net: *const PioNetwork,
487    out_dir: *const c_char,
488    warnbuf: *mut c_char,
489    warnlen: usize,
490    errbuf: *mut c_char,
491    errlen: usize,
492) -> i32 {
493    unsafe {
494        let r = catch_unwind(AssertUnwindSafe(|| {
495            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
496            let out_dir =
497                cstr(out_dir).ok_or_else(|| "out_dir is NULL or not UTF-8".to_string())?;
498            powerio::write_pypsa_csv_folder(&c.net, std::path::Path::new(out_dir))
499                .map(|outputs| outputs.warnings)
500                .map_err(|e| e.to_string())
501        }));
502        match r {
503            Ok(Ok(warnings)) => {
504                copy_to_buf(warnbuf, warnlen, &warnings.join("\n"));
505                0
506            }
507            Ok(Err(msg)) => {
508                copy_to_buf(errbuf, errlen, &msg);
509                -1
510            }
511            Err(_) => {
512                copy_to_buf(errbuf, errlen, "panic while writing PyPSA CSV folder");
513                -1
514            }
515        }
516    }
517}
518
519/// Read one scenario of a gridfm-datakit Parquet dataset into a network handle —
520/// the inverse of the gridfm writer. `dir` resolves leniently: the `raw/` leaf
521/// holding the parquet files, a `<case>/` directory with a `raw/` child, or a
522/// parent holding exactly one such case. Returns `NULL` on error and writes the message
523/// into `errbuf`; the lossy read's fidelity warnings (what the gridfm schema
524/// couldn't round-trip) are written `\n`-joined into `warnbuf`. Free the handle
525/// with [`pio_network_free`]. Built `--features gridfm`.
526#[cfg(feature = "gridfm")]
527#[unsafe(no_mangle)]
528pub unsafe extern "C" fn pio_read_gridfm(
529    dir: *const c_char,
530    scenario: i64,
531    warnbuf: *mut c_char,
532    warnlen: usize,
533    errbuf: *mut c_char,
534    errlen: usize,
535) -> *mut PioNetwork {
536    unsafe {
537        let r = catch_unwind(AssertUnwindSafe(|| {
538            let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?;
539            powerio_matrix::read_gridfm_dataset(std::path::Path::new(dir), scenario)
540                .map_err(|e| e.to_string())
541        }));
542        match r {
543            Ok(Ok(read)) => {
544                copy_to_buf(warnbuf, warnlen, &read.warnings.join("\n"));
545                make_network(read.network, read.warnings)
546            }
547            Ok(Err(msg)) => {
548                copy_to_buf(errbuf, errlen, &msg);
549                std::ptr::null_mut()
550            }
551            Err(_) => {
552                copy_to_buf(errbuf, errlen, "panic while reading gridfm dataset");
553                std::ptr::null_mut()
554            }
555        }
556    }
557}
558
559/// Write a gridfm dataset's distinct scenario ids (ascending) into `out`, up to
560/// `cap` entries, and return the total count. Call once with `cap == 0` (or `out`
561/// NULL) to size, allocate, then call again to fill — the standard count/buffer
562/// pattern of [`pio_bus_ids`]. Returns `-1` on error and writes the message into
563/// `errbuf`. Built `--features gridfm`.
564#[cfg(feature = "gridfm")]
565#[unsafe(no_mangle)]
566pub unsafe extern "C" fn pio_gridfm_scenario_ids(
567    dir: *const c_char,
568    out: *mut i64,
569    cap: usize,
570    errbuf: *mut c_char,
571    errlen: usize,
572) -> isize {
573    unsafe {
574        let r = catch_unwind(AssertUnwindSafe(|| {
575            let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?;
576            powerio_matrix::gridfm_scenario_ids(std::path::Path::new(dir))
577                .map_err(|e| e.to_string())
578        }));
579        match r {
580            Ok(Ok(ids)) => {
581                if !out.is_null() {
582                    let n = ids.len().min(cap);
583                    std::ptr::copy_nonoverlapping(ids.as_ptr(), out, n);
584                }
585                ids.len() as isize
586            }
587            Ok(Err(msg)) => {
588                copy_to_buf(errbuf, errlen, &msg);
589                -1
590            }
591            Err(_) => {
592                copy_to_buf(errbuf, errlen, "panic while reading gridfm scenario ids");
593                -1
594            }
595        }
596    }
597}
598
599/// Free a string returned by [`pio_to_matpower`], [`pio_to_format`],
600/// [`pio_convert_file`], or
601/// [`pio_to_json`].
602#[unsafe(no_mangle)]
603pub unsafe extern "C" fn pio_string_free(s: *mut c_char) {
604    unsafe {
605        if !s.is_null() {
606            drop(CString::from_raw(s));
607        }
608    }
609}
610
611/// Serialize the case to JSON: the structured-table transport every Julia
612/// bridge consumes. Carries the whole [`Network`] (buses, loads, shunts,
613/// branches, generators, storage, HVDC, extras) but not the retained source
614/// text, so it is structured data, not the byte-exact echo. Returns an owned C
615/// string (free with [`pio_string_free`]), `NULL` on error (message into
616/// `errbuf`).
617#[unsafe(no_mangle)]
618pub unsafe extern "C" fn pio_to_json(
619    net: *const PioNetwork,
620    errbuf: *mut c_char,
621    errlen: usize,
622) -> *mut c_char {
623    unsafe {
624        let r = catch_unwind(AssertUnwindSafe(|| {
625            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
626            c.net.to_json().map_err(|e| e.to_string())
627        }));
628        match r {
629            Ok(Ok(json)) => finish_cstring(json, errbuf, errlen),
630            Ok(Err(msg)) => {
631                copy_to_buf(errbuf, errlen, &msg);
632                std::ptr::null_mut()
633            }
634            Err(_) => {
635                copy_to_buf(errbuf, errlen, "panic while serializing to JSON");
636                std::ptr::null_mut()
637            }
638        }
639    }
640}
641
642/// Rebuild a network handle from JSON produced by [`pio_to_json`]. Returns a new
643/// handle (free with [`pio_network_free`]), or `NULL` on error (message into
644/// `errbuf`). The handle has no retained source, so [`pio_to_matpower`]
645/// reformats it rather than echoing a byte-exact original.
646#[unsafe(no_mangle)]
647pub unsafe extern "C" fn pio_from_json(
648    json: *const c_char,
649    errbuf: *mut c_char,
650    errlen: usize,
651) -> *mut PioNetwork {
652    unsafe {
653        finish_network(errbuf, errlen, "panic while parsing JSON", || {
654            let json = cstr(json).ok_or_else(|| "json is NULL or not UTF-8".to_string())?;
655            Network::from_json(json)
656                .map(|n| (n, Vec::new()))
657                .map_err(|e| e.to_string())
658        })
659    }
660}
661
662unsafe fn fill<T: Copy>(ptr: *mut T, vals: impl Iterator<Item = T>) {
663    unsafe {
664        if ptr.is_null() {
665            return;
666        }
667        for (i, v) in vals.enumerate() {
668            *ptr.add(i) = v;
669        }
670    }
671}
672
673/// Fill `out` (length `pio_n_buses`) with the 1-based bus ids in dense order.
674#[unsafe(no_mangle)]
675pub unsafe extern "C" fn pio_bus_ids(net: *const PioNetwork, out: *mut i64) {
676    unsafe {
677        guard((), || {
678            if let Some(c) = network_ref(net) {
679                fill(
680                    out,
681                    c.net
682                        .buses
683                        .iter()
684                        .map(|b| i64::try_from(b.id.0).unwrap_or(-1)),
685                );
686            }
687        })
688    }
689}
690
691/// Fill the branch tables (each length `pio_n_branches`). `from`/`to` are the
692/// 1-based bus ids (the same id space as [`pio_bus_ids`], not dense indices);
693/// map them to dense matrix rows with the [`pio_bus_ids`] ordering. Any pointer
694/// may be `NULL` to skip.
695#[unsafe(no_mangle)]
696pub unsafe extern "C" fn pio_branches(
697    net: *const PioNetwork,
698    from: *mut i64,
699    to: *mut i64,
700    r: *mut f64,
701    x: *mut f64,
702    b: *mut f64,
703    tap: *mut f64,
704    shift: *mut f64,
705    in_service: *mut u8,
706) {
707    unsafe {
708        guard((), || {
709            let Some(c) = network_ref(net) else { return };
710            let net = &c.net;
711            fill(
712                from,
713                net.branches
714                    .iter()
715                    .map(|br| i64::try_from(br.from.0).unwrap_or(-1)),
716            );
717            fill(
718                to,
719                net.branches
720                    .iter()
721                    .map(|br| i64::try_from(br.to.0).unwrap_or(-1)),
722            );
723            fill(r, net.branches.iter().map(|br| br.r));
724            fill(x, net.branches.iter().map(|br| br.x));
725            fill(b, net.branches.iter().map(|br| br.b));
726            fill(tap, net.branches.iter().map(|br| br.tap));
727            fill(shift, net.branches.iter().map(|br| br.shift));
728            fill(
729                in_service,
730                net.branches.iter().map(|br| u8::from(br.in_service)),
731            );
732        })
733    }
734}
735
736/// Fill the generator tables (each length `pio_n_gens`; `bus` is the 1-based bus
737/// id, the same id space as [`pio_bus_ids`]). Any pointer may be `NULL` to skip.
738#[unsafe(no_mangle)]
739pub unsafe extern "C" fn pio_gens(
740    net: *const PioNetwork,
741    bus: *mut i64,
742    pg: *mut f64,
743    pmax: *mut f64,
744    pmin: *mut f64,
745    in_service: *mut u8,
746) {
747    unsafe {
748        guard((), || {
749            let Some(c) = network_ref(net) else { return };
750            let net = &c.net;
751            fill(
752                bus,
753                net.generators
754                    .iter()
755                    .map(|g| i64::try_from(g.bus.0).unwrap_or(-1)),
756            );
757            fill(pg, net.generators.iter().map(|g| g.pg));
758            fill(pmax, net.generators.iter().map(|g| g.pmax));
759            fill(pmin, net.generators.iter().map(|g| g.pmin));
760            fill(
761                in_service,
762                net.generators.iter().map(|g| u8::from(g.in_service)),
763            );
764        })
765    }
766}
767
768/// Fill nodal aggregates (each length `pio_n_buses`, dense order): active and
769/// reactive demand summed per bus. Any pointer may be `NULL`.
770#[unsafe(no_mangle)]
771pub unsafe extern "C" fn pio_nodal_demand(net: *const PioNetwork, pd: *mut f64, qd: *mut f64) {
772    unsafe {
773        guard((), || {
774            if let Some(v) = view(net) {
775                fill(pd, v.pd().iter().copied());
776                fill(qd, v.qd().iter().copied());
777            }
778        })
779    }
780}
781
782/// Fill nodal shunt aggregates (each length `pio_n_buses`, dense order).
783#[unsafe(no_mangle)]
784pub unsafe extern "C" fn pio_nodal_shunt(net: *const PioNetwork, gs: *mut f64, bs: *mut f64) {
785    unsafe {
786        guard((), || {
787            if let Some(v) = view(net) {
788                fill(gs, v.gs().iter().copied());
789                fill(bs, v.bs().iter().copied());
790            }
791        })
792    }
793}
794
795/// Export one raw network table over the Arrow C Data Interface.
796///
797/// `table` is one of the `PIO_ARROW_TABLE_*` selectors (bus/branch/gen/load/
798/// shunt); the columns are the parsed network fields with EXTERNAL bus ids (the
799/// `pio_bus_ids` id space), not the gridfm schema. On success (returns `0`),
800/// `out_array` and `out_schema` are populated with owned C Data Interface
801/// structs: ownership of the Arrow buffers transfers to the caller, both
802/// `release` callbacks are non-NULL, and the caller MUST invoke each exactly
803/// once when done (skipping one leaks; the structs outlive `pio_network_free`).
804/// On error (returns `-1`) the message is written into `errbuf` and the
805/// out-params are left untouched. Only built with the `arrow` cargo feature.
806#[cfg(feature = "arrow")]
807#[unsafe(no_mangle)]
808pub unsafe extern "C" fn pio_export_arrow(
809    net: *const PioNetwork,
810    table: i32,
811    out_array: *mut arrow::ffi::FFI_ArrowArray,
812    out_schema: *mut arrow::ffi::FFI_ArrowSchema,
813    errbuf: *mut c_char,
814    errlen: usize,
815) -> i32 {
816    unsafe {
817        let r = catch_unwind(AssertUnwindSafe(|| {
818            if out_array.is_null() || out_schema.is_null() {
819                return Err("out_array or out_schema is NULL".to_string());
820            }
821            let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?;
822            arrow_export::export(&c.net, table)
823        }));
824        match r {
825            Ok(Ok((array, schema))) => {
826                // Move the FFI structs into caller memory: ptr::write does not
827                // drop the (caller-zeroed) destination and does not run Drop on
828                // `array`/`schema`, so the producer release callbacks transfer to
829                // the caller. Exactly one owner.
830                std::ptr::write(out_array, array);
831                std::ptr::write(out_schema, schema);
832                0
833            }
834            Ok(Err(msg)) => {
835                copy_to_buf(errbuf, errlen, &msg);
836                -1
837            }
838            Err(_) => {
839                copy_to_buf(errbuf, errlen, "panic while exporting Arrow");
840                -1
841            }
842        }
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use std::ffi::CString;
850
851    fn data_path(name: &str) -> CString {
852        CString::new(
853            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
854                .join("../tests/data")
855                .join(name)
856                .to_str()
857                .unwrap(),
858        )
859        .unwrap()
860    }
861
862    fn case9() -> *mut PioNetwork {
863        let path = CString::new(
864            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
865                .join("../tests/data/case9.m")
866                .to_str()
867                .unwrap(),
868        )
869        .unwrap();
870        let mut err = [0 as c_char; 256];
871        let c =
872            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
873        assert!(!c.is_null(), "parse returned null");
874        c
875    }
876
877    #[test]
878    fn version_surface() {
879        // The ABI version is the compatibility contract a consumer checks at
880        // load; the version string is static, NUL-terminated, and non-empty.
881        assert_eq!(pio_abi_version(), PIO_ABI_VERSION);
882        let v = unsafe { CStr::from_ptr(pio_version()) }.to_str().unwrap();
883        assert_eq!(v, env!("CARGO_PKG_VERSION"));
884        assert!(!v.is_empty());
885    }
886
887    #[test]
888    fn parse_query_free() {
889        let c = case9();
890        unsafe {
891            assert_eq!(pio_n_buses(c), 9);
892            assert_eq!(pio_n_branches(c), 9);
893            assert_eq!(pio_n_gens(c), 3);
894            assert_eq!(pio_base_mva(c), 100.0);
895            assert_eq!(pio_n_components(c), 1);
896            assert!(pio_reference_bus(c) >= 0);
897            // The MATPOWER reader is total: no read warnings.
898            assert_eq!(pio_parse_warnings(c, std::ptr::null_mut(), 0), 0);
899            pio_network_free(c);
900        }
901    }
902
903    #[test]
904    fn parse_warnings_reach_the_buffer() {
905        // The pandapower fixture carries switches the model ignores; the read
906        // warning must be countable and readable from the handle.
907        let path = data_path("pandapower/example.json");
908        let mut err = [0 as c_char; 256];
909        let c =
910            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
911        assert!(
912            !c.is_null(),
913            "parse failed: {}",
914            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap()
915        );
916        unsafe {
917            let mut warn = [0 as c_char; 4096];
918            let n = pio_parse_warnings(c, warn.as_mut_ptr(), warn.len());
919            assert!(n > 0, "expected read warnings");
920            let w = CStr::from_ptr(warn.as_ptr()).to_str().unwrap();
921            assert!(w.contains("switch"), "expected a switch warning, got {w:?}");
922            // A NULL handle reports zero warnings.
923            assert_eq!(
924                pio_parse_warnings(std::ptr::null(), warn.as_mut_ptr(), warn.len()),
925                0
926            );
927            pio_network_free(c);
928        }
929    }
930
931    #[test]
932    fn write_is_byte_exact() {
933        let src = std::fs::read_to_string(
934            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"),
935        )
936        .unwrap();
937        let c = case9();
938        unsafe {
939            let mut err = [0 as c_char; 256];
940            let s = pio_to_matpower(c, err.as_mut_ptr(), err.len());
941            assert!(!s.is_null());
942            let got = CStr::from_ptr(s).to_str().unwrap();
943            assert_eq!(got, src);
944            pio_string_free(s);
945
946            let null = pio_to_matpower(std::ptr::null(), err.as_mut_ptr(), err.len());
947            assert!(null.is_null());
948            assert_eq!(
949                CStr::from_ptr(err.as_ptr()).to_str().unwrap(),
950                "network handle is NULL"
951            );
952            pio_network_free(c);
953        }
954    }
955
956    #[test]
957    fn extract_branch_tables() {
958        let c = case9();
959        unsafe {
960            let nb = pio_n_branches(c);
961            let mut from = vec![0i64; nb];
962            let mut x = vec![0f64; nb];
963            pio_branches(
964                c,
965                from.as_mut_ptr(),
966                std::ptr::null_mut(),
967                std::ptr::null_mut(),
968                x.as_mut_ptr(),
969                std::ptr::null_mut(),
970                std::ptr::null_mut(),
971                std::ptr::null_mut(),
972                std::ptr::null_mut(),
973            );
974            // `from` carries the 1-based bus ids (case9 buses are 1..=9), the
975            // same id space as pio_bus_ids, not dense indices.
976            assert!(from.iter().all(|&f| f >= 1));
977            assert!(x.iter().all(|&xx| xx > 0.0));
978            pio_network_free(c);
979        }
980    }
981
982    #[test]
983    fn convert_matpower_echo() {
984        let path = CString::new(
985            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
986                .join("../tests/data/case14.m")
987                .to_str()
988                .unwrap(),
989        )
990        .unwrap();
991        let to = CString::new("matpower").unwrap();
992        let mut warn = [0 as c_char; 256];
993        let mut err = [0 as c_char; 256];
994        unsafe {
995            let s = pio_convert_file(
996                path.as_ptr(),
997                to.as_ptr(),
998                std::ptr::null(),
999                warn.as_mut_ptr(),
1000                warn.len(),
1001                err.as_mut_ptr(),
1002                err.len(),
1003            );
1004            assert!(!s.is_null());
1005            let got = CStr::from_ptr(s).to_str().unwrap();
1006            let src = std::fs::read_to_string(
1007                std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case14.m"),
1008            )
1009            .unwrap();
1010            assert_eq!(got, src);
1011            pio_string_free(s);
1012        }
1013    }
1014
1015    #[test]
1016    fn to_format_converts_live_handle() {
1017        let c = case9();
1018        let to = CString::new("powermodels-json").unwrap();
1019        let mut warn = [0 as c_char; 256];
1020        let mut err = [0 as c_char; 256];
1021        unsafe {
1022            let s = pio_to_format(
1023                c,
1024                to.as_ptr(),
1025                warn.as_mut_ptr(),
1026                warn.len(),
1027                err.as_mut_ptr(),
1028                err.len(),
1029            );
1030            assert!(!s.is_null());
1031            let text = CStr::from_ptr(s).to_str().unwrap();
1032            assert!(text.contains("\"bus\""));
1033            pio_string_free(s);
1034            pio_network_free(c);
1035        }
1036    }
1037
1038    #[test]
1039    fn parse_error_sets_message_not_null_handle() {
1040        let path = CString::new("/no/such/case.m").unwrap();
1041        let mut err = [0 as c_char; 256];
1042        let c =
1043            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
1044        assert!(c.is_null());
1045        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
1046        assert!(!msg.is_empty(), "expected an error message");
1047    }
1048
1049    #[test]
1050    fn non_utf8_from_hint_errors_instead_of_falling_back() {
1051        let path = data_path("case9.m");
1052        let to = CString::new("matpower").unwrap();
1053        let bad_from = [0xff_u8, 0];
1054        let mut err = [0 as c_char; 256];
1055        let c = unsafe {
1056            pio_parse_file(
1057                path.as_ptr(),
1058                bad_from.as_ptr().cast::<c_char>(),
1059                err.as_mut_ptr(),
1060                err.len(),
1061            )
1062        };
1063        assert!(c.is_null());
1064        assert_eq!(
1065            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
1066            "from is not UTF-8"
1067        );
1068
1069        let mut warn = [0 as c_char; 256];
1070        err.fill(0);
1071        let s = unsafe {
1072            pio_convert_file(
1073                path.as_ptr(),
1074                to.as_ptr(),
1075                bad_from.as_ptr().cast::<c_char>(),
1076                warn.as_mut_ptr(),
1077                warn.len(),
1078                err.as_mut_ptr(),
1079                err.len(),
1080            )
1081        };
1082        assert!(s.is_null());
1083        assert_eq!(
1084            unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
1085            "from is not UTF-8"
1086        );
1087    }
1088
1089    #[test]
1090    fn extract_gen_and_nodal_tables() {
1091        // case30 carries generators, loads, and shunts: cross-check the table
1092        // extractors against known counts and aggregate signs (a column swap in
1093        // pio_gens/pio_nodal_* would otherwise ship silently).
1094        let path = data_path("case30.m");
1095        let mut err = [0 as c_char; 256];
1096        let c =
1097            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
1098        assert!(!c.is_null());
1099        unsafe {
1100            let nb = pio_n_buses(c);
1101            let ng = pio_n_gens(c);
1102            assert_eq!(nb, 30);
1103            assert!(ng > 0);
1104
1105            let mut gbus = vec![-9i64; ng];
1106            let mut pmax = vec![0f64; ng];
1107            pio_gens(
1108                c,
1109                gbus.as_mut_ptr(),
1110                std::ptr::null_mut(),
1111                pmax.as_mut_ptr(),
1112                std::ptr::null_mut(),
1113                std::ptr::null_mut(),
1114            );
1115            assert!(gbus.iter().all(|&b| b >= 0 && (b as usize) < nb));
1116            assert!(pmax.iter().any(|&p| p > 0.0));
1117
1118            let mut ids = vec![0i64; nb];
1119            pio_bus_ids(c, ids.as_mut_ptr());
1120            assert!(ids.iter().all(|&id| id >= 1)); // MATPOWER bus ids are 1-based
1121
1122            let mut pd = vec![0f64; nb];
1123            let mut qd = vec![0f64; nb];
1124            pio_nodal_demand(c, pd.as_mut_ptr(), qd.as_mut_ptr());
1125            assert!(pd.iter().sum::<f64>() > 0.0, "case30 has active demand");
1126
1127            let mut gs = vec![0f64; nb];
1128            let mut bs = vec![0f64; nb];
1129            pio_nodal_shunt(c, gs.as_mut_ptr(), bs.as_mut_ptr());
1130            assert!(gs.iter().chain(bs.iter()).all(|x| x.is_finite()));
1131
1132            pio_network_free(c);
1133        }
1134    }
1135
1136    #[test]
1137    fn null_handle_and_null_out_are_safe() {
1138        // Every query tolerates a NULL handle (the documented safe default), and
1139        // a NULL output pointer on a valid case is skipped, not dereferenced.
1140        unsafe {
1141            let nil: *const PioNetwork = std::ptr::null();
1142            assert_eq!(pio_n_buses(nil), 0);
1143            assert_eq!(pio_n_branches(nil), 0);
1144            assert_eq!(pio_n_gens(nil), 0);
1145            assert_eq!(pio_base_mva(nil), 0.0);
1146            assert_eq!(pio_reference_bus(nil), -1);
1147            assert_eq!(pio_n_reference_buses(nil), 0);
1148            assert_eq!(pio_is_radial(nil), 0);
1149            assert_eq!(pio_n_components(nil), 0);
1150
1151            // The two FFI constructors reject a NULL input rather than crash.
1152            let mut err = [0 as c_char; 128];
1153            assert!(pio_to_normalized(nil, err.as_mut_ptr(), err.len()).is_null());
1154            let fmt = CString::new("matpower").unwrap();
1155            assert!(
1156                pio_parse_str(std::ptr::null(), fmt.as_ptr(), err.as_mut_ptr(), err.len())
1157                    .is_null()
1158            );
1159
1160            let c = case9();
1161            pio_bus_ids(c, std::ptr::null_mut());
1162            pio_reference_buses(c, std::ptr::null_mut());
1163            pio_nodal_demand(c, std::ptr::null_mut(), std::ptr::null_mut());
1164            pio_gens(
1165                c,
1166                std::ptr::null_mut(),
1167                std::ptr::null_mut(),
1168                std::ptr::null_mut(),
1169                std::ptr::null_mut(),
1170                std::ptr::null_mut(),
1171            );
1172            pio_network_free(c);
1173        }
1174    }
1175
1176    #[test]
1177    fn normalized_multi_ref_is_legible() {
1178        // A two-slack case (both gen-backed file REF buses) normalizes to a
1179        // handle that keeps both references. `pio_reference_bus` can't name a
1180        // single slack (returns -1), but the reference-set accessors do, so a C
1181        // consumer can tell "two slacks, you pick" from "no slack, broken".
1182        let src = "\
1183function mpc = tworef
1184mpc.version = '2';
1185mpc.baseMVA = 100;
1186mpc.bus = [
1187\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1188\t2\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1189\t3\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1190];
1191mpc.gen = [
1192\t1\t0\t0\t100\t-100\t1\t100\t1\t100\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
1193\t2\t0\t0\t100\t-100\t1\t100\t1\t300\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
1194];
1195mpc.branch = [
1196\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1197\t2\t3\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1198];
1199";
1200        let text = CString::new(src).unwrap();
1201        let fmt = CString::new("matpower").unwrap();
1202        let mut err = [0 as c_char; 256];
1203        unsafe {
1204            let cs = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
1205            assert!(!cs.is_null(), "parse_str returned null");
1206            let cn = pio_to_normalized(cs, err.as_mut_ptr(), err.len());
1207            assert!(!cn.is_null(), "to_normalized returned null");
1208
1209            assert_eq!(pio_n_reference_buses(cn), 2);
1210            // Multiple references: the single-slack query reports -1, by design.
1211            assert_eq!(pio_reference_bus(cn), -1);
1212            let mut refs = vec![0i64; pio_n_reference_buses(cn)];
1213            pio_reference_buses(cn, refs.as_mut_ptr());
1214            assert_eq!(refs, vec![0, 1]);
1215
1216            pio_network_free(cn);
1217            pio_network_free(cs);
1218        }
1219    }
1220
1221    #[test]
1222    fn normalized_preserves_source_bus_ids() {
1223        let src = "\
1224function mpc = sparseids
1225mpc.version = '2';
1226mpc.baseMVA = 100;
1227mpc.bus = [
1228\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1229\t2\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1230\t3\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1231\t4\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1232\t10\t1\t50\t10\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
1233];
1234mpc.gen = [
1235\t1\t0\t0\t100\t-100\t1\t100\t1\t200\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0;
1236];
1237mpc.branch = [
1238\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1239\t2\t3\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1240\t3\t4\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1241\t4\t10\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
1242];
1243";
1244        let text = CString::new(src).unwrap();
1245        let fmt = CString::new("matpower").unwrap();
1246        let mut err = [0 as c_char; 256];
1247        unsafe {
1248            let cs = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len());
1249            assert!(!cs.is_null(), "parse_str returned null");
1250            let cn = pio_to_normalized(cs, err.as_mut_ptr(), err.len());
1251            assert!(!cn.is_null(), "to_normalized returned null");
1252
1253            let mut ids = vec![0i64; pio_n_buses(cn)];
1254            pio_bus_ids(cn, ids.as_mut_ptr());
1255            assert_eq!(ids, vec![1, 2, 3, 4, 10]);
1256
1257            let mut from = vec![0i64; pio_n_branches(cn)];
1258            let mut to = vec![0i64; pio_n_branches(cn)];
1259            pio_branches(
1260                cn,
1261                from.as_mut_ptr(),
1262                to.as_mut_ptr(),
1263                std::ptr::null_mut(),
1264                std::ptr::null_mut(),
1265                std::ptr::null_mut(),
1266                std::ptr::null_mut(),
1267                std::ptr::null_mut(),
1268                std::ptr::null_mut(),
1269            );
1270            assert_eq!((from[3], to[3]), (4, 10));
1271
1272            pio_network_free(cn);
1273            pio_network_free(cs);
1274        }
1275    }
1276
1277    #[test]
1278    fn convert_emits_warning_into_buffer() {
1279        // t_case9_dcline carries an HVDC dcline PSS/E can't represent; the drop
1280        // must reach the caller's warning buffer, not vanish.
1281        let path = data_path("t_case9_dcline.m");
1282        let to = CString::new("psse").unwrap();
1283        let mut warn = [0 as c_char; 512];
1284        let mut err = [0 as c_char; 256];
1285        unsafe {
1286            let s = pio_convert_file(
1287                path.as_ptr(),
1288                to.as_ptr(),
1289                std::ptr::null(),
1290                warn.as_mut_ptr(),
1291                warn.len(),
1292                err.as_mut_ptr(),
1293                err.len(),
1294            );
1295            assert!(!s.is_null());
1296            let w = CStr::from_ptr(warn.as_ptr()).to_str().unwrap();
1297            assert!(
1298                w.contains("dcline"),
1299                "expected an HVDC/dcline warning, got {w:?}"
1300            );
1301            pio_string_free(s);
1302        }
1303    }
1304
1305    #[test]
1306    fn json_round_trip_preserves_structure() {
1307        // to_json -> from_json must reproduce the structured tables. case30
1308        // carries loads, shunts, and gen costs, so a dropped field shows up.
1309        let c = {
1310            let path = data_path("case30.m");
1311            let mut err = [0 as c_char; 256];
1312            let h = unsafe {
1313                pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len())
1314            };
1315            assert!(!h.is_null());
1316            h
1317        };
1318        unsafe {
1319            let mut err = [0 as c_char; 256];
1320            let json = pio_to_json(c, err.as_mut_ptr(), err.len());
1321            assert!(!json.is_null(), "to_json returned null");
1322            let text = CStr::from_ptr(json).to_str().unwrap().to_owned();
1323            assert!(text.contains("\"buses\""));
1324
1325            let back = pio_from_json(json.cast_const(), err.as_mut_ptr(), err.len());
1326            assert!(!back.is_null(), "from_json returned null");
1327            // Counts and base survive the round trip through JSON.
1328            assert_eq!(pio_n_buses(back), pio_n_buses(c));
1329            assert_eq!(pio_n_branches(back), pio_n_branches(c));
1330            assert_eq!(pio_n_gens(back), pio_n_gens(c));
1331            assert_eq!(pio_base_mva(back), pio_base_mva(c));
1332            assert_eq!(pio_reference_bus(back), pio_reference_bus(c));
1333
1334            pio_string_free(json);
1335            pio_network_free(back);
1336            pio_network_free(c);
1337        }
1338    }
1339
1340    #[test]
1341    fn from_json_rejects_garbage() {
1342        let bad = CString::new("{ not json").unwrap();
1343        let mut err = [0 as c_char; 256];
1344        let h = unsafe { pio_from_json(bad.as_ptr(), err.as_mut_ptr(), err.len()) };
1345        assert!(h.is_null());
1346        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
1347        assert!(!msg.is_empty(), "expected a JSON parse error message");
1348    }
1349
1350    #[test]
1351    fn to_json_null_handle_is_safe() {
1352        let mut err = [0 as c_char; 256];
1353        let s = unsafe { pio_to_json(std::ptr::null(), err.as_mut_ptr(), err.len()) };
1354        assert!(s.is_null());
1355    }
1356
1357    #[test]
1358    fn error_buffer_truncates_and_nul_terminates() {
1359        // copy_to_buf must truncate an oversized message to fit and keep the
1360        // trailing NUL (the one piece of pointer arithmetic in the file).
1361        let path = CString::new("/no/such/directory/deeply/nested/missing/case.m").unwrap();
1362        let mut err = [0x7f as c_char; 16]; // prefill nonzero so the NUL is visible
1363        let c =
1364            unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) };
1365        assert!(c.is_null());
1366        let nul = err
1367            .iter()
1368            .position(|&b| b == 0)
1369            .expect("buffer must be NUL-terminated");
1370        assert!(nul <= 15);
1371    }
1372
1373    #[test]
1374    fn truncation_lands_on_a_utf8_char_boundary() {
1375        // "aé€" is 1+2+3 bytes; a 6-byte buffer fits 5 message bytes, which
1376        // would split '€'. The copy must back up to "aé" instead of emitting a
1377        // dangling partial codepoint.
1378        let mut buf = [0x7f as c_char; 6];
1379        unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") };
1380        let s = unsafe { CStr::from_ptr(buf.as_ptr()) }
1381            .to_str()
1382            .expect("truncated message must be valid UTF-8");
1383        assert_eq!(s, "aé");
1384
1385        // A message that fits is copied whole.
1386        let mut buf = [0x7f as c_char; 8];
1387        unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") };
1388        let s = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap();
1389        assert_eq!(s, "aé€");
1390    }
1391
1392    #[cfg(feature = "arrow")]
1393    #[test]
1394    fn export_arrow_null_out_params_return_error() {
1395        // A NULL out_array/out_schema must be reported (-1), not dereferenced.
1396        let c = case9();
1397        let mut err = [0 as c_char; 256];
1398        let rc = unsafe {
1399            pio_export_arrow(
1400                c,
1401                PIO_ARROW_TABLE_BUS,
1402                std::ptr::null_mut(),
1403                std::ptr::null_mut(),
1404                err.as_mut_ptr(),
1405                err.len(),
1406            )
1407        };
1408        assert_eq!(rc, -1);
1409        let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap();
1410        assert!(!msg.is_empty(), "expected an error message");
1411        unsafe { pio_network_free(c) };
1412    }
1413
1414    #[cfg(feature = "gridfm")]
1415    #[test]
1416    fn read_gridfm_round_trips_and_enumerates_scenarios() {
1417        use powerio_matrix::{GridfmOptions, write_gridfm_dataset};
1418        // Write a one-scenario dataset, then read it back over the C ABI.
1419        let net = powerio::parse_file(
1420            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case14.m"),
1421            None,
1422        )
1423        .unwrap()
1424        .network;
1425        let tmp = tempfile::tempdir().unwrap();
1426        let out = write_gridfm_dataset(&net, 0, tmp.path(), &GridfmOptions::default()).unwrap();
1427        let dir = CString::new(out.dir.to_str().unwrap()).unwrap();
1428
1429        let mut warn = [0 as c_char; 512];
1430        let mut err = [0 as c_char; 256];
1431        unsafe {
1432            let h = pio_read_gridfm(
1433                dir.as_ptr(),
1434                0,
1435                warn.as_mut_ptr(),
1436                warn.len(),
1437                err.as_mut_ptr(),
1438                err.len(),
1439            );
1440            assert!(
1441                !h.is_null(),
1442                "read failed: {}",
1443                CStr::from_ptr(err.as_ptr()).to_str().unwrap()
1444            );
1445            assert_eq!(pio_n_buses(h), 14);
1446            // The lossy read always reports fidelity warnings into warnbuf.
1447            assert!(
1448                !CStr::from_ptr(warn.as_ptr()).to_str().unwrap().is_empty(),
1449                "expected fidelity warnings"
1450            );
1451            pio_network_free(h);
1452
1453            // Scenario ids: size with a NULL out, then fill. One scenario -> [0].
1454            let count = pio_gridfm_scenario_ids(
1455                dir.as_ptr(),
1456                std::ptr::null_mut(),
1457                0,
1458                err.as_mut_ptr(),
1459                err.len(),
1460            );
1461            assert_eq!(count, 1);
1462            let mut ids = [-1i64; 4];
1463            let n = pio_gridfm_scenario_ids(
1464                dir.as_ptr(),
1465                ids.as_mut_ptr(),
1466                ids.len(),
1467                err.as_mut_ptr(),
1468                err.len(),
1469            );
1470            assert_eq!(n, 1);
1471            assert_eq!(ids[0], 0);
1472
1473            // A missing dataset directory errors (NULL handle + message), not a panic.
1474            let missing = CString::new(tmp.path().join("nope").to_str().unwrap()).unwrap();
1475            let bad = pio_read_gridfm(
1476                missing.as_ptr(),
1477                0,
1478                warn.as_mut_ptr(),
1479                warn.len(),
1480                err.as_mut_ptr(),
1481                err.len(),
1482            );
1483            assert!(bad.is_null());
1484            assert!(!CStr::from_ptr(err.as_ptr()).to_str().unwrap().is_empty());
1485        }
1486    }
1487}