Skip to content

Migrate to PowerIO 0.9 (powerio C ABI 5) - #76

Open
samtalki wants to merge 3 commits into
mainfrom
claude/powerdiff-powerio-migration-sulux7
Open

Migrate to PowerIO 0.9 (powerio C ABI 5)#76
samtalki wants to merge 3 commits into
mainfrom
claude/powerdiff-powerio-migration-sulux7

Conversation

@samtalki

@samtalki samtalki commented Aug 22, 2026

Copy link
Copy Markdown
Member

Summary

Track PowerIO 0.9. The binding gates its C ABI handshake on equality, so the package and its binaries move together and [compat] PowerIO = "0.9" cannot be relaxed below it.

Three things in 0.9 reach PowerDiff, and each is answered where it belongs.

The format vocabulary is PowerIO's, not a copy of it

parse_file maintained a five-format allowlist and refused everything else, so every reader PowerIO shipped needed a PowerDiff release to become reachable, and the error message named a set that was already wrong. An unrecognized from token now goes to PowerIO, which answers with what the linked library actually reads.

Reachable as a result, with no code here naming them: pandapower, pypsa, pslf/epc, pwb, gridfm, goc3, surge, opfdata, psse34, psse35.

PowerDiff's own short spellings (:m, :raw, :aux, :pm, :powermodels, :egret) still resolve. Two answers stay local because PowerIO cannot give them: a bare json names a container rather than a reader, and a distribution token parses to a MulticonductorNetwork, refused with the lowering route to run first.

One normalize pass, findings as data

0.9 raises the normalize pass's fidelity findings as a @warn burst from inside to_powerdata — once per constructor call, with nothing saying whose they are or which network they describe.

PowerDiff runs that pass itself now and reads to_powerdata off the normalized network, which recognizes an already-normalized input and skips its own. One pass, not two, with the findings returned as values instead of logged.

New exported network_findings(net) returns both channels — reader (what the source format could not represent) and normalize — as CODE: message lines. PowerDiff reports the normalize findings itself, one per distinct code, once per network, under silence().

The ingest is memoized on the parsed network, so DCNetwork(net) and ACNetwork(net) share it: the JSON payload materializes once, each finding is said once, and the two constructors can no longer describe different cases.

Generator reactive limits may be absent

0.9 carries a bound the case does not state as ±Inf rather than refusing the case. That is how MATPOWER, PowerModels, pandapower and PyPSA all spell "no limit", and stock case9241pegase leaves the reactive limits off seven generators.

The KKT layout is fixed, one complementarity row per bound, so the bound can be neither dropped nor carried: ρ · (qg − qmin) with qmin == -Inf is 0 * Inf — a NaN in the residual and an Inf in the Jacobian.

So the bound is left off the solver model, and its row reads ρ = 0: the multiplier of a constraint that is not there, and what a solver reports for a bound it was never given, so the solved duals and the residual agree by construction. Its derivative is constant, so the fixed-regime sensitivity of an absent bound is zero. Sparsity is unchanged — a zero entry is dropped exactly as a non-binding stated bound's already is.

Without this the upgrade would have turned a clean refusal into a silent NaN, on stock pglib cases.

A non-finite value anywhere else is a modeling error, now named with its element and field instead of reaching a factorization; ACNetwork applies the same rule to a caller-built table. A branch whose rating is non-finite is unrated and takes the same synthesized thermal limit as rate_a == 0.

Also

PowerIO.source_format reports the lowercase token every from argument accepts ("powermodels-json", not "PowerModelsJson") — a powerio 0.9 break. The parser tests assert the new spelling.

__init__ clears the ingest cache: an ingest is only valid for the live Rust handle it was read through, so none may reach a session from a precompiled image.

Verification status

CI is green on e831561test, build (docs, with doctests and cross-reference checks), and benchmark all pass. test-full and test-nightly are skipped on pull_request by design.

This PR was authored in an environment with no Julia toolchain — both julialang-s3.julialang.org and GitHub release assets are refused by its network policy — and CI does not run on branch pushes, so opening the PR triggered the first execution of any of it. Everything below the diff was reviewed by hand first; the run is what confirms it.

The new testsets all executed and passed:

testset assertions
PowerIO format routing 32
PowerIO ingest is one pass per network 8
PowerIO findings travel with the network 7
Absent reactive limits 22
Non-finite values PowerDiff cannot model 6

The diagnostics path is visible in the log doing its job on a real case: the PSS/E round-trip in test_non_matpower_parsers.jl loses generator cost data, and each of the two networks it parses reports PowerIO normalize: CANONICALIZE.NORMALIZE.GEN_COST_ABSENT: the case has 5 in-service generator(s) and no cost data exactly once — one report per network, which is the dedupe contract.

Benchmarks show no regression on PGLib IEEE 300; dc_opf/kkt_jacobian/susceptance is ~1.19× faster and the rest are within noise.

Tests

test/test_powerio_integration.jl covers the seam directly: format routing (aliases, passthrough, the two local refusals), the one-pass memoized ingest, network_findings on both channels, absent reactive limits end to end (solver model, solved duals, KKT residual, Jacobian entries), and the values PowerDiff refuses.

Existing coverage carries the rest: test_parser_parity.jl holds path and IO parsing to identical tables, and test_non_matpower_parsers.jl round-trips MATPOWER through PowerModels JSON, Egret JSON and PSS/E RAW.

Upstream

Four gaps found while doing this are filed rather than worked around here:

with C ABI companions at eigenergy/powerio#399 and eigenergy/powerio#400.

🤖 Generated with Claude Code

https://claude.ai/code/session_018zCNDAiTv7ZjZKbBLFM9EM

claude added 3 commits August 22, 2026 22:56
Track PowerIO 0.9. The binding gates its C ABI handshake on equality, so the
package and its binaries move together and the `[compat]` bound cannot be
relaxed below 0.9.

Three things in 0.9 reach PowerDiff, and each is answered where it belongs.

The format vocabulary is PowerIO's, not a copy of it. `parse_file` maintained a
five-format allowlist that refused everything else, so every reader PowerIO
shipped needed a PowerDiff release to become reachable and the error message
named a set that was already wrong. An unrecognized `from` token is now handed
to PowerIO, which answers with what the linked library actually reads.
PowerDiff's own short spellings still resolve, a bare `json` is still refused as
ambiguous, and a distribution token is refused with the lowering step to run
first.

The normalize pass is run here rather than inside `to_powerdata`, which
recognizes an already-normalized input and skips its own — one pass, not two.
0.9 raises that pass's fidelity findings as a `@warn` burst from inside the
bridge, once per constructor and with nothing saying whose they are; running the
pass here returns them as data instead. New exported `network_findings(net)`
gives both channels, reader and normalize, as `CODE: message` lines. PowerDiff
reports the normalize findings itself, one per distinct code, once per network,
under `silence()`. The ingest is memoized on the parsed network, so `DCNetwork`
and `ACNetwork` built from one network share it and cannot describe different
cases.

Generator reactive limits may now be absent. 0.9 carries a bound the case does
not state as `±Inf` rather than refusing the case, and stock case9241pegase
leaves them off seven generators. The KKT layout is fixed, one complementarity
row per bound, so the bound can be neither dropped nor carried: `ρ · (qg - qmin)`
with `qmin == -Inf` is `0 * Inf`, a NaN in the residual and an Inf in the
Jacobian. The bound is left off the solver model and the row reads `ρ = 0` — the
multiplier of a constraint that is not there, and what a solver reports for a
bound it was never given, so the solved duals and the residual agree. Its
derivative is constant, so the fixed-regime sensitivity is zero. Sparsity is
unchanged. Without this the upgrade would have turned a clean refusal into a
silent NaN.

A non-finite value anywhere else is a modeling error, now named with its element
and field instead of reaching a factorization; `ACNetwork` applies the same rule
to a caller-built table. A branch whose rating is non-finite is unrated and takes
the same synthesized thermal limit as `rate_a == 0`.

`PowerIO.source_format` reports the lowercase token every `from` accepts
(`"powermodels-json"`, not `"PowerModelsJson"`); the parser tests assert it.

test/test_powerio_integration.jl covers the seam: format routing, the one-pass
memoized ingest, findings, absent bounds, and the values PowerDiff refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zCNDAiTv7ZjZKbBLFM9EM
The ingest is only valid for the live Rust handle it was read through, so
`__init__` empties it: a handle does not survive a process, and a precompile
workload added later must not be able to carry an entry into a session.

The distribution-format refusal named `lower_multiconductor_to_balanced`
alone, which takes a `NetworkPackage` rather than a `MulticonductorNetwork`.
Name the whole route: `to_package`, lower, `from_package`.

Assert `Diagnostic` element-wise rather than on the container's element type,
which an empty findings vector need not carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zCNDAiTv7ZjZKbBLFM9EM
@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results (Julia v1)

Time benchmarks
main e831561... main / e831561...
ac_opf/kkt_jacobian/pglib_opf_case300_ieee.m 0.0329 ± 0.0014 s 0.0325 ± 0.0006 s 1.01 ± 0.048
ac_opf/kkt_param/pglib_opf_case300_ieee.m/switching 2.63 ± 0.48 ms 2.58 ± 0.57 ms 1.02 ± 0.29
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/cost_linear 0.972 ± 0.04 μs 0.962 ± 0.04 μs 1.01 ± 0.059
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/cost_quadratic 0.371 ± 0.031 μs 0.381 ± 0.03 μs 0.974 ± 0.11
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/demand 2.59 ± 0.21 μs 2.58 ± 0.18 μs 1.01 ± 0.11
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/flowlimit 8.62 ± 5.4 μs 3.69 ± 5.5 μs 2.34 ± 3.8
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/full 0.1 ± 0.0088 ms 0.0943 ± 0.011 ms 1.06 ± 0.16
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/susceptance 6.11 ± 0.46 ms 5.15 ± 0.31 ms 1.19 ± 0.11
parser/pglib_opf_case300_ieee.m 0.72 ± 0.01 ms 0.722 ± 0.012 ms 0.997 ± 0.022
time_to_load 1.87 ± 0.047 s 1.85 ± 0.019 s 1.01 ± 0.027
Memory benchmarks
main e831561... main / e831561...
ac_opf/kkt_jacobian/pglib_opf_case300_ieee.m 0.373 M allocs: 12.3 MB 0.373 M allocs: 12.3 MB 1
ac_opf/kkt_param/pglib_opf_case300_ieee.m/switching 14.8 k allocs: 26.2 MB 14.8 k allocs: 26.2 MB 1
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/cost_linear 6 allocs: 1.83 kB 6 allocs: 1.83 kB 1
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/cost_quadratic 6 allocs: 1.83 kB 6 allocs: 1.83 kB 1
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/demand 9 allocs: 12 kB 9 allocs: 12 kB 1
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/flowlimit 9 allocs: 16.3 kB 9 allocs: 16.3 kB 1
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/full 0.111 k allocs: 0.754 MB 0.111 k allocs: 0.754 MB 1
dc_opf/kkt_jacobian/pglib_opf_case300_ieee.m/susceptance 27.4 k allocs: 21.5 MB 27.4 k allocs: 21.5 MB 1
parser/pglib_opf_case300_ieee.m 0.082 k allocs: 4.7 kB 0.081 k allocs: 4.69 kB 1
time_to_load 0.149 k allocs: 11.1 kB 0.149 k allocs: 11.1 kB 1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants