Skip to content

feat: powerio-dist, a multiconductor distribution crate with dss/PMD/BMOPF converters#82

Draft
samtalki wants to merge 20 commits into
mainfrom
worktree-powerio-dist
Draft

feat: powerio-dist, a multiconductor distribution crate with dss/PMD/BMOPF converters#82
samtalki wants to merge 20 commits into
mainfrom
worktree-powerio-dist

Conversation

@samtalki

@samtalki samtalki commented Jun 10, 2026

Copy link
Copy Markdown
Member

Scope

New workspace crate powerio-dist: a multiconductor distribution network model in wire coordinates with lossless conversion between OpenDSS .dss, PowerModelsDistribution ENGINEERING JSON ("PMD JSON"), and the draft JSON schema of the IEEE PES Task Force on Benchmarking Multiconductor OPF ("BMOPF JSON", frederikgeth/bmopf-report). The surface ships in all bindings: Rust (powerio_dist::{parse_file, parse_str, convert_file, convert_str}), the CLI (powerio convert feeder.dss --to pmd-json), Python (powerio.dist), and the C ABI (pio_dist_* behind a dist cargo feature of powerio-capi, additive, no ABI bump). PowerIO.jl is untouched; a follow-up issue tracks its dist surface.

Closes #2 (reframed per the discussion there). Starts #53.

Design

The canonical model (DistNetwork) is a network in wire coordinates: string bus ids, ordered string terminal names per bus, explicit grounding, terminal maps on every element, SI units and radians internally (BMOPF semantics, the most explicit of the three formats). The transmission Network is positive sequence and stays separate; the two hubs share conventions, not types. dss node 0 connections materialize as a perfectly grounded terminal, matching both PMD and the public BMOPF examples.

Fidelity contract. Same as the transmission crates, two tiers. Writing back to the source format echoes the retained source text byte for byte. Every cross format write regenerates from the typed model and reports each field the target cannot represent in Conversion::warnings; nothing drops silently. The one-shot converters (convert_file/convert_str, and their C and Python forms) merge the reader's parse warnings into the result, since there is no handle to query them from.

Explicit defaults with provenance. The dss reader materializes every OpenDSS class default into an explicit model value and records which fields were defaulted (DistNetwork::defaulted). The default table was read out of the OpenDSS source (epri-dev/OpenDSS-C class constructors) and then verified empirically against opendssdirect; two display strings in the source disagree with the constructor values (generator kW and PF), and the constructor wins. Consequence: BMOPF output is always fully explicit, and a degenerate circuit converts with a warning naming each materialized default.

Format conventions reproduced exactly. dss: TParser semantics (delimiters, quote pairs, ~/More continuations, Redirect/Compile, @var interpolation, RPN expressions, property name prefixes, | matrix rows, bus dotting), per the OpenDSS source. PMD JSON: column major matrices, null for nonfinite with field suffix restoration, uppercase enums, kV/kW scales, degrees, the ENGINEERING data_model marker; PMD parse_file accepts the emitted JSON unchanged (one documented exception below). BMOPF JSON: schema valid output, flat matrix keys, fully explicit values; every emitted document validates against the vendored schema in tests.

Conversion matrix

Generated into powerio-dist/docs/conversion-matrix.md by an ignored test. echo is the byte exact diagonal; ok is a canonical write that reparses to the common projection of the model; warning counts are fidelity losses the conversion reports individually.

fixture source → dss → BMOPF → PMD
IEEE 13 dss echo ok (143 warn) ok (44 warn)
IEEE 34 dss echo ok (374 warn) ok (218 warn)
IEEE 123 dss echo ok (543 warn) ok (266 warn)
single phase transformer dss echo ok (10 warn) ok (4 warn)
center tap transformer dss echo ok (23 warn) ok (12 warn)
wye delta transformer dss echo ok (10 warn) ok (4 warn)
delta wye transformer dss echo ok (10 warn) ok (4 warn)
switch states dss echo ok (12 warn) ok (4 warn)
four wire linecode dss echo ok (25 warn) ok (12 warn)
constructor defaults dss echo ok (9 warn) ok (2 warn)
ten conductor linecode dss echo ok (25 warn) ok (12 warn)
BMOPF IEEE 13 example BMOPF ok (1 warn) echo ok
BMOPF ENWL example BMOPF ok (1035 warn) echo ok (1019 warn)
PMD IEEE 13 PMD ok (9 warn) ok (152 warn) echo
PMD four wire PMD ok (1 warn) ok (8 warn) echo

Diagonal byte identity and canonical idempotence (write(parse(write(parse(x)))) == write(parse(x))) hold on every fixture. Off diagonal round trips reproduce the typed model exactly on the fields both formats carry.

Oracle gates

  • OpenDSS re-solve (tools/physics_check.py): every emitted .dss re-solves in opendssdirect (controls off, tolerance 1e-10) and is compared per node against the original. Where conversion fidelity is the only variable the worst deviation meets the 1e-8 pu bound (IEEE 13 canonical 2.2e-9, IEEE 13 through PMD 1.8e-9, IEEE 34 and 123 canonical at 1e-11, micro cases at machine epsilon). Cells above the bound carry a documented cause that the conversion also reports as warnings: BMOPF holds constant power loads only, the center tap collapse to two windings, no vminpu in the ENGINEERING model, PMD's 1e-7 ohm switch convention, regulator banking, and an engine asymmetry where OpenDSS seeds an untouched load's voltage base differently from a written kv= (isolated in a four line reproduction; affects any pipeline that materializes defaults).
  • PMD acceptance (tools/pmd/pmdtool.jl): PMD parse_file accepts the emitted PMD JSON and agrees with PMD's own parse of the source .dss (with the documented stripped field list). Exception: PMD v0.16 parse_json cannot read ragged transformer connections (its _fix_arrays! hcats lists of lists), which affects PMD's own print_file output for the same cases identically; verified, and carved out of the gate with a comment.
  • Schema validation: every strict BMOPF emission validates against the vendored schema; negative tests cover missing required fields, unknown fields, wrong enum case, wrong types, and negative bounds.

Feedback on the draft BMOPF schema

Observations from building a strict reader and writer against the public frederikgeth/bmopf-report repo, offered as input to the task force. The ten conductor fixture in this PR is the concrete artifact for the first item.

  1. Matrix key patterns reject two digit conductor indices. ^R_series_\d_\d matches single digits only, so a 10x10 linecode emits R_series_10_10 keys the schema rejects; the pattern is also unanchored at the tail, so R_series_1_1junk validates. ^R_series_\d+_\d+$ fixes both (same for X_series, G_from/G_to, B_from/B_to, and the shunt G_i_j/B_i_j keys).
  2. s_rating units. Documented as kVA, used as VA (their issue #1). The example values only make sense as VA.
  3. Optionality markers (their issue Add OpenDSS .dss parser #2): per field required/optional annotations would remove guessing for implementers.
  4. Stale $id. The schema's $id points at an earlier repository URL.
  5. Counterexamples predate the matrix key rename. The invalid example networks still carry the old concatenated key spelling, so they no longer test what they were written to test.
  6. additionalProperties: false vs extensibility. Schema valid output cannot carry any sidecar data, so every conversion into BMOPF is lossy by construction (this PR keeps dropped fields in the in-memory model so round trips through BMOPF stay lossless, and reports each drop). Whether the schema wants an explicit extension mechanism is an open question worth settling before tooling proliferates.
  7. switch.i_max items are unconstrained while linecode.i_max items must be nonnegative; a negative switch ampacity validates.
  8. Transformer impedances in example_ieee13.json. The substation transformer's series impedance does not pass dimensional analysis against the prose "ohms on the wye side": ohms on the 4.16 kV wye side gives 2.77e-4 for the entered 0.008 percent XHL, while the example holds 2.116e-4, which works out to the delta side base divided by 1000. The single phase regulators show a similar factor. Possibly a kV/V or kVA/VA slip in the example generation; the OpenDSS re-solve gate in this PR is consistent with the prose interpretation.
  9. Fixed generation encoding. Generators carry bounds and cost only, with no dispatch setpoint; this PR maps fixed injections to pinned p_min = p_max. A normative statement on whether pinned bounds are the intended encoding would help.
  10. Wye-wye three phase decomposition. The example decomposes XFM1 into per phase single_phase units; this convention is currently inferable only from the example and deserves a sentence in the spec.
  11. Grounding in example_ieee13.json. The example marks the highest numbered terminal of every bus perfectly grounded, including three wire buses where that terminal is a phase (bus 632, terminals ["1","2","3"], grounded ["3"]). This PR grounds only where OpenDSS grounds (node 0 connections).
  12. b_fr/g_fr split vs a single b/g on lines and shunts remains an open modeling question; either answer is implementable, but the choice affects every converter.

Deferred, with follow-up issues

  • dss classes outside phase A (reactor, pvsystem, storage, loadshape and time series, fault, and others) parse into the raw layer and convert with an explicit unsupported warning; typed support is a follow-up issue.
  • PowerIO.jl bindings for the dist C ABI surface: follow-up issue on the PowerIO.jl side workflow.
  • RegControl converts to a fixed tap with a warning; OLTC modeling is out of scope for the BMOPF spec itself.

Release notes for the maintainer

  • The release binary workflow currently builds powerio-capi --features arrow for the five platform tarballs; shipping the dist surface to Julia and other C consumers means adding dist to that feature list at the next release. Decide alongside whether powerio-dist joins the crates.io publish list.
  • Python wheels need no workflow change: the wheel depends on powerio-dist unconditionally.

🤖 Generated with Claude Code

samtalki and others added 6 commits June 10, 2026 02:17
New workspace member for the multiconductor distribution domain: typed
model in wire coordinates with lossless converters between OpenDSS .dss,
PowerModelsDistribution ENGINEERING JSON, and the draft BMOPF schema
(frederikgeth/bmopf-report). This commit adds the crate skeleton, the
workspace wiring (members, default-members, dep pin), and the CI clippy
and test coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BMOPF draft schema + both example networks (frederikgeth/bmopf-report
f93bca6), IEEE 13/34/123 feeders from the official OpenDSS test case
tree (dss-extensions/electricdss-tst 3b20839), and eight original micro
cases isolating the four transformer subtypes, switch state, a four
wire linecode, constructor defaults, and a ten conductor linecode. All
.dss cases solve in OpenDSS. tools/solve_dss.py dumps reference node
voltages via opendssdirect; tools/pmd/ is a scratch Julia project that
generates and checks ENGINEERING JSON with PowerModelsDistribution.
Anchor the Python dist/ and build/ gitignore patterns to the repo root
so the tests/data/dist fixture tree is trackable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tokenizer mirrors OpenDSS's TParser: comma and equals delimiters, the
five quote pairs, ! and // comments, @var substitution with node suffix
retention, and quoted tokens evaluating as RPN (ten register stack,
degree trig, exact reference shift semantics). The script layer splits
command lines with line level block comments, resolves verbs and
property names with the reference's exact-then-first-prefix rule over
the definition order tables extracted from epri-dev/OpenDSS-C, follows
Redirect/Compile relative to the including file, and accumulates
New/Edit/~/like assignments into raw objects with values kept as
untyped tokens for the readers to interpret. Property tables cover
line, linecode, load, transformer, vsource, capacitor, generator,
swtcontrol, and regcontrol; other classes parse untyped. Parser vars
live on the accumulator so definitions cross redirect boundaries.

Integration tests pin object counts on the vendored IEEE 13/34/123
feeders (nested redirect resolution included) and the micro cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DistNetwork is the multiconductor hub in wire coordinates: string bus
ids, ordered terminal names (OpenDSS node numbers as strings, ground as
terminal "0" in the bus's grounded set), terminal maps on every
element, SI units and radians. The dss reader lowers the raw layer into
it, materializing every constructor default explicitly and recording
each materialization per element in DistNetwork::defaulted; specified
properties outside the typed fields ride in extras so writers can
reproduce them.

Semantics verified against the engine and the reference source: the
ProcessBusDefs node fill rule, To_Meters factors with ConvertLineUnits'
none handling (SI conversion of both sides reproduces the engine's
length multiplier in every case), sequence to phase matrices, switch
lines becoming ideal switches with SwtControl state resolved in source
order, wdg context and array forms on transformers (numeric arrays
through the RPN capable vector parser), capacitors as phase only shunt
admittances, and the constructor defaults table dumped empirically by
tools/verify_defaults.py (generator kW=1000/PF=0.88 per the constructor,
not the property display strings). Bus and node sets match
dss.Circuit.AllBusNames()/dss.Bus.Nodes() on IEEE 13, 34, and 123.

Vendored fixture JSON is linguist-vendored so language stats stay Rust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
write_bmopf_json emits the strict document: schema valid wherever the
draft schema permits the data, with every dropped field named in the
conversion warnings. Generators map a fixed injection to pinned
p_min=p_max bounds (BMOPF carries bounds and cost, no dispatch
setpoint); a missing cost emits 0 with a warning. Three phase wye-wye
units decompose into one single_phase entry per phase, the convention
the public example networks use; center tap secondaries collapse into
the shared two winding shape with the xht/xlt split reported as
dropped. The ten conductor linecode emits double digit matrix keys the
draft schema's single digit patterns reject; a test pins the rejection
as the concrete artifact behind the schema feedback.

parse_bmopf_str is liberal where the writer is strict: out of schema
fields land in extras with warnings, transformer subtypes ride in
extras so writing back reproduces the grouping, and DistBus gained the
four voltage bound families so the ENWL example round trips. Both
public examples parse, re-emit schema valid, and round trip to model
equality; negative tests cover missing required fields, unknown
fields, enum case, wrong types, and negative linecode ampacity (the
draft constrains linecode i_max items but not switch i_max, an
asymmetry the tests note for feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
samtalki and others added 8 commits June 10, 2026 04:22
write_pmd_json reproduces the conventions PMD's own dss2eng emits:
integer terminals with the grounded neutral as terminal 4 and zero
rg/xg, ENABLED status and source_id strings, linecode cm_ub from the
emergency rating, b_fr/b_to as cmatrix halves in nanofarads per meter
(the numeric convention the ENGINEERING JSON actually carries), the
delta-wye lag connection's barrel roll with polarity -1, per winding
tap arrays with the engine's 0.9/1.1/(1/32) defaults, the switch as a
1e-7 ohm series element, and voltage source Thevenin matrices computed
with the engine's short circuit formulas. parse_pmd_str inverts all of
it, restoring null to Inf or NaN by field suffix and rebuilding
matrices with inner arrays as columns; raw b_fr/b_to and rs/xs arrays
ride in extras so ENGINEERING round trips stay bit exact across the
basis changes.

The model convention moved with it: implicit dss ground connections now
materialize as a perfectly grounded neutral terminal named max(4,
highest node + 1), matching PMD and the public BMOPF examples, and
i_max carries the emergency rating everywhere. Voltage source
magnitudes and angles use the engine's general phase formulas.

Reference fixtures under tests/data/dist/pmd were generated by PMD
itself (provenance and regeneration command in the fixture README); the
agreement tests pin bus maps, grounding, powers, ratings, impedances
(to PMD's rounded 1609.3 m mile), and the Thevenin matrices against
them. PMD parse_file accepts our emitted JSON for transformer free
cases; documents with windings of unequal connection counts hit a PMD
JSON reader limitation that rejects PMD's own print_file output the
same way, pinned in tests and noted for upstream feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ness

write_dss regenerates a solvable case from the model: linecodes in
meters with cmatrix recovered from the susceptance halves, elements
with explicit bus dots (perfectly grounded terminals emit as node 0,
the reader's exact inverse), materialized defaults emitted explicitly,
extras whose keys are class properties reproduced verbatim, and a
VoltageBases/Calcvoltagebases/Solve tail from a per bus voltage
estimate propagated from the sources through lines and transformer
ratios. DistNetwork::to_format dispatches all three writers behind the
echo tier: writing back to the source format returns the retained
source bytes.

The matrix harness pins the full 3x3: diagonal byte identity on all 15
fixtures, canonical writer idempotence for every (fixture, target)
pair, and off diagonal round trips compared on the common projection
with the lossy transforms named per cell (BMOPF transformer
restatements; grounded terminal identity for the one public example
that grounds phase terminals). docs/conversion-matrix.md is generated
by an ignored test; every cell passes. serde_json's float_roundtrip
feature is on: the default parser can sit one ULP off its own
serializer, which the round trip contracts cannot absorb.

tools/physics_check.py re-solves every emitted dss case against its
original under opendssdirect with control actions frozen on both sides
(the converter drops RegControl to fixed taps by documented policy).
Canonical regeneration currently meets the 1e-8 bound on IEEE 13 and
all micro cases (the degenerate defaults case surfaced a real bug,
now fixed: a defaulted load kv has to materialize into the model like
every other default); the remaining JSON path deviations are the
documented BMOPF model losses plus small residues under diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three conversion fidelity fixes driven by the physics oracle:

- The dss reader now applies the engine's %loadloss coupling (setting it
  rewrites %R to %loadloss/2 on the first two windings), so regulator
  transformers keep their real resistance through the JSON formats
  instead of the 0.2 percent default.
- The dss writer reconstructs Z1/Z0 sequence impedances from ENGINEERING
  rs/xs Thevenin matrices (z1 = self - mutual, z0 = self + 2 mutual), so
  a source that lost its MVAsc form through PMD keeps its stiffness.
- tools/physics_check.py freezes control actions before the in-file
  Solve on both sides, tightens the solver tolerance to 1e-10 (the
  default 1e-4 swamps the bound), and normalizes deviations per bus.

The deliberately degenerate defaults fixture surfaced an OpenDSS
behavioral asymmetry, reproduced in isolation: an untouched load seeds
VBase 7200 V while writing kv=12.47 (the same default) computes
12470/sqrt(3), a 1.2e-4 relative difference in the load's linearized
admittance. Materialized defaults are self consistent; the residual
voltage deviation on that fixture is engine state, not data, and the
element admittances agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The z0/z1 emission uses the lowercase keys and sorted order a reparse
reproduces from extras, so the canonical text reaches its fixed point on
the first write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cs gate

A switch that came through the ENGINEERING model carries PMD's series
matrices; the dss writer now overrides the engine's switch dummy with
sequence values derived over the forced length, taking IEEE 13 through
PMD and back to 1.8e-9 pu agreement. The PMD writer reports every
dropped extra per element (vminpu and friends fell silently before).

tools/physics_check.py now classifies all 33 cells: the 1e-8 bound
holds wherever conversion fidelity is the only variable; cells with a
documented cause (BMOPF constant power loads, the center tap collapse,
no vminpu field in ENGINEERING, PMD's 1e-7 ohm switch convention
against the engine's 1e-3 ohm dummy, regulator bank restatement, and
the engine's written versus defaulted property seeding) carry their
reason and a bound, and the script exits nonzero only on unexplained
deviations. Current run: every cell passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The matrix harness gives each dss reparse a unique temp path (the test
binary runs threads in parallel and raced on a shared file), bus_ref
reports non numeric terminal names it cannot turn into dss nodes, and
the physics script cleans its staged copy up on the exception path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd Python wheel

powerio-dist grows the dispatch layer the bindings share: parse_str /
parse_file / convert_str / convert_file with format inference (.dss is
OpenDSS; .json is sniffed for the top level PMD ENGINEERING data_model key
against the BMOPF layout, via serde IgnoredAny so a nested or quoted
occurrence is not the marker). Format names are validated before any file
read or parse, and the one-shot converters merge the reader's parse warnings
into the returned Conversion: with no handle to query, that is the only
place the loud half of the contract can surface.

powerio-capi gains a `dist` cargo feature (off by default, like `arrow`)
with pio_dist_parse_file / pio_dist_parse_str / pio_dist_warnings /
pio_dist_to_format / pio_dist_convert_file / pio_dist_convert_str /
pio_dist_network_free behind an opaque PioDistNetwork handle, following the
existing errbuf/warnbuf conventions. Additive only; PIO_ABI_VERSION stays 3.
The header is regenerated (PIO_DIST guard via cbindgen [defines]), smoke.c
exercises the surface under -DPIO_DIST, and a c-abi-dist CI job mirrors the
arrow one. Shared tails were deduplicated along the way: finish_handle now
underlies both handle constructors (IndexCore::build moves under the panic
guard), finish_conversion underlies all four converters, and required_cstr
replaces eleven inline NULL/UTF-8 checks.

The CLI convert subcommand accepts dss / pmd-json / bmopf-json targets and
routes by family with FormatArg::transmission()/distribution(); a cross
family request errors with the family message even when --from is omitted
(unambiguous extensions decide), and read_network rejects distribution
--from values for the transmission-only subcommands instead of letting the
hub report a confusing unknown format.

The Python wheel ships powerio.dist unconditionally (DistCase + parse/convert
functions returning the shared Conversion namedtuple), with type stubs and a
test suite; io errors map to the precise OSError subclass to match the
transmission surface.

Part of #2 / #53.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
samtalki and others added 4 commits June 10, 2026 06:32
…anguage map updates

The powerio-dist crate doc now leads with the three formats, states the
fidelity contract including defaults materialization, and documents the
float formatting policy: shortest round trip representation everywhere,
float_roundtrip on the parse side so canonical writes are idempotent, null
for nonfinite in PMD (suffix restoration), 0 plus a warning in BMOPF. The
workspace README lists the crate and the distribution formats, the crate
README points at the generated conversion matrix and the oracle harnesses,
and docs/languages.md gains the dist naming map across Rust, Python, and
the C ABI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onvergence review

A review pass over the whole branch with the OpenDSS source as ground truth
surfaced defects in every module; all are fixed with focused tests.

dss engine semantics, verified against epri-dev/OpenDSS-C: the Load property
table gains the missing vlowpu slot (its absence shifted every later
positional index); var values store brace wrapped so RPN substitution
evaluates; Compile keeps the redirected directory while Redirect restores it;
Class.Name.Prop= references canonicalize abbreviations, and the prop= /
objname.prop= forms edit the active object; an unknown named property resets
the positional pointer; vsource magnitude uses the engine's polygon chord
formula (basekv*1e3*pu/(2 sin(pi/n))); units=none on either side of a
line/linecode pair takes the other side's units for the length product;
2 phase wye capacitors divide kv by sqrt(3) and conn=ll means delta for
capacitors and generators; the load/generator kw/kvar/pf spec follows the
last written property, matching RecalcElementData; malformed matrices warn
and keep their text instead of silently substituting defaults recorded as
"defaulted".

dss writer: Set VoltageBases derives from the stashed basekv token so the
sqrt(3) round trip is a fixed point; written phases/conn/kv tokens stash in
extras and the writer prefers them (a 2 phase delta load no longer
reconstructs as 3 phase, a 1 phase delta no longer re-emits as wye);
unrepresentable names warn; Set options re-emit verbatim and dropped
commands warn; non numeric terminal names positionalize to their bus index;
half present impedance pairs warn instead of evaporating; degenerate shapes
warn instead of panicking.

bmopf: shunt G/B size mismatches pad instead of zeroing the smaller matrix;
the center tap collapse converts resistance through ohms (it was 4x off on
the 240 V base); unknown configurations, subtypes, and malformed matrix keys
warn into extras; empty matrices emit schema valid zero matrices loudly;
a missing voltage source warns; 3 wire wye-wye maps route to Unsupported
instead of decomposing wrongly.

pmd: status, polarity (euro lead transformers no longer force convert to the
ANSI lag connection), inline line impedances, per phase taps with their
bounds, settings/files, bus rg/xg, switch impedances, and linecode sm_ub all
round trip through extras stashes instead of silently rewriting to defaults.

API, pre release: convert_file/convert_str take a typed DistTargetFormat and
the argument order is input, target, source across Rust, C, and Python;
DistTargetFormat gains FromStr and name(); Conversion is non_exhaustive;
Mat is re-exported; DistNetwork's manual Default seeds 60 Hz; to_format
documents the parse warning split and the mutate-then-echo caveat;
pio_dist_warnings returns an owned string (warning text is unbounded);
Python io errors raise the precise OSError subclass with the path attached.

Gates: the physics harness now stages Controlmode/tolerance for the
object=circuit spelling too, which tightened the IEEE 34/123 baselines from
1e-4 convergence noise to 1e-11 and let three documented carve-outs drop to
the plain 1e-8 bound; an empty emission glob fails instead of passing
vacuously; the matrix harness compares x_series, sizes, endpoints, and both
terminal maps; the unused IEEE 34 Run wrapper is dropped from the fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er token safety

Compile pins the working directory to the compiled file's own directory at
exit (the engine never reassigns CurrDir), so a Compile inside a Compile no
longer leaks the inner script's directory into the caller's relative paths.

The load kw/kvar/pf specification now follows the engine across edit
boundaries: the raw layer records a checkpoint at the end of every object
command line (like= splices carry the source's checkpoints), and the reader
replays a per edit state machine with RecalcElementData semantics at each
boundary. A load specified by kvar whose kw arrives in a later edit (~, Edit,
like=) derives q from the rederived power factor, matching opendssdirect;
the single line case keeps the once-only recalc. Generators stay on the
verified eager write-time fold.

The dss writer routes every option and extras value through one tokenizer
aware emitter: values pick the first quote pair whose closer they do not
contain (the lexer protects comment characters inside quotes), and values no
wrapper can carry emit as written with a warning instead of silently
splitting onto the next positional property on reparse. The derived option
skip and the reader's base frequency pickup accept engine style
abbreviations (Set volt=, Set defaultb=), non numeric source extras warn
before falling back, source phases= derives from the terminal map instead of
re-energizing de-energized phases, and maps shorter than the dss conductor
fill warn that a reparse materializes the neutral.

The PMD reader processes document sections in a fixed order (linecode before
line), so an inline impedance line whose synthesized {name}_z code collides
with a real linecode takes the collision suffix instead of silently sharing
the name; linecodes size from the widest of the six matrices, so an xs-only
ten conductor code reaches the BMOPF double digit key warning. The BMOPF
center tap collapse converts each half winding through its own s_rating
(unequal half ratings were 20 percent off) and warns when the discarded half
ratings differ from the primary's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t; wrap empty values

The engine binds Set option abbreviations to the FIRST option in table
order, so prefixes of defaultbasefrequency shorter than `defaultb` mean
DefaultDaily, and `ca` means CapkVAR. The reader's frequency pickup and the
writer's derived key skip now stop at the unique resolution point instead of
claiming every prefix, and calcvoltagebases leaves the option skip entirely
(it is a command, never a Set option). An empty extras value emits as `()`
instead of `key=`, which made the lexer eat the next token as the value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@samtalki samtalki marked this pull request as ready for review June 10, 2026 13:27
@samtalki

Copy link
Copy Markdown
Member Author

summoning @deakinmt @amritanshup7 @frederikgeth @MartaVanin for their wisdom

@samtalki samtalki marked this pull request as draft June 10, 2026 17:41
@deakinmt

Copy link
Copy Markdown
  1. Matrix key patterns reject two digit conductor indices. ^R_series_\d_\d matches single digits only, so a 10x10 linecode emits R_series_10_10 keys the schema rejects; the pattern is also unanchored at the tail, so R_series_1_1junk validates. ^R_series_\d+_\d+$ fixes both (same for X_series, G_from/G_to, B_from/B_to, and the shunt G_i_j/B_i_j keys).

Updated and pushed to the experimental branch here

  1. s_rating units. Documented as kVA, used as VA (their issue #1). The example values only make sense as VA.

Updated and pushed that that same branch

  1. Optionality markers (their issue Add OpenDSS .dss parser #2): per field required/optional annotations would remove guessing for implementers.

Also updated

  1. Stale $id. The schema's $id points at an earlier repository URL.

Also updated (although as it is draft so not taking too seriously so I am not for example point to the branch)

  1. Counterexamples predate the matrix key rename. The invalid example networks still carry the old concatenated key spelling, so they no longer test what they were written to test.

I assume it is referring to the models in the (private) OpendssToPmd repo, yes this is correct that it would be good to get some counterexamples which function as counterexamples (todo)

  1. additionalProperties: false vs extensibility. Schema valid output cannot carry any sidecar data, so every conversion into BMOPF is lossy by construction (this PR keeps dropped fields in the in-memory model so round trips through BMOPF stay lossless, and reports each drop). Whether the schema wants an explicit extension mechanism is an open question worth settling before tooling proliferates.

To discuss (even the Optional parameters at present need confirming)

  1. switch.i_max items are unconstrained while linecode.i_max items must be nonnegative; a negative switch ampacity validates.

Updated

  1. Transformer impedances in example_ieee13.json. The substation transformer's series impedance does not pass dimensional analysis against the prose "ohms on the wye side": ohms on the 4.16 kV wye side gives 2.77e-4 for the entered 0.008 percent XHL, while the example holds 2.116e-4, which works out to the delta side base divided by 1000.

Transformer impedances are on the todo list / are derived from a (non-standard) ieee 13 bus pmd case file

  1. The single phase regulators show a similar factor. Possibly a kV/V or kVA/VA slip in the example generation; the OpenDSS re-solve gate in this PR is consistent with the prose interpretation.

Transformer impedances are on the todo list / are derived from a (non-standard) ieee 13 bus pmd case file

  1. Fixed generation encoding. Generators carry bounds and cost only, with no dispatch setpoint; this PR maps fixed injections to pinned p_min = p_max. A normative statement on whether pinned bounds are the intended encoding would help.

The section of the spec where this is covered has been clarified to make the point that generators with fixed P, Q make sense to be captured by a load element with negative value for the powers.

  1. Wye-wye three phase decomposition. The example decomposes XFM1 into per phase single_phase units; this convention is currently inferable only from the example and deserves a sentence in the spec.

We won't have IEEE 13 node as an example in the long-run due to the issues around licensing, and probably we will support wye-wye in the next spec version, so this can be fixed but not super high priority.

  1. Grounding in example_ieee13.json. The example marks the highest numbered terminal of every bus perfectly grounded, including three wire buses where that terminal is a phase (bus 632, terminals ["1","2","3"], grounded ["3"]). This PR grounds only where OpenDSS grounds (node 0 connections).

There is are no '0' nodes in the ieee case example shared. I have updated this so that only nodes '4' are perfectly grounded (in the PMD file I am taking these from, it appears ambiguous which nodes are perfectly grounded).

NB: I am wondering if we explicitly park the ieee 13 bus case as one of these examples as long-term we are not going to be able to support it anyway due to the licence issue.

  1. b_fr/g_fr split vs a single b/g on lines and shunts remains an open modeling question; either answer is implementable, but the choice affects every converter.

Happy either way here - not sure there are that many use-cases exist for different from / to values of these parameters, so perhaps we combine.

NB we spell out *_from in bmopf rather than *_fr (change from pmd).

@samtalki

Copy link
Copy Markdown
Member Author

Naming note for when this revives, following the taxonomy now codified in docs/languages.md (#93): BMOPF and PMD engineering data are single-document JSON formats, so they belong to the parse_*/to_format family through the dist hub, like the five classical formats. The read_*/write_* verbs are reserved for filesystem datasets (multi-file directories, e.g. the gridfm Parquet tables) — read_gridfm is the only reader there because a directory can't go through parse_str. Keeping #82's converters on the hub verbs keeps all four language bindings consistent.

🤖 Generated with Claude Code

…tion data

The opendss tree retains the upstream BSD 3 clause notice verbatim (its
redistribution condition), the authored micro cases release under CC BY 4.0,
the PMD exports carry their sources' licenses, and the bmopf directory
documents the vendoring basis and the underlying data lineage (the ENWL
example derives from the CSIRO four wire dataset, CC BY 4.0) while tracking
whatever license the task force publishes for the schema and examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… surface

Union merge with the 0.1.0 release line. The capi gains both optional
features side by side (dist and gridfm; pio_read_gridfm now routes through
the shared finish_network tail, which also moves its IndexCore build under
the panic guard); the CLI FormatArg carries the gridfm read-only variant
next to the distribution targets with the family routing intact; the README
moves the distribution formats from "under development" to supported and
points at the conversion matrix and the fixture licenses.

Taxonomy conformance with the hub's new convert_str(text, to, format): the
dist converter's source parameter is now `format` in Rust and Python,
matching the transmission sibling exactly (required rather than defaulted,
since in-memory text has no extension to infer from).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@samtalki

Copy link
Copy Markdown
Member Author

Thanks for the quick turnaround on all of this @deakinmt .

  • 1-4, 7, 12: tracked in BMOPF: update schema fixtures after upstream schema merge #96 .
  • I set it up so that when june26-report-updates merges we will now re-vendor the schema and examples in one go. Then, the ten conductor case flips from rejected passing.
  • 10: we'll switch to the negative load encoding in the same update.
  • 13: good catch, we already export *_from.
  • 5: regenerated counterexamples will go straight into our negative test suite.
  • 6: agreed, worth settling early. Happy to write up what conversions actually need from an extension mechanism if that helps the discussion.

On licensing:

  • The PR now has per directory license files.
  • The IEEE feeders keep the BSD notice from the OpenDSS distribution, our own micro cases are CC BY 4.0.
  • The bmopf directory now documents data lineage and will follow whatever license the task force publishes.

@frederikgeth

Copy link
Copy Markdown
Collaborator

Awesome, thanks. This is very exciting progress!

@frederikgeth

frederikgeth commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Here's some notes on transformer models and load models I generated a while back, to serve as extensions of the spec.
transformer_integrated.pdf

Haven't been critically reviewed yet, so feel free to ask Fable to do that first.

update: new load model note
load_model_note_v2.pdf

@frederikgeth

frederikgeth commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

@samtalki Do you have any BMOPF JSONs for the IEEE test cases? Then I can use those to test my Julia parser and data (quality) profiler?

@frederikgeth

frederikgeth commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Confirming that based on preliminary testing in frederikgeth/BMOPFTools.jl#11, there are no significant mismatches between parsing through PMD and through Powerio. (And the shunt issue should be resolved when merging the changes on main back into this branch).

For now I recommend to stick with a raw conversion from OpenDSS into the BMOPF JSON. We need to do inference on the OpenDSS data to tag the neutral wires/terminals and tag things like voltage regulators. Let me first work through that in my package. We can port / move that functionality down the line from BMFOPFTools to PowerIO.

@frederikgeth

Copy link
Copy Markdown
Collaborator

by the way, in case you are not aware, there's more test cases hiding here: https://github.com/tshort/OpenDSS/tree/master/Distrib/IEEETestCases
and also in the parent folder there

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.

powerio-dist: distribution crate integration Add OpenDSS .dss parser

3 participants