Skip to content

Migrate to the PowerIO parser backend for broad non-MATPOWER format support#50

Open
samtalki wants to merge 6 commits into
MadNLP:mainfrom
samtalki:agent/powerio-parser-backend
Open

Migrate to the PowerIO parser backend for broad non-MATPOWER format support#50
samtalki wants to merge 6 commits into
MadNLP:mainfrom
samtalki:agent/powerio-parser-backend

Conversation

@samtalki

@samtalki samtalki commented Jun 25, 2026

Copy link
Copy Markdown

This PR routes network parsing through PowerIO while keeping ExaPowerIO available for PGLib artifact lookup.

Changes

  • add PowerIO as the parser dependency and require PowerIO v0.6.1
  • call PowerIO.parse_ac_power_data from parse_ac_power_data
  • keep ExaPowerIO for ExaPowerIO.get_path(:pglib) so existing PGLib case names still work
  • pass from through for ambiguous formats such as PowerModels JSON
  • update multi period load rewriting for NamedTuple bus rows
  • add parser evidence tests for MATPOWER parity and generated RAW, AUX, and PowerModels JSON inputs

Validation

  • focused parser and model construction smoke test against PowerIO v0.6.1: PowerIO parser backend | 2107 passed
  • the focused test covers MATPOWER parity against ExaPowerIO, generated RAW/AUX/PowerModels JSON parsing, AC OPF model construction, DC OPF model construction, and MPOPF model construction

Full Pkg.test("ExaModelsPower") was not rerun in this refresh.

Closes #52

samtalki added 4 commits June 24, 2026 23:19
PowerIO is the default parser, with parser = :exapowerio as the MATPOWER
and PGLib fallback. The vendored GO Challenge 3 parser is replaced by
PowerIO.parse_goc3_json: bus ids come from its bus_id_by_uid table
instead of regex uid parsing, and UC startup and shutdown flags come
from PowerIO.goc3_add_status_flags!. Parser parity and format routing
are covered by test/powerio_parser_tests.jl.
@samtalki

samtalki commented Jul 2, 2026

Copy link
Copy Markdown
Author

Added GO Challenge 3 parsing in the last few commits.

In commit ce6eef9 I tried out removing the ExaPowerIO fallback because it made things a lot simpler and made the PR nearly -100 LOC net. Please feel free to cherry pick and edit as desired.

@sshin23

sshin23 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Thank you for the contribution; running CI now

@samtalki

samtalki commented Jul 6, 2026

Copy link
Copy Markdown
Author

@sshin23 thank you for your contributions with ExaModels! I will resolve these CI errors today and will ping you.

@sshin23

sshin23 commented Jul 6, 2026

Copy link
Copy Markdown
Member

@samtalki, I'm curious if there are benchmark results available

@samtalki

samtalki commented Jul 6, 2026

Copy link
Copy Markdown
Author

@sshin23 yes, some benchmarks for the powerio Rust API are here: https://github.com/eigenergy/powerio/blob/main/benchmarks/RESULTS.md

I recently found a serialization bug that is hurting the Julia side, but this will be fixed in the next patch: https://github.com/eigenergy/PowerIO.jl/tree/agent/matrix-axis-maps-jl/benchmark

On a related note, we will hopefully soon have support for the Arrow C Data Interface in Julia: apache/arrow-julia#603 which one of the maintainers seems supportive of: apache/arrow-julia#594

Once that is upstreamed, PowerIO.jl will behave identically to the Rust API.

please do not hesitate to let me know if you have any other thoughts

@sshin23

sshin23 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Awesome! Congratulations on the very impressive results

* Ingest mpopf loads through PowerIO.LoadSeries

Replace the readdlm + update_load_data path with PowerIO.LoadSeries. The
network is parsed once via parse_mp_network and feeds both the powerdata
NamedTuple and the load series, so bus order aligns without positional
guessing. apply_load_series! writes per-period pd/qd onto each (bus, period)
entry; the matrix path is per-unit identical to before. The curve path now
scales loads only, leaving fixed bus shunts gs/bs at their base value.

* Consume PowerIO GOC3 static index-set builders in sc_parser

Replace the moved pure-topology construction (parse_sc_data_static, the
p_sdpc / energy-window / price-block / contingency-survivor blocks, and the
index helpers) with calls into PowerIO's new goc3_* functions. The UC-coupled
and model-shaped code stays: T_supc/T_sdpc accumulations, u_on attachment and
period expansion over the AC contingency survivors, the sc_time_data assembly,
and save_go3_solution. sc_parser.jl shrinks by ~625 lines; parse_sc_data output
is byte-identical, so scopf.jl needs no changes.

* Thread GOC3 stacked variable indices locally in sc_parser

PowerIO no longer emits the model's stacked j/j_pr/j_cs/j_prcs/j_sh indices.
Define the is_pr/get_j_* helpers and goc3_shutdown_power_cap here, and add a
pass that threads those indices back onto PowerIO's general uid-keyed rows,
reproducing the exact numbering scopf.jl consumes.

* Build the multiperiod busarray immutably from the load series

parse_mp_power_data now takes the LoadSeries and bakes each period's pd/qd onto
the bus entries as the busarray is constructed, replacing the separate
apply_load_series! mutation pass (removed). The result is identical per (bus,
period).

Drop the hand-written empty_stor fallback and its stray assignment: PowerIO now
returns concrete storage rows, so the storarray comprehension is type-stable
even when a case has no storage.

* Own combined GOC3 indices in the SCOPF decorator

goc3_static_data returns per-class indices only now, so thread the
combined j_ac (AC lines and transformers) and n (reserve zones) onto the
rows here, mirroring how the DC-line j is already stacked client-side.
Drop the goc3_static_data producers_first argument and the per-parse
@info that materialized the full sc_data type string. Precompute the
cumulative interval times and per-device values once in
goc3_shutdown_power_cap. GOC3 model data is unchanged.

* Compute SCOPF transformer indices in parser

* Compute reserve membership indices in parser

* Tighten PowerIO parser boundary

* Fix SCOPF sentinel types, load-series N guard, drop dead dep

Type the reactive-capability empty sentinels' sum2_T_supc/sum2_T_sdpc fields as
Float64 to match the populated branches and their zeros(...) sources, removing a
branch-dependent eltype flip on capability-free cases.

Drop the stray empty_data = ... assignment from the fpd/fwr/vpd/vwr array
ternaries (the binding was never read).

Assert N == size(data.busarray, 2) in both build_mpopf methods so a load-series
period-count mismatch fails with a clear message instead of a downstream
BoundsError.

Remove the unused DelimitedFiles dependency.

Tests: assert the multiperiod busarray element type is concrete/isbits on CPU;
pin the positional lengths-tuple index contract in the GOC3 boundary test.

* Pass through PowerIO lengths NamedTuple; dedup uid parsing

Replace the positional 18-tuple of static index-set sizes with PowerIO's lengths
NamedTuple passed through and extended via merge with the five model-owned window
counts and K. scopf.jl, sc_parser.jl, and the boundary test now read lengths by
name instead of positional destructure/indexing. Parsed scenario_303 output is
identical (same 18 length fields, same sc_time_data).

Route the twelve inline parse(Int, match(r"\d+", uid)) sites through the existing
_uidnum helper, and drop the unused goc3_bus_id alias.

Assert element-type concreteness across the SCOPF static arrays so a PowerIO
regression that widened a row eltype surfaces on CPU without a GPU runner.

* Consume PowerIO.goc3_scopf_data in one call

Replace the six qualified calls into PowerIO GOC3 internals (goc3_static_data,
goc3_energy_windows, goc3_price_blocks, goc3_ac_contingency_survivors,
goc3_dc_contingency_flows) with a single PowerIO.goc3_scopf_data(data) and read
its fields. The stacked-index threading and UC attachment are unchanged, so the
parsed sc_time_data is identical (verified field-for-field on scenario_303).

ExaModelsPower now depends only on exported PowerIO names.

* Error clearly when only one of pd/qd is given to mpopf_model

The matrix mpopf_model methods treated the pd/qd override as all-or-nothing:
passing only `pd` fell through to PowerIO.LoadSeries(net, pd, nothing), which
has no Nothing method and threw a MethodError. Require both matrices or
neither (read both from file), with a clear ArgumentError otherwise.
@samtalki

samtalki commented Jul 8, 2026

Copy link
Copy Markdown
Author

@sshin23 thanks for your patience. We are ready for another CI run, PowerIO.jl v0.6.4 just merged into the Julia general registry: JuliaRegistries/General#160390.

v0.6.4 implements type-stable PowerData rows in the parser backend, which came from this PR: eigenergy/PowerIO.jl#72.. I bumped the PowerIO.jl compat to v0.6.4.

summary

Just squash merged this PR in my fork into this PR's branch, which improves the integration of PowerIO.jl and ExaModelsPower.jl. I believe this improves the UX and DX of ExaModelsPower.jl and also opens the pathway to more capabilities in the future without breaking changes.

architectural changes

  • I took a deeper look at your GOC3 parser, and I realized that the GOC3 parser on the powerio side could be improved to simplify data ingest for you and other consumers. I created a new PowerIO.LoadSeries object that provides you with the data you expect, and then ExaModelsPower.jl just handles indexing.
  • within the next few releases of the rust version of powerio, I will migrate the LoadSeries object to the rust level, and provide you instead with a more general OperatingPointSeries interface that makes in-place updates to the loads, but in a more general way than the Julia side LoadSeries does. The advantage is that you will then be able to support arbitrary time series of changes to the network that go beyond just load time series. (see below)

I have set up issues to track this so you can understand what is coming:

I set this up so that there will hopefully be no breaking changes for ExaModelsPower, the new features will simply appear and you can choose to use them if you wish.

extra bug fixes for ExaModelsPower.jl

fixes without behavioral changes

  • mpopf_model no longer accepts misspecified problems where only one of pd/qd is passed in. It used to fail loudly with malformed inputs, e.g., if one of them was set to nothing, and would blow up with a confusing MethodError.
  • Both methods now want pd and qd together, or neither (in which case they read the file path args). Otherwise, it now fails gracefully with ArgumentError. There's a regression test is in powerio_parser_tests.jl, but it can probably be removed if you want to avoid bloat in CI.

one fix with a behavioral change #52

See #52.

On main, multiperiod OPF with curve inputs was scaling the fixed bus shunts gs/bs by the demand curve, which I believe is physically inconsistent. In contrast, the pd/qd matrix input option leaves them at the base. So, I think the two inputs to the same model disagree, and a case with nonzero gs/bs solves a different system depending on which one you use.

I think holding the shunts at their base values, as you do with the pd/qd matrix inputs is the correct one.

  • A fixed bus shunt admittance draws active power $p_s = g_s \cdot |v_s|^2$ and reactive power $q_s = −b_s\cdot |v_s|^2$, which depend on the voltage magnitude decision variable.
  • The model encodes this in the bus active power balance term as b.pd + b.gs*vm^2 and reactive power balance as b.qd - b.bs*vm^2
  • matpower's scale_load scales only PD/QD and does not have a situation where the bus GS/BS gets scaled: https://matpower.org/docs/ref/matpower5.0/scale_load.html
  • PowerModels rescales gs/bs only once, by 1/baseMVA, during per-unit conversion.

I could be totally wrong, but I think the only alternative explanations would be:

  • curve[t] == 1/(baseMVA[t]) is true for all t, but I don't think this could be true because I didn't see any @assert statements for that, baseMVA is always processed as a scalar, and I didn't see curve[t] clearly indicated as a per period base, so I figured that 1/baseMVA is a single constant while curve[t] varies independently with t.
  • gs and bs represent a model of the constant-impedance part of a load, but it's entered as a matpower fixed bus shunt and used as a fixed admittance, so I don't think that's the intent.

I made both input paths hold gs/bs at base in this PR, but please do not hesitate to let me know if I am wrong and I will make another follow up PR.

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.

Inconsistent shunt admittance scaling in multiperiod OPF methods

2 participants