diff --git a/AGENTS.md b/AGENTS.md index 2ae15b1..8d938c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,8 @@ graph views for any downstream solver. (Planned) feeds the GridFM ML pipeline. (`python/powerio/`); hands back COO triplets that scipy assembles. - **`powerio-capi`**: C ABI over `powerio` (`pio_*`, header `powerio.h`) for C, C++, Julia, and other FFI users. `--features arrow` adds - `pio_export_arrow`, an Arrow C Data Interface export; `--features gridfm` adds - `pio_read_gridfm` / `pio_gridfm_scenario_ids` (the gridfm-datakit Parquet + `pio_to_arrow`, an Arrow C Data Interface export; `--features gridfm` adds + `pio_read_dir` / `pio_scenario_ids` (the gridfm-datakit Parquet reader, pulling in `powerio-matrix`). Both are additive/feature-gated, so no ABI bump. @@ -64,7 +64,7 @@ powerio gridfm tests/data/case14.m -o out # gridfm-datakit Parquet dataset # C ABI (cdylib + staticlib; header powerio-capi/include/powerio.h): cargo build -p powerio-capi -cargo build -p powerio-capi --features arrow # + pio_export_arrow (Arrow C Data Interface) +cargo build -p powerio-capi --features arrow # + pio_to_arrow (Arrow C Data Interface) # Python (PyO3 crate needs libpython, so it is NOT in default-members): cargo build -p powerio-py # plain cargo build of the extension @@ -124,7 +124,7 @@ powerio-py/src/lib.rs # PyO3 extension → COO triplets (module `_powerio python/powerio/ # importable package (scipy/networkx assembly, lazy) python/tests/ # test_powerio.py, test_gridfm.py, test_mcp.py powerio-capi/ # C ABI (pio_*, include/powerio.h, examples/smoke.c) -│ # src/arrow_export.rs: pio_export_arrow (feature = "arrow") +│ # src/arrow_export.rs: pio_to_arrow (feature = "arrow") tests/data/ # shared fixtures (used by CLI examples) benchmarks/ # parse benchmarks + Julia validation harnesses ``` @@ -159,7 +159,7 @@ benchmarks/ # parse benchmarks + Julia validation harnesses `powerio/src/lib.rs`, add a CLI/`TargetFormat` arm. `Network` is the unifying hub. - **JSON transport.** `Network::to_json`/`from_json` (serde) is the structured - transport; over the C ABI it is `pio_to_json`/`pio_from_json`. The retained + transport; over the C ABI it is the `powerio-json` format through `pio_to_format`/`pio_parse_str`. The retained `source` text is `#[serde(skip)]`, so JSON carries the tables, not the byte exact echo, and a `from_json` round trip returns `source` as `None`. - **Sign convention.** Positive Laplacian: off diag negative, diag positive, `diag = sum |off-diag|` for B'. The positive (M-matrix) Laplacian form SDDM solvers expect. diff --git a/Cargo.toml b/Cargo.toml index e4db6dd..1b794bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ members = ["powerio", "powerio-matrix", "powerio-cli", "powerio-py", "powerio-ca # toolchain never compiles the PyO3 extension (which needs libpython). Build the # binding explicitly with `-p powerio-py`. default-members = ["powerio", "powerio-matrix", "powerio-cli"] +# The fuzz harnesses need nightly + cargo-fuzz; they build on their own (see +# fuzz/README.md), never as part of the workspace. +exclude = ["fuzz"] resolver = "2" # Single source for the release version and shared metadata; the crates inherit diff --git a/README.md b/README.md index f38e38d..fe40cfa 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ julia -e 'using Pkg; Pkg.add(url="https://github.com/eigenergy/PowerIO.jl")' ```rust use powerio::{TargetFormat, parse_file}; -let net = parse_file("case14.m")?; -let conv = net.to_format(TargetFormat::PowerModelsJson); +let net = parse_file("case14.m")?.network; +let conv = net.to_format(TargetFormat::PowerModelsJson)?; for warning in &conv.warnings { eprintln!("conversion warning: {warning}"); @@ -175,7 +175,7 @@ Current conventions for signs, taps, phase shifts, per unit scaling, reference b The normalized copy carries no retained source text, so writing it emits the derived model rather than the original file. -Python exposes the normalized view as `case.to_normalized()`, the C ABI as `pio_to_normalized`, +Python exposes the normalized view as `case.to_normalized()`, the C ABI as `pio_normalize`, and Julia as `to_normalized(case)`. @@ -184,7 +184,7 @@ and Julia as `to_normalized(case)`. `powerio-capi` exposes parse, query, conversion, JSON transport, normalization, and numeric table extraction through `pio_*` functions. The public header is [powerio-capi/include/powerio.h](https://github.com/eigenergy/powerio/blob/main/powerio-capi/include/powerio.h). -Build with `--features arrow` to enable `pio_export_arrow` over the +Build with `--features arrow` to enable `pio_to_arrow` over the [Arrow C Data Interface](https://arrow.apache.org/docs/format/CDataInterface.html). ### PowerAgent diff --git a/benchmarks/powerio_ffi.jl b/benchmarks/powerio_ffi.jl index 03ead54..b84905a 100644 --- a/benchmarks/powerio_ffi.jl +++ b/benchmarks/powerio_ffi.jl @@ -31,9 +31,13 @@ pio_n_branches(h) = Int(ccall((:pio_n_branches, LIBPOWERIO), Csize_t, (Ptr{Cvoid pio_n_gens(h) = Int(ccall((:pio_n_gens, LIBPOWERIO), Csize_t, (Ptr{Cvoid},), h)) pio_base_mva(h) = ccall((:pio_base_mva, LIBPOWERIO), Cdouble, (Ptr{Cvoid},), h) +# ABI v4 extractors: every array call passes a cap and returns the total +# available (NULL out is the count query). The caps below come from the +# matching pio_n_* call, so the returned totals always equal them. + function pio_bus_ids(h, n) out = Vector{Int64}(undef, n) - ccall((:pio_bus_ids, LIBPOWERIO), Cvoid, (Ptr{Cvoid}, Ptr{Int64}), h, out) + ccall((:pio_bus_ids, LIBPOWERIO), Csize_t, (Ptr{Cvoid}, Ptr{Int64}, Csize_t), h, out, n) out end @@ -42,10 +46,10 @@ function pio_branches(h, m) r = Vector{Float64}(undef, m); x = Vector{Float64}(undef, m) b = Vector{Float64}(undef, m); tap = Vector{Float64}(undef, m) shift = Vector{Float64}(undef, m); insvc = Vector{UInt8}(undef, m) - ccall((:pio_branches, LIBPOWERIO), Cvoid, + ccall((:pio_branches, LIBPOWERIO), Csize_t, (Ptr{Cvoid}, Ptr{Int64}, Ptr{Int64}, Ptr{Float64}, Ptr{Float64}, - Ptr{Float64}, Ptr{Float64}, Ptr{Float64}, Ptr{UInt8}), - h, from, to, r, x, b, tap, shift, insvc) + Ptr{Float64}, Ptr{Float64}, Ptr{Float64}, Ptr{UInt8}, Csize_t), + h, from, to, r, x, b, tap, shift, insvc, m) (; from, to, r, x, b, tap, shift, in_service = insvc) end @@ -53,23 +57,23 @@ function pio_gens(h, ng) bus = Vector{Int64}(undef, ng); pg = Vector{Float64}(undef, ng) pmax = Vector{Float64}(undef, ng); pmin = Vector{Float64}(undef, ng) insvc = Vector{UInt8}(undef, ng) - ccall((:pio_gens, LIBPOWERIO), Cvoid, - (Ptr{Cvoid}, Ptr{Int64}, Ptr{Float64}, Ptr{Float64}, Ptr{Float64}, Ptr{UInt8}), - h, bus, pg, pmax, pmin, insvc) + ccall((:pio_gens, LIBPOWERIO), Csize_t, + (Ptr{Cvoid}, Ptr{Int64}, Ptr{Float64}, Ptr{Float64}, Ptr{Float64}, Ptr{UInt8}, Csize_t), + h, bus, pg, pmax, pmin, insvc, ng) (; bus, pg, pmax, pmin, in_service = insvc) end -function pio_nodal_demand(h, n) +function pio_bus_demand(h, n) pd = Vector{Float64}(undef, n); qd = Vector{Float64}(undef, n) - ccall((:pio_nodal_demand, LIBPOWERIO), Cvoid, - (Ptr{Cvoid}, Ptr{Float64}, Ptr{Float64}), h, pd, qd) + ccall((:pio_bus_demand, LIBPOWERIO), Csize_t, + (Ptr{Cvoid}, Ptr{Float64}, Ptr{Float64}, Csize_t), h, pd, qd, n) (; pd, qd) end -function pio_nodal_shunt(h, n) +function pio_bus_shunt(h, n) gs = Vector{Float64}(undef, n); bs = Vector{Float64}(undef, n) - ccall((:pio_nodal_shunt, LIBPOWERIO), Cvoid, - (Ptr{Cvoid}, Ptr{Float64}, Ptr{Float64}), h, gs, bs) + ccall((:pio_bus_shunt, LIBPOWERIO), Csize_t, + (Ptr{Cvoid}, Ptr{Float64}, Ptr{Float64}, Csize_t), h, gs, bs, n) (; gs, bs) end @@ -82,8 +86,8 @@ function powerio_load(path::AbstractString) bus_ids = pio_bus_ids(h, n), branch = pio_branches(h, m), gen = pio_gens(h, ng), - demand = pio_nodal_demand(h, n), - shunt = pio_nodal_shunt(h, n), + demand = pio_bus_demand(h, n), + shunt = pio_bus_shunt(h, n), n, m, ng) finally pio_free(h) diff --git a/docs/languages.md b/docs/languages.md index b7a534a..9766fdd 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -23,18 +23,20 @@ Verb taxonomy: | Parse path | `parse_file(path, from)` | `parse_file(path, from_=None)` | `parse_file(path; from=nothing)` | `pio_parse_file` | | Parse text | `parse_str(text, format)` | `parse_str(text, format)` | `parse_str(text, format)` | `pio_parse_str` | | Parse IO | n/a | file object later | `parse_file(io, format)` | n/a | -| JSON to Network | `Network::from_json` | `from_json` | `from_json` | `pio_from_json` | +| JSON to Network | `Network::from_json` | `from_json` | `from_json` | `pio_parse_str` + `"powerio-json"` | | File conversion | `convert_file(path, to, from)` | `convert_file(path, to, from_=None)` | `convert_file(path, to; from=nothing)` | `pio_convert_file` | -| Text conversion | `convert_str(text, to, format)` | `convert_str(text, to, format)` | planned | planned | +| Text conversion | `convert_str(text, to, format)` | `convert_str(text, to, format)` | planned | `pio_convert_str` | | Parsed conversion | `net.to_format(to)` | `net.to_format(to)` | `to_format(net, to)` | `pio_to_format` | -| MATPOWER text | `net.to_matpower()` | `net.to_matpower()` | `to_matpower(net)` | `pio_to_matpower` | -| JSON text | `net.to_json()` | `net.to_json()` | `to_json(net)` | `pio_to_json` | -| Normalized copy | `net.to_normalized()` | `net.to_normalized()` | `to_normalized(net)` | `pio_to_normalized` | +| MATPOWER text | `net.to_matpower()` | `net.to_matpower()` | `to_matpower(net)` | `pio_to_format` + `"matpower"` | +| JSON text | `net.to_json()` | `net.to_json()` | `to_json(net)` | `pio_to_format` + `"powerio-json"` | +| Normalized copy | `net.to_normalized()` | `net.to_normalized()` | `to_normalized(net)` | `pio_normalize` | | Dense tables | typed table API | `to_dense` | `to_dense` | `pio_*` extractors | -| PyPSA CSV folder | `read_pypsa_csv_folder` / `write_pypsa_csv_folder` | `read_pypsa_csv_folder` / `net.write_pypsa_csv_folder` | planned | `pio_parse_file` / `pio_write_pypsa_csv_folder` | -| gridfm read | `read_gridfm_dataset(dir, scenario)` | `read_gridfm(dir, scenario=0)` | `read_gridfm(dir; scenario=0)` (PR open) | `pio_read_gridfm` | -| Arrow handoff | internal/C ABI | later | `to_arrow` | `pio_export_arrow` | +| PyPSA CSV folder | `read_pypsa_csv_folder` / `write_pypsa_csv_folder` | `read_pypsa_csv_folder` / `net.write_pypsa_csv_folder` | planned | `pio_parse_file` / `pio_write_dir` + `"pypsa-csv"` | +| gridfm read | `read_gridfm_dataset(dir, scenario)` | `read_gridfm(dir, scenario=0)` | `read_gridfm(dir; scenario=0)` | `pio_read_dir` + `"gridfm"` | +| Arrow handoff | internal/C ABI | later | `to_arrow` | `pio_to_arrow` | -**Note:** `pio_export_arrow` keeps `export` because it fills Arrow C Data Interface -structs with release callbacks. It is not an owned string or handle return like -the `to_*` functions. +**Note:** the C ABI carries no per-format symbols: matpower, the powerio-json +snapshot, PyPSA CSV directories, and gridfm datasets are all format strings into +`pio_to_format` / `pio_parse_str` / `pio_write_dir` / `pio_read_dir`. The +language APIs keep their per-format conveniences (`to_matpower`, `from_json`, +...) as wrappers over the same paths. diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..ab0eaa1 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,5 @@ +target/ +corpus/ +artifacts/ +coverage/ +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..c1ad574 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "powerio-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +powerio = { path = "../powerio" } + +# Detached from the parent workspace (and excluded there): the fuzz crate +# needs nightly + cargo-fuzz and must not ride along on workspace builds. +[workspace] + +[profile.release] +debug = 1 + +[[bin]] +name = "matpower" +path = "fuzz_targets/matpower.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "psse" +path = "fuzz_targets/psse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "powerio_json" +path = "fuzz_targets/powerio_json.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "powerworld_aux" +path = "fuzz_targets/powerworld_aux.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "pwb" +path = "fuzz_targets/pwb.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "pwd" +path = "fuzz_targets/pwd.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..bdc9a80 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,28 @@ +# Fuzzing the parser surface + +libFuzzer harnesses for the readers that take untrusted input: `matpower`, +`psse`, `powerworld_aux`, and `powerio_json` feed `parse_str`; `pwb` and `pwd` +feed the PowerWorld binary decoders raw bytes. The remaining `parse_str` +formats (PowerModels, egret, pandapower) ride serde_json rather than a +hand-written tokenizer, so the harnesses cover every hand-rolled reader. The +invariant under test is the parser trust model: any input returns `Ok` or a +structured `Err`, never a panic and never undefined behavior. + +Needs nightly and [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz): + +```sh +cargo install cargo-fuzz +cargo +nightly fuzz run matpower -- -max_total_time=60 +``` + +Seed a corpus from the test fixtures for much better coverage: + +```sh +mkdir -p corpus/matpower && cp ../tests/data/*.m corpus/matpower/ +mkdir -p corpus/powerworld_aux && cp ../tests/data/powerworld/*.aux corpus/powerworld_aux/ 2>/dev/null || true +mkdir -p corpus/pwb && cp ../tests/data/powerworld/*.pwb corpus/pwb/ 2>/dev/null || true +``` + +The crate is excluded from the workspace and from CI; run it when touching a +reader. A crash reproducer lands in `artifacts//` — turn it into a +regression test next to the reader before fixing. diff --git a/fuzz/fuzz_targets/matpower.rs b/fuzz/fuzz_targets/matpower.rs new file mode 100644 index 0000000..131aa45 --- /dev/null +++ b/fuzz/fuzz_targets/matpower.rs @@ -0,0 +1,12 @@ +//! Malformed-input fuzzing of the MATPOWER text reader: any input must come +//! back as `Ok` or a structured `Err`, never a panic (the gencost NCOST +//! overflow this crate exists to keep caught was found exactly this way). +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(text) = std::str::from_utf8(data) { + let _ = powerio::parse_str(text, "matpower"); + } +}); diff --git a/fuzz/fuzz_targets/powerio_json.rs b/fuzz/fuzz_targets/powerio_json.rs new file mode 100644 index 0000000..73d63b7 --- /dev/null +++ b/fuzz/fuzz_targets/powerio_json.rs @@ -0,0 +1,11 @@ +//! Malformed-input fuzzing of the canonical `powerio-json` snapshot reader +//! (serde deserialization plus the reference validation pass). +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(text) = std::str::from_utf8(data) { + let _ = powerio::parse_str(text, "powerio-json"); + } +}); diff --git a/fuzz/fuzz_targets/powerworld_aux.rs b/fuzz/fuzz_targets/powerworld_aux.rs new file mode 100644 index 0000000..def728f --- /dev/null +++ b/fuzz/fuzz_targets/powerworld_aux.rs @@ -0,0 +1,13 @@ +//! Malformed-input fuzzing of the PowerWorld `.aux` reader — the one +//! hand-written text tokenizer `parse_str` reaches (the JSON dialects ride +//! serde), so it carries the same byte-indexing hazards as the binary +//! decoders. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(text) = std::str::from_utf8(data) { + let _ = powerio::parse_str(text, "powerworld"); + } +}); diff --git a/fuzz/fuzz_targets/psse.rs b/fuzz/fuzz_targets/psse.rs new file mode 100644 index 0000000..05ea112 --- /dev/null +++ b/fuzz/fuzz_targets/psse.rs @@ -0,0 +1,10 @@ +//! Malformed-input fuzzing of the PSS/E `.raw` reader. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(text) = std::str::from_utf8(data) { + let _ = powerio::parse_str(text, "psse"); + } +}); diff --git a/fuzz/fuzz_targets/pwb.rs b/fuzz/fuzz_targets/pwb.rs new file mode 100644 index 0000000..4bb0220 --- /dev/null +++ b/fuzz/fuzz_targets/pwb.rs @@ -0,0 +1,9 @@ +//! Malformed-input fuzzing of the PowerWorld `.pwb` binary decoder — raw +//! attacker-controlled bytes drive every offset and length it reads. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = powerio::format::powerworld::parse_pwb(data, None); +}); diff --git a/fuzz/fuzz_targets/pwd.rs b/fuzz/fuzz_targets/pwd.rs new file mode 100644 index 0000000..6f529cf --- /dev/null +++ b/fuzz/fuzz_targets/pwd.rs @@ -0,0 +1,9 @@ +//! Malformed-input fuzzing of the PowerWorld `.pwd` display decoder — raw +//! attacker-controlled bytes drive every offset and length it reads. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = powerio::format::powerworld::parse_pwd(data); +}); diff --git a/powerio-capi/Cargo.toml b/powerio-capi/Cargo.toml index 7e693a7..b3f3fae 100644 --- a/powerio-capi/Cargo.toml +++ b/powerio-capi/Cargo.toml @@ -15,10 +15,10 @@ name = "powerio_capi" crate-type = ["cdylib", "staticlib", "rlib"] [features] -# Zero-copy raw network export over the Arrow C Data Interface (`pio_export_arrow`). +# Zero-copy raw network export over the Arrow C Data Interface (`pio_to_arrow`). # Off by default so the base ABI pulls in nothing but `powerio`. arrow = ["dep:arrow"] -# gridfm-datakit Parquet reader (`pio_read_gridfm` / `pio_gridfm_scenario_ids`). +# gridfm-datakit Parquet reader (`pio_read_dir` / `pio_scenario_ids`). # Off by default so the base ABI pulls in nothing but `powerio`; enabling it pulls # in powerio-matrix (arrow + parquet) for the reader. gridfm = ["dep:powerio-matrix", "powerio-matrix/gridfm"] diff --git a/powerio-capi/README.md b/powerio-capi/README.md index 8a459f0..88babe2 100644 --- a/powerio-capi/README.md +++ b/powerio-capi/README.md @@ -37,20 +37,24 @@ int main(void) { size_t n = pio_n_buses(c), m = pio_n_branches(c); printf("%zu buses, %zu branches, baseMVA %g\n", n, m, pio_base_mva(c)); - /* Pull the branch table to build a susceptance matrix yourself. */ + /* Pull the branch table to build a susceptance matrix yourself. Extractors + * write up to cap entries and return the total, so a short buffer is + * detectable; NULL out (or cap 0) is the count query. */ int64_t *from = malloc(m * sizeof *from), *to = malloc(m * sizeof *to); double *x = malloc(m * sizeof *x); - pio_branches(c, from, to, NULL, x, NULL, NULL, NULL, NULL); + pio_branches(c, from, to, NULL, x, NULL, NULL, NULL, NULL, m); /* ... assemble B' from (from, to, 1/x) ... */ - char *matpower = pio_to_matpower(c, err, sizeof err); + /* Every format is a string: matpower echoes byte-exact, powerio-json is + * the lossless snapshot, powermodels-json/psse/... convert with warnings. */ + char warn[256]; + char *matpower = pio_to_format(c, "matpower", warn, sizeof warn, err, sizeof err); if (matpower) { /* ... use MATPOWER text ... */ pio_string_free(matpower); } - char warn[256]; char *json = pio_to_format(c, "powermodels-json", warn, sizeof warn, err, sizeof err); if (json) { /* ... use PowerModels JSON text ... */ pio_string_free(json); } - char *raw = pio_convert_file("case9.m", "psse", NULL, NULL, 0, err, sizeof err); + char *raw = pio_convert_file("case9.m", NULL, "psse", NULL, 0, err, sizeof err); if (raw) { /* ... use PSS/E text ... */ pio_string_free(raw); } free(from); free(to); free(x); @@ -84,39 +88,45 @@ m = ccall((:pio_n_branches, LIB), Csize_t, (Ptr{Cvoid},), h) from = Vector{Int64}(undef, m); to = Vector{Int64}(undef, m) x = Vector{Float64}(undef, m) -ccall((:pio_branches, LIB), Cvoid, +ccall((:pio_branches, LIB), Csize_t, (Ptr{Cvoid}, Ptr{Int64}, Ptr{Int64}, Ptr{Float64}, Ptr{Float64}, - Ptr{Float64}, Ptr{Float64}, Ptr{Float64}, Ptr{UInt8}), - h, from, to, C_NULL, x, C_NULL, C_NULL, C_NULL, C_NULL) + Ptr{Float64}, Ptr{Float64}, Ptr{Float64}, Ptr{UInt8}, Csize_t), + h, from, to, C_NULL, x, C_NULL, C_NULL, C_NULL, C_NULL, m) # build your matrices from (from, to, x), then: ccall((:pio_network_free, LIB), Cvoid, (Ptr{Cvoid},), h) ``` -## JSON transport +## The powerio-json snapshot -For consumers that want the whole case rather than the dense table slices, -`pio_to_json` serializes the entire `Network` (buses, loads, shunts, branches, -generators, storage, HVDC, and extras) to a string, and `pio_from_json` rebuilds -a handle from it. This is the transport the Julia package consumes: one call -instead of stitching the ~dozen table extractors together. The retained source -text is not part of the JSON, so a `from_json` handle reformats on write rather -than echoing a byte-exact original. +For consumers that want the whole case rather than the dense table slices, the +`powerio-json` format name serializes the entire `Network` (buses, loads, +shunts, branches, generators, storage, HVDC, and extras) through +`pio_to_format`, and `pio_parse_str` validates it back into a handle. This is +the transport the Julia package consumes: one call instead of stitching the +~dozen table extractors together. The retained source text is the one field the +snapshot omits, so a reloaded handle reformats on write rather than echoing a +byte-exact original. ## API names -The release ABI uses the same verb taxonomy as the Rust, Python, and Julia APIs: - -- `pio_parse_file` and `pio_parse_str` turn files or text into handles. -- `pio_to_format`, `pio_to_matpower`, `pio_to_json`, and `pio_to_normalized` - derive new values from a handle. -- `pio_convert_file` converts a file path to output text in one call. -- `pio_export_arrow` uses `export` because it fills Arrow C Data Interface - structs with release callbacks. -- `pio_read_gridfm` and `pio_gridfm_scenario_ids` read a gridfm-datakit Parquet - dataset back into a handle (built `--features gridfm`; the reader itself lives - in `powerio-matrix`). Lossy, but it recovers everything a power flow needs; - the fidelity notes come back `\n`-joined in `warnbuf`, like `pio_to_format`'s - warnings. +The grammar is written out in the header preamble; the short version: + +- Verb-led names are operations, and the verb fixes the return family: + `pio_parse_file`, `pio_parse_str`, `pio_read_dir`, and `pio_normalize` + return a new handle; `pio_write_dir` writes the filesystem; + `pio_convert_file`/`pio_convert_str` transcode without keeping a handle. +- `pio_to_format` is the one text serializer; `pio_to_arrow` earns its own + symbol only because its output type is Arrow C Data Interface structs. +- Format names never appear in symbols — `matpower`, `psse`, `powerio-json`, + `pypsa-csv`, `gridfm`, and every future format are strings, so a new format + never changes this ABI. +- Noun phrases are queries: `pio_n_*` counts, `pio_is_radial`, + `pio_bus_ids`/`pio_branches`/`pio_gens`/`pio_bus_demand`/`pio_bus_shunt` + extractors, `pio_warnings` for the handle's fidelity warnings. +- One meaning per word, transmission and distribution alike: a *bus* is a + named connection point (this surface is bus granular), a *node* is one + conductor's point at a bus (reserved for the multiconductor surface), and a + *branch* is any two-terminal series element, lines and transformers alike. ## Safety contract @@ -132,14 +142,28 @@ Every entry point is hardened at the boundary: the buffer is truncated to fit. `PIO_ERRBUF_MIN` (256) is a comfortable size. - Input strings must be valid UTF-8; anything else is rejected as an error, never dereferenced past its NUL. -- Ownership is symmetric: handles from `pio_parse_*`/`pio_from_json`/ - `pio_to_normalized` are freed with `pio_network_free`, strings from the `pio_to_*` - functions with `pio_string_free`, each exactly once. The Arrow export hands - the caller two C Data Interface structs whose non-NULL `release` callbacks - must each be invoked exactly once. -- The table extractors (`pio_branches`, `pio_gens`, ...) write exactly the - matching `pio_n_*` count of elements into each non-NULL buffer; the caller - must size them accordingly. +- Ownership is symmetric: handles from `pio_parse_*`/`pio_read_dir`/ + `pio_normalize` are freed with `pio_network_free`, strings from + `pio_to_format`/`pio_convert_*` with `pio_string_free`, each exactly once. + The Arrow export hands the caller two C Data Interface structs whose + non-NULL `release` callbacks must each be invoked exactly once. +- The table extractors (`pio_branches`, `pio_gens`, ...) write at most `cap` + elements into each non-NULL buffer and return the total available, so a + miscounted buffer reads short instead of overflowing, and `(NULL, 0)` sizes + it. `pio_warnings` returns the byte length needed the same way. +- A handle is immutable after construction: concurrent reads from any number + of threads are safe. `pio_network_free` is not; free exactly once. + +Two notes on the trust model: + +- Malformed or hostile input surfaces as an error or, at worst, a caught + panic; the parsers are safe Rust and fuzzed (see `fuzz/`), so undefined + behavior is out of reach on any input. Resource use is the caveat: memory + scales with input size and no size caps are enforced, so cap untrusted + inputs yourself if you parse them in bulk. +- The panic guards assume the default `panic = "unwind"`. A downstream + rebuild with `panic = "abort"` turns a caught-class bug into an orderly + process abort instead of an error return. ## ABI history @@ -153,10 +177,19 @@ additive symbols do not. | 1 | First versioned surface: opaque handles, typed extractors, JSON transport (#54). | | 2 | `pio_parse` → `pio_parse_file`, `pio_convert` → `pio_convert_file`, `pio_write_matpower` → `pio_to_matpower` with an `errbuf` (#69). | | 3 | `pio_case_free` → `pio_network_free`; `PioCase` → `PioNetwork` (opaque typedef) (#77). | - -From v0.1.0 the ABI is additive only: new symbols may appear, but an existing -signature never changes or disappears without a `PIO_ABI_VERSION` bump released -in lockstep with PowerIO.jl. +| 4 | The naming grammar: format symbols folded into format strings (`pio_to_matpower`/`pio_to_json`/`pio_from_json` → `pio_to_format`/`pio_parse_str` with `powerio-json`; `pio_write_pypsa_csv_folder` → `pio_write_dir`; `pio_read_gridfm`/`pio_gridfm_scenario_ids` → `pio_read_dir`/`pio_scenario_ids`), `pio_to_normalized` → `pio_normalize`, `pio_export_arrow` → `pio_to_arrow`, cap/count extractors, byte-length `pio_warnings`, `pio_ref_bus_index`/`pio_ref_bus_indices`, `pio_n_islands`, `pio_bus_demand`/`pio_bus_shunt`, `pio_convert_*(input, from, to, ...)`, new `pio_convert_str`. | + +One v4 break deserves a callout: `pio_convert_file` kept its symbol, arity, +and parameter types but reordered arguments 2 and 3 from `(path, to, from)` +to `(path, from, to)`. Every other v4 change renames a symbol or changes an +arity, so a stale caller fails at link or load; this one links fine and reads +the formats reversed. It is the reason the `pio_abi_version()` handshake is +not optional. + +The grammar v4 fixed is the freeze: existing signatures never change again, +new data means new symbols, and rich or multiconductor data rides the Arrow +and `powerio-json` schemas, which evolve without touching a C signature. Any +future break would bump `PIO_ABI_VERSION` in lockstep with PowerIO.jl. ## Scope diff --git a/powerio-capi/cbindgen.toml b/powerio-capi/cbindgen.toml index 6c73340..d5efab7 100644 --- a/powerio-capi/cbindgen.toml +++ b/powerio-capi/cbindgen.toml @@ -11,35 +11,72 @@ usize_is_size_t = true # File preamble. cbindgen generates the rest from the Rust source, so the only # hand-written prose lives here; per-symbol docs come from the Rust doc comments. header = """ -/* powerio C ABI: parse any power system case format, query it, convert - * it, and extract the numeric tables for matrix assembly. +/* powerio C ABI, version 4: parse any power system case format, query it, + * convert it, and extract the numeric tables for matrix assembly. Check + * pio_abi_version() against PIO_ABI_VERSION at load; the integer is the + * compatibility contract, the version string is informational. * - * Memory: strings returned by pio_to_matpower / pio_to_format / - * pio_convert_file / pio_to_json are owned by the library; free them with - * pio_string_free. Network handles from pio_parse_file / pio_parse_str / - * pio_from_json / pio_to_normalized are freed with pio_network_free. Array - * extractors fill caller-allocated buffers whose length is the matching pio_n_* - * count; pass NULL to skip an output. + * Naming grammar (fixed; the surface evolves additively from here): + * - Verb-led names are operations and the verb fixes the return family: + * parse/read/normalize return a new handle, write has a filesystem effect, + * convert transcodes without keeping a handle, free destroys. + * - to_ marks a representation change of the same network (the strtol/htons + * lineage). The target is a format string unless the output type differs: + * pio_to_format returns text for every named format, pio_to_arrow fills + * Arrow C Data Interface structs. + * - Noun phrases are queries (no get_); n_ prefixes counts, is_ predicates. + * - Format names never appear in symbols. Formats are strings ("matpower", + * "psse", "powerio-json", ...), so a new format never changes this ABI. * - * Message buffers: errbuf/warnbuf may be NULL (or length 0) to discard the - * message. A message longer than the buffer is truncated to fit and is always - * NUL-terminated. PIO_ERRBUF_MIN is a comfortable size for any error string. + * Vocabulary (one meaning per word, transmission and distribution alike): + * - bus: a named connection point, any number of phases. This surface is bus + * granular (pio_n_buses, pio_bus_ids, pio_bus_demand, ...). + * - node: one conductor's point at a bus (OpenDSS bus.1.2.3). Reserved for + * the multiconductor surface; never a synonym for bus here. + * - branch: any two-terminal series element, lines and transformers alike + * (circuit theory; MATPOWER mpc.branch; the Branch Flow Model). "line" is + * the transformer-excluding subset and never names the umbrella table. * - * Every entry point catches Rust panics at the boundary and returns the documented - * failure value (NULL, 0, -1, 0.0, or unchanged output) rather than unwinding - * across the ABI. + * Conventions: + * - Array extractors write up to `cap` values per output array and return the + * total available; NULL out (or cap 0) is a pure count query, so a short + * read is detectable and a caller buffer can never silently overflow. + * - errbuf/errlen caller buffers (the libpcap/curl idiom: allocation-free + * across the boundary, no thread-local state). NULL or length 0 discards + * the message; a long message truncates on a UTF-8 character boundary and + * is always NUL-terminated. PIO_ERRBUF_MIN is a comfortable size. The ABI + * reports errors as messages and defines no error codes. + * - Warnings attach to the network handle; query them with pio_warnings, + * which returns the byte length needed (call with NULL/0 to size). Only + * functions returning no handle (pio_to_format, pio_convert_*, + * pio_write_dir) take a warnbuf. + * - Strings returned by pio_to_format / pio_convert_file / pio_convert_str + * are owned by the library; free them with pio_string_free. Handles from + * pio_parse_file / pio_parse_str / pio_read_dir / pio_normalize are freed + * with pio_network_free. Arrow buffers are freed through their own release + * callbacks (the C Data Interface contract). + * - A handle is immutable after construction: concurrent reads from any + * number of threads are safe; pio_network_free is not, free exactly once. + * - Every entry point catches Rust panics at the boundary and returns the + * documented failure value (NULL, 0, -1, 0.0) rather than unwinding across + * the ABI (requires the default panic = "unwind"; a panic = "abort" build + * aborts the process instead). * - * Optional: build with `--features arrow` to get pio_export_arrow, a raw - * network export over the Arrow C Data Interface (guarded by PIO_ARROW). + * Evolution policy: existing signatures are frozen. New data means new + * symbols; rich or multiconductor data rides the Arrow C Data Interface + * (pio_to_arrow) and the powerio-json snapshot, whose schemas carry their own + * structure and grow without touching a C signature. The dense extractors are + * the frozen balanced positive-sequence projection, complete as-is. * - * Optional: build with `--features gridfm` to get pio_read_gridfm and - * pio_gridfm_scenario_ids, the gridfm-datakit Parquet reader (guarded by PIO_GRIDFM). + * Optional: build with `--features arrow` for pio_to_arrow (guarded by + * PIO_ARROW), and `--features gridfm` for pio_read_dir / pio_scenario_ids + * (guarded by PIO_GRIDFM). * * Checked in and generated; regenerate from the Rust source with * cbindgen --config cbindgen.toml --crate powerio-capi --output include/powerio.h */""" -# pio_export_arrow takes arrow-rs's FFI_ArrowArray/FFI_ArrowSchema, which are +# pio_to_arrow takes arrow-rs's FFI_ArrowArray/FFI_ArrowSchema, which are # #[repr(C)] and layout-identical to the Arrow C Data Interface structs. Present # them under the standard ArrowArray/ArrowSchema names so a consumer can pass the # structs from arrow_c_data_interface.h (forward-declared below, gated on diff --git a/powerio-capi/examples/smoke.c b/powerio-capi/examples/smoke.c index abe4c86..16bb8fa 100644 --- a/powerio-capi/examples/smoke.c +++ b/powerio-capi/examples/smoke.c @@ -50,15 +50,21 @@ int main(int argc, char **argv) { printf("parsed %s: %zu buses, %zu branches, %zu gens, baseMVA %g\n", argv[1], nb, m, ng, base); CHECK(nb > 0 && m > 0, "empty case"); - CHECK(pio_n_components(c) >= 1, "bad component count"); - CHECK(pio_reference_bus(c) >= 0, "no single reference bus"); + CHECK(pio_n_islands(c) >= 1, "bad island count"); + CHECK(pio_ref_bus_index(c) >= 0, "no single reference bus"); + /* The MATPOWER reader is total: no warnings attached to the handle. */ + CHECK(pio_warnings(c, NULL, 0) == 0, "unexpected parse warnings"); /* Pull branch endpoints (1-based bus ids, same space as pio_bus_ids) and - * reactances, as a solver would. */ + * reactances, as a solver would. The all-NULL call is the count query; the + * fill returns the same total, so a short buffer is detectable. */ + CHECK(pio_branches(c, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0) == m, + "count query disagrees with pio_n_branches"); int64_t *from = malloc(m * sizeof *from); double *x = malloc(m * sizeof *x); CHECK(from && x, "out of memory"); - pio_branches(c, from, NULL, NULL, x, NULL, NULL, NULL, NULL); + CHECK(pio_branches(c, from, NULL, NULL, x, NULL, NULL, NULL, NULL, m) == m, + "branch fill did not return the total"); for (size_t k = 0; k < m; k++) { CHECK(from[k] >= 1, "branch from-id should be a valid 1-based bus id"); CHECK(x[k] != 0.0, "zero reactance"); @@ -66,23 +72,26 @@ int main(int argc, char **argv) { free(from); free(x); - /* Byte-exact MATPOWER echo comes back as an owned string. */ - char *echo = pio_to_matpower(c, err, sizeof err); + /* Byte-exact MATPOWER echo: matpower is a format string, not a symbol. */ + char warn[PIO_ERRBUF_MIN]; + warn[0] = '\0'; + char *echo = pio_to_format(c, "matpower", warn, sizeof warn, err, sizeof err); CHECK(echo != NULL && strlen(echo) > 0, err); pio_string_free(echo); /* Cross-format convert reaches the converter and returns owned text. */ - char *raw = pio_convert_file(argv[1], "psse", NULL, NULL, 0, err, sizeof err); + char *raw = pio_convert_file(argv[1], NULL, "psse", NULL, 0, err, sizeof err); CHECK(raw != NULL, err); pio_string_free(raw); - /* JSON transport: serialize, rebuild, and confirm the counts survive. */ - char *json = pio_to_json(c, err, sizeof err); + /* The canonical snapshot: serialize to powerio-json, parse it back, and + * confirm the counts survive. Lossless, validated on read. */ + char *json = pio_to_format(c, "powerio-json", NULL, 0, err, sizeof err); CHECK(json != NULL, err); - PioNetwork *c2 = pio_from_json(json, err, sizeof err); + PioNetwork *c2 = pio_parse_str(json, "powerio-json", err, sizeof err); CHECK(c2 != NULL, err); CHECK(pio_n_buses(c2) == nb && pio_n_branches(c2) == m && pio_n_gens(c2) == ng, - "JSON round-trip changed the table sizes"); + "snapshot round-trip changed the table sizes"); pio_string_free(json); pio_network_free(c2); @@ -104,41 +113,47 @@ int main(int argc, char **argv) { CHECK(cs != NULL, err); CHECK(pio_n_buses(cs) == nb && pio_n_branches(cs) == m && pio_n_gens(cs) == ng, "pio_parse_str disagrees with pio_parse_file on table sizes"); + + /* In-memory convert: parse + serialize fused, no filesystem. */ + char *pm = pio_convert_str(buf, "matpower", "powermodels-json", + NULL, 0, err, sizeof err); + CHECK(pm != NULL, err); + pio_string_free(pm); free(buf); /* Normalize into a NEW handle: per unit, radians, filtered, reindexed. * It has no more buses than the raw case, has at least one reference bus - * (several if the file marked several), and still serializes through the - * JSON transport. Use pio_n_reference_buses, not pio_reference_bus >= 0: - * the latter returns -1 for a multi-slack case, which is valid here. */ - PioNetwork *cn = pio_to_normalized(cs, err, sizeof err); + * (several if the file marked several), and still snapshots. Count the + * references with the NULL-out query, not pio_ref_bus_index >= 0: the + * latter returns -1 for a multi-slack case, which is valid here. */ + PioNetwork *cn = pio_normalize(cs, err, sizeof err); CHECK(cn != NULL, err); CHECK(pio_n_buses(cn) <= nb && pio_n_buses(cn) > 0, "normalized bus count out of range"); - CHECK(pio_n_reference_buses(cn) >= 1, "normalized case lost its reference bus"); - char *njson = pio_to_json(cn, err, sizeof err); + CHECK(pio_ref_bus_indices(cn, NULL, 0) >= 1, "normalized case lost its reference bus"); + char *njson = pio_to_format(cn, "powerio-json", NULL, 0, err, sizeof err); CHECK(njson != NULL, err); pio_string_free(njson); pio_network_free(cn); pio_network_free(cs); - printf("parse_str + to_normalized OK\n"); + printf("parse_str + convert_str + normalize OK\n"); } - /* PyPSA CSV folder writer: 0 on success, -1 on error (message in errbuf). */ + /* Directory writer: 0 on success, -1 on error (message in errbuf). The + * format is a string here too; pypsa-csv is the one directory format. */ { - char warn[PIO_ERRBUF_MIN]; warn[0] = '\0'; char outdir[512]; snprintf(outdir, sizeof outdir, "%s-pypsa-smoke", argv[1]); - int rc = pio_write_pypsa_csv_folder(c, outdir, warn, sizeof warn, err, sizeof err); + int rc = pio_write_dir(c, "pypsa-csv", outdir, warn, sizeof warn, err, sizeof err); CHECK(rc == 0, err); char buses[600]; snprintf(buses, sizeof buses, "%s/buses.csv", outdir); FILE *bf = fopen(buses, "rb"); CHECK(bf != NULL, "PyPSA folder missing buses.csv"); fclose(bf); - rc = pio_write_pypsa_csv_folder(NULL, outdir, NULL, 0, err, sizeof err); - CHECK(rc == -1, "NULL network handle should fail the PyPSA write"); - printf("pypsa csv folder write OK: %s\n", outdir); + rc = pio_write_dir(NULL, "pypsa-csv", outdir, NULL, 0, err, sizeof err); + CHECK(rc == -1, "NULL network handle should fail the directory write"); + printf("pypsa csv directory write OK: %s\n", outdir); } #ifdef PIO_ARROW @@ -149,7 +164,7 @@ int main(int argc, char **argv) { struct ArrowSchema sch; memset(&arr, 0, sizeof arr); memset(&sch, 0, sizeof sch); - int rc = pio_export_arrow(c, PIO_ARROW_TABLE_BUS, &arr, &sch, err, sizeof err); + int rc = pio_to_arrow(c, PIO_ARROW_TABLE_BUS, &arr, &sch, err, sizeof err); CHECK(rc == 0, err); CHECK(arr.length == (int64_t)nb, "arrow bus table row count mismatch"); CHECK(arr.release != NULL && sch.release != NULL, "missing arrow release callbacks"); @@ -161,7 +176,7 @@ int main(int argc, char **argv) { /* NULL handle is the documented safe default. */ CHECK(pio_n_buses(NULL) == 0, "NULL handle did not return 0"); - CHECK(pio_reference_bus(NULL) == -1, "NULL handle did not return -1"); + CHECK(pio_ref_bus_index(NULL) == -1, "NULL handle did not return -1"); pio_network_free(c); printf("C ABI smoke test OK\n"); diff --git a/powerio-capi/include/powerio.h b/powerio-capi/include/powerio.h index a061ca3..f072bb8 100644 --- a/powerio-capi/include/powerio.h +++ b/powerio-capi/include/powerio.h @@ -1,26 +1,63 @@ -/* powerio C ABI: parse any power system case format, query it, convert - * it, and extract the numeric tables for matrix assembly. +/* powerio C ABI, version 4: parse any power system case format, query it, + * convert it, and extract the numeric tables for matrix assembly. Check + * pio_abi_version() against PIO_ABI_VERSION at load; the integer is the + * compatibility contract, the version string is informational. * - * Memory: strings returned by pio_to_matpower / pio_to_format / - * pio_convert_file / pio_to_json are owned by the library; free them with - * pio_string_free. Network handles from pio_parse_file / pio_parse_str / - * pio_from_json / pio_to_normalized are freed with pio_network_free. Array - * extractors fill caller-allocated buffers whose length is the matching pio_n_* - * count; pass NULL to skip an output. + * Naming grammar (fixed; the surface evolves additively from here): + * - Verb-led names are operations and the verb fixes the return family: + * parse/read/normalize return a new handle, write has a filesystem effect, + * convert transcodes without keeping a handle, free destroys. + * - to_ marks a representation change of the same network (the strtol/htons + * lineage). The target is a format string unless the output type differs: + * pio_to_format returns text for every named format, pio_to_arrow fills + * Arrow C Data Interface structs. + * - Noun phrases are queries (no get_); n_ prefixes counts, is_ predicates. + * - Format names never appear in symbols. Formats are strings ("matpower", + * "psse", "powerio-json", ...), so a new format never changes this ABI. * - * Message buffers: errbuf/warnbuf may be NULL (or length 0) to discard the - * message. A message longer than the buffer is truncated to fit and is always - * NUL-terminated. PIO_ERRBUF_MIN is a comfortable size for any error string. + * Vocabulary (one meaning per word, transmission and distribution alike): + * - bus: a named connection point, any number of phases. This surface is bus + * granular (pio_n_buses, pio_bus_ids, pio_bus_demand, ...). + * - node: one conductor's point at a bus (OpenDSS bus.1.2.3). Reserved for + * the multiconductor surface; never a synonym for bus here. + * - branch: any two-terminal series element, lines and transformers alike + * (circuit theory; MATPOWER mpc.branch; the Branch Flow Model). "line" is + * the transformer-excluding subset and never names the umbrella table. * - * Every entry point catches Rust panics at the boundary and returns the documented - * failure value (NULL, 0, -1, 0.0, or unchanged output) rather than unwinding - * across the ABI. + * Conventions: + * - Array extractors write up to `cap` values per output array and return the + * total available; NULL out (or cap 0) is a pure count query, so a short + * read is detectable and a caller buffer can never silently overflow. + * - errbuf/errlen caller buffers (the libpcap/curl idiom: allocation-free + * across the boundary, no thread-local state). NULL or length 0 discards + * the message; a long message truncates on a UTF-8 character boundary and + * is always NUL-terminated. PIO_ERRBUF_MIN is a comfortable size. The ABI + * reports errors as messages and defines no error codes. + * - Warnings attach to the network handle; query them with pio_warnings, + * which returns the byte length needed (call with NULL/0 to size). Only + * functions returning no handle (pio_to_format, pio_convert_*, + * pio_write_dir) take a warnbuf. + * - Strings returned by pio_to_format / pio_convert_file / pio_convert_str + * are owned by the library; free them with pio_string_free. Handles from + * pio_parse_file / pio_parse_str / pio_read_dir / pio_normalize are freed + * with pio_network_free. Arrow buffers are freed through their own release + * callbacks (the C Data Interface contract). + * - A handle is immutable after construction: concurrent reads from any + * number of threads are safe; pio_network_free is not, free exactly once. + * - Every entry point catches Rust panics at the boundary and returns the + * documented failure value (NULL, 0, -1, 0.0) rather than unwinding across + * the ABI (requires the default panic = "unwind"; a panic = "abort" build + * aborts the process instead). * - * Optional: build with `--features arrow` to get pio_export_arrow, a raw - * network export over the Arrow C Data Interface (guarded by PIO_ARROW). + * Evolution policy: existing signatures are frozen. New data means new + * symbols; rich or multiconductor data rides the Arrow C Data Interface + * (pio_to_arrow) and the powerio-json snapshot, whose schemas carry their own + * structure and grow without touching a C signature. The dense extractors are + * the frozen balanced positive-sequence projection, complete as-is. * - * Optional: build with `--features gridfm` to get pio_read_gridfm and - * pio_gridfm_scenario_ids, the gridfm-datakit Parquet reader (guarded by PIO_GRIDFM). + * Optional: build with `--features arrow` for pio_to_arrow (guarded by + * PIO_ARROW), and `--features gridfm` for pio_read_dir / pio_scenario_ids + * (guarded by PIO_GRIDFM). * * Checked in and generated; regenerate from the Rust source with * cbindgen --config cbindgen.toml --crate powerio-capi --output include/powerio.h @@ -41,12 +78,17 @@ struct ArrowSchema; /** * ABI version of this C interface. Bump on any breaking change to an existing - * `pio_*` signature or to the JSON transport schema (new additive symbols don't - * require a bump). A consumer compares [`pio_abi_version`] against the value it - * was built against (the `PIO_ABI_VERSION` macro in `powerio.h`) and refuses a - * mismatched library instead of calling in blind. + * `pio_*` signature or to the `powerio-json` snapshot schema (new additive + * symbols don't require a bump). A consumer compares [`pio_abi_version`] + * against the value it was built against (the `PIO_ABI_VERSION` macro in + * `powerio.h`) and refuses a mismatched library instead of calling in blind. + * + * v4 froze the naming grammar and conventions (see the header preamble); the + * surface evolves additively from here — new data means new symbols, and rich + * or multiconductor data rides the Arrow and `powerio-json` schemas, which + * carry their own structure and never force a signature change. */ -#define PIO_ABI_VERSION 3 +#define PIO_ABI_VERSION 4 /** * A comfortable error-buffer size: pass a `char[PIO_ERRBUF_MIN]` to any @@ -56,7 +98,7 @@ struct ArrowSchema; #if defined(PIO_ARROW) /** - * Table selectors for [`pio_export_arrow`](crate::pio_export_arrow); the C + * Table selectors for [`pio_to_arrow`](crate::pio_to_arrow); the C * header mirrors these as `PIO_ARROW_TABLE_*`. */ #define PIO_ARROW_TABLE_BUS 0 @@ -81,8 +123,8 @@ struct ArrowSchema; /** * Opaque parsed network handle. Carries the parsed [`Network`], the * [`IndexCore`] derived from it once at parse time (so every indexed query - * reuses the same bus-id map and nodal aggregates instead of rebuilding - * them), and the reader's fidelity warnings ([`pio_parse_warnings`]). + * reuses the same bus-id map and per-bus aggregates instead of rebuilding + * them), and the reader's fidelity warnings ([`pio_warnings`]). */ typedef struct PioNetwork PioNetwork; @@ -97,58 +139,101 @@ extern "C" { uint32_t pio_abi_version(void); /** - * The crate version string (e.g. `"0.0.1"`), `'static` and NUL-terminated. Do + * The crate version string (e.g. `"0.2.0"`), `'static` and NUL-terminated. Do * NOT free it. Informational; pair it with [`pio_abi_version`] for the actual * compatibility check. */ const char *pio_version(void); /** - * Parse `path` (format from extension, or `from` if non-NULL) into a case + * Parse `path` (format from extension, or `from` if non-NULL) into a network * handle. `from` accepts the [`pio_parse_str`] format names plus * `pypsa-csv`/`pypsa`; a PyPSA CSV folder is a directory, so it can only enter * through this function, with `from = "pypsa-csv"` (or NULL when the directory * holds a `network.csv`). Read fidelity warnings attach to the handle - * ([`pio_parse_warnings`]). Returns `NULL` on error and writes the message - * into `errbuf`. + * ([`pio_warnings`]). Returns `NULL` on error and writes the message into + * `errbuf`. Free the handle with [`pio_network_free`]. */ PioNetwork *pio_parse_file(const char *path, const char *from, char *errbuf, size_t errlen); /** - * Parse in-memory case `text` of the named `format` into a network handle. Unlike - * [`pio_parse_file`] there is no path to infer from, so `format` is required: one of - * `matpower`/`m`, `powermodels`/`pm`, `egret`, `pandapower-json`/`pandapower`/`pp`, - * `psse`/`raw`, `powerworld`/`aux` (see `TargetFormat::from_str`). PyPSA CSV - * folders are directories, not text; parse them with [`pio_parse_file`] and - * `from = "pypsa-csv"`. Read fidelity warnings attach to the handle - * ([`pio_parse_warnings`]). Returns `NULL` on error and writes the message - * into `errbuf`. Free the handle with [`pio_network_free`]. + * Parse in-memory case `text` of the named `format` into a network handle. + * Unlike [`pio_parse_file`] there is no path to infer from, so `format` is + * required: one of `matpower`/`m`, `powermodels`/`pm`, `egret`, + * `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`, `powerworld`/`aux`, or + * `powerio-json`/`json` (the canonical snapshot [`pio_to_format`] writes, + * validated on read). PyPSA CSV folders are directories, not text; parse them + * with [`pio_parse_file`] and `from = "pypsa-csv"`. Read fidelity warnings + * attach to the handle ([`pio_warnings`]). Returns `NULL` on error and writes + * the message into `errbuf`. Free the handle with [`pio_network_free`]. */ PioNetwork *pio_parse_str(const char *text, const char *format, char *errbuf, size_t errlen); +#if defined(PIO_GRIDFM) +/** + * Read one scenario of a dataset directory in the named `from` format into a + * network handle — the directory sibling of [`pio_parse_file`]. `gridfm` (the + * gridfm-datakit Parquet layout; `dir` resolves leniently: the `raw/` leaf, + * a `/` directory with a `raw/` child, or a parent holding exactly one + * such case) is the one dataset format today. `scenario` selects within a + * multi-scenario dataset ([`pio_scenario_ids`] enumerates them); formats + * without scenarios take `0`. Read fidelity warnings attach to the handle + * ([`pio_warnings`]). Returns `NULL` on error and writes the message into + * `errbuf`. Free the handle with [`pio_network_free`]. Built + * `--features gridfm`. + */ +PioNetwork *pio_read_dir(const char *dir, + const char *from, + int64_t scenario, + char *errbuf, + size_t errlen); +#endif + +#if defined(PIO_GRIDFM) /** - * Read fidelity warnings attached at parse time, `\n`-joined into `warnbuf` - * (truncated to fit; NULL/0 to skip). Returns the warning count; 0 for a - * NULL handle. Empty for formats whose readers are total. + * Write the distinct scenario ids (ascending) of the dataset directory `dir` + * in the named `from` format into `out`, up to `cap` entries, and return the + * total count — the cap/count convention of [`pio_bus_ids`]. `gridfm` is the + * one dataset format today. Returns `-1` on error and writes the message into + * `errbuf` (unlike the handle extractors, this reads the filesystem and can + * fail). Built `--features gridfm`. */ -size_t pio_parse_warnings(const PioNetwork *net, char *warnbuf, size_t warnlen); +ptrdiff_t pio_scenario_ids(const char *dir, + const char *from, + int64_t *out, + size_t cap, + char *errbuf, + size_t errlen); +#endif + +/** + * The fidelity warnings attached to the handle at construction (by whichever + * of [`pio_parse_file`], [`pio_parse_str`], [`pio_read_dir`], or + * [`pio_normalize`] built it), `\n`-joined into `warnbuf` (truncated to fit + * on a UTF-8 boundary; NULL/0 to skip). Returns the byte length of the full + * joined text, excluding the NUL — call once with `(NULL, 0)` to size, then + * pass a `char[len + 1]`. `0` means no warnings (or a NULL handle); readers + * that are total attach none. + */ +size_t pio_warnings(const PioNetwork *net, char *warnbuf, size_t warnlen); /** * Free a network handle from [`pio_parse_file`], [`pio_parse_str`], - * [`pio_to_normalized`], or [`pio_from_json`]. + * [`pio_read_dir`], or [`pio_normalize`]. */ void pio_network_free(PioNetwork *net); /** - * Normalize `net` into a NEW per-unit network handle: per unit, radians, - * out-of-service filtered, densely reindexed, bus types canonicalized (see - * `Network::to_normalized`). The result is independent of `net`; free both - * with [`pio_network_free`]. Every extractor and [`pio_to_json`] works on it - * unchanged (the handle is per unit, not MW). Returns `NULL` on error (no - * reference bus can be chosen, or a non-positive base MVA) and writes the - * message into `errbuf`. + * Normalize `net` into a NEW network handle: per unit, radians, out-of-service + * filtered, densely reindexed, bus types canonicalized (see + * `Network::to_normalized`). A value transform, not a serialization — hence + * the verb, while the `to_*` family re-encodes unchanged data. The result is + * independent of `net`; free both with [`pio_network_free`]. Every extractor + * and serializer works on it unchanged (the handle is per unit, not MW). + * Returns `NULL` on error (no reference bus can be chosen, or a non-positive + * base MVA) and writes the message into `errbuf`. */ -PioNetwork *pio_to_normalized(const PioNetwork *net, char *errbuf, size_t errlen); +PioNetwork *pio_normalize(const PioNetwork *net, char *errbuf, size_t errlen); size_t pio_n_buses(const PioNetwork *net); @@ -159,47 +244,46 @@ size_t pio_n_gens(const PioNetwork *net); double pio_base_mva(const PioNetwork *net); /** - * Dense `[0, n)` index of the single reference bus, or `-1` if not exactly one - * (also `-1` if the index is too large for `isize`). A network may carry - * several references (one per island, or a normalized case that kept the file's - * multiple `REF` buses); use [`pio_n_reference_buses`] to tell zero from many, - * and [`pio_reference_buses`] to read them all. + * Dense `[0, n)` index of the single reference (slack) bus, or `-1` if not + * exactly one. An INDEX into the [`pio_bus_ids`] ordering, not a bus id — + * `pio_branches` from/to carry ids, so the unit is in the name. A network may + * carry several references (one per island, or a normalized case that kept + * the file's multiple `REF` buses); [`pio_ref_bus_indices`] reads them all, + * and its count (`NULL` out) tells zero from many. */ -ptrdiff_t pio_reference_bus(const PioNetwork *net); +int64_t pio_ref_bus_index(const PioNetwork *net); /** - * Number of reference (slack) buses. `0` means none; `> 1` means one reference - * per island or several fixed reference buses in one island. A normalized case - * always reports `>= 1`. + * Write the dense `[0, n)` indices of the reference (slack) buses, ascending, + * into `out`, up to `cap` entries, and return the total count — the cap/count + * convention of [`pio_bus_ids`]. `0` means none; `> 1` means one reference + * per island or several fixed references in one island (a normalized case + * always reports `>= 1`). */ -size_t pio_n_reference_buses(const PioNetwork *net); +size_t pio_ref_bus_indices(const PioNetwork *net, int64_t *out, size_t cap); /** - * Fill `out` (length [`pio_n_reference_buses`]) with the dense `[0, n)` indices - * of the reference buses, ascending. + * Number of islands: connected components of the in-service topology. */ -void pio_reference_buses(const PioNetwork *net, int64_t *out); - -size_t pio_n_components(const PioNetwork *net); +size_t pio_n_islands(const PioNetwork *net); /** - * `1` if the in-service topology is a forest, else `0`. + * `1` if the in-service topology is radial (every island a tree), else `0`. */ int32_t pio_is_radial(const PioNetwork *net); /** - * Serialize `net` to MATPOWER `.m` text (byte-exact echo when parsed from - * MATPOWER). Returns an owned C string; free with [`pio_string_free`]. Returns - * `NULL` on error and writes the message into `errbuf`. - */ -char *pio_to_matpower(const PioNetwork *net, char *errbuf, size_t errlen); - -/** - * Serialize `net` to format `to`. + * Serialize `net` to the named format `to` — the one text serializer; every + * format is named by a string. Accepts the [`pio_parse_str`] names: + * `matpower` is a byte-exact echo when the handle was parsed from MATPOWER, + * and `powerio-json` is the canonical lossless snapshot (validated by + * [`pio_parse_str`] on the way back; the retained source text is the one + * field it omits). * - * Returns the converted text as an owned C string (free with - * [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written - * `\n`-joined into `warnbuf`. + * Returns the text as an owned C string (free with [`pio_string_free`]), + * `NULL` on error (message into `errbuf`). Fidelity warnings, if any, are + * written `\n`-joined into `warnbuf` — a returned string has no handle to + * attach them to. */ char *pio_to_format(const PioNetwork *net, const char *to, @@ -209,136 +293,117 @@ char *pio_to_format(const PioNetwork *net, size_t errlen); /** - * Convert `path` to format `to` (optionally forcing the source via `from`). + * Convert the case file at `path` from format `from` (NULL to infer from the + * path, as [`pio_parse_file`]) to format `to`, without keeping a handle. * Returns the converted text as an owned C string (free with - * [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written - * `\n`-joined into `warnbuf`. + * [`pio_string_free`]), `NULL` on error. Fidelity warnings, read side first, + * are written `\n`-joined into `warnbuf`. */ char *pio_convert_file(const char *path, - const char *to, const char *from, + const char *to, char *warnbuf, size_t warnlen, char *errbuf, size_t errlen); /** - * Write `net` as a PyPSA CSV folder at `out_dir`. Returns `0` on success and - * `-1` on error (the message is written into `errbuf`), the same convention as - * the other fallible `int` returns in this ABI. Fidelity warnings, if any, are - * written `\n`-joined into `warnbuf`. + * Convert in-memory case `text` from format `from` (required — there is no + * path to infer from) to format `to`, without keeping a handle: the in-memory + * sibling of [`pio_convert_file`]. Returns the converted text as an owned C + * string (free with [`pio_string_free`]), `NULL` on error. Fidelity warnings, + * read side first, are written `\n`-joined into `warnbuf`. */ -int32_t pio_write_pypsa_csv_folder(const PioNetwork *net, - const char *out_dir, - char *warnbuf, - size_t warnlen, - char *errbuf, - size_t errlen); +char *pio_convert_str(const char *text, + const char *from, + const char *to, + char *warnbuf, + size_t warnlen, + char *errbuf, + size_t errlen); -#if defined(PIO_GRIDFM) /** - * Read one scenario of a gridfm-datakit Parquet dataset into a network handle — - * the inverse of the gridfm writer. `dir` resolves leniently: the `raw/` leaf - * holding the parquet files, a `/` directory with a `raw/` child, or a - * parent with one `*/raw/` child. Returns `NULL` on error and writes the message - * into `errbuf`; the lossy read's fidelity warnings (what the gridfm schema - * couldn't round-trip) are written `\n`-joined into `warnbuf`. Free the handle - * with [`pio_network_free`]. Built `--features gridfm`. - */ -PioNetwork *pio_read_gridfm(const char *dir, - int64_t scenario, - char *warnbuf, - size_t warnlen, - char *errbuf, - size_t errlen); -#endif - -#if defined(PIO_GRIDFM) -/** - * Write a gridfm dataset's distinct scenario ids (ascending) into `out`, up to - * `cap` entries, and return the total count. Call once with `cap == 0` (or `out` - * NULL) to size, allocate, then call again to fill — the standard count/buffer - * pattern of [`pio_bus_ids`]. Returns `-1` on error and writes the message into - * `errbuf`. Built `--features gridfm`. + * Write `net` into the directory `out_dir` as the named directory-shaped + * format `to` — the directory sibling of [`pio_to_format`]. PyPSA CSV + * (`pypsa-csv`/`pypsa`) is the one such format today; a text format name is + * an error pointing back at [`pio_to_format`]. Returns `0` on success and + * `-1` on error (message into `errbuf`). Fidelity warnings, if any, are + * written `\n`-joined into `warnbuf`. */ -ptrdiff_t pio_gridfm_scenario_ids(const char *dir, - int64_t *out, - size_t cap, - char *errbuf, - size_t errlen); -#endif +int32_t pio_write_dir(const PioNetwork *net, + const char *to, + const char *out_dir, + char *warnbuf, + size_t warnlen, + char *errbuf, + size_t errlen); /** - * Free a string returned by [`pio_to_matpower`], [`pio_to_format`], - * [`pio_convert_file`], or - * [`pio_to_json`]. + * Free a string returned by [`pio_to_format`], [`pio_convert_file`], or + * [`pio_convert_str`]. */ void pio_string_free(char *s); /** - * Serialize the case to JSON: the structured-table transport every Julia - * bridge consumes. Carries the whole [`Network`] (buses, loads, shunts, - * branches, generators, storage, HVDC, extras) but not the retained source - * text, so it is structured data, not the byte-exact echo. Returns an owned C - * string (free with [`pio_string_free`]), `NULL` on error (message into - * `errbuf`). + * Write the 1-based external bus ids, in dense order, into `out`, up to `cap` + * entries, and return the total bus count. This ordering DEFINES the dense + * index space every other per-bus array shares. Call once with `(NULL, 0)` to + * size, allocate, then call again to fill. */ -char *pio_to_json(const PioNetwork *net, char *errbuf, size_t errlen); +size_t pio_bus_ids(const PioNetwork *net, int64_t *out, size_t cap); /** - * Rebuild a network handle from JSON produced by [`pio_to_json`]. Returns a new - * handle (free with [`pio_network_free`]), or `NULL` on error (message into - * `errbuf`). The handle has no retained source, so [`pio_to_matpower`] - * reformats it rather than echoing a byte-exact original. + * Write the branch table as parallel arrays, each up to `cap` entries, and + * return the total branch count. A branch is any two-terminal series element + * — lines and transformers alike (a transformer has `tap != 0`). `from`/`to` + * are 1-based bus IDS (the [`pio_bus_ids`] id space, not dense indices); map + * them to dense matrix rows with the [`pio_bus_ids`] ordering. Any output + * pointer may be NULL to skip that column; all NULL is the count query. */ -PioNetwork *pio_from_json(const char *json, char *errbuf, size_t errlen); +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); /** - * Fill `out` (length `pio_n_buses`) with the 1-based bus ids in dense order. + * Write the generator table as parallel arrays, each up to `cap` entries, and + * return the total generator count. `bus` is the 1-based bus id (the + * [`pio_bus_ids`] id space). Any output pointer may be NULL to skip. */ -void pio_bus_ids(const PioNetwork *net, int64_t *out); +size_t pio_gens(const PioNetwork *net, + int64_t *bus, + double *pg, + double *pmax, + double *pmin, + uint8_t *in_service, + size_t cap); /** - * Fill the branch tables (each length `pio_n_branches`). `from`/`to` are the - * 1-based bus ids (the same id space as [`pio_bus_ids`], not dense indices); - * map them to dense matrix rows with the [`pio_bus_ids`] ordering. Any pointer - * may be `NULL` to skip. + * Write the per-bus demand aggregates (active `pd`, reactive `qd`, summed + * over each bus's loads, dense [`pio_bus_ids`] order), each up to `cap` + * entries, and return the total bus count. Either pointer may be NULL. */ -void 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 pio_bus_demand(const PioNetwork *net, double *pd, double *qd, size_t cap); /** - * Fill the generator tables (each length `pio_n_gens`; `bus` is the 1-based bus - * id, the same id space as [`pio_bus_ids`]). Any pointer may be `NULL` to skip. + * Write the per-bus shunt aggregates (conductance `gs`, susceptance `bs`, + * dense [`pio_bus_ids`] order), each up to `cap` entries, and return the + * total bus count. Either pointer may be NULL. */ -void pio_gens(const PioNetwork *net, - int64_t *bus, - double *pg, - double *pmax, - double *pmin, - uint8_t *in_service); - -/** - * Fill nodal aggregates (each length `pio_n_buses`, dense order): active and - * reactive demand summed per bus. Any pointer may be `NULL`. - */ -void pio_nodal_demand(const PioNetwork *net, double *pd, double *qd); - -/** - * Fill nodal shunt aggregates (each length `pio_n_buses`, dense order). - */ -void pio_nodal_shunt(const PioNetwork *net, double *gs, double *bs); +size_t pio_bus_shunt(const PioNetwork *net, double *gs, double *bs, size_t cap); #if defined(PIO_ARROW) /** - * Export one raw network table over the Arrow C Data Interface. + * Export one raw network table over the Arrow C Data Interface — the `to_` + * conversion whose output type is Arrow structs rather than a string, and the + * bulk plane this ABI evolves on: new or richer columns arrive in the Arrow + * schema, leaving the C signatures fixed. * * `table` is one of the `PIO_ARROW_TABLE_*` selectors (bus/branch/gen/load/ * shunt); the columns are the parsed network fields with EXTERNAL bus ids (the @@ -350,12 +415,12 @@ void pio_nodal_shunt(const PioNetwork *net, double *gs, double *bs); * On error (returns `-1`) the message is written into `errbuf` and the * out-params are left untouched. Only built with the `arrow` cargo feature. */ -int32_t pio_export_arrow(const PioNetwork *net, - int32_t table, - struct ArrowArray *out_array, - struct ArrowSchema *out_schema, - char *errbuf, - size_t errlen); +int32_t pio_to_arrow(const PioNetwork *net, + int32_t table, + struct ArrowArray *out_array, + struct ArrowSchema *out_schema, + char *errbuf, + size_t errlen); #endif #ifdef __cplusplus diff --git a/powerio-capi/src/arrow_export.rs b/powerio-capi/src/arrow_export.rs index 5e220e3..90a67d0 100644 --- a/powerio-capi/src/arrow_export.rs +++ b/powerio-capi/src/arrow_export.rs @@ -3,9 +3,11 @@ //! Builds the parsed [`Network`] element tables (bus/branch/gen/load/shunt) as //! Arrow record batches and lends them across the C ABI zero-copy via //! [`arrow::ffi::to_ffi`]. This is the in-memory, self-describing sibling of -//! [`pio_to_json`](crate::pio_to_json) and the `pio_branches`-style numeric +//! the `powerio-json` snapshot and the `pio_branches`-style numeric //! extractors: any Arrow consumer (pyarrow, Arrow.jl, Arrow C++, polars, DuckDB) -//! can pull a whole table without a copy or a temp file. +//! can pull a whole table without a copy or a temp file. The schema is the +//! ABI's evolution valve: richer columns arrive here, never as new C +//! signatures. //! //! These are the *raw* network fields, with EXTERNAL bus ids (the same id space //! as `pio_bus_ids`), not the gridfm-datakit schema — no admittances or flows @@ -20,7 +22,7 @@ use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema, to_ffi}; use arrow::record_batch::RecordBatch; use powerio::{BusId, Network}; -/// Table selectors for [`pio_export_arrow`](crate::pio_export_arrow); the C +/// Table selectors for [`pio_to_arrow`](crate::pio_to_arrow); the C /// header mirrors these as `PIO_ARROW_TABLE_*`. pub const PIO_ARROW_TABLE_BUS: i32 = 0; pub const PIO_ARROW_TABLE_BRANCH: i32 = 1; diff --git a/powerio-capi/src/lib.rs b/powerio-capi/src/lib.rs index 1cc513b..d27f211 100644 --- a/powerio-capi/src/lib.rs +++ b/powerio-capi/src/lib.rs @@ -1,14 +1,29 @@ -//! C ABI for `powerio`. +//! C ABI for `powerio` — ABI v4. //! -//! Parse any supported power system case format into an opaque handle, query it, -//! convert it to another format, and pull out the numeric tables a -//! downstream solver needs to assemble matrices. Every entry point is `extern -//! "C"`, catches panics at the boundary, and returns error text into a -//! caller-provided buffer. Strings handed back are owned by the library; free -//! them with [`pio_string_free`]. Array extractors fill caller-allocated -//! buffers (length = the matching `pio_n_*` count); pass `NULL` to skip one. +//! Parse any supported power system case format into an opaque handle, query +//! it, convert it to another format, and pull out the numeric tables a +//! downstream solver needs to assemble matrices. Every entry point is +//! `extern "C"`, catches panics at the boundary, and returns error text into a +//! caller-provided buffer. //! -//! Naming: every symbol is prefixed `pio_`. The header is `include/powerio.h`. +//! The surface follows a fixed grammar, written out in the header preamble +//! (`include/powerio.h`, generated by cbindgen — never hand-edit): +//! +//! - Verb-led names are operations and the verb fixes the return family: +//! `parse`/`read`/`normalize` return a new handle, `write` has a filesystem +//! effect, `convert` transcodes without keeping a handle, `free` destroys. +//! - `to_` marks a representation change of the same network; the target is a +//! format string (`pio_to_format`) unless the output type differs +//! (`pio_to_arrow` fills Arrow C Data Interface structs). +//! - Format names never appear in symbols: formats are strings, so a new +//! format never changes this ABI. The canonical snapshot is the +//! `powerio-json` format name. +//! - Array extractors share the cap/count convention: write up to `cap` +//! values, return the total available, `NULL` out is a pure count query. +//! - Vocabulary: a *bus* is a named connection point (this surface is bus +//! granular); a *node* is one conductor's point at a bus, reserved for the +//! multiconductor surface; a *branch* is any two-terminal series element, +//! lines and transformers alike. #![allow(clippy::missing_safety_doc)] @@ -27,8 +42,8 @@ pub use arrow_export::{ /// Opaque parsed network handle. Carries the parsed [`Network`], the /// [`IndexCore`] derived from it once at parse time (so every indexed query -/// reuses the same bus-id map and nodal aggregates instead of rebuilding -/// them), and the reader's fidelity warnings ([`pio_parse_warnings`]). +/// reuses the same bus-id map and per-bus aggregates instead of rebuilding +/// them), and the reader's fidelity warnings ([`pio_warnings`]). pub struct PioNetwork { net: Network, core: IndexCore, @@ -36,7 +51,9 @@ pub struct PioNetwork { } /// Copy `msg` (truncated to fit) into a caller `char[len]` buffer, always -/// NUL-terminated. Shared by the error and warning outputs. +/// NUL-terminated. Truncation backs up to a UTF-8 character boundary so a +/// clipped message is still valid UTF-8. Shared by the error and warning +/// outputs. /// /// # Safety /// A non-NULL `buf` must point to at least `len` writable bytes; the write @@ -48,7 +65,10 @@ unsafe fn copy_to_buf(buf: *mut c_char, len: usize, msg: &str) { return; } let bytes = msg.as_bytes(); - let n = bytes.len().min(len - 1); + let mut n = bytes.len().min(len - 1); + while n > 0 && !msg.is_char_boundary(n) { + n -= 1; + } std::ptr::copy_nonoverlapping(bytes.as_ptr().cast::(), buf, n); *buf.add(n) = 0; } @@ -104,7 +124,7 @@ fn make_network(net: Network, warnings: Vec) -> *mut PioNetwork { /// its read warnings, or an error message) under the panic guard, hand back an /// owned handle, or write the error, `panic_msg` if `f` panicked, into `errbuf` /// and return NULL. The shared tail of every handle-returning function -/// (`pio_parse_file`, `pio_parse_str`, `pio_to_normalized`, `pio_from_json`). +/// (`pio_parse_file`, `pio_parse_str`, `pio_read_dir`, `pio_normalize`). unsafe fn finish_network( errbuf: *mut c_char, errlen: usize, @@ -127,11 +147,16 @@ unsafe fn finish_network( } /// ABI version of this C interface. Bump on any breaking change to an existing -/// `pio_*` signature or to the JSON transport schema (new additive symbols don't -/// require a bump). A consumer compares [`pio_abi_version`] against the value it -/// was built against (the `PIO_ABI_VERSION` macro in `powerio.h`) and refuses a -/// mismatched library instead of calling in blind. -pub const PIO_ABI_VERSION: u32 = 3; +/// `pio_*` signature or to the `powerio-json` snapshot schema (new additive +/// symbols don't require a bump). A consumer compares [`pio_abi_version`] +/// against the value it was built against (the `PIO_ABI_VERSION` macro in +/// `powerio.h`) and refuses a mismatched library instead of calling in blind. +/// +/// v4 froze the naming grammar and conventions (see the header preamble); the +/// surface evolves additively from here — new data means new symbols, and rich +/// or multiconductor data rides the Arrow and `powerio-json` schemas, which +/// carry their own structure and never force a signature change. +pub const PIO_ABI_VERSION: u32 = 4; /// A comfortable error-buffer size: pass a `char[PIO_ERRBUF_MIN]` to any /// `errbuf`/`warnbuf` parameter and a message always fits without truncation. @@ -144,7 +169,7 @@ pub extern "C" fn pio_abi_version() -> u32 { PIO_ABI_VERSION } -/// The crate version string (e.g. `"0.0.1"`), `'static` and NUL-terminated. Do +/// The crate version string (e.g. `"0.2.0"`), `'static` and NUL-terminated. Do /// NOT free it. Informational; pair it with [`pio_abi_version`] for the actual /// compatibility check. #[unsafe(no_mangle)] @@ -172,13 +197,13 @@ fn optional_cstr<'a>(p: *const c_char, name: &str) -> Result, St } } -/// Parse `path` (format from extension, or `from` if non-NULL) into a case +/// Parse `path` (format from extension, or `from` if non-NULL) into a network /// handle. `from` accepts the [`pio_parse_str`] format names plus /// `pypsa-csv`/`pypsa`; a PyPSA CSV folder is a directory, so it can only enter /// through this function, with `from = "pypsa-csv"` (or NULL when the directory /// holds a `network.csv`). Read fidelity warnings attach to the handle -/// ([`pio_parse_warnings`]). Returns `NULL` on error and writes the message -/// into `errbuf`. +/// ([`pio_warnings`]). Returns `NULL` on error and writes the message into +/// `errbuf`. Free the handle with [`pio_network_free`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_parse_file( path: *const c_char, @@ -197,14 +222,15 @@ pub unsafe extern "C" fn pio_parse_file( } } -/// Parse in-memory case `text` of the named `format` into a network handle. Unlike -/// [`pio_parse_file`] there is no path to infer from, so `format` is required: one of -/// `matpower`/`m`, `powermodels`/`pm`, `egret`, `pandapower-json`/`pandapower`/`pp`, -/// `psse`/`raw`, `powerworld`/`aux` (see `TargetFormat::from_str`). PyPSA CSV -/// folders are directories, not text; parse them with [`pio_parse_file`] and -/// `from = "pypsa-csv"`. Read fidelity warnings attach to the handle -/// ([`pio_parse_warnings`]). Returns `NULL` on error and writes the message -/// into `errbuf`. Free the handle with [`pio_network_free`]. +/// Parse in-memory case `text` of the named `format` into a network handle. +/// Unlike [`pio_parse_file`] there is no path to infer from, so `format` is +/// required: one of `matpower`/`m`, `powermodels`/`pm`, `egret`, +/// `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`, `powerworld`/`aux`, or +/// `powerio-json`/`json` (the canonical snapshot [`pio_to_format`] writes, +/// validated on read). PyPSA CSV folders are directories, not text; parse them +/// with [`pio_parse_file`] and `from = "pypsa-csv"`. Read fidelity warnings +/// attach to the handle ([`pio_warnings`]). Returns `NULL` on error and writes +/// the message into `errbuf`. Free the handle with [`pio_network_free`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_parse_str( text: *const c_char, @@ -223,11 +249,85 @@ pub unsafe extern "C" fn pio_parse_str( } } -/// Read fidelity warnings attached at parse time, `\n`-joined into `warnbuf` -/// (truncated to fit; NULL/0 to skip). Returns the warning count; 0 for a -/// NULL handle. Empty for formats whose readers are total. +/// Read one scenario of a dataset directory in the named `from` format into a +/// network handle — the directory sibling of [`pio_parse_file`]. `gridfm` (the +/// gridfm-datakit Parquet layout; `dir` resolves leniently: the `raw/` leaf, +/// a `/` directory with a `raw/` child, or a parent holding exactly one +/// such case) is the one dataset format today. `scenario` selects within a +/// multi-scenario dataset ([`pio_scenario_ids`] enumerates them); formats +/// without scenarios take `0`. Read fidelity warnings attach to the handle +/// ([`pio_warnings`]). Returns `NULL` on error and writes the message into +/// `errbuf`. Free the handle with [`pio_network_free`]. Built +/// `--features gridfm`. +#[cfg(feature = "gridfm")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_read_dir( + dir: *const c_char, + from: *const c_char, + scenario: i64, + errbuf: *mut c_char, + errlen: usize, +) -> *mut PioNetwork { + unsafe { + finish_network(errbuf, errlen, "panic while reading dataset", || { + let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?; + let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?; + powerio_matrix::read_dataset_dir(std::path::Path::new(dir), from, scenario) + .map(|read| (read.network, read.warnings)) + .map_err(|e| e.to_string()) + }) + } +} + +/// Write the distinct scenario ids (ascending) of the dataset directory `dir` +/// in the named `from` format into `out`, up to `cap` entries, and return the +/// total count — the cap/count convention of [`pio_bus_ids`]. `gridfm` is the +/// one dataset format today. Returns `-1` on error and writes the message into +/// `errbuf` (unlike the handle extractors, this reads the filesystem and can +/// fail). Built `--features gridfm`. +#[cfg(feature = "gridfm")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_scenario_ids( + dir: *const c_char, + from: *const c_char, + out: *mut i64, + cap: usize, + errbuf: *mut c_char, + errlen: usize, +) -> isize { + unsafe { + let r = catch_unwind(AssertUnwindSafe(|| { + let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?; + let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?; + powerio_matrix::dataset_scenario_ids(std::path::Path::new(dir), from) + .map_err(|e| e.to_string()) + })); + match r { + Ok(Ok(ids)) => { + fill(out, cap, ids.iter().copied()); + isize::try_from(ids.len()).unwrap_or(-1) + } + Ok(Err(msg)) => { + copy_to_buf(errbuf, errlen, &msg); + -1 + } + Err(_) => { + copy_to_buf(errbuf, errlen, "panic while reading scenario ids"); + -1 + } + } + } +} + +/// The fidelity warnings attached to the handle at construction (by whichever +/// of [`pio_parse_file`], [`pio_parse_str`], [`pio_read_dir`], or +/// [`pio_normalize`] built it), `\n`-joined into `warnbuf` (truncated to fit +/// on a UTF-8 boundary; NULL/0 to skip). Returns the byte length of the full +/// joined text, excluding the NUL — call once with `(NULL, 0)` to size, then +/// pass a `char[len + 1]`. `0` means no warnings (or a NULL handle); readers +/// that are total attach none. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_parse_warnings( +pub unsafe extern "C" fn pio_warnings( net: *const PioNetwork, warnbuf: *mut c_char, warnlen: usize, @@ -235,20 +335,26 @@ pub unsafe extern "C" fn pio_parse_warnings( unsafe { guard(0, || { let Some(c) = network_ref(net) else { return 0 }; - copy_to_buf(warnbuf, warnlen, &c.warnings.join("\n")); - c.warnings.len() + let msg = c.warnings.join("\n"); + copy_to_buf(warnbuf, warnlen, &msg); + msg.len() }) } } /// Free a network handle from [`pio_parse_file`], [`pio_parse_str`], -/// [`pio_to_normalized`], or [`pio_from_json`]. +/// [`pio_read_dir`], or [`pio_normalize`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_network_free(net: *mut PioNetwork) { unsafe { - if !net.is_null() { - drop(Box::from_raw(net)); - } + // Under the same panic guard as every other entry point: the drop is + // pure deallocation today, but the boundary contract ("catches panics") + // must not depend on that staying true. + guard((), || { + if !net.is_null() { + drop(Box::from_raw(net)); + } + }); } } @@ -264,15 +370,16 @@ unsafe fn view<'a>(net: *const PioNetwork) -> Option> { } } -/// Normalize `net` into a NEW per-unit network handle: per unit, radians, -/// out-of-service filtered, densely reindexed, bus types canonicalized (see -/// `Network::to_normalized`). The result is independent of `net`; free both -/// with [`pio_network_free`]. Every extractor and [`pio_to_json`] works on it -/// unchanged (the handle is per unit, not MW). Returns `NULL` on error (no -/// reference bus can be chosen, or a non-positive base MVA) and writes the -/// message into `errbuf`. +/// Normalize `net` into a NEW network handle: per unit, radians, out-of-service +/// filtered, densely reindexed, bus types canonicalized (see +/// `Network::to_normalized`). A value transform, not a serialization — hence +/// the verb, while the `to_*` family re-encodes unchanged data. The result is +/// independent of `net`; free both with [`pio_network_free`]. Every extractor +/// and serializer works on it unchanged (the handle is per unit, not MW). +/// Returns `NULL` on error (no reference bus can be chosen, or a non-positive +/// base MVA) and writes the message into `errbuf`. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_to_normalized( +pub unsafe extern "C" fn pio_normalize( net: *const PioNetwork, errbuf: *mut c_char, errlen: usize, @@ -308,114 +415,106 @@ pub unsafe extern "C" fn pio_base_mva(net: *const PioNetwork) -> f64 { unsafe { guard(0.0, || network_ref(net).map_or(0.0, |c| c.net.base_mva)) } } -/// Dense `[0, n)` index of the single reference bus, or `-1` if not exactly one -/// (also `-1` if the index is too large for `isize`). A network may carry -/// several references (one per island, or a normalized case that kept the file's -/// multiple `REF` buses); use [`pio_n_reference_buses`] to tell zero from many, -/// and [`pio_reference_buses`] to read them all. +/// Dense `[0, n)` index of the single reference (slack) bus, or `-1` if not +/// exactly one. An INDEX into the [`pio_bus_ids`] ordering, not a bus id — +/// `pio_branches` from/to carry ids, so the unit is in the name. A network may +/// carry several references (one per island, or a normalized case that kept +/// the file's multiple `REF` buses); [`pio_ref_bus_indices`] reads them all, +/// and its count (`NULL` out) tells zero from many. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_reference_bus(net: *const PioNetwork) -> isize { +pub unsafe extern "C" fn pio_ref_bus_index(net: *const PioNetwork) -> i64 { unsafe { guard(-1, || match view(net) { Some(v) => v .reference_bus_index() - .map_or(-1, |i| isize::try_from(i).unwrap_or(-1)), + .map_or(-1, |i| i64::try_from(i).unwrap_or(-1)), None => -1, }) } } -/// Number of reference (slack) buses. `0` means none; `> 1` means one reference -/// per island or several fixed reference buses in one island. A normalized case -/// always reports `>= 1`. +/// Write the dense `[0, n)` indices of the reference (slack) buses, ascending, +/// into `out`, up to `cap` entries, and return the total count — the cap/count +/// convention of [`pio_bus_ids`]. `0` means none; `> 1` means one reference +/// per island or several fixed references in one island (a normalized case +/// always reports `>= 1`). #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_n_reference_buses(net: *const PioNetwork) -> usize { +pub unsafe extern "C" fn pio_ref_bus_indices( + net: *const PioNetwork, + out: *mut i64, + cap: usize, +) -> usize { unsafe { guard(0, || { - view(net).map_or(0, |v| v.reference_bus_indices().len()) - }) - } -} - -/// Fill `out` (length [`pio_n_reference_buses`]) with the dense `[0, n)` indices -/// of the reference buses, ascending. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_reference_buses(net: *const PioNetwork, out: *mut i64) { - unsafe { - guard((), || { - if let Some(v) = view(net) { + view(net).map_or(0, |v| { fill( out, + cap, v.reference_bus_indices() .into_iter() .map(|i| i64::try_from(i).unwrap_or(-1)), - ); - } + ) + }) }) } } +/// Number of islands: connected components of the in-service topology. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_n_components(net: *const PioNetwork) -> usize { +pub unsafe extern "C" fn pio_n_islands(net: *const PioNetwork) -> usize { unsafe { guard(0, || view(net).map_or(0, |v| v.n_connected_components())) } } -/// `1` if the in-service topology is a forest, else `0`. +/// `1` if the in-service topology is radial (every island a tree), else `0`. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_is_radial(net: *const PioNetwork) -> i32 { unsafe { guard(0, || view(net).map_or(0, |v| i32::from(v.is_radial()))) } } -/// Serialize `net` to MATPOWER `.m` text (byte-exact echo when parsed from -/// MATPOWER). Returns an owned C string; free with [`pio_string_free`]. Returns -/// `NULL` on error and writes the message into `errbuf`. +/// Serialize `net` to the named format `to` — the one text serializer; every +/// format is named by a string. Accepts the [`pio_parse_str`] names: +/// `matpower` is a byte-exact echo when the handle was parsed from MATPOWER, +/// and `powerio-json` is the canonical lossless snapshot (validated by +/// [`pio_parse_str`] on the way back; the retained source text is the one +/// field it omits). +/// +/// Returns the text as an owned C string (free with [`pio_string_free`]), +/// `NULL` on error (message into `errbuf`). Fidelity warnings, if any, are +/// written `\n`-joined into `warnbuf` — a returned string has no handle to +/// attach them to. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_to_matpower( +pub unsafe extern "C" fn pio_to_format( net: *const PioNetwork, + to: *const c_char, + warnbuf: *mut c_char, + warnlen: usize, errbuf: *mut c_char, errlen: usize, ) -> *mut c_char { unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?; - Ok::<_, String>(c.net.to_matpower()) - })); - match r { - Ok(Ok(text)) => finish_cstring(text, errbuf, errlen), - Ok(Err(msg)) => { - copy_to_buf(errbuf, errlen, &msg); - std::ptr::null_mut() - } - Err(_) => { - copy_to_buf(errbuf, errlen, "panic while serializing to MATPOWER"); - std::ptr::null_mut() - } - } + let target = target_format_from_c(to)?; + let conv = c.net.to_format(target).map_err(|e| e.to_string())?; + Ok((conv.text, conv.warnings)) + }) } } -/// Serialize `net` to format `to`. -/// -/// Returns the converted text as an owned C string (free with -/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written -/// `\n`-joined into `warnbuf`. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_to_format( - net: *const PioNetwork, - to: *const c_char, +/// Finish a text-conversion entry point: run `f` (producing the converted text +/// with its fidelity warnings, or an error message) under the panic guard, +/// write the warnings into `warnbuf`, and hand back the owned C string — or +/// write the error and return NULL. The shared tail of [`pio_to_format`], +/// [`pio_convert_file`], and [`pio_convert_str`], mirroring [`finish_network`]. +unsafe fn finish_conversion( warnbuf: *mut c_char, warnlen: usize, errbuf: *mut c_char, errlen: usize, + f: impl FnOnce() -> Result<(String, Vec), String>, ) -> *mut c_char { unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { - let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?; - let target = target_format_from_c(to)?; - let conv = c.net.to_format(target); - Ok::<_, String>((conv.text, conv.warnings)) - })); - match r { + match catch_unwind(AssertUnwindSafe(f)) { Ok(Ok((text, warnings))) => { copy_to_buf(warnbuf, warnlen, &warnings.join("\n")); finish_cstring(text, errbuf, errlen) @@ -432,53 +531,69 @@ pub unsafe extern "C" fn pio_to_format( } } -/// Convert `path` to format `to` (optionally forcing the source via `from`). +/// Convert the case file at `path` from format `from` (NULL to infer from the +/// path, as [`pio_parse_file`]) to format `to`, without keeping a handle. /// Returns the converted text as an owned C string (free with -/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written -/// `\n`-joined into `warnbuf`. +/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, read side first, +/// are written `\n`-joined into `warnbuf`. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_convert_file( path: *const c_char, - to: *const c_char, from: *const c_char, + to: *const c_char, warnbuf: *mut c_char, warnlen: usize, errbuf: *mut c_char, errlen: usize, ) -> *mut c_char { unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?; let from = optional_cstr(from, "from")?; let target = target_format_from_c(to)?; let conv = powerio::convert_file(std::path::Path::new(path), target, from) .map_err(|e| e.to_string())?; - Ok::<_, String>((conv.text, conv.warnings)) - })); - match r { - Ok(Ok((text, warnings))) => { - copy_to_buf(warnbuf, warnlen, &warnings.join("\n")); - finish_cstring(text, errbuf, errlen) - } - Ok(Err(msg)) => { - copy_to_buf(errbuf, errlen, &msg); - std::ptr::null_mut() - } - Err(_) => { - copy_to_buf(errbuf, errlen, "panic while converting"); - std::ptr::null_mut() - } - } + Ok((conv.text, conv.warnings)) + }) + } +} + +/// Convert in-memory case `text` from format `from` (required — there is no +/// path to infer from) to format `to`, without keeping a handle: the in-memory +/// sibling of [`pio_convert_file`]. Returns the converted text as an owned C +/// string (free with [`pio_string_free`]), `NULL` on error. Fidelity warnings, +/// read side first, are written `\n`-joined into `warnbuf`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_convert_str( + text: *const c_char, + from: *const c_char, + to: *const c_char, + warnbuf: *mut c_char, + warnlen: usize, + errbuf: *mut c_char, + errlen: usize, +) -> *mut c_char { + unsafe { + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { + let text = cstr(text).ok_or_else(|| "text is NULL or not UTF-8".to_string())?; + let from = cstr(from).ok_or_else(|| "from is NULL or not UTF-8".to_string())?; + let target = target_format_from_c(to)?; + let conv = powerio::convert_str(text, target, from).map_err(|e| e.to_string())?; + Ok((conv.text, conv.warnings)) + }) } } -/// Write `net` as a PyPSA CSV folder at `out_dir`. Returns `0` on success and -/// `-1` on error (the message is written into `errbuf`), the same convention as -/// the other fallible `int` returns in this ABI. Fidelity warnings, if any, are +/// Write `net` into the directory `out_dir` as the named directory-shaped +/// format `to` — the directory sibling of [`pio_to_format`]. PyPSA CSV +/// (`pypsa-csv`/`pypsa`) is the one such format today; a text format name is +/// an error pointing back at [`pio_to_format`]. Returns `0` on success and +/// `-1` on error (message into `errbuf`). Fidelity warnings, if any, are /// written `\n`-joined into `warnbuf`. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_write_pypsa_csv_folder( +pub unsafe extern "C" fn pio_write_dir( net: *const PioNetwork, + to: *const c_char, out_dir: *const c_char, warnbuf: *mut c_char, warnlen: usize, @@ -488,11 +603,10 @@ pub unsafe extern "C" fn pio_write_pypsa_csv_folder( unsafe { let r = catch_unwind(AssertUnwindSafe(|| { let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?; + let to = cstr(to).ok_or_else(|| "to is NULL or not UTF-8".to_string())?; let out_dir = cstr(out_dir).ok_or_else(|| "out_dir is NULL or not UTF-8".to_string())?; - powerio::write_pypsa_csv_folder(&c.net, std::path::Path::new(out_dir)) - .map(|outputs| outputs.warnings) - .map_err(|e| e.to_string()) + powerio::write_dir(&c.net, to, std::path::Path::new(out_dir)).map_err(|e| e.to_string()) })); match r { Ok(Ok(warnings)) => { @@ -504,189 +618,70 @@ pub unsafe extern "C" fn pio_write_pypsa_csv_folder( -1 } Err(_) => { - copy_to_buf(errbuf, errlen, "panic while writing PyPSA CSV folder"); + copy_to_buf(errbuf, errlen, "panic while writing directory"); -1 } } } } -/// Read one scenario of a gridfm-datakit Parquet dataset into a network handle — -/// the inverse of the gridfm writer. `dir` resolves leniently: the `raw/` leaf -/// holding the parquet files, a `/` directory with a `raw/` child, or a -/// parent with one `*/raw/` child. Returns `NULL` on error and writes the message -/// into `errbuf`; the lossy read's fidelity warnings (what the gridfm schema -/// couldn't round-trip) are written `\n`-joined into `warnbuf`. Free the handle -/// with [`pio_network_free`]. Built `--features gridfm`. -#[cfg(feature = "gridfm")] -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_read_gridfm( - dir: *const c_char, - scenario: i64, - warnbuf: *mut c_char, - warnlen: usize, - errbuf: *mut c_char, - errlen: usize, -) -> *mut PioNetwork { - unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { - let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?; - powerio_matrix::read_gridfm_dataset(std::path::Path::new(dir), scenario) - .map_err(|e| e.to_string()) - })); - match r { - Ok(Ok(read)) => { - copy_to_buf(warnbuf, warnlen, &read.warnings.join("\n")); - make_network(read.network, read.warnings) - } - Ok(Err(msg)) => { - copy_to_buf(errbuf, errlen, &msg); - std::ptr::null_mut() - } - Err(_) => { - copy_to_buf(errbuf, errlen, "panic while reading gridfm dataset"); - std::ptr::null_mut() - } - } - } -} - -/// Write a gridfm dataset's distinct scenario ids (ascending) into `out`, up to -/// `cap` entries, and return the total count. Call once with `cap == 0` (or `out` -/// NULL) to size, allocate, then call again to fill — the standard count/buffer -/// pattern of [`pio_bus_ids`]. Returns `-1` on error and writes the message into -/// `errbuf`. Built `--features gridfm`. -#[cfg(feature = "gridfm")] -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_gridfm_scenario_ids( - dir: *const c_char, - out: *mut i64, - cap: usize, - errbuf: *mut c_char, - errlen: usize, -) -> isize { - unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { - let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?; - powerio_matrix::gridfm_scenario_ids(std::path::Path::new(dir)) - .map_err(|e| e.to_string()) - })); - match r { - Ok(Ok(ids)) => { - if !out.is_null() { - let n = ids.len().min(cap); - std::ptr::copy_nonoverlapping(ids.as_ptr(), out, n); - } - ids.len() as isize - } - Ok(Err(msg)) => { - copy_to_buf(errbuf, errlen, &msg); - -1 - } - Err(_) => { - copy_to_buf(errbuf, errlen, "panic while reading gridfm scenario ids"); - -1 - } - } - } -} - -/// Free a string returned by [`pio_to_matpower`], [`pio_to_format`], -/// [`pio_convert_file`], or -/// [`pio_to_json`]. +/// Free a string returned by [`pio_to_format`], [`pio_convert_file`], or +/// [`pio_convert_str`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_string_free(s: *mut c_char) { unsafe { - if !s.is_null() { - drop(CString::from_raw(s)); - } - } -} - -/// Serialize the case to JSON: the structured-table transport every Julia -/// bridge consumes. Carries the whole [`Network`] (buses, loads, shunts, -/// branches, generators, storage, HVDC, extras) but not the retained source -/// text, so it is structured data, not the byte-exact echo. Returns an owned C -/// string (free with [`pio_string_free`]), `NULL` on error (message into -/// `errbuf`). -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_to_json( - net: *const PioNetwork, - errbuf: *mut c_char, - errlen: usize, -) -> *mut c_char { - unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { - let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?; - c.net.to_json().map_err(|e| e.to_string()) - })); - match r { - Ok(Ok(json)) => finish_cstring(json, errbuf, errlen), - Ok(Err(msg)) => { - copy_to_buf(errbuf, errlen, &msg); - std::ptr::null_mut() - } - Err(_) => { - copy_to_buf(errbuf, errlen, "panic while serializing to JSON"); - std::ptr::null_mut() + // Same rationale as `pio_network_free`: the boundary catches panics. + guard((), || { + if !s.is_null() { + drop(CString::from_raw(s)); } - } - } -} - -/// Rebuild a network handle from JSON produced by [`pio_to_json`]. Returns a new -/// handle (free with [`pio_network_free`]), or `NULL` on error (message into -/// `errbuf`). The handle has no retained source, so [`pio_to_matpower`] -/// reformats it rather than echoing a byte-exact original. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_from_json( - json: *const c_char, - errbuf: *mut c_char, - errlen: usize, -) -> *mut PioNetwork { - unsafe { - finish_network(errbuf, errlen, "panic while parsing JSON", || { - let json = cstr(json).ok_or_else(|| "json is NULL or not UTF-8".to_string())?; - Network::from_json(json) - .map(|n| (n, Vec::new())) - .map_err(|e| e.to_string()) - }) + }); } } -unsafe fn fill(ptr: *mut T, vals: impl Iterator) { +/// Write up to `cap` values from `vals` into `out` and return the total number +/// available. A NULL `out` skips the write, so `(NULL, 0)` is the pure count +/// query of the cap/count convention every array extractor shares. +unsafe fn fill(out: *mut T, cap: usize, vals: impl ExactSizeIterator) -> usize { unsafe { - if ptr.is_null() { - return; - } - for (i, v) in vals.enumerate() { - *ptr.add(i) = v; + let total = vals.len(); + if !out.is_null() { + for (i, v) in vals.take(cap).enumerate() { + *out.add(i) = v; + } } + total } } -/// Fill `out` (length `pio_n_buses`) with the 1-based bus ids in dense order. +/// Write the 1-based external bus ids, in dense order, into `out`, up to `cap` +/// entries, and return the total bus count. This ordering DEFINES the dense +/// index space every other per-bus array shares. Call once with `(NULL, 0)` to +/// size, allocate, then call again to fill. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_bus_ids(net: *const PioNetwork, out: *mut i64) { +pub unsafe extern "C" fn pio_bus_ids(net: *const PioNetwork, out: *mut i64, cap: usize) -> usize { unsafe { - guard((), || { - if let Some(c) = network_ref(net) { + guard(0, || { + network_ref(net).map_or(0, |c| { fill( out, + cap, c.net .buses .iter() .map(|b| i64::try_from(b.id.0).unwrap_or(-1)), - ); - } + ) + }) }) } } -/// Fill the branch tables (each length `pio_n_branches`). `from`/`to` are the -/// 1-based bus ids (the same id space as [`pio_bus_ids`], not dense indices); -/// map them to dense matrix rows with the [`pio_bus_ids`] ordering. Any pointer -/// may be `NULL` to skip. +/// Write the branch table as parallel arrays, each up to `cap` entries, and +/// return the total branch count. A branch is any two-terminal series element +/// — lines and transformers alike (a transformer has `tap != 0`). `from`/`to` +/// are 1-based bus IDS (the [`pio_bus_ids`] id space, not dense indices); map +/// them to dense matrix rows with the [`pio_bus_ids`] ordering. Any output +/// pointer may be NULL to skip that column; all NULL is the count query. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_branches( net: *const PioNetwork, @@ -698,38 +693,44 @@ pub unsafe extern "C" fn pio_branches( tap: *mut f64, shift: *mut f64, in_service: *mut u8, -) { + cap: usize, +) -> usize { unsafe { - guard((), || { - let Some(c) = network_ref(net) else { return }; + guard(0, || { + let Some(c) = network_ref(net) else { return 0 }; let net = &c.net; fill( from, + cap, net.branches .iter() .map(|br| i64::try_from(br.from.0).unwrap_or(-1)), ); fill( to, + cap, net.branches .iter() .map(|br| i64::try_from(br.to.0).unwrap_or(-1)), ); - fill(r, net.branches.iter().map(|br| br.r)); - fill(x, net.branches.iter().map(|br| br.x)); - fill(b, net.branches.iter().map(|br| br.b)); - fill(tap, net.branches.iter().map(|br| br.tap)); - fill(shift, net.branches.iter().map(|br| br.shift)); + fill(r, cap, net.branches.iter().map(|br| br.r)); + fill(x, cap, net.branches.iter().map(|br| br.x)); + fill(b, cap, net.branches.iter().map(|br| br.b)); + fill(tap, cap, net.branches.iter().map(|br| br.tap)); + fill(shift, cap, net.branches.iter().map(|br| br.shift)); fill( in_service, + cap, net.branches.iter().map(|br| u8::from(br.in_service)), ); + net.branches.len() }) } } -/// Fill the generator tables (each length `pio_n_gens`; `bus` is the 1-based bus -/// id, the same id space as [`pio_bus_ids`]). Any pointer may be `NULL` to skip. +/// Write the generator table as parallel arrays, each up to `cap` entries, and +/// return the total generator count. `bus` is the 1-based bus id (the +/// [`pio_bus_ids`] id space). Any output pointer may be NULL to skip. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_gens( net: *const PioNetwork, @@ -738,56 +739,76 @@ pub unsafe extern "C" fn pio_gens( pmax: *mut f64, pmin: *mut f64, in_service: *mut u8, -) { + cap: usize, +) -> usize { unsafe { - guard((), || { - let Some(c) = network_ref(net) else { return }; + guard(0, || { + let Some(c) = network_ref(net) else { return 0 }; let net = &c.net; fill( bus, + cap, net.generators .iter() .map(|g| i64::try_from(g.bus.0).unwrap_or(-1)), ); - fill(pg, net.generators.iter().map(|g| g.pg)); - fill(pmax, net.generators.iter().map(|g| g.pmax)); - fill(pmin, net.generators.iter().map(|g| g.pmin)); + fill(pg, cap, net.generators.iter().map(|g| g.pg)); + fill(pmax, cap, net.generators.iter().map(|g| g.pmax)); + fill(pmin, cap, net.generators.iter().map(|g| g.pmin)); fill( in_service, + cap, net.generators.iter().map(|g| u8::from(g.in_service)), ); + net.generators.len() }) } } -/// Fill nodal aggregates (each length `pio_n_buses`, dense order): active and -/// reactive demand summed per bus. Any pointer may be `NULL`. +/// Write the per-bus demand aggregates (active `pd`, reactive `qd`, summed +/// over each bus's loads, dense [`pio_bus_ids`] order), each up to `cap` +/// entries, and return the total bus count. Either pointer may be NULL. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_nodal_demand(net: *const PioNetwork, pd: *mut f64, qd: *mut f64) { +pub unsafe extern "C" fn pio_bus_demand( + net: *const PioNetwork, + pd: *mut f64, + qd: *mut f64, + cap: usize, +) -> usize { unsafe { - guard((), || { - if let Some(v) = view(net) { - fill(pd, v.pd().iter().copied()); - fill(qd, v.qd().iter().copied()); - } + guard(0, || { + view(net).map_or(0, |v| { + fill(pd, cap, v.pd().iter().copied()); + fill(qd, cap, v.qd().iter().copied()) + }) }) } } -/// Fill nodal shunt aggregates (each length `pio_n_buses`, dense order). +/// Write the per-bus shunt aggregates (conductance `gs`, susceptance `bs`, +/// dense [`pio_bus_ids`] order), each up to `cap` entries, and return the +/// total bus count. Either pointer may be NULL. #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_nodal_shunt(net: *const PioNetwork, gs: *mut f64, bs: *mut f64) { +pub unsafe extern "C" fn pio_bus_shunt( + net: *const PioNetwork, + gs: *mut f64, + bs: *mut f64, + cap: usize, +) -> usize { unsafe { - guard((), || { - if let Some(v) = view(net) { - fill(gs, v.gs().iter().copied()); - fill(bs, v.bs().iter().copied()); - } + guard(0, || { + view(net).map_or(0, |v| { + fill(gs, cap, v.gs().iter().copied()); + fill(bs, cap, v.bs().iter().copied()) + }) }) } } -/// Export one raw network table over the Arrow C Data Interface. +/// Export one raw network table over the Arrow C Data Interface — the `to_` +/// conversion whose output type is Arrow structs rather than a string, and the +/// bulk plane this ABI evolves on: new or richer columns arrive in the Arrow +/// schema, leaving the C signatures fixed. /// /// `table` is one of the `PIO_ARROW_TABLE_*` selectors (bus/branch/gen/load/ /// shunt); the columns are the parsed network fields with EXTERNAL bus ids (the @@ -800,7 +821,7 @@ pub unsafe extern "C" fn pio_nodal_shunt(net: *const PioNetwork, gs: *mut f64, b /// out-params are left untouched. Only built with the `arrow` cargo feature. #[cfg(feature = "arrow")] #[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_export_arrow( +pub unsafe extern "C" fn pio_to_arrow( net: *const PioNetwork, table: i32, out_array: *mut arrow::ffi::FFI_ArrowArray, @@ -855,13 +876,7 @@ mod tests { } fn case9() -> *mut PioNetwork { - let path = CString::new( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../tests/data/case9.m") - .to_str() - .unwrap(), - ) - .unwrap(); + let path = data_path("case9.m"); let mut err = [0 as c_char; 256]; let c = unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) }; @@ -869,11 +884,37 @@ mod tests { c } + /// `pio_to_format` with a Rust-side format name, asserting success. + unsafe fn to_format(net: *const PioNetwork, to: &str) -> String { + let to = CString::new(to).unwrap(); + let mut warn = [0 as c_char; 512]; + let mut err = [0 as c_char; 256]; + unsafe { + let s = pio_to_format( + net, + to.as_ptr(), + warn.as_mut_ptr(), + warn.len(), + err.as_mut_ptr(), + err.len(), + ); + assert!( + !s.is_null(), + "to_format failed: {}", + CStr::from_ptr(err.as_ptr()).to_str().unwrap() + ); + let text = CStr::from_ptr(s).to_str().unwrap().to_owned(); + pio_string_free(s); + text + } + } + #[test] fn version_surface() { // The ABI version is the compatibility contract a consumer checks at // load; the version string is static, NUL-terminated, and non-empty. assert_eq!(pio_abi_version(), PIO_ABI_VERSION); + assert_eq!(PIO_ABI_VERSION, 4); let v = unsafe { CStr::from_ptr(pio_version()) }.to_str().unwrap(); assert_eq!(v, env!("CARGO_PKG_VERSION")); assert!(!v.is_empty()); @@ -887,18 +928,19 @@ mod tests { assert_eq!(pio_n_branches(c), 9); assert_eq!(pio_n_gens(c), 3); assert_eq!(pio_base_mva(c), 100.0); - assert_eq!(pio_n_components(c), 1); - assert!(pio_reference_bus(c) >= 0); - // The MATPOWER reader is total: no read warnings. - assert_eq!(pio_parse_warnings(c, std::ptr::null_mut(), 0), 0); + assert_eq!(pio_n_islands(c), 1); + assert!(pio_ref_bus_index(c) >= 0); + // The MATPOWER reader is total: no warnings, zero bytes. + assert_eq!(pio_warnings(c, std::ptr::null_mut(), 0), 0); pio_network_free(c); } } #[test] - fn parse_warnings_reach_the_buffer() { - // The pandapower fixture carries switches the model ignores; the read - // warning must be countable and readable from the handle. + fn warnings_size_then_fill_exactly() { + // The pandapower fixture carries switches the model ignores. The byte + // length returned by the NULL-out call must size a buffer that then + // receives the full text untruncated. let path = data_path("pandapower/example.json"); let mut err = [0 as c_char; 256]; let c = @@ -909,14 +951,16 @@ mod tests { unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap() ); unsafe { - let mut warn = [0 as c_char; 4096]; - let n = pio_parse_warnings(c, warn.as_mut_ptr(), warn.len()); - assert!(n > 0, "expected read warnings"); + let len = pio_warnings(c, std::ptr::null_mut(), 0); + assert!(len > 0, "expected read warnings"); + let mut warn = vec![0x7f as c_char; len + 1]; + assert_eq!(pio_warnings(c, warn.as_mut_ptr(), warn.len()), len); let w = CStr::from_ptr(warn.as_ptr()).to_str().unwrap(); + assert_eq!(w.len(), len, "buffer sized from the return holds it all"); assert!(w.contains("switch"), "expected a switch warning, got {w:?}"); - // A NULL handle reports zero warnings. + // A NULL handle reports zero bytes. assert_eq!( - pio_parse_warnings(std::ptr::null(), warn.as_mut_ptr(), warn.len()), + pio_warnings(std::ptr::null(), warn.as_mut_ptr(), warn.len()), 0 ); pio_network_free(c); @@ -924,21 +968,26 @@ mod tests { } #[test] - fn write_is_byte_exact() { + fn matpower_write_is_byte_exact() { let src = std::fs::read_to_string( std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"), ) .unwrap(); let c = case9(); unsafe { - let mut err = [0 as c_char; 256]; - let s = pio_to_matpower(c, err.as_mut_ptr(), err.len()); - assert!(!s.is_null()); - let got = CStr::from_ptr(s).to_str().unwrap(); - assert_eq!(got, src); - pio_string_free(s); + assert_eq!(to_format(c, "matpower"), src); - let null = pio_to_matpower(std::ptr::null(), err.as_mut_ptr(), err.len()); + // A NULL handle is an error message, not a crash. + let to = CString::new("matpower").unwrap(); + let mut err = [0 as c_char; 256]; + let null = pio_to_format( + std::ptr::null(), + to.as_ptr(), + std::ptr::null_mut(), + 0, + err.as_mut_ptr(), + err.len(), + ); assert!(null.is_null()); assert_eq!( CStr::from_ptr(err.as_ptr()).to_str().unwrap(), @@ -952,10 +1001,23 @@ mod tests { fn extract_branch_tables() { let c = case9(); unsafe { - let nb = pio_n_branches(c); + // All-NULL is the count query. + let nb = pio_branches( + c, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + 0, + ); + assert_eq!(nb, pio_n_branches(c)); let mut from = vec![0i64; nb]; let mut x = vec![0f64; nb]; - pio_branches( + let total = pio_branches( c, from.as_mut_ptr(), std::ptr::null_mut(), @@ -965,7 +1027,9 @@ mod tests { std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), + nb, ); + assert_eq!(total, nb); // `from` carries the 1-based bus ids (case9 buses are 1..=9), the // same id space as pio_bus_ids, not dense indices. assert!(from.iter().all(|&f| f >= 1)); @@ -974,23 +1038,32 @@ mod tests { } } + #[test] + fn cap_clamps_the_write_and_returns_the_total() { + let c = case9(); + unsafe { + let total = pio_bus_ids(c, std::ptr::null_mut(), 0); + assert_eq!(total, 9); + // A two-slot buffer gets exactly two ids; the total still comes back, + // so a short read is detectable. + let mut ids = [-1i64; 2]; + assert_eq!(pio_bus_ids(c, ids.as_mut_ptr(), ids.len()), 9); + assert!(ids.iter().all(|&id| id >= 1)); + pio_network_free(c); + } + } + #[test] fn convert_matpower_echo() { - let path = CString::new( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../tests/data/case14.m") - .to_str() - .unwrap(), - ) - .unwrap(); + let path = data_path("case14.m"); let to = CString::new("matpower").unwrap(); let mut warn = [0 as c_char; 256]; let mut err = [0 as c_char; 256]; unsafe { let s = pio_convert_file( path.as_ptr(), - to.as_ptr(), std::ptr::null(), + to.as_ptr(), warn.as_mut_ptr(), warn.len(), err.as_mut_ptr(), @@ -1008,24 +1081,45 @@ mod tests { } #[test] - fn to_format_converts_live_handle() { - let c = case9(); + fn convert_str_round_trips_in_memory() { + // The in-memory converter is parse_str + to_format fused: matpower in, + // powermodels out, no filesystem. + let src = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/case9.m"), + ) + .unwrap(); + let text = CString::new(src).unwrap(); + let from = CString::new("matpower").unwrap(); let to = CString::new("powermodels-json").unwrap(); - let mut warn = [0 as c_char; 256]; + let mut warn = [0 as c_char; 512]; let mut err = [0 as c_char; 256]; unsafe { - let s = pio_to_format( - c, + let s = pio_convert_str( + text.as_ptr(), + from.as_ptr(), to.as_ptr(), warn.as_mut_ptr(), warn.len(), err.as_mut_ptr(), err.len(), ); - assert!(!s.is_null()); - let text = CStr::from_ptr(s).to_str().unwrap(); - assert!(text.contains("\"bus\"")); + assert!( + !s.is_null(), + "convert_str failed: {}", + CStr::from_ptr(err.as_ptr()).to_str().unwrap() + ); + let out = CStr::from_ptr(s).to_str().unwrap(); + assert!(out.contains("\"bus\"")); pio_string_free(s); + } + } + + #[test] + fn to_format_converts_live_handle() { + let c = case9(); + unsafe { + let text = to_format(c, "powermodels-json"); + assert!(text.contains("\"bus\"")); pio_network_free(c); } } @@ -1066,8 +1160,8 @@ mod tests { let s = unsafe { pio_convert_file( path.as_ptr(), - to.as_ptr(), bad_from.as_ptr().cast::(), + to.as_ptr(), warn.as_mut_ptr(), warn.len(), err.as_mut_ptr(), @@ -1082,10 +1176,10 @@ mod tests { } #[test] - fn extract_gen_and_nodal_tables() { + fn extract_gen_and_bus_aggregate_tables() { // case30 carries generators, loads, and shunts: cross-check the table // extractors against known counts and aggregate signs (a column swap in - // pio_gens/pio_nodal_* would otherwise ship silently). + // pio_gens/pio_bus_* would otherwise ship silently). let path = data_path("case30.m"); let mut err = [0 as c_char; 256]; let c = @@ -1099,29 +1193,32 @@ mod tests { let mut gbus = vec![-9i64; ng]; let mut pmax = vec![0f64; ng]; - pio_gens( + let total = pio_gens( c, gbus.as_mut_ptr(), std::ptr::null_mut(), pmax.as_mut_ptr(), std::ptr::null_mut(), std::ptr::null_mut(), + ng, ); - assert!(gbus.iter().all(|&b| b >= 0 && (b as usize) < nb)); + assert_eq!(total, ng); + // Generator buses are 1-based ids within the case's id range. + assert!(gbus.iter().all(|&b| (1..=nb as i64).contains(&b))); assert!(pmax.iter().any(|&p| p > 0.0)); let mut ids = vec![0i64; nb]; - pio_bus_ids(c, ids.as_mut_ptr()); + assert_eq!(pio_bus_ids(c, ids.as_mut_ptr(), nb), nb); assert!(ids.iter().all(|&id| id >= 1)); // MATPOWER bus ids are 1-based let mut pd = vec![0f64; nb]; let mut qd = vec![0f64; nb]; - pio_nodal_demand(c, pd.as_mut_ptr(), qd.as_mut_ptr()); + assert_eq!(pio_bus_demand(c, pd.as_mut_ptr(), qd.as_mut_ptr(), nb), nb); assert!(pd.iter().sum::() > 0.0, "case30 has active demand"); let mut gs = vec![0f64; nb]; let mut bs = vec![0f64; nb]; - pio_nodal_shunt(c, gs.as_mut_ptr(), bs.as_mut_ptr()); + assert_eq!(pio_bus_shunt(c, gs.as_mut_ptr(), bs.as_mut_ptr(), nb), nb); assert!(gs.iter().chain(bs.iter()).all(|x| x.is_finite())); pio_network_free(c); @@ -1131,21 +1228,21 @@ mod tests { #[test] fn null_handle_and_null_out_are_safe() { // Every query tolerates a NULL handle (the documented safe default), and - // a NULL output pointer on a valid case is skipped, not dereferenced. + // a NULL output pointer on a valid case is a count query, not a deref. unsafe { let nil: *const PioNetwork = std::ptr::null(); assert_eq!(pio_n_buses(nil), 0); assert_eq!(pio_n_branches(nil), 0); assert_eq!(pio_n_gens(nil), 0); assert_eq!(pio_base_mva(nil), 0.0); - assert_eq!(pio_reference_bus(nil), -1); - assert_eq!(pio_n_reference_buses(nil), 0); + assert_eq!(pio_ref_bus_index(nil), -1); + assert_eq!(pio_ref_bus_indices(nil, std::ptr::null_mut(), 0), 0); assert_eq!(pio_is_radial(nil), 0); - assert_eq!(pio_n_components(nil), 0); + assert_eq!(pio_n_islands(nil), 0); // The two FFI constructors reject a NULL input rather than crash. let mut err = [0 as c_char; 128]; - assert!(pio_to_normalized(nil, err.as_mut_ptr(), err.len()).is_null()); + assert!(pio_normalize(nil, err.as_mut_ptr(), err.len()).is_null()); let fmt = CString::new("matpower").unwrap(); assert!( pio_parse_str(std::ptr::null(), fmt.as_ptr(), err.as_mut_ptr(), err.len()) @@ -1153,9 +1250,9 @@ mod tests { ); let c = case9(); - pio_bus_ids(c, std::ptr::null_mut()); - pio_reference_buses(c, std::ptr::null_mut()); - pio_nodal_demand(c, std::ptr::null_mut(), std::ptr::null_mut()); + assert_eq!(pio_bus_ids(c, std::ptr::null_mut(), 0), 9); + pio_ref_bus_indices(c, std::ptr::null_mut(), 0); + pio_bus_demand(c, std::ptr::null_mut(), std::ptr::null_mut(), 0); pio_gens( c, std::ptr::null_mut(), @@ -1163,6 +1260,7 @@ mod tests { std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), + 0, ); pio_network_free(c); } @@ -1171,9 +1269,9 @@ mod tests { #[test] fn normalized_multi_ref_is_legible() { // A two-slack case (both gen-backed file REF buses) normalizes to a - // handle that keeps both references. `pio_reference_bus` can't name a - // single slack (returns -1), but the reference-set accessors do, so a C - // consumer can tell "two slacks, you pick" from "no slack, broken". + // handle that keeps both references. `pio_ref_bus_index` can't name a + // single slack (returns -1), but the reference-set extractor does, so a + // C consumer can tell "two slacks, you pick" from "no slack, broken". let src = "\ function mpc = tworef mpc.version = '2'; @@ -1198,15 +1296,16 @@ mpc.branch = [ unsafe { let cs = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len()); assert!(!cs.is_null(), "parse_str returned null"); - let cn = pio_to_normalized(cs, err.as_mut_ptr(), err.len()); - assert!(!cn.is_null(), "to_normalized returned null"); + let cn = pio_normalize(cs, err.as_mut_ptr(), err.len()); + assert!(!cn.is_null(), "normalize returned null"); - assert_eq!(pio_n_reference_buses(cn), 2); + // Count via NULL out, then fill. + assert_eq!(pio_ref_bus_indices(cn, std::ptr::null_mut(), 0), 2); // Multiple references: the single-slack query reports -1, by design. - assert_eq!(pio_reference_bus(cn), -1); - let mut refs = vec![0i64; pio_n_reference_buses(cn)]; - pio_reference_buses(cn, refs.as_mut_ptr()); - assert_eq!(refs, vec![0, 1]); + assert_eq!(pio_ref_bus_index(cn), -1); + let mut refs = [-1i64; 2]; + assert_eq!(pio_ref_bus_indices(cn, refs.as_mut_ptr(), refs.len()), 2); + assert_eq!(refs, [0, 1]); pio_network_free(cn); pio_network_free(cs); @@ -1224,8 +1323,8 @@ mpc.branch = [ unsafe { let s = pio_convert_file( path.as_ptr(), - to.as_ptr(), std::ptr::null(), + to.as_ptr(), warn.as_mut_ptr(), warn.len(), err.as_mut_ptr(), @@ -1242,57 +1341,59 @@ mpc.branch = [ } #[test] - fn json_round_trip_preserves_structure() { - // to_json -> from_json must reproduce the structured tables. case30 - // carries loads, shunts, and gen costs, so a dropped field shows up. - let c = { - let path = data_path("case30.m"); - let mut err = [0 as c_char; 256]; - let h = unsafe { - pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) - }; - assert!(!h.is_null()); - h - }; + fn snapshot_round_trip_preserves_structure() { + // to_format("powerio-json") -> parse_str("powerio-json") must reproduce + // the structured tables. case30 carries loads, shunts, and gen costs, + // so a dropped field shows up. + let path = data_path("case30.m"); + let mut err = [0 as c_char; 256]; + let c = + unsafe { pio_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) }; + assert!(!c.is_null()); unsafe { - let mut err = [0 as c_char; 256]; - let json = pio_to_json(c, err.as_mut_ptr(), err.len()); - assert!(!json.is_null(), "to_json returned null"); - let text = CStr::from_ptr(json).to_str().unwrap().to_owned(); - assert!(text.contains("\"buses\"")); - - let back = pio_from_json(json.cast_const(), err.as_mut_ptr(), err.len()); - assert!(!back.is_null(), "from_json returned null"); - // Counts and base survive the round trip through JSON. + let json = to_format(c, "powerio-json"); + assert!(json.contains("\"buses\"")); + + let text = CString::new(json).unwrap(); + let fmt = CString::new("powerio-json").unwrap(); + let back = pio_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len()); + assert!( + !back.is_null(), + "snapshot parse failed: {}", + CStr::from_ptr(err.as_ptr()).to_str().unwrap() + ); + // The snapshot is lossless: no fidelity warnings on the way back. + assert_eq!(pio_warnings(back, std::ptr::null_mut(), 0), 0); + // Counts and base survive the round trip. assert_eq!(pio_n_buses(back), pio_n_buses(c)); assert_eq!(pio_n_branches(back), pio_n_branches(c)); assert_eq!(pio_n_gens(back), pio_n_gens(c)); assert_eq!(pio_base_mva(back), pio_base_mva(c)); - assert_eq!(pio_reference_bus(back), pio_reference_bus(c)); + assert_eq!(pio_ref_bus_index(back), pio_ref_bus_index(c)); + + // The bare "json" alias means the same snapshot format. + let alias = CString::new("json").unwrap(); + let again = pio_parse_str(text.as_ptr(), alias.as_ptr(), err.as_mut_ptr(), err.len()); + assert!(!again.is_null()); + assert_eq!(pio_n_buses(again), pio_n_buses(c)); - pio_string_free(json); + pio_network_free(again); pio_network_free(back); pio_network_free(c); } } #[test] - fn from_json_rejects_garbage() { + fn snapshot_rejects_garbage() { let bad = CString::new("{ not json").unwrap(); + let fmt = CString::new("powerio-json").unwrap(); let mut err = [0 as c_char; 256]; - let h = unsafe { pio_from_json(bad.as_ptr(), err.as_mut_ptr(), err.len()) }; + let h = unsafe { pio_parse_str(bad.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len()) }; assert!(h.is_null()); let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(); assert!(!msg.is_empty(), "expected a JSON parse error message"); } - #[test] - fn to_json_null_handle_is_safe() { - let mut err = [0 as c_char; 256]; - let s = unsafe { pio_to_json(std::ptr::null(), err.as_mut_ptr(), err.len()) }; - assert!(s.is_null()); - } - #[test] fn error_buffer_truncates_and_nul_terminates() { // copy_to_buf must truncate an oversized message to fit and keep the @@ -1309,14 +1410,33 @@ mpc.branch = [ assert!(nul <= 15); } + #[test] + fn truncation_lands_on_a_utf8_char_boundary() { + // "aé€" is 1+2+3 bytes; a 6-byte buffer fits 5 message bytes, which + // would split '€'. The copy must back up to "aé" instead of emitting a + // dangling partial codepoint. + let mut buf = [0x7f as c_char; 6]; + unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") }; + let s = unsafe { CStr::from_ptr(buf.as_ptr()) } + .to_str() + .expect("truncated message must be valid UTF-8"); + assert_eq!(s, "aé"); + + // A message that fits is copied whole. + let mut buf = [0x7f as c_char; 8]; + unsafe { copy_to_buf(buf.as_mut_ptr(), buf.len(), "aé€") }; + let s = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(); + assert_eq!(s, "aé€"); + } + #[cfg(feature = "arrow")] #[test] - fn export_arrow_null_out_params_return_error() { + fn to_arrow_null_out_params_return_error() { // A NULL out_array/out_schema must be reported (-1), not dereferenced. let c = case9(); let mut err = [0 as c_char; 256]; let rc = unsafe { - pio_export_arrow( + pio_to_arrow( c, PIO_ARROW_TABLE_BUS, std::ptr::null_mut(), @@ -1333,7 +1453,7 @@ mpc.branch = [ #[cfg(feature = "gridfm")] #[test] - fn read_gridfm_round_trips_and_enumerates_scenarios() { + fn read_dir_round_trips_and_enumerates_scenarios() { use powerio_matrix::{GridfmOptions, write_gridfm_dataset}; // Write a one-scenario dataset, then read it back over the C ABI. let net = powerio::parse_file( @@ -1345,34 +1465,29 @@ mpc.branch = [ let tmp = tempfile::tempdir().unwrap(); let out = write_gridfm_dataset(&net, 0, tmp.path(), &GridfmOptions::default()).unwrap(); let dir = CString::new(out.dir.to_str().unwrap()).unwrap(); + let from = CString::new("gridfm").unwrap(); - let mut warn = [0 as c_char; 512]; let mut err = [0 as c_char; 256]; unsafe { - let h = pio_read_gridfm( - dir.as_ptr(), - 0, - warn.as_mut_ptr(), - warn.len(), - err.as_mut_ptr(), - err.len(), - ); + let h = pio_read_dir(dir.as_ptr(), from.as_ptr(), 0, err.as_mut_ptr(), err.len()); assert!( !h.is_null(), "read failed: {}", CStr::from_ptr(err.as_ptr()).to_str().unwrap() ); assert_eq!(pio_n_buses(h), 14); - // The lossy read always reports fidelity warnings into warnbuf. + // The lossy read's fidelity warnings attach to the handle, like + // every other constructor's. assert!( - !CStr::from_ptr(warn.as_ptr()).to_str().unwrap().is_empty(), - "expected fidelity warnings" + pio_warnings(h, std::ptr::null_mut(), 0) > 0, + "expected fidelity warnings on the handle" ); pio_network_free(h); // Scenario ids: size with a NULL out, then fill. One scenario -> [0]. - let count = pio_gridfm_scenario_ids( + let count = pio_scenario_ids( dir.as_ptr(), + from.as_ptr(), std::ptr::null_mut(), 0, err.as_mut_ptr(), @@ -1380,8 +1495,9 @@ mpc.branch = [ ); assert_eq!(count, 1); let mut ids = [-1i64; 4]; - let n = pio_gridfm_scenario_ids( + let n = pio_scenario_ids( dir.as_ptr(), + from.as_ptr(), ids.as_mut_ptr(), ids.len(), err.as_mut_ptr(), @@ -1390,13 +1506,19 @@ mpc.branch = [ assert_eq!(n, 1); assert_eq!(ids[0], 0); + // An unknown dataset format is a loud error naming the known ones. + let bad = CString::new("pypsa").unwrap(); + let h = pio_read_dir(dir.as_ptr(), bad.as_ptr(), 0, err.as_mut_ptr(), err.len()); + assert!(h.is_null()); + let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap(); + assert!(msg.contains("gridfm"), "got: {msg}"); + // A missing dataset directory errors (NULL handle + message), not a panic. let missing = CString::new(tmp.path().join("nope").to_str().unwrap()).unwrap(); - let bad = pio_read_gridfm( + let bad = pio_read_dir( missing.as_ptr(), + from.as_ptr(), 0, - warn.as_mut_ptr(), - warn.len(), err.as_mut_ptr(), err.len(), ); @@ -1404,4 +1526,27 @@ mpc.branch = [ assert!(!CStr::from_ptr(err.as_ptr()).to_str().unwrap().is_empty()); } } + + #[test] + fn write_dir_rejects_text_formats_by_name() { + let c = case9(); + let to = CString::new("matpower").unwrap(); + let dir = CString::new("/tmp/unused").unwrap(); + let mut err = [0 as c_char; 256]; + unsafe { + let rc = pio_write_dir( + c, + to.as_ptr(), + dir.as_ptr(), + std::ptr::null_mut(), + 0, + err.as_mut_ptr(), + err.len(), + ); + assert_eq!(rc, -1); + let msg = CStr::from_ptr(err.as_ptr()).to_str().unwrap(); + assert!(msg.contains("pypsa"), "got: {msg}"); + pio_network_free(c); + } + } } diff --git a/powerio-cli/src/main.rs b/powerio-cli/src/main.rs index 1e1adee..fe785a1 100644 --- a/powerio-cli/src/main.rs +++ b/powerio-cli/src/main.rs @@ -165,6 +165,9 @@ enum FormatArg { PowerWorld, #[value(name = "pandapower-json", alias = "pandapower", alias = "pp")] PandapowerJson, + /// The canonical lossless snapshot (`Network` as validated JSON). + #[value(name = "powerio-json", alias = "powerio", alias = "json")] + PowerioJson, #[value(name = "pypsa-csv", alias = "pypsa")] PypsaCsv, /// Read a gridfm-datakit Parquet dataset directory (read-only). @@ -187,6 +190,7 @@ impl FormatArg { FormatArg::Psse => TargetFormat::Psse, FormatArg::PowerWorld => TargetFormat::PowerWorld, FormatArg::PandapowerJson => TargetFormat::PandapowerJson, + FormatArg::PowerioJson => TargetFormat::PowerioJson, FormatArg::PypsaCsv => anyhow::bail!( "`convert` cannot return a PyPSA CSV folder as text; pass `--to pypsa-csv -o `" ), @@ -209,6 +213,7 @@ impl FormatArg { FormatArg::Psse => "psse", FormatArg::PowerWorld => "powerworld", FormatArg::PandapowerJson => "pandapower-json", + FormatArg::PowerioJson => "powerio-json", FormatArg::PypsaCsv => "pypsa-csv", FormatArg::Gridfm => "gridfm", FormatArg::Pwb => "pwb", @@ -646,7 +651,8 @@ fn run_convert( } else { read_network(input, from)? }; - let conv = powerio_matrix::write_as(&net, target); + let conv = powerio_matrix::write_as(&net, target) + .with_context(|| format!("serializing to {target}"))?; for w in &conv.warnings { eprintln!("fidelity: {w}"); } diff --git a/powerio-matrix/src/io/gridfm.rs b/powerio-matrix/src/io/gridfm.rs index e9262b9..ac103ee 100644 --- a/powerio-matrix/src/io/gridfm.rs +++ b/powerio-matrix/src/io/gridfm.rs @@ -1027,7 +1027,7 @@ pub fn read_gridfm_scenarios(dir: impl AsRef) -> Result> { /// The distinct scenario ids in a gridfm dataset, ascending — the keys /// [`read_gridfm_scenarios`] rebuilds a [`Network`] for. Reads only `bus_data`'s /// scenario column, so it enumerates a dataset's scenarios without rebuilding -/// every network; the C ABI's `pio_gridfm_scenario_ids` is a thin wrapper over it. +/// every network; the C ABI's `pio_scenario_ids` is a thin wrapper over it. /// /// # Errors /// Propagates the directory resolution and `bus_data.parquet` read errors. diff --git a/powerio-matrix/src/io/mod.rs b/powerio-matrix/src/io/mod.rs index ebc1afd..c7d4b2d 100644 --- a/powerio-matrix/src/io/mod.rs +++ b/powerio-matrix/src/io/mod.rs @@ -14,3 +14,49 @@ pub use gridfm::{ }; pub use meta::{CaseMetadata, MatrixMetadata, write_meta_json}; pub use mtx::{read_mtx, read_vector_mtx, write_mtx, write_vector_mtx}; + +/// Read one scenario of a dataset directory in the named `from` format — the +/// directory sibling of [`powerio::parse_file`], and the one place dataset +/// format names are dispatched (the C ABI's `pio_read_dir` is a thin wrapper). +/// `gridfm` is the one dataset format today; `scenario` selects within it. +/// PyPSA CSV directories are case inputs, not datasets, and parse through +/// `parse_file`. +/// +/// # Errors +/// [`powerio::Error::UnknownFormat`] for a non-dataset format name; otherwise +/// as [`read_gridfm_dataset`]. +#[cfg(feature = "gridfm")] +pub fn read_dataset_dir( + dir: impl AsRef, + from: &str, + scenario: i64, +) -> powerio::Result { + require_dataset_format(from)?; + read_gridfm_dataset(dir, scenario) +} + +/// The distinct scenario ids of the dataset directory `dir` in the named +/// `from` format, ascending — the introspection sibling of +/// [`read_dataset_dir`] (and the C ABI's `pio_scenario_ids`). +/// +/// # Errors +/// As [`read_dataset_dir`]. +#[cfg(feature = "gridfm")] +pub fn dataset_scenario_ids( + dir: impl AsRef, + from: &str, +) -> powerio::Result> { + require_dataset_format(from)?; + gridfm::gridfm_scenario_ids(dir) +} + +#[cfg(feature = "gridfm")] +fn require_dataset_format(from: &str) -> powerio::Result<()> { + if from.eq_ignore_ascii_case("gridfm") { + return Ok(()); + } + Err(powerio::Error::UnknownFormat(format!( + "{from} is not a dataset directory format (dataset formats: gridfm); \ + PyPSA CSV directories parse through parse_file" + ))) +} diff --git a/powerio-matrix/src/lib.rs b/powerio-matrix/src/lib.rs index de075e1..dbdee33 100644 --- a/powerio-matrix/src/lib.rs +++ b/powerio-matrix/src/lib.rs @@ -68,3 +68,5 @@ pub use io::gridfm::{ read_gridfm_dataset, read_gridfm_network, read_gridfm_scenarios, write_gridfm_batch, write_gridfm_dataset, }; +#[cfg(feature = "gridfm")] +pub use io::{dataset_scenario_ids, read_dataset_dir}; diff --git a/powerio-py/src/lib.rs b/powerio-py/src/lib.rs index 2cdda92..b0202f3 100644 --- a/powerio-py/src/lib.rs +++ b/powerio-py/src/lib.rs @@ -415,7 +415,7 @@ impl PyCase { let target = to .parse::() .map_err(to_pyerr)?; - let conv = self.inner.to_format(target); + let conv = self.inner.to_format(target).map_err(to_pyerr)?; Ok((conv.text, conv.warnings)) } diff --git a/powerio/README.md b/powerio/README.md index 1641f68..4efd9cb 100644 --- a/powerio/README.md +++ b/powerio/README.md @@ -7,8 +7,8 @@ PowerWorld, PowerModels JSON, and egret JSON. ```rust use powerio::{TargetFormat, parse_file}; -let net = parse_file("case14.m")?; -let converted = net.to_format(TargetFormat::PowerModelsJson); +let net = parse_file("case14.m")?.network; +let converted = net.to_format(TargetFormat::PowerModelsJson)?; std::fs::write("case14.json", converted.text)?; ``` diff --git a/powerio/benches/parse.rs b/powerio/benches/parse.rs index dad98f8..797e4c1 100644 --- a/powerio/benches/parse.rs +++ b/powerio/benches/parse.rs @@ -62,7 +62,7 @@ fn bench_parse_formats(c: &mut Criterion) { for (name, fmt) in FORMATS { // Convert once outside the timed loop; `parse_str` runs the same // owned-source reader the file path does. - let text = write_as(&net, *fmt).text; + let text = write_as(&net, *fmt).unwrap().text; // A reader that can't re-read its own writer would make the timing // meaningless, so fail loudly here rather than benchmark an error path. parse_str(&text, name) @@ -124,11 +124,24 @@ fn bench_powerworld_pwb(c: &mut Criterion) { } } +/// The `.pwd` display decoder: a byte-offset scan over the whole file, the one +/// reader whose hot loop runs per byte rather than per record — regression +/// coverage for the total (Option-returning) byte accessors. +fn bench_powerworld_pwd(c: &mut Criterion) { + let Ok(bytes) = std::fs::read("../tests/data/powerworld/ACTIVSg200.pwd") else { + return; + }; + c.bench_function("parse_pwd_activsg200", |b| { + b.iter(|| powerio::format::powerworld::parse_pwd(black_box(&bytes)).unwrap()); + }); +} + criterion_group!( benches, bench_parse, bench_roundtrip, bench_parse_formats, - bench_powerworld_pwb + bench_powerworld_pwb, + bench_powerworld_pwd ); criterion_main!(benches); diff --git a/powerio/src/format/matpower/rows.rs b/powerio/src/format/matpower/rows.rs index 05b7654..6b0e82b 100644 --- a/powerio/src/format/matpower/rows.rs +++ b/powerio/src/format/matpower/rows.rs @@ -238,9 +238,19 @@ pub(super) fn gencost_row(row: &[f64], i: usize) -> Result { // only this row's values, not the padding. Require the row to actually hold // them: a NCOST larger than the row is malformed, and silently truncating it // would misrepresent the cost curve. - let want = if model == 1 { 2 * ncost } else { ncost }; + // `ncost` is an untrusted file field truncated from an f64, so a huge or + // non-finite NCOST saturates near `usize::MAX`. Size the requirement with + // saturating arithmetic: an implausible NCOST is then rejected by the length + // check below (a loud `ShortRow`), instead of overflowing the add (a panic + // under debug overflow checks) or wrapping into a reversed `start..start+want` + // slice range at `coeffs` (a slice-index panic in release). + let want = if model == 1 { + ncost.saturating_mul(2) + } else { + ncost + }; let start = gencost_col::REQUIRED; - require("gencost", row, i, start + want)?; + require("gencost", row, i, start.saturating_add(want))?; Ok(GenCost { model, startup: row[gencost_col::STARTUP], diff --git a/powerio/src/format/matpower/tests.rs b/powerio/src/format/matpower/tests.rs index 3425f52..6cc3651 100644 --- a/powerio/src/format/matpower/tests.rs +++ b/powerio/src/format/matpower/tests.rs @@ -152,6 +152,31 @@ mpc.storage = [ assert!(err.to_string().contains("storage"), "got: {err}"); } +#[test] +fn rejects_oversized_gencost_ncost_without_panicking() { + // NCOST is read from the file as an f64 truncated to usize, so a huge value + // saturates near usize::MAX. The row-width requirement (`start + 2*ncost` for + // model 1, `start + ncost` for model 2) must be computed without overflowing: + // a malformed NCOST has to surface as a loud `ShortRow`, not an add-overflow + // panic (debug) or a reversed-slice panic (release). Both cost models exercise + // a distinct saturating op (`saturating_mul` vs `saturating_add`). + let case = |gencost: &str| { + format!( + "mpc.baseMVA = 100;\n\ + mpc.bus = [\n1 3 0 0 0 0 1 1 0 345 1 1.1 0.9;\n];\n\ + mpc.gen = [\n1 0 0 100 -100 1 100 1 100 0 0 0 0 0 0 0 0 0 0 0 0;\n];\n\ + mpc.branch = [\n1 1 0.01 0.05 0.02 0 0 0 0 0 1 -360 360;\n];\n\ + mpc.gencost = [\n{gencost}\n];\n" + ) + }; + // model 2 (polynomial): want = ncost + let err = parse_mpc(&case("2 0 0 1e20 500 300 200;")).expect_err("huge model-2 ncost"); + assert!(err.to_string().contains("gencost"), "got: {err}"); + // model 1 (piecewise): want = 2 * ncost, the saturating_mul path + let err = parse_mpc(&case("1 0 0 1e19 0 0 1 1;")).expect_err("huge model-1 ncost"); + assert!(err.to_string().contains("gencost"), "got: {err}"); +} + #[test] fn rejects_unterminated_matrix() { // A `mpc.bus = [ … ` truncated at EOF with no closing `];`. The streaming diff --git a/powerio/src/format/mod.rs b/powerio/src/format/mod.rs index 84fbbd3..488b53b 100644 --- a/powerio/src/format/mod.rs +++ b/powerio/src/format/mod.rs @@ -65,6 +65,10 @@ pub enum TargetFormat { PandapowerJson, /// MATPOWER `.m` (round-trip; byte-exact when the case kept its source). Matpower, + /// The canonical PowerIO snapshot: [`Network`] serialized as JSON, validated + /// on read. Lossless for every model field; the retained source text is the + /// one exclusion (see [`Network::to_json`]). + PowerioJson, } impl TargetFormat { @@ -74,7 +78,8 @@ impl TargetFormat { match self { TargetFormat::PowerModelsJson | TargetFormat::EgretJson - | TargetFormat::PandapowerJson => "json", + | TargetFormat::PandapowerJson + | TargetFormat::PowerioJson => "json", TargetFormat::Psse => "raw", TargetFormat::PowerWorld => "aux", TargetFormat::Matpower => "m", @@ -91,6 +96,7 @@ impl TargetFormat { TargetFormat::PowerWorld => "PowerWorld .aux", TargetFormat::PandapowerJson => "pandapower JSON", TargetFormat::Matpower => "MATPOWER .m", + TargetFormat::PowerioJson => "PowerIO JSON", } } @@ -104,6 +110,7 @@ impl TargetFormat { TargetFormat::PowerWorld => "powerworld", TargetFormat::PandapowerJson => "pandapower-json", TargetFormat::Matpower => "matpower", + TargetFormat::PowerioJson => "powerio-json", } } } @@ -125,7 +132,9 @@ impl FromStr for TargetFormat { /// Map a format name (with the common aliases) to a [`TargetFormat`], or `None` /// if unrecognized. Accepts `matpower`/`m`, `powermodels-json`/`powermodels`/`pm`, /// `egret-json`/`egret`, `pandapower-json`/`pandapower`/`pp`, `psse`/`raw`, -/// `powerworld`/`aux`. Case-insensitive. The one place the bindings (Python, C +/// `powerworld`/`aux`, `powerio-json`/`powerio`/`json` (the canonical snapshot; +/// plain `json` means this one, the foreign JSON dialects are namespaced). +/// Case-insensitive. The one place the bindings (Python, C /// ABI) share, so a new text format means one new arm here, not three. PyPSA /// CSV folders are directory inputs with no text target; their aliases are /// matched by the private `is_pypsa_csv_name` next to this. @@ -145,6 +154,7 @@ pub fn target_format_from_name(name: &str) -> Option { "psse" | "raw" => TargetFormat::Psse, "powerworld" | "aux" => TargetFormat::PowerWorld, "pandapower-json" | "pandapower" | "pandapowerjson" | "pp" => TargetFormat::PandapowerJson, + "powerio-json" | "powerio" | "poweriojson" | "json" => TargetFormat::PowerioJson, _ => return None, }) } @@ -167,8 +177,9 @@ fn is_pypsa_csv_name(name: &str) -> bool { /// when its name maps to no extension, the I/O error otherwise), and a /// file maps by extension (`m`/`json`/`raw`/`aux`/`pwb`), case-insensitively /// (issue #97: `.RAW` is as common as `.raw` in the wild). A `.json` file is -/// sniffed three ways: pandapower (`"_class": "pandapowerNet"`), egret (top -/// level `elements` and `system`), else PowerModels. Pass `from` to force one. +/// sniffed four ways: pandapower (`"_class": "pandapowerNet"`), egret (top +/// level `elements` and `system`), the powerio-json snapshot (top level +/// `buses`), else PowerModels. Pass `from` to force one. /// `.pwb` binaries are read only and carry no retained source. Returns /// [`Parsed`]: the network plus the reader's fidelity warnings. /// @@ -271,6 +282,9 @@ fn read_source(source: Arc, fmt: TargetFormat, name_hint: Option<&str>) TargetFormat::PandapowerJson => { pandapower::parse_pandapower_source(source, name_hint, &mut warnings) } + // The canonical snapshot: validated deserialization of the model itself. + // It carries its own name and source_format, so the hint doesn't apply. + TargetFormat::PowerioJson => Network::from_json(&source), }?; reject_empty_case(&net, fmt.label())?; Ok(Parsed { @@ -294,11 +308,12 @@ pub(crate) fn reject_empty_case(net: &Network, format: &'static str) -> Result<( Ok(()) } -/// The interchange JSON formats share the `.json` extension, so an explicit -/// source format isn't always given. Sniff three ways: pandapower declares -/// itself (`"_class": "pandapowerNet"`); egret `ModelData` has top level -/// `elements` and `system`; else fall back to PowerModels (the more common -/// input). +/// The JSON formats share the `.json` extension, so an explicit source format +/// isn't always given. Sniff four ways: pandapower declares itself +/// (`"_class": "pandapowerNet"`); egret `ModelData` has top level `elements` +/// and `system`; the powerio-json snapshot carries a top level `buses` array +/// (the other dialects key their bus tables differently: PowerModels `bus`, +/// egret `elements`); else fall back to PowerModels (the more common input). /// /// Deserializing into [`IgnoredAny`] fields scans the JSON to find the /// top level keys without building the whole `Value` tree, so a large @@ -312,6 +327,7 @@ fn sniff_json(text: &str) -> TargetFormat { class: Option, elements: Option, system: Option, + buses: Option, } match serde_json::from_str::(text) { Ok(Shape { @@ -322,6 +338,7 @@ fn sniff_json(text: &str) -> TargetFormat { system: Some(_), .. }) => TargetFormat::EgretJson, + Ok(Shape { buses: Some(_), .. }) => TargetFormat::PowerioJson, _ => TargetFormat::PowerModelsJson, } } @@ -374,14 +391,21 @@ pub struct Conversion { /// Convert a [`Network`] to `format`. Writing back to the source format returns /// the retained source text; otherwise the network is serialized into the target. -#[must_use] -pub fn write_as(net: &Network, format: TargetFormat) -> Conversion { +/// +/// # Errors +/// Only a `PowerioJson` serialization failure (none arise from this model +/// today). A non-finite value is not an error: readers legitimately produce +/// `Inf` limits and the bindings materialize every network through the +/// snapshot, so it is written as `null` with a fidelity warning naming the +/// field — that output serves the one-way transports but does not read back +/// (the validating reader rejects the `null`). +pub fn write_as(net: &Network, format: TargetFormat) -> Result { if is_echo(net, format) { if let Some(src) = &net.source { - return Conversion { + return Ok(Conversion { text: src.to_string(), warnings: Vec::new(), - }; + }); } } let mut conv = match format { @@ -394,10 +418,27 @@ pub fn write_as(net: &Network, format: TargetFormat) -> Conversion { // the folded model, which itemizes what it can't carry (HVDC, gen caps, // extras, a partial-cost case). TargetFormat::Matpower => matpower::write_matpower_conversion(net), + // The snapshot serializes the model itself, so the usual target + // passes don't apply (warn_normalized_tap would even be FALSE here: + // the snapshot preserves the line/transformer labels it warns about); + // return before them. The one fidelity loss the snapshot can suffer + // is JSON's missing Inf/NaN — serde writes them as `null`, which + // `from_json` rejects on the way back — so warn, naming the field. + TargetFormat::PowerioJson => { + return net.to_json().map(|text| Conversion { + text, + warnings: net.first_non_finite().map_or_else(Vec::new, |path| { + vec![format!( + "{path} is not finite; JSON has no Inf/NaN, so it is written as \ + null and this snapshot will not read back as powerio-json" + )] + }), + }); + } }; warn_normalized_tap(net, format, &mut conv); warn_missing_reference(net, format, &mut conv); - conv + Ok(conv) } /// Convert a case file to `to`, optionally forcing the source format with @@ -416,7 +457,7 @@ pub fn convert_file( from: Option<&str>, ) -> Result { let parsed = parse_file(path, from)?; - let mut conv = write_as(&parsed.network, to); + let mut conv = write_as(&parsed.network, to)?; if !is_echo(&parsed.network, to) { conv.warnings.splice(0..0, parsed.warnings); } @@ -434,13 +475,36 @@ pub fn convert_file( /// As [`parse_str`]. pub fn convert_str(text: &str, to: TargetFormat, format: &str) -> Result { let parsed = parse_str(text, format)?; - let mut conv = write_as(&parsed.network, to); + let mut conv = write_as(&parsed.network, to)?; if !is_echo(&parsed.network, to) { conv.warnings.splice(0..0, parsed.warnings); } Ok(conv) } +/// Write `net` into the directory `out_dir` as the named directory-shaped +/// format — the directory sibling of [`write_as`], sharing its name-dispatch +/// role for the bindings. PyPSA CSV (`pypsa-csv`/`pypsa`) is the one such +/// format today; a text format name is rejected by name, pointing at +/// [`write_as`]. Returns the write's fidelity warnings. +/// +/// # Errors +/// [`Error::UnknownFormat`] for a non-directory format name; the writer's own +/// [`Error`] otherwise. +pub fn write_dir( + net: &Network, + to: &str, + out_dir: impl AsRef, +) -> Result> { + if is_pypsa_csv_name(to) { + return write_pypsa_csv_folder(net, out_dir.as_ref()).map(|o| o.warnings); + } + Err(Error::UnknownFormat(format!( + "{to} is not a directory format (directory targets: pypsa-csv); \ + text formats serialize through write_as / to_format" + ))) +} + /// Warn when a network with no reference (slack) bus converts to a format /// whose solvers require one. PowerWorld `.pwb` is the one source that /// systematically lacks the designation (the binary does not store it), so diff --git a/powerio/src/format/powerworld/pwd.rs b/powerio/src/format/powerworld/pwd.rs index a2f38dd..bffba44 100644 --- a/powerio/src/format/powerworld/pwd.rs +++ b/powerio/src/format/powerworld/pwd.rs @@ -68,21 +68,17 @@ pub fn parse_pwd(bytes: &[u8]) -> Result> { format: FMT, message, }; - if bytes.len() < 0x40 || u32_at(bytes, 0) != 50 { + if bytes.len() < 0x40 || u32_at(bytes, 0) != Some(50) { return Err(err(format!( "not a recognized PowerWorld display file (header word {}; the probed saves all \ carry 50)", - if bytes.len() >= 4 { - u32_at(bytes, 0) - } else { - 0 - }, + u32_at(bytes, 0).unwrap_or(0), ))); } - if u16_at(bytes, 4) == 0 || u16_at(bytes, 6) == 0 { + if u16_at(bytes, 4) == Some(0) || u16_at(bytes, 6) == Some(0) { return Err(err("display header canvas dimensions are zero".into())); } - let stamp = u32_at(bytes, 22); + let stamp = u32_at(bytes, 22).unwrap_or(0); if stamp == 0 { return Err(err( "display header stamp is zero; every validated save carries a nonzero stamp the \ @@ -96,13 +92,14 @@ pub fn parse_pwd(bytes: &[u8]) -> Result> { // Every drawing object record repeats the header stamp at +18 and dual // encodes its position (f64 at +22/+30, f32 echo at +2/+6); the scan // collects every offset with that shape and groups by the u16 type tag. - let mut groups: Vec<(u16, Vec)> = Vec::new(); + let mut groups: Vec<(u16, Vec)> = Vec::new(); for i in 0..bytes.len().saturating_sub(38) { - if u32_at(bytes, i + 18) != stamp { + if u32_at(bytes, i + 18) != Some(stamp) { continue; } - let x = f64_at(bytes, i + 22); - let y = f64_at(bytes, i + 30); + let (Some(x), Some(y)) = (f64_at(bytes, i + 22), f64_at(bytes, i + 30)) else { + continue; + }; if !x.is_finite() || !y.is_finite() { continue; } @@ -110,8 +107,8 @@ pub fn parse_pwd(bytes: &[u8]) -> Result> { let (rx, ry) = (x as f32, y as f32); // Bit equality: the magnitude gate below excludes zero, so the only // value the echo can hold is the rounded f64 itself. - if f32_at(bytes, i + 2).to_bits() != rx.to_bits() - || f32_at(bytes, i + 6).to_bits() != ry.to_bits() + if f32_at(bytes, i + 2).map(f32::to_bits) != Some(rx.to_bits()) + || f32_at(bytes, i + 6).map(f32::to_bits) != Some(ry.to_bits()) { continue; } @@ -119,10 +116,13 @@ pub fn parse_pwd(bytes: &[u8]) -> Result> { if !(1.0..1.0e7).contains(&magnitude) { continue; } - let tag = u16_at(bytes, i); + let Some(tag) = u16_at(bytes, i) else { + continue; + }; + let rec = DrawRecord { at: i, x, y }; match groups.iter_mut().find(|(t, _)| *t == tag) { - Some((_, v)) => v.push(i), - None => groups.push((tag, vec![i])), + Some((_, v)) => v.push(rec), + None => groups.push((tag, vec![rec])), } } @@ -131,17 +131,17 @@ pub fn parse_pwd(bytes: &[u8]) -> Result> { // era) followed by the row's u32 number, somewhere in the style tail. // Field label decoys carry other markers (0x05 observed) or another // order and fail; ambiguity is a loud error, never a pick. - let matches: Vec<&(u16, Vec)> = groups + let matches: Vec<&(u16, Vec)> = groups .iter() - .filter(|(_, offsets)| { - offsets.len() == identity.len() - && offsets + .filter(|(_, records)| { + records.len() == identity.len() + && records .iter() .zip(&identity) - .all(|(&i, (number, _))| links_number(bytes, i, *number)) + .all(|(rec, (number, _))| links_number(bytes, rec.at, *number)) }) .collect(); - let (_, offsets) = match matches.as_slice() { + let (_, records) = match matches.as_slice() { [one] => *one, [] => { return Err(err(format!( @@ -159,18 +159,27 @@ pub fn parse_pwd(bytes: &[u8]) -> Result> { } }; - Ok(offsets + Ok(records .iter() .zip(identity) - .map(|(&i, (number, name))| PwdSubstation { + .map(|(rec, (number, name))| PwdSubstation { number, name, - x: f64_at(bytes, i + 22), - y: f64_at(bytes, i + 30), + x: rec.x, + y: rec.y, }) .collect()) } +/// A drawing record that passed the shape gate: its stream offset (for the +/// identity link check) and the decoded coordinates, kept so the final mapping +/// never re-reads the bytes. +struct DrawRecord { + at: usize, + x: f64, + y: f64, +} + /// The substation identity table: exactly one valid walk behind a /// `ff ff ff ff 3d 0f` anchor. Zero (bus only diagrams, pre 2016 shapes) /// and several are loud errors. @@ -207,22 +216,19 @@ fn identity_walk(b: &[u8], mut at: usize) -> Option> { let mut rows = Vec::new(); let mut seen = HashSet::new(); loop { - if at + 4 <= b.len() && b[at..at + 4] == [0xff; 4] { + if b.get(at..at + 4) == Some([0xff; 4].as_slice()) { return (!rows.is_empty()).then_some(rows); } - if at + 13 > b.len() { + let number = u32_at(b, at)?; + if number == 0 || number > 99_999_999 || u32_at(b, at + 4) != Some(number) { return None; } - let number = u32_at(b, at); - if number == 0 || number > 99_999_999 || u32_at(b, at + 4) != number { + let len = u32_at(b, at + 8)? as usize; + if len == 0 || len >= 64 { return None; } - let len = u32_at(b, at + 8) as usize; - if len == 0 || len >= 64 || at + 12 + len + 1 > b.len() { - return None; - } - let name = &b[at + 12..at + 12 + len]; - if !name.iter().all(|&c| (0x20..0x7f).contains(&c)) || b[at + 12 + len] != 0x02 { + let name = b.get(at + 12..at + 12 + len)?; + if !name.iter().all(|&c| (0x20..0x7f).contains(&c)) || b.get(at + 12 + len) != Some(&0x02) { return None; } if !seen.insert(number) { @@ -239,11 +245,8 @@ fn identity_walk(b: &[u8], mut at: usize) -> Option> { /// variable because a digit string of 1 to 4 characters precedes the link /// in some saves. fn links_number(b: &[u8], i: usize, number: u32) -> bool { - (40..140).any(|d| { - i + d + 5 <= b.len() - && (b[i + d] == 0x03 || b[i + d] == 0x07) - && u32_at(b, i + d + 1) == number - }) + (40..140) + .any(|d| matches!(b.get(i + d), Some(0x03 | 0x07)) && u32_at(b, i + d + 1) == Some(number)) } /// Every start of `needle` in `haystack`. @@ -254,18 +257,22 @@ fn memmem<'a>(haystack: &'a [u8], needle: &'a [u8]) -> impl Iterator u16 { - u16::from_le_bytes(b[i..i + 2].try_into().unwrap()) +// Total little endian reads: `None` past the end of the buffer, no index +// arithmetic that can panic or wrap. Every offset in this reader derives +// from untrusted file bytes, so the accessors carry the bounds check. + +fn u16_at(b: &[u8], i: usize) -> Option { + Some(u16::from_le_bytes(*b.get(i..)?.first_chunk()?)) } -fn u32_at(b: &[u8], i: usize) -> u32 { - u32::from_le_bytes(b[i..i + 4].try_into().unwrap()) +fn u32_at(b: &[u8], i: usize) -> Option { + Some(u32::from_le_bytes(*b.get(i..)?.first_chunk()?)) } -fn f32_at(b: &[u8], i: usize) -> f32 { - f32::from_le_bytes(b[i..i + 4].try_into().unwrap()) +fn f32_at(b: &[u8], i: usize) -> Option { + Some(f32::from_le_bytes(*b.get(i..)?.first_chunk()?)) } -fn f64_at(b: &[u8], i: usize) -> f64 { - f64::from_le_bytes(b[i..i + 8].try_into().unwrap()) +fn f64_at(b: &[u8], i: usize) -> Option { + Some(f64::from_le_bytes(*b.get(i..)?.first_chunk()?)) } diff --git a/powerio/src/lib.rs b/powerio/src/lib.rs index 5a305a3..8305c5b 100644 --- a/powerio/src/lib.rs +++ b/powerio/src/lib.rs @@ -30,7 +30,7 @@ //! "; //! let net = parse_str(src, "matpower")?.network; //! assert_eq!(net.buses.len(), 2); -//! assert_eq!(net.to_format(TargetFormat::Matpower).text, src); +//! assert_eq!(net.to_format(TargetFormat::Matpower)?.text, src); //! # Ok::<(), powerio::Error>(()) //! ``` @@ -45,8 +45,8 @@ pub use format::{ Conversion, Parsed, PypsaCsvOutputs, TargetFormat, convert_file, convert_str, parse_egret_json, parse_file, parse_matpower, parse_matpower_file, parse_pandapower_json, parse_powermodels_json, parse_powerworld, parse_psse, parse_str, read_pypsa_csv_folder, target_format_from_name, - write_as, write_egret_json, write_matpower, write_pandapower_json, write_powermodels_json, - write_powerworld, write_psse, write_pypsa_csv_folder, + write_as, write_dir, write_egret_json, write_matpower, write_pandapower_json, + write_powermodels_json, write_powerworld, write_psse, write_pypsa_csv_folder, }; pub use indexed::{ConnectivityReport, IndexCore, IndexedNetwork}; pub use network::{ diff --git a/powerio/src/network.rs b/powerio/src/network.rs index 8920d55..fdafd54 100644 --- a/powerio/src/network.rs +++ b/powerio/src/network.rs @@ -394,10 +394,21 @@ impl Network { } /// Serialize the structured tables to JSON — the transport the C ABI - /// (`pio_to_json`) and the Julia bridge consume. The retained `source` text + /// (the `powerio-json` format) and the Julia bridge consume. The retained `source` text /// is excluded (see the field's `#[serde(skip)]`), so the byte-exact echo /// stays on the same-format write path; a [`from_json`](Network::from_json) /// round-trip reproduces every field except `source`, which returns `None`. + /// + /// JSON has no `Inf`/`NaN`: `serde_json` writes a non-finite field as + /// `null`, which [`from_json`](Network::from_json) rejects on the way back + /// (`null` is not an `f64`). The write stays total — the bindings + /// materialize every parsed network through this transport, and readers + /// legitimately produce `Inf` limits — but such a snapshot does not round + /// trip; [`write_as`](crate::write_as) reports the degradation as a + /// fidelity warning naming the field. + /// + /// # Errors + /// A `serde_json` serialization failure (none arise from this model today). pub fn to_json(&self) -> crate::Result { serde_json::to_string(self).map_err(|e| Error::FormatRead { format: "JSON", @@ -405,10 +416,174 @@ impl Network { }) } + /// The path of the first non-finite numeric field, or `None` when every + /// value is finite — drives the snapshot writer's degradation warning + /// (see [`to_json`](Network::to_json)). + /// `extras` maps hold `serde_json::Value`, which cannot carry a non-finite + /// number, so only the typed `f64` fields need scanning. Every struct is + /// destructured exhaustively: adding an `f64` field without classifying it + /// here is a compile error, not a silently unguarded value. + // The length IS the exhaustive field walk; splitting it would only scatter + // the per-struct lists the compile-time check exists to keep in one place. + #[allow(clippy::too_many_lines)] + pub(crate) fn first_non_finite(&self) -> Option { + fn bad<'a>(fields: impl IntoIterator) -> Option<&'a str> { + fields + .into_iter() + .find_map(|(name, v)| (!v.is_finite()).then_some(name)) + } + if !self.base_mva.is_finite() { + return Some("base_mva".into()); + } + for (i, b) in self.buses.iter().enumerate() { + #[rustfmt::skip] + let Bus { id: _, kind: _, vm, va, base_kv, vmax, vmin, area: _, zone: _, name: _, extras: _ } = b; + let fields = [ + ("vm", *vm), + ("va", *va), + ("base_kv", *base_kv), + ("vmax", *vmax), + ("vmin", *vmin), + ]; + if let Some(f) = bad(fields) { + return Some(format!("buses[{i}].{f}")); + } + } + for (i, l) in self.loads.iter().enumerate() { + let Load { + bus: _, + p, + q, + in_service: _, + extras: _, + } = l; + if let Some(f) = bad([("p", *p), ("q", *q)]) { + return Some(format!("loads[{i}].{f}")); + } + } + for (i, s) in self.shunts.iter().enumerate() { + let Shunt { + bus: _, + g, + b, + in_service: _, + extras: _, + } = s; + if let Some(f) = bad([("g", *g), ("b", *b)]) { + return Some(format!("shunts[{i}].{f}")); + } + } + for (i, br) in self.branches.iter().enumerate() { + #[rustfmt::skip] + let Branch { from: _, to: _, r, x, b, rate_a, rate_b, rate_c, tap, shift, in_service: _, angmin, angmax, extras: _ } = br; + let fields = [ + ("r", *r), + ("x", *x), + ("b", *b), + ("rate_a", *rate_a), + ("rate_b", *rate_b), + ("rate_c", *rate_c), + ("tap", *tap), + ("shift", *shift), + ("angmin", *angmin), + ("angmax", *angmax), + ]; + if let Some(f) = bad(fields) { + return Some(format!("branches[{i}].{f}")); + } + } + for (i, g) in self.generators.iter().enumerate() { + #[rustfmt::skip] + let Generator { bus: _, pg, qg, pmax, pmin, qmax, qmin, vg, mbase, in_service: _, cost, caps } = g; + let fields = [ + ("pg", *pg), + ("qg", *qg), + ("pmax", *pmax), + ("pmin", *pmin), + ("qmax", *qmax), + ("qmin", *qmin), + ("vg", *vg), + ("mbase", *mbase), + ]; + if let Some(f) = bad(fields) { + return Some(format!("generators[{i}].{f}")); + } + if let Some(GenCost { + model: _, + startup, + shutdown, + ncost: _, + coeffs, + }) = cost + { + if let Some(f) = bad([("startup", *startup), ("shutdown", *shutdown)]) { + return Some(format!("generators[{i}].cost.{f}")); + } + if coeffs.iter().any(|c| !c.is_finite()) { + return Some(format!("generators[{i}].cost.coeffs")); + } + } + if caps.iter().flatten().any(|c| !c.is_finite()) { + return Some(format!("generators[{i}].caps")); + } + } + for (i, s) in self.storage.iter().enumerate() { + #[rustfmt::skip] + let Storage { bus: _, ps, qs, energy, energy_rating, charge_rating, discharge_rating, charge_efficiency, discharge_efficiency, thermal_rating, qmin, qmax, r, x, p_loss, q_loss, in_service: _, extras: _ } = s; + let fields = [ + ("ps", *ps), + ("qs", *qs), + ("energy", *energy), + ("energy_rating", *energy_rating), + ("charge_rating", *charge_rating), + ("discharge_rating", *discharge_rating), + ("charge_efficiency", *charge_efficiency), + ("discharge_efficiency", *discharge_efficiency), + ("thermal_rating", *thermal_rating), + ("qmin", *qmin), + ("qmax", *qmax), + ("r", *r), + ("x", *x), + ("p_loss", *p_loss), + ("q_loss", *q_loss), + ]; + if let Some(f) = bad(fields) { + return Some(format!("storage[{i}].{f}")); + } + } + for (i, h) in self.hvdc.iter().enumerate() { + #[rustfmt::skip] + let Hvdc { from: _, to: _, in_service: _, pf, pt, qf, qt, vf, vt, pmin, pmax, qminf, qmaxf, qmint, qmaxt, loss0, loss1, extras: _ } = h; + let fields = [ + ("pf", *pf), + ("pt", *pt), + ("qf", *qf), + ("qt", *qt), + ("vf", *vf), + ("vt", *vt), + ("pmin", *pmin), + ("pmax", *pmax), + ("qminf", *qminf), + ("qmaxf", *qmaxf), + ("qmint", *qmint), + ("qmaxt", *qmaxt), + ("loss0", *loss0), + ("loss1", *loss1), + ]; + if let Some(f) = bad(fields) { + return Some(format!("hvdc[{i}].{f}")); + } + } + None + } + /// Serialize this network to `format`, preserving the retained source text /// on same-format writes and reporting any target-format fidelity warnings. - #[must_use] - pub fn to_format(&self, format: crate::TargetFormat) -> crate::Conversion { + /// + /// # Errors + /// As [`write_as`](crate::write_as): only a `PowerioJson` serialization + /// failure. + pub fn to_format(&self, format: crate::TargetFormat) -> crate::Result { crate::write_as(self, format) } diff --git a/powerio/tests/convert.rs b/powerio/tests/convert.rs index 2862039..7384fba 100644 --- a/powerio/tests/convert.rs +++ b/powerio/tests/convert.rs @@ -35,7 +35,7 @@ fn canonical_api_names_parse_and_convert() { assert_eq!(TargetFormat::Psse.to_string(), "psse"); assert_eq!(net.to_matpower(), src); - let pm = net.to_format(TargetFormat::PowerModelsJson); + let pm = net.to_format(TargetFormat::PowerModelsJson).unwrap(); assert_eq!( serde_json::from_str::(&pm.text).unwrap()["name"], "case14" @@ -87,7 +87,7 @@ fn core(net: &Network) -> Core { #[test] fn pandapower_json_round_trips_core_and_echoes_source() { let net = parse_matpower_file(data("case9.m")).unwrap(); - let conv = write_as(&net, TargetFormat::PandapowerJson); + let conv = write_as(&net, TargetFormat::PandapowerJson).unwrap(); assert!( !conv.warnings.iter().any(|w| w.contains("dcline")), "case9 has no dclines, got warnings: {:?}", @@ -99,7 +99,7 @@ fn pandapower_json_round_trips_core_and_echoes_source() { assert_eq!(back.source_format, SourceFormat::PandapowerJson); assert_eq!(core(&back), core(&net)); assert_eq!( - write_as(&back, TargetFormat::PandapowerJson).text, + write_as(&back, TargetFormat::PandapowerJson).unwrap().text, conv.text ); @@ -292,7 +292,7 @@ fn pandapower_writer_keeps_zero_rating_zero() { let mut net = parse_matpower_file(data("case9.m")).unwrap(); net.branches[0].rate_a = 0.0; net.source = None; // force the canonical (non-echo) writer - let conv = write_as(&net, TargetFormat::PandapowerJson); + let conv = write_as(&net, TargetFormat::PandapowerJson).unwrap(); let back = powerio::parse_str(&conv.text, "pandapower-json") .unwrap() .network; @@ -480,7 +480,10 @@ fn powermodels_json_same_format_is_byte_exact_echo() { let net = parse_matpower_file(data("case30.m")).unwrap(); let json = write_powermodels_json(&net).text; let net2 = parse_powermodels_json(&json).unwrap(); - assert_eq!(write_as(&net2, TargetFormat::PowerModelsJson).text, json); + assert_eq!( + write_as(&net2, TargetFormat::PowerModelsJson).unwrap().text, + json + ); } #[test] @@ -495,7 +498,7 @@ fn powermodels_json_to_matpower_two_way() { let net = parse_powermodels_json(&json).unwrap(); assert_eq!(net.source_format, powerio::SourceFormat::PowerModelsJson); - let reparsed = parse_matpower(&write_as(&net, TargetFormat::Matpower).text).unwrap(); + let reparsed = parse_matpower(&write_as(&net, TargetFormat::Matpower).unwrap().text).unwrap(); assert_eq!(reparsed.buses.len(), orig.buses.len()); assert_eq!(reparsed.branches.len(), orig.branches.len()); assert_eq!(reparsed.generators.len(), orig.generators.len()); @@ -556,7 +559,7 @@ fn hvdc_converts_and_round_trips() { // write_as would echo its source; convert through PowerModels first to reach // the canonical MATPOWER path with HVDC still present. assert_eq!(back.source_format, SourceFormat::PowerModelsJson); - let to_mp = write_as(&back, TargetFormat::Matpower); + let to_mp = write_as(&back, TargetFormat::Matpower).unwrap(); assert!( to_mp.warnings.iter().any(|w| w.contains("dcline")), "cross-format → MATPOWER should warn on dropped dclines, got {:?}", @@ -618,7 +621,7 @@ fn readers_reject_malformed_input() { #[test] fn matpower_target_round_trips() { let net = parse_matpower_file(data("case14.m")).unwrap(); - let conv = write_as(&net, TargetFormat::Matpower); + let conv = write_as(&net, TargetFormat::Matpower).unwrap(); assert!(conv.warnings.is_empty()); // Matpower target is the lossless echo: byte-identical to the source. let src = std::fs::read_to_string(data("case14.m")).unwrap(); @@ -1100,7 +1103,7 @@ fn pandapower_json_round_trips_transformers_shunts_and_oos() { if case == "case14.m" { knock_out_case14(&mut net); } - let conv = write_as(&net, TargetFormat::PandapowerJson); + let conv = write_as(&net, TargetFormat::PandapowerJson).unwrap(); let back = powerio::parse_str(&conv.text, "pandapower-json") .unwrap() .network; @@ -1180,7 +1183,7 @@ fn gen_costs_round_trip_through_pandapower_json() { // case9 costs are quadratic [c2, c1, c0] = [0.11, 5.0, 150.0]; poly_cost // must carry all three back without reordering (a cp0/cp2 swap fails here). let net = parse_matpower_file(data("case9.m")).unwrap(); - let conv = write_as(&net, TargetFormat::PandapowerJson); + let conv = write_as(&net, TargetFormat::PandapowerJson).unwrap(); let back = powerio::parse_str(&conv.text, "pandapower-json") .unwrap() .network; @@ -1369,7 +1372,7 @@ fn slackless_network_conversion_warns_for_power_flow_targets() { TargetFormat::Psse, TargetFormat::PowerModelsJson, ] { - let conv = write_as(&net, fmt); + let conv = write_as(&net, fmt).unwrap(); assert!( conv.warnings .iter() @@ -1387,8 +1390,78 @@ fn slackless_network_conversion_warns_for_power_flow_targets() { ); assert!( !write_as(&with_ref, TargetFormat::Matpower) + .unwrap() .warnings .iter() .any(|w| w.contains("reference (slack) bus")) ); } + +#[test] +fn snapshot_warns_on_non_finite_and_does_not_read_back() { + // JSON has no Inf/NaN: serde writes them as `null`, which the validating + // reader rejects. Readers legitimately produce Inf limits and the bindings + // materialize every network through the snapshot, so the write stays total + // — but it must SAY what degraded (naming the field), and the no-read-back + // consequence is pinned here so a change to either side surfaces. + let mut net = parse_matpower_file(data("case9.m")).unwrap(); + net.branches[2].angmax = f64::INFINITY; + let conv = write_as(&net, TargetFormat::PowerioJson).unwrap(); + assert!( + conv.warnings + .iter() + .any(|w| w.contains("branches[2].angmax")), + "the degradation warning should name the field: {:?}", + conv.warnings + ); + let err = powerio::parse_str(&conv.text, "powerio-json") + .expect_err("a null-degraded snapshot must not validate"); + assert!(err.to_string().contains("null"), "got: {err}"); + + // A NaN bus voltage warns the same way. + let mut net = parse_matpower_file(data("case9.m")).unwrap(); + net.buses[0].vm = f64::NAN; + let conv = write_as(&net, TargetFormat::PowerioJson).unwrap(); + assert!( + conv.warnings.iter().any(|w| w.contains("buses[0].vm")), + "got: {:?}", + conv.warnings + ); +} + +#[test] +fn snapshot_round_trips_through_core_api() { + // write_as -> parse_str at the core level (the C ABI test covers the same + // path over FFI). case30 carries loads, shunts, and gen costs. + let net = parse_matpower_file(data("case30.m")).unwrap(); + let conv = write_as(&net, TargetFormat::PowerioJson).unwrap(); + assert!(conv.warnings.is_empty(), "the snapshot writes no warnings"); + let parsed = powerio::parse_str(&conv.text, "powerio-json").unwrap(); + assert!(parsed.warnings.is_empty(), "the snapshot reads back total"); + let back = parsed.network; + assert_eq!(back.buses.len(), net.buses.len()); + assert_eq!(back.branches.len(), net.branches.len()); + assert_eq!(back.generators.len(), net.generators.len()); + // Bit-exact: the snapshot is lossless, so even the sign of a zero survives. + assert_eq!(back.base_mva.to_bits(), net.base_mva.to_bits()); + assert_eq!(back.source_format, net.source_format); +} + +#[test] +fn snapshot_json_file_is_sniffed_without_a_format_hint() { + // A snapshot written to disk carries the generic .json extension; the + // sniffer must route it to the powerio-json reader (top level `buses`), + // not the PowerModels fallback, so parse_file works with from=None. + let net = parse_matpower_file(data("case14.m")).unwrap(); + let text = write_as(&net, TargetFormat::PowerioJson).unwrap().text; + let path = std::env::temp_dir().join(format!( + "powerio_snapshot_sniff_{}.json", + std::process::id() + )); + std::fs::write(&path, &text).unwrap(); + let parsed = parse_file(&path, None); + std::fs::remove_file(&path).ok(); + let back = parsed.unwrap().network; + assert_eq!(back.buses.len(), 14); + assert_eq!(back.source_format, SourceFormat::Matpower); +} diff --git a/powerio/tests/normalize.rs b/powerio/tests/normalize.rs index 4c7b6fb..6367956 100644 --- a/powerio/tests/normalize.rs +++ b/powerio/tests/normalize.rs @@ -113,7 +113,7 @@ fn no_false_write_back() { // Writing it serializes the per-unit/radian model, so it must NOT echo the // raw MATPOWER bytes. - let out = write_as(&n, TargetFormat::Matpower); + let out = write_as(&n, TargetFormat::Matpower).unwrap(); assert_ne!( out.text.trim_end(), src.replace("\r\n", "\n").trim_end(), @@ -135,7 +135,7 @@ fn warns_when_writing_normalized_lines_as_transformers() { .all(|b| approx(b.tap, 1.0) && approx(b.shift, 0.0)) ); - let out = write_as(&n, TargetFormat::Psse); + let out = write_as(&n, TargetFormat::Psse).unwrap(); assert!( out.warnings .iter() @@ -145,7 +145,7 @@ fn warns_when_writing_normalized_lines_as_transformers() { ); // A raw network keeps lines at tap 0, so the warning must not fire for it. - let raw_out = write_as(&raw, TargetFormat::Psse); + let raw_out = write_as(&raw, TargetFormat::Psse).unwrap(); assert!( !raw_out .warnings diff --git a/powerio/tests/powerworld_aux.rs b/powerio/tests/powerworld_aux.rs index 1704756..71586d0 100644 --- a/powerio/tests/powerworld_aux.rs +++ b/powerio/tests/powerworld_aux.rs @@ -94,7 +94,7 @@ fn activsg200_values_survive_tokenizing() { #[test] fn activsg200_echo_is_byte_exact() { let net = parse_file(fixture("ACTIVSg200.aux"), None).unwrap().network; - let echo = net.to_format(TargetFormat::PowerWorld); + let echo = net.to_format(TargetFormat::PowerWorld).unwrap(); assert!(echo.warnings.is_empty()); assert_eq!(echo.text, activsg200()); } diff --git a/powerio/tests/powerworld_pwb.rs b/powerio/tests/powerworld_pwb.rs index d665af3..fb6ed16 100644 --- a/powerio/tests/powerworld_pwb.rs +++ b/powerio/tests/powerworld_pwb.rs @@ -730,7 +730,7 @@ fn parse_file_dispatches_pwb_and_converts() { .network; assert_eq!(by_name.buses.len(), 200); - let conv = powerio::write_as(&net, powerio::TargetFormat::Matpower); + let conv = powerio::write_as(&net, powerio::TargetFormat::Matpower).unwrap(); let back = powerio::parse_str(&conv.text, "matpower").unwrap().network; assert_eq!(back.buses.len(), 200); assert_eq!(back.branches.len(), 246); diff --git a/powerio/tests/roundtrip_formats.rs b/powerio/tests/roundtrip_formats.rs index ca44507..af7636d 100644 --- a/powerio/tests/roundtrip_formats.rs +++ b/powerio/tests/roundtrip_formats.rs @@ -153,7 +153,7 @@ fn same_format_round_trip_is_byte_exact() { let text = (fmt.write)(&net0); let net_from_text = (fmt.read)(&text); // carries source = text, format = fmt assert_eq!( - write_as(&net_from_text, fmt.format).text, + write_as(&net_from_text, fmt.format).unwrap().text, text, "{case} {}: same-format write is not a byte-exact echo", fmt.name @@ -193,7 +193,7 @@ fn egret_fixtures_round_trip_byte_exact() { let text = std::fs::read_to_string(data(f)).unwrap(); let net = parse_egret_json(&text).unwrap(); assert_eq!( - write_as(&net, TargetFormat::EgretJson).text, + write_as(&net, TargetFormat::EgretJson).unwrap().text, text, "{f}: egret same-format write is not a byte-exact echo" );