Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -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)`.


Expand All @@ -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
Expand Down
34 changes: 19 additions & 15 deletions benchmarks/powerio_ffi.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -42,34 +46,34 @@ 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

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

Expand All @@ -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)
Expand Down
24 changes: 13 additions & 11 deletions docs/languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target/
corpus/
artifacts/
coverage/
Cargo.lock
61 changes: 61 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -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/<target>/` — turn it into a
regression test next to the reader before fixing.
12 changes: 12 additions & 0 deletions fuzz/fuzz_targets/matpower.rs
Original file line number Diff line number Diff line change
@@ -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");
}
});
11 changes: 11 additions & 0 deletions fuzz/fuzz_targets/powerio_json.rs
Original file line number Diff line number Diff line change
@@ -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");
}
});
13 changes: 13 additions & 0 deletions fuzz/fuzz_targets/powerworld_aux.rs
Original file line number Diff line number Diff line change
@@ -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");
}
});
10 changes: 10 additions & 0 deletions fuzz/fuzz_targets/psse.rs
Original file line number Diff line number Diff line change
@@ -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");
}
});
9 changes: 9 additions & 0 deletions fuzz/fuzz_targets/pwb.rs
Original file line number Diff line number Diff line change
@@ -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);
});
9 changes: 9 additions & 0 deletions fuzz/fuzz_targets/pwd.rs
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 2 additions & 2 deletions powerio-capi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading