Conversion guide

This page documents both directions of format conversion:

  • Ingest — OpenDSS .dss models are read by from_dss and emitted as a BMOPF data model.
  • Export — a BMOPF data model is written to a PowerModelsDistribution ENGINEERING dict by to_pmd, or back to an OpenDSS .dss model by to_dss.

Each section records the deliberate decisions in those converters — the things you would otherwise have to discover by diffing data.

PowerIO readers and distribution-tool data

PowerIO is a Rust project for grid-data parsing and conversion. BMOPFTools uses its Julia wrapper, PowerIO.jl, which calls the native Rust library in-process, and requires 0.11.11 or later within the 0.11 series. PowerIO reads several distribution formats into a typed PioModule{MulticonductorNetwork}. BMOPFTools' tested import wrapper is from_dss for OpenDSS; parse_bmopf reads BMOPF JSON. Other PowerIO readers are available through PowerIO directly and do not gain BMOPFTools' DSS-specific normalization and provenance audit automatically.

Source dataPowerIO format tokenCoverage and limits
OpenDSS circuit filesdssStatic circuit equipment and electrical data; load shapes, solve commands, and monitors remain source instructions with diagnostics. Use from_dss for the BMOPFTools intake path below.
PowerModelsDistribution ENGINEERING JSONpmdA multiconductor reader in PowerIO. BMOPFTools' to_pmd is an export API; the former from_pmd import API has been removed.
BMOPF JSONbmopf-jsonPowerIO supports explicit 0.1.0 and draft 0.2.0 output profiles. BMOPFTools selects 0.1.0 and restores supported extensions from extras; a PowerIO release does not ratify a schema proposal.
Distribution CIM RDF/XML (GridAPPS-D / CIMHub profile)cim-distPer-phase connectivity, impedances, loads, shunts, switches, transformers, and sources. Transformer tanks, DER, controls, geometry, and other unmapped classes produce diagnostics; unresolved line impedances block conversion. The writer has no independent CIMHub or profile-schema validation yet.
PSS®SINCAL distribution projectssincal-multiconductorSelected native SQLite/.sinx profiles and acquired Access tables paired with the original MDB, with explicit variant/snapshot selection. Unsupported required components reject parsing. Source echo, IR restoration, and PF preparation are supported; native SINCAL execution remains unverified.

For the exact reader profiles and current limits, see PowerIO's distribution guide, SINCAL guide, and 0.11.11 Julia release notes. GridLAB-D, PowerFactory, CYME, and Synergi Electric are potential sources through an appropriate supported exchange format; this is not a claim of direct native readers for those tools.

PowerIO also reads pandapower JSON and several transmission exchange formats into a BalancedNetwork. SINCAL has a separate sincal-balanced profile. Those readers do not supply a conductor-level BMOPFTools model or reconstruct missing phase and neutral data. The two network families remain distinct.

For example, inspect a distribution CIM input and its BMOPF emission directly:

import PowerIO

module_ = PowerIO.parse("feeder.xml"; format="cim-dist")
module_.diagnostics                      # source-reader evidence
emission = PowerIO.emit(module_, "bmopf-json@0.1.0")
emission.diagnostics                     # target conversion losses

Review both diagnostic lists before using the emitted data. A successful PowerIO parse or emission does not establish BMOPFTools schema validity, equipment applicability, or numerical agreement. Feeding an intermediate OpenDSS export to from_dss also cannot recover information lost in that export.

SINCAL Access intake additionally requires explicitly acquired companion tables; PowerIO does not run acquisition tools. It checks the original MDB's length and hash, without independently attesting the companion table cells. Active daily profiles require a snapshot. The distribution reader is strict by default; assume_inactive_source_controls=true opts into an experimental schema-11.5 NULL-control assumption with diagnostics and provenance. Referenced voltage sources currently block ordinary DSS/PMD/BMOPF emission, so a parsed SINCAL network is not necessarily convertible to BMOPFTools.

Design philosophy: ingestion is a semantic projection

This is one instance of a model-wide principle

Reconstructing the named real-world asset rather than a solver-convenient encoding is a stance that governs the whole data model — validation findings and fix recipes as much as ingest. The general statement, and the catalog of representational collisions it resolves (capacitor-vs-shunt, load-vs-generator, line-vs-transformer, …), live on the Object identity & semantic projection page. This section covers the OpenDSS-specific lowering.

from_dss does not aim to be a byte-faithful transcoder. It is closer to a compiler frontend: PowerIO does the lexing and parsing, and from_dss then performs semantic analysis — inferring phase and neutral identity, fingerprinting voltage regulators, classifying grounding and galvanic zones — to lower a loosely specified OpenDSS model onto a canonical, conductor-level data model. Just as a compiler is not expected to round-trip back to the original source whitespace but is expected to preserve program semantics and diagnose errors the source obscured, BMOPFTools optimizes for a different goal than a faithful copy.

The goal hierarchy is explicit:

Semantic faithfulness + data-quality leverage > byte-level round-trip.

A lossless transcode of an OpenDSS deck would preserve every quirk of its encoding — implicit earth nodes, regulators expressed as control loops over ordinary lines, phases identified only positionally — and in doing so would leave the network unanalysable. You cannot run a semantic data-quality check (is this star point grounded? which buses share a galvanic zone? is this a four-wire or three-wire segment?) on a representation that never made those facts explicit. The non-trivial mapping is therefore not incidental; it is the price of being able to reason about the network at all.

Three pillars make a non-identity transformation safe to rely on:

  1. Canonicalize representation. Normalize terminals, neutral/earth, regulator structure and grounding onto one explicit convention (the ingest steps below), so every downstream pass sees the same shape.
  2. Surface inferences as findings, never silent assumptions. Every inferred fact is emitted as a provenance finding (I.PROV.*) with a confidence tag, so a heuristic that guesses wrong is diagnosable, not hidden. This is the correctness burden a pure transcoder never takes on, paid down openly.
  3. Record provenance. The transformation manifest and net["_meta"] keep an auditable trail of what was normalized, so the projection is reproducible (same inputs + pinned versions ⇒ same canonical output) and any drift is visible.

The aim, then, is reproducible compatibility with deliberate surgery — not losslessness. What is preserved is the physics and the semantics; what is normalized away is redundant or ambiguous encoding, and even that is recorded.

Background and precedents

This is a principled stance with a clear lineage, in power systems and beyond.

  • In this project's own design goals. The Task Force benchmark architecture argues for maintaining semantics in the data model precisely to make data debugging tractable and to avoid benchmarking pitfalls (ref. 2), building on a survey of the data-quality problems that pervade existing distribution datasets (ref. 14).
  • A two-layer model is established practice. PowerModelsDistribution deliberately separates a human-facing engineering model from a solver-facing mathematical model, connected by an explicit lowering step (ref. 15); BMOPFTools pushes the same idea one stage upstream, from "engineering vs math" to "raw export vs benchmark-grade model."
  • Model the entities, not the coordinate transform. The IEC Common Information Model represents a network through explicit terminals and connectivity nodes (ref. 23) — the very structure OpenDSS leaves implicit and from_dss reconstructs. This runs against a long-standing reflex in power engineering to reason in coordinate-transformed models (symmetrical components, Kron reduction) that discard the conductor-level data a four-wire model — and a non-expert collecting it — actually needs (ref. 10, ref. 17); see the buses & terminals primer.
  • Good data modeling is a discipline in its own right. Outside power systems the same lesson recurs: tidy data observes that data is usually organized for ease of entry rather than analysis, and that standardizing the mapping from semantics to representation is what makes reliable, composable tooling possible (ref. 22); the FAIR principles make provenance and shared vocabularies — not raw dumps — the basis of reusable data (ref. 21); Open Power System Data brings the same validated, reproducible raw-to-results discipline to energy-system modeling (ref. 24); and W3C PROV formalizes provenance as the mechanism that makes a non-identity transformation trustworthy — notably defining validity itself through a normalization process (ref. 25).

What is distinctive about BMOPFTools is not any one of these ideas but their synthesis: semantic canonicalization, a stable and code-addressable data-quality vocabulary (findings), standards-grounded augmentation (case augmentation), and a provenance manifest — assembled specifically to make distribution-OPF benchmarks reproducible.

OpenDSS ingest (from_dss)

from_dss parses an OpenDSS model in-process via PowerIO.jl and emits BMOPF JSON directly — there is no PowerModelsDistribution step in the loop, and the earlier PMD-based from_pmd parser has been removed. Values arrive in SI units already (line lengths in meters, impedances in Ω, powers in W/var/VA); PowerIO normalizes the DSS unit declarations internally, so from_dss applies no further scaling.

The pipeline is:

OpenDSS .dss ──PowerIO.jl──► raw BMOPF dict ──from_dss post-processing──► BMOPF data model

On top of what PowerIO emits, from_dss (and the parse_bmopf/migrate path it shares) applies a handful of deliberate normalizations so the result lands on the BMOPF conventions:

  • Identifier case-folding — all ids and references lower-cased (below).
  • Terminal remap — 1,2,3,4 → a,b,c,n, with the OpenDSS earth node .0 (surfaced by PowerIO as terminal "5") routed to the bus neutral (below).
  • Transformer impedance normalization — PowerIO's lumped single-impedance form is migrated onto the per-winding fields the OPF reads (below).
  • Transformer fidelity — PowerIO 0.11 retains BMOPF transformer neutral grounding, fixed taps, center-tap leakage, delta-wye leakage, and the validated n_winding cases. BMOPF schema 0.1.0 sets additionalProperties: false and has no slot for taps, neutral impedance or no-load admittance, so a conforming writer parks them under extras; migrate folds them back onto the transformers (W.MIGRATE.XFMR_EXTRAS_FOLD) and folds the whole-table classes the root schema dropped — ibr, control_profile, the DC tables, time_series — back to the top level (W.MIGRATE.TOP_LEVEL_EXTRAS_FOLD). Conversion explicitly selects bmopf-json@0.1.0; this does not imply support for every field in the draft 0.2.0 schema. An explicit no_load_shunt = {winding, g, b} becomes an ordinary shunt on its declared winding, with provenance in _meta.explicit_transformer_core_shunts, and suppresses duplicate percentage-based core branches. Its losses stay assigned to the owning transformer and enter the network balance once. See Transformer models for the shunt conventions.
  • System frequency capture — the OpenDSS base frequency (Set DefaultBaseFreq, itself defaulting to 60 Hz; European decks set it to 50) is otherwise absent from BMOPF impedance data, so from_dss reads the typed PowerIO network's base_frequency and records it on net["meta"]["frequency"], keeping the case self-contained. It is metadata only — never used to rescale impedances (there is no OpenDSS-style base-frequency scaling in BMOPF) — and feeds the cross-object consistency check W.DOM.FREQUENCY_MISMATCH. Override it with from_dss(path; frequency=…) when the deck relied on a base frequency it never stated, or you know the intended value; the chosen source ("powerio" or "override", plus the parsed value when overridden) is recorded on net["_meta"]["frequency_source"]. A PowerIO regression that dropped or hard-coded the base frequency would be caught by the ingest tests, which assert distinct 50 Hz and 60 Hz decks map to distinct meta.frequency.
  • No slack price — the imported voltage_source is left without a cost; the augmentation pass supplies one (below).

What is faithful: connectivity, phasing, linecode matrices, load models, transformer neutral grounding, native 3-winding transformer data, and SI impedances pass through unchanged. What is lossy or not yet supported — earth terminal grounding through the bus neutral, dynamic regulator control, and unsupported auto-transformer representations — is cataloged under Known limitations; most are tracked as upstream PowerIO.jl issues.

Ingest warnings: the full report

from_dss is loud about fidelity loss: every piece of OpenDSS information that could not be represented in BMOPF is collected during the parse. The console Warning you see after a call is a preview only — one line per diagnostic class. Nothing is lost, and nothing is written to disk: the received diagnostic records travel with the returned network dict, so you can inspect it whenever you like:

net = from_dss("Master.dss")

net["_meta"]["powerio_warnings"]      # Vector{String} — the warning list
println.(net["_meta"]["powerio_warnings"]);   # print them all, one per line
net["_meta"]["powerio_diagnostics"]   # the same list, one record per class
net["_meta"]["powerio_source"]        # absolute path of the parsed .dss file

PowerIO 0.11 aggregates dropped fields and retains at most 100 element locators per field. The record includes the total count and an elements_truncated flag. A shorter warning list does not establish fewer losses. Inspect net["_meta"]["powerio_diagnostic_details"] for the structured records; filtering rendered messages alone can miss elements named only in details.

The source field ledger reports incomplete attribution as truncated_upstream. This occurs on supported builds, including the tested LV17_279bus feeder. Known element identities survive, but omitted identities are not inferred. Reader diagnostics remain separate evidence: a complete writer field mapping does not imply that every source command was understood.

The same diagnostics reach the analysis framework as Findings — powerio_findings reads them off the dict and analyze reports them in the provenance section. They keep powerio's own code (EMIT.BMOPF.FIELD_DROPPED), so an import loss stays distinguishable from a validation finding in this package's W./E./I. grammar. The two still answer different questions — what the conversion could not carry over, against the quality of what arrived — and one report now carries both. A from_dss call fills a caller's vector directly:

findings = Finding[]
net = from_dss("Master.dss"; findings=findings)

One Finding stands for one (code, severity, component type) class. Its detail retains the element paths, bounded field attributions, and source_records indexing the raw intake diagnostics. count counts source records contributing to that class, not affected elements. An aggregate can span several classes; the analysis summary counts the original diagnostics once and sums all records for each code. Each attribution's source_element_count is the upstream aggregate total, not a per-class count. Incomplete lists carry attribution_complete = false and an unattributed network record. PowerIO remark and note severities map to INFO.

The source field ledger

Alongside the raw warnings, from_dss records which OpenDSS fields it did and did not carry over, so a caller can gate on the answer instead of grepping prose:

mapping = net["_meta"]["powerio_source_mapping"]

mapping["fields"]                     # source fields demonstrably represented in BMOPF
mapping["unmapped_fields"]            # source fields with no BMOPF representation
mapping["blocking_unmapped_fields"]   # …of those, the ones that move the physics
mapping["by_field"]["kv"]             # per-field record (see below)

Every by_field record carries the same keys. mapped_scopes are the objects where the mapping was demonstrated on the converted output; warned_scopes are the objects whose source carried the field and where the parser dropped it; unmapped_scopes is the difference — the actual fidelity gap — and scopes is the union, i.e. the field's full footprint in the source. impact, reason and physical_readiness_blocking classify the gap.

Classification is per (element kind, field) where the kind matters: dropping a length from a switch is benign, because BMOPF models a switch as an ideal closure with no series impedance, whereas dropping the same field from a line would change its impedance. An unrecognized field stays blocking.

blocking_unmapped_fields is only as complete as the warning list it was built from, so read it together with the status keys:

mapping["warning_status"]                # parsed | partially_classified |
                                         # truncated_upstream | unrecognized_warning_format
mapping["warnings_truncated_upstream"]   # true ⇒ unmapped_fields under-reports
mapping["unclassified_warnings"]         # fidelity losses not attributable to one field

An empty blocking_unmapped_fields is a fidelity statement only when warning_status == "parsed". Under any other status the ledger did not see the whole input, and from_dss emits a warning saying so.

Two source semantics have no BMOPF field but are preserved rather than dropped, on net["_meta"]["powerio_source_semantics"]: an OpenDSS load's vminpu / vmaxpu (the voltage domain over which its load law is valid — not a bus voltage bound) and a voltage source's model. Read them through powerio_source_behavior_contract, which joins each threshold to the converted load's bus, terminal map and nominal voltage. It is non-mutating: with plan_auxiliary_constraints=true it returns the same records as candidate terminal-voltage-ratio constraints, but nothing is ever added to a model.

Scope keys ("load:ld1", "source:source") are lower-cased for the same reason identifiers are — see below — so records join across all three _meta blocks.

Identifier case-folding

OpenDSS identifiers are case-insensitive but case-preserving: SourceBus, sourcebus and SOURCEBUS denote the same bus, and different statements in one model may spell it differently. BMOPF keys are matched exactly, so from_dss case-folds every identifier and every reference to lower case on ingest (bus names, linecodes, component ids; bus / bus_from / bus_to / linecode references). This is lossless — OpenDSS uniqueness up to case guarantees folding only reunites references to the same object. If two ids in a collection ever fold to the same value (which valid OpenDSS cannot produce) from_dss raises rather than silently dropping one.

The earth terminal (OpenDSS .0)

from_dss preserves PowerIO's materialized node-0 terminal as a separate, perfectly grounded return named n_ref. The conductor neutral (OpenDSS node 4) remains n. PowerIO chooses the synthetic reference number from the bus's node inventory; it is not always terminal 5. Intake uses the source IR's grounded terminal list, not a fixed numeric substitution. Regression tests in test/opendss_semantics_tests.jl check that the two returns remain distinct.

For example, a transformer with bus=lv conn=wye and a feeder carrying node 4 imports with winding map [a,b,c,n_ref], feeder map [a,b,c,n], and perfectly_grounded_terminals=[n_ref]. A 0.3 Ω shunt on n remains finite and does not become the transformer star's connection to ground. The explicitly wired bus=lv.1.2.3.4 instead imports with winding map [a,b,c,n].

The neutral role includes these device return terminals for existing winding consumers; the reserved name n_ref and _meta.opendss_ground_references.by_bus identify the mathematical reference. It is not evidence of a physical fourth wire. A bus carrying both n and the perfectly grounded n_ref retains n as its conductor-neutral metadata. The case-wide role declaration and source mapping survive write_bmopf/parse_bmopf. Other multiple-neutral declarations remain subject to the existing diagnostics.

Older imports that merged the two terminals cannot be repaired from the saved target alone. Re-import the original DSS input; historical _meta.earth_terminal_routing records describe the old lossy behavior.

Source phase relation and report qualification

PowerIO 0.11.6 retains authored leadlag in transformer source extras but drops it from its BMOPF transformer export, which always encodes the ANSI relation. BMOPFTools does not invent a phase mapping or change the target's winding equations to compensate. Instead it checks each authored value: the source clock follows from the source winding connections and leadlag, and the target clock from transformer_phase_relation on the converted model.

Source connectionsleadlagSource clock (winding 2 vs 1)Ledger outcome
mixed wye/delta, 3-phase, 2 windingslag, ansi (OpenDSS default)1verified if the target clock is 1 → mapped, not blocking
mixed wye/delta, 3-phase, 2 windingslead, euro11known_mismatch (target clock 1) → blocking
equal connections (Yy, Dd)any0not_applicable → mapped, not blocking
any other shape, or an unrecognized value——indeterminate → blocking

The source clocks were measured against OpenDSS for both connection orders and are re-checked by the test suite whenever OpenDSSDirect is available. A known_mismatch is a definite 60° error in the converted model's phase relation. It does not change magnitudes on a radial feeder fed from one side, but it does matter for meshed or parallel transformers and for any result compared by angle across the transformer.

_meta.powerio_source_semantics.transformer_phase_relations records the authored value, source_clock, target_clock and status per transformer. The structured phase relation describes the represented target and carries this record in source_preservation. Markdown, terminal and tree views append source leadlag=… verified, a SOURCE MISMATCH note, or unverified. The existing upstream Finding EMIT.BMOPF.TRANSFORMER_EXTRA_DROPPED is kept.

Earthing and nominal-voltage report semantics

Earthing zones describe represented connections, not TT/TN/IT installation identity. star_earthing is perfect for an exact zero-voltage reference, impedance for a positive finite local path, none for a represented infeed return without a local path, and indeterminate when that return is absent. A voltage source whose terminal map holds only phase terminals pins phase-to-reference voltages, so it is an ideal source referenced to ground by construction. Its zone is perfect, and the star_points entry carries basis = "voltage_source_pins_phase_to_reference" with no terminal. star_points records terminal identities; neutral_earths separately records conductor-neutral paths. star_R_ohm retains its legacy key but reports impedance magnitude in ohms. Finite values remain finite on both sides of the old 1 Ω and 200 Ω cutoffs. Those configuration keys remain readable for compatibility but no longer control this classification.

n_downstream_earths counts buses with a represented local neutral earth away from infeed buses. It does not count electrodes within a bus, parallel paths, or prove a protective-earth arrangement. protective_system_status remains indeterminate. A 4-wire tag requires a neutral carried by represented closed branches at every bus in the zone; source reference terminals alone do not qualify. Missing conductors do not establish Kron-reduction provenance.

Voltage-level results expose voltage_basis=phase_to_reference and explain the basis in human-readable output. For the balanced three-phase domain, 6.35 kV and 250 V correspond to approximately 11 kV and 433 V line-to-line. These are nominal propagated values, not solved voltages. Conversion summaries count diagnostic records and classes, not asserted distinct fidelity losses; mapped source fields, retained source-only properties and parsing diagnostics can all contribute.

OpenDSS execution still depends on source and solution frequency. A default frequency set after circuit creation can leave a previously created source at a different frequency. This static importer does not execute solve or reproduce session initialization; specify frequency before circuit creation or explicitly on the source when reproducible native solves are required.

Transformer impedance on ingest

PowerIO emits wye_delta/delta_wye units with a single lumped r_series/x_series rather than the per-winding T-model the OPF and Ybus builders read. That lump is already referred to the wye winding's base (powerio-dist's referred_resistance/referred_ohms both take the wye winding's v_wye2/zb, whichever side it sits on).

Which side it lands on is therefore not a free choice. _yprim_yd and the OPF builder re-refer whichever side is the delta winding down to the wye base via n_ph/n_eff0²; routing an already-wye-referred lump through that path would apply the referral twice. So migrate / _migrate_transformer_series_fields! moves it onto the wye side and leaves the delta side at zero, where it picks up no extra referral:

SubtypeWye windingLumped value lands onDelta side
wye_delta (Yd)fromr_series_from / x_series_from0.0
delta_wye (Dy)tor_series_to / x_series_to0.0

A W.MIGRATE.XFMR_SERIES_FIELDS note is recorded on net["_meta"]["migration_notes"]. The migration is unconditional and runs for any lumped source, and a transformer that already carries the per-winding fields is never overwritten.

For example, the 500 kVA 11 kV delta / 415 V wye unit in test/data/pf_comparison/pf_dy_xfmr.dss (%r=1.0 per winding, xhl=4.0) arrives as r_series_to = 0.02 × 0.415² / 0.5 = 0.006889 Ω and x_series_to = 0.013778 Ω, with both _from fields zero. Its Yd counterpart pf_yd_xfmr.dss (11 kV wye / 415 V delta) lands on the from side instead, at 0.02 × 11² / 0.5 = 4.84 Ω.

The percentage→ohm conversions PowerIO performs upstream — and that to_pmd reverses — are tabulated under Transformer impedance bases.

Capacitor banks

BMOPF models a fixed shunt capacitor bank as the top-level capacitor object — a nameplate-faithful, connection-aware shunt:

net["capacitor"]["c1"] = {
  "bus": "b035", "terminal_map": ["a","b","c","n"], "configuration": "WYE",
  "q_rated": [600000.0, 600000.0, 600000.0],   // var, per phase (WYE)/pair (DELTA)
  "v_nom": 14400.0                            // V (L-N for WYE, L-L for DELTA)
}

A capacitor is a constant susceptance B = q_rated / v_nom², so it delivers the voltage-dependent reactive power Q = B·V² (exact capacitor physics — only equal to the nameplate kvar at the rated voltage). Connections: WYE (phase-to-neutral), SINGLE_PHASE (two terminals), DELTA (phase-to-phase). It compiles to a terminal-space susceptance matrix and reuses the same KCL injection as a shunt (no decision variables — fixed). A continuously-controllable / smooth capacitor (B a bounded decision variable) is a documented future extension.

Nameplate convention (matches OpenDSS Capacitor): for a 3-phase bank of total kvar, q_rated is the per-phase (WYE) or per-pair (DELTA) share (kvar_3ph/3), and v_nom is phase-to-neutral for WYE/SINGLE_PHASE, line-to-line for DELTA. Validated against OpenDSS's own Capacitor solve for WYE and DELTA.

Modeling a fixed bank in OpenDSS (replicable, no accidental switching). The toolbox models a fixed capacitor only. To produce a matching, constant bank in OpenDSS (as the parity tests do):

  • no New CapControl… object — a Capacitor without a CapControl never switches during a solve;
  • single step, all states on — leave Numsteps at its default (1) and states at its default (all on), so the full nameplate kvar stays connected;
  • snapshot solve (the default Set Mode=Snapshot) — no time series to drive a control;
  • then New Capacitor.x bus1=… phases=3 conn=wye|delta kv=<rated> kvar=<total> is a constant susceptance B = kvar/kv² delivering Q = B·V².

A multi-step Capacitor (Numsteps>1) or one driven by a CapControl is a switched bank — discrete switching is out of scope here (planned later).

Need a controllable/continuous source of reactive power today? Use an ibr (bounded q_min/q_max, or a Volt-VAr control_profile) or a generator with reactive bounds — those already provide continuous, dispatchable reactive support. The capacitor is specifically the fixed physical device.

Ingest status: like n_winding, this is a hand-authored / future-PowerIO target — from_dss is unchanged and PowerIO still emits OpenDSS Capacitors as plain shunts (B = kvar/kv²), so existing behavior is untouched.

General n-winding transformers

BMOPF models a general n-winding (3+) transformer as the n_winding transformer subtype: a winding-indexed list rather than the two-bus bus_from/bus_to shape used by the other subtypes. This is the canonical representation for substations with three (or more) galvanically isolated voltage levels — e.g. an HV→MV→LV station, or a dual-secondary unit. It is implemented as a fully independent code path (src/io/nwinding.jl, ext/BMOPFOpfExt/nwinding.jl, and dedicated n_winding branches in the Ybus, per-unit, results and analysis code) that shares no functions with the two-bus transformer subtypes.

Data shape (net["transformer"]["n_winding"][id]):

{
  "windings": [
    {"bus":"hv","terminal_map":["a","b","c","n"],"v_nom":66395.0,"connection":"WYE","r_winding":0.21},
    {"bus":"mv","terminal_map":["a","b","c","n"],"v_nom":14376.0,"connection":"WYE","r_winding":0.31},
    {"bus":"lv","terminal_map":["a","b","c","n"],"v_nom":2402.0, "connection":"WYE","r_winding":0.32}
  ],
  "x_sc": {"1_2":5.0,"1_3":5.0,"2_3":3.0},
  "s_rating": 30.0e6,
  "g_no_load": 0.0, "b_no_load": 0.0
}

Conventions (mirroring OpenDSS, the n-winding reference data model):

  • windings is ordered; winding 1 is the reference (v_nom are phase-to-neutral volts; N_k = v_nom[k]/v_nom[1]).
  • Inter-winding leakage is stored as pairwise short-circuit reactances x_sc["i_j"] (i<j, Ω, all referred to winding 1's base) — the OpenDSS XHL/XHT/XLT / Xscarray form. Per-winding resistance r_winding[k] is in Ω on winding k's own base.
  • The OPF/Ybus convert the pairwise reactances to the OpenDSS-style ZB short-circuit matrix referred to winding 1 — an (n−1)×(n−1) impedance matrix with winding 1 as the reference (ZB[i,i] = Z_{1,i+1}, ZB[i,j] = ½(Z_{1,i+1}+Z_{1,j+1}−Z_{i+1,j+1})). This is exact for any n: ZB has exactly n(n−1)/2 independent entries and reconstructs every pairwise reactance (for n≤3 it coincides with the star/T model). The OPF references winding 1 (V_1ʳ − V_{i+1}ʳ = −Σⱼ ZB[i,j]·I_{j+1}ʳ, ampere-turn Σ N_k I_k = 0) so no internal star node is needed; the Ybus uses Yref = Cᵀ·ZB⁻¹·C de-referred by the turns ratios. An off-diagonal/leg value may be negative for n≥3 — physically correct, not an error. The OPF/PF is validated against OpenDSS's own 3- and 4-winding solves.
  • WYE and DELTA windings are both supported. A WYE winding's coil voltage is line-to-neutral (v_nom = L-N, terminal map carries a neutral); a DELTA winding's coil voltage is line-to-line (v_nom = L-L, no neutral, and delta_roll = ±1 picks the vector-group rotation — -1 matches OpenDSS). The √3/coil-base factor lives in v_nom, so r_winding/x_sc are on the coil base n_ph·v_nom²/s_rating and per-unit needs no √3 correction.

Ingest and export status: PowerIO 0.11 retains n_winding under the explicit BMOPF 0.1 profile's extras.transformer; migration restores it for the validated OpenDSS cases. Exact unit winding tap_ratio defaults without competing tap bounds or controls normalize to the implicit nominal ratio, with W.MIGRATE.NWINDING_NOMINAL_TAP evidence. Non-unit taps and tap bounds remain unsupported by this numerical model and cause a solver-build rejection. Per-winding s_rating is retained nameplate metadata, not an active constraint; only an explicit supported s_max or i_max declares a winding limit. No rating or missing pairwise reactance is inferred from the other windings. to_pmd skips n_winding transformers with a warning, since PowerModelsDistribution has no general n-winding model. The OPF/PF model is validated to match OpenDSS's own 3-winding solve.

Per-phase voltage source merge

OpenDSS commonly models a three-phase substation source as a Circuit element plus per-phase VSource objects (…_phB, …_phC) — each a single-phase source wired to one phase of a shared bus. PowerIO emits the BMOPF source data directly; BMOPFTools keeps the phase arrangement classifier for provenance and connectivity checks.

provenance_analysis classifies every polyphase source's stored angles and raises W.PROV.SOURCE_ZERO_SEQUENCE / W.PROV.SOURCE_NEGATIVE_SEQUENCE / W.PROV.SOURCE_INCOHERENT_ROTATION (see Findings). Connectivity analysis uses the same classifier to flag a galvanic zone whose source arrangement cannot supply the phase count declared by downstream buses.

Pricing the slack source

The OpenDSS circuit object is simultaneously a voltage reference and an implicit unbounded power injection. The BMOPF voltage_source captures both: it is the network's current slack (see Voltage source as current slack). What's missing for a well-posed cost objective on a raw utility dataset (no generators at all) is a price on that imported power.

from_dss imports the source without a price (from_dss does not add a cost). The augmentation pass supplies one: it attaches a per-phase cost to the source itself (default 1.0 $/kWh, kwarg slack_cost), so minimum-cost dispatch equals loss minimization. No flow bounds are added, so the source stays an unbounded slack, and no auxiliary generator is created — the cost lives on the voltage_source.

PMD field mapping (to_pmd)

to_pmd exports a BMOPF data model to a PowerModelsDistribution ENGINEERING dict. The same field mapping (read in reverse) describes how a PMD ENGINEERING dict maps onto BMOPF, which is useful when comparing against PMD-based tooling.

Scaling and basic field mapping

  • Voltages: PMD vm (per-unit on voltage_scale_factor) → volts; va degrees → radians.
  • Powers: PMD values × power_scale_factor → W/var/VA.
  • Terminals: PMD integers 1,2,3 → "1","2","3", 4 → "n".
  • PMD enum values (WYE, DELTA, …) → strings.
  • Unrecognized PMD fields are preserved per component under a _pmd sub-dict — nothing is silently dropped; to_pmd merges them back.
  • Line lengths and per-length linecode values arrive from PMD already in meters / Ω-per-meter (PMD normalizes DSS units internally).

Load configuration

The spec distinguishes SINGLE_PHASE (any two nodes) from WYE (4-terminal midpoint return). 2-terminal loads are SINGLE_PHASE; to_pmd maps SINGLE_PHASE back to a PMD 2-terminal "wye" load.

Transformer impedance bases

PMD and OpenDSS give per-winding resistance rw (%) and pair-wise leakage xsc/xhl/xlt/xht (%), both on the winding's own kVA/kV² base. BMOPF stores ohms on each winding's own voltage base.

single_phase (2-winding, Γ-model):

Z_base,from = v_nom_from² / s_rating
Z_base,to   = v_nom_to²   / s_rating
r_series_from = rw₁ · Z_base,from
r_series_to   = rw₂ · Z_base,to
x_series_from = (xhl / 2) · Z_base,from    # half of pair leakage on each side
x_series_to   = (xhl / 2) · Z_base,to

center_tap (coupled-coil 3-winding — star-network leakage conversion required):

OpenDSS specifies three pair-wise leakage values XHL, XLT, XHT (%). These are not split evenly — they must be converted via the star (Steinmetz) network formula before storing. from_dss does this automatically (recovering the values from PowerIO's pmd export); the formulas below are for hand-built nets:

x_series_from = (XHL + XHT − XLT) / 2  ×  Z_base,from / 100
x_series_to   = (XHL + XLT − XHT) / 2  ×  Z_base,to   / 100

For the common symmetric case XHT = XHL (both legs same leakage to HV), this simplifies to x_series_from = (XHL − XLT/2) × Z_base,from / 100 and x_series_to = (XLT/2) × Z_base,to / 100.

Warning

Using XHL/2 for both sides (copying the 2-winding formula) produces identical leg voltages regardless of load imbalance. The error is ~0.4–0.5 V per leg under a 3 kW imbalance on a 120 V feeder.

Resistance maps directly per winding:

r_series_from = rw₁ · Z_base,from      # wdg1 (HV)
r_series_to   = rw₂ · Z_base,to        # wdg2 = wdg3 for a symmetric unit

Note: v_nom_to is the per-leg voltage (e.g. 120 V), not the full secondary span (240 V).

No-load branch (applies to both single_phase and center_tap):

OpenDSS places the exciting (no-load) branch across winding 2 — verified against its Yprim — so %noloadloss/%imag (cmag) convert to SI admittances on winding 2's coil voltage V_stamp (the per-leg LV voltage for center_tap; the to-winding voltage for single_phase). The magnetizing branch is inductive, so b_no_load is negative:

V_stamp = v_nom_to (coil voltage of winding 2)
g_no_load =  (%noloadloss / 100) · s_rating / V_stamp²    (S)
b_no_load = -(%imag       / 100) · s_rating / V_stamp²    (S)

Both fields are omitted when zero.

wye_delta / delta_wye (2-winding, per-winding T-model):

These now use the same per-winding field set as single_phase — separate r/x_series_from (wye/primary winding) and r/x_series_to (delta/secondary winding), plus a g/b_no_load core-loss branch — matching the OpenDSS / PMD eng2math reference loss network. The series impedance enters the OPF as a voltage drop on the winding currents behind the ideal Yd/Dy transform; the delta side is no longer assumed ideal.

Z_base,from = v_nom_from² / s_rating
Z_base,to   = v_nom_to²   / s_rating
r_series_from = rw₁ · Z_base,from
r_series_to   = rw₂ · Z_base,to
x_series_from = (xhl / 2) · Z_base,from   # one leakage-split convention…
x_series_to   = (xhl / 2) · Z_base,to

The short-circuit test fixes only the series sum Z_from + N²·Z_to; how it splits between the windings is a convention, not a measurement. PowerIO's current BMOPF export (and therefore from_dss) splits xhl evenly in percent as above; PMD's _sc2br_impedance star conversion instead lumps the entire xhl on the winding-1 branch (x_series_to = 0). Both describe the same device at nominal tap — when comparing hand conversions against an import, check the HV-referred series sum, not the split (see the transformer test-data tutorial). The split becomes observable only under off-nominal taps, where the tapped winding's share scales as tap² (transformer models).

The no-load branch is on winding 2 (the to side): a delta of branches across the LV delta coils for wye_delta, phase-to-neutral on the LV wye for delta_wye. V_stamp is winding 2's coil voltage — the full line-to-line v_nom_to for a delta winding 2, the line-to-neutral v_nom_to/√3 for a wye winding 2:

g_no_load =  (noloadloss) · s_rating / V_stamp²   # noloadloss = %noloadloss / 100
b_no_load = -(cmag)       · s_rating / V_stamp²   # cmag       = %imag       / 100
Leakage placement

The winding split of the leakage is convention-dependent (see above): PMD's star conversion (_sc2br_impedance) puts the entire xhl on the winding-1 (HV) branch with zero on the LV branch, while PowerIO's BMOPF export — what from_dss delivers — carries a single lumped total already referred to the wye winding's base, which lands entirely on that side. BMOPFTools accepts either; the physics (the series sum, and the exact tap² referral of the tapped winding's share) is convention-independent. Any lumped single-impedance form is migrated onto the per-winding fields at parse time — see Transformer impedance on ingest. to_pmd writes the per-winding fields back to PMD rw/xsc.

to_pmd transformer specifics

  • No-load branch: noloadloss = g_no_load / Y_base and cmag = |b_no_load| / Y_base — cmag carries the magnetizing susceptance only, mirroring the ingest convention above (where b_no_load is deliberately left at 0, cmag is 0 too; core loss is never double-counted as magnetizing current).
  • Fixed tap: a tap ≠ 1 exports as PMD tm_set = [fill(tap, n_ph), fill(1, n_ph)] with tm_fix = true — the same N_eff = (v_nom_from/v_nom_to)·tap convention, so a fixed off-nominal tap round-trips exactly. Free-tap bounds (tap_min/tap_max), per-winding current limits (i_max_from/i_max_to), and regulator tap_ratio have no faithful counterpart in this exporter and are dropped with a warning.
  • center_tap is skipped with a warning: the split-phase unit needs PMD's 3-winding representation, which this exporter does not implement; emitting a 2-winding WYE-WYE with a 3-terminal secondary would be malformed.
  • Settings: to_pmd(net; frequency=..., sbase=...) sets settings.base_frequency / settings.sbase_default (defaults 50 Hz, 1 MVA). The optional network-level meta.frequency (populated on ingest — see below) is not read automatically; pass frequency = net["meta"]["frequency"] when you want the export to reflect it.

OpenDSS export (to_dss)

to_dss is the inverse of from_dss: it serializes a BMOPF data model to BMOPF JSON (via write_bmopf) and hands it to PowerIO's DSS writer, which emits OpenDSS text.

BMOPF data model ──write_bmopf──► BMOPF JSON ──PowerIO.jl──► OpenDSS .dss text

to_dss(net) returns the generated text and PowerIO's fidelity-loss warnings; to_dss(net, path) writes the text to path (creating parent directories) and returns the warnings. A name keyword overrides the circuit name without mutating the input dict.

BMOPF terminal labels ("a", "b", "c", "n") are accepted by the writer and re-normalized to OpenDSS numeric nodes (.1, .2, .3, .0); merged polyphase sources are re-expanded by the writer as needed.

Rating-preservation guard (PowerIO 0.11.7)

By default, export re-imports the generated DSS and compares declared bounds and ratings under identity component and terminal correspondence, resolving linecode current ratings to each line. Changed identities, added equivalent branches, missing bounds, changed ratings, dynamic controls/time series, DC networks, or an unsuccessful re-import produce an indeterminate admission Finding (W.EXPORT.DSS_RATING_UNVERIFIED) and an ArgumentError. The file-writing overload refuses before creating directories or overwriting the destination. This is a conservative declaration check; passing it certifies neither electrical equivalence nor OPF feasibility or objectives.

For an independent power-flow verification deck, explicitly opt in:

findings = Finding[]
text, warnings = to_dss(net; allow_unverified_ratings=true, findings)
to_dss(net, "verification/Master.dss"; allow_unverified_ratings=true)
write_bmopf(net, "authoritative.bmopf.json")  # OPF and Tellegen input

Opt-in retains the warning and structured evidence in findings: status, checked domain, source/target declaration changes, unassessed dimensions, PowerIO version, and the upstream issue link. The input dict is unchanged.

powerio#762 records the concrete missing-rating → 600 A defect on a source-equivalent branch in PowerIO 0.11.9 and earlier. PowerIO 0.11.10 keeps an absent OpenDSS ampacity absent, so unrated source-equivalent branches now return unrated. The guard still refuses these exports by default: the synthesized ideal-source and shunt-equivalent lines change component correspondence, and an explicit zero-A limit returns unrated because OpenDSS reads normamps=0 as no limit. Power-flow agreement cannot certify these constraints. Keep BMOPF JSON as the authoritative model for OPF and Tellegen, and do not strip every 600 A limit or substitute zero: both may be real active constraints.

The 36 feature fixtures retain exact structural change inventories, explicit terminal correspondences, effective-line and shunt primitive checks, source +R/−R and tiny-impedance equivalent checks, grounding checks, physical feeder rating checks, and an independent OpenDSS voltage comparison. Renamed neutral terminals may also lose their neutral-role metadata after re-import; electrical grounding agreement does not repair that role. The minimized rating witness and one neutral-role witness are narrowly expected failures. The round-trip suite as a whole is not marked as expected failure. The original open-delta fixture's independent solve does not converge and remains an explicitly recorded skip.

Conversion failures with source evidence

from_dss wraps a PowerIO.PowerIOError raised during parsing or BMOPF emission in DSSConversionError. The absolute input path and stage are attached to the wrapper. The original exception remains available unchanged as err.cause, and reader diagnostics captured before emission are retained separately from its failure diagnostics.

using BMOPFTools, JSON3

findings = Finding[]
path = joinpath(pkgdir(BMOPFTools), "test", "data", "line_geometry",
                "unresolved_geometry.dss")
try
    from_dss(path; findings)
catch err
    err isa DSSConversionError || rethrow()
    @show err.source_path err.stage err.cause.code
    println(JSON3.write(dss_conversion_error_to_dict(err)))
end

This changes the exception type at those two boundaries: callers previously catching PowerIO.PowerIOError should catch DSSConversionError and inspect err.cause.code. Direct PowerIO calls retain their upstream behavior. Missing files and unrelated package/audit errors propagate unchanged.

dss_conversion_error_to_dict provides JSON-safe copies of source_path, stage, reader_diagnostics, and upstream_error. Diagnostic codes, severities, details, targets, spans and related records are preserved. Absent targets/spans stay absent; a component named only in a message is not promoted to structured identity. The serializer includes no foreign module handle, backtrace, partial network or scientific-contract status.

On a wrapped failure the optional findings vector receives the available diagnostic classes once for that call, preserving prior entries. Their detail includes source_path, conversion_stage, and diagnostic_details with reader/failure origins. Grouping can put multiple upstream observations in one Finding; the raw per-stage arrays remain intact in the serialized error. No Finding is fabricated if neither stage supplies diagnostic records. Catch the wrapper to inspect the failure code even in that case.

Successful conversions retain their existing warning/Findings views and message-attribution conventions. Unresolved geometry still rejects the entire conversion.

Known limitations

Geometry-defined OpenDSS lines: PowerIO 0.11.7 resolves supported geometry into line-specific effective impedance snapshots. The synthetic overhead fixture is checked against all entries of independent OpenDSS R/X/C matrices, including equivalent meter/kilometer lengths and both IR and BMOPF serialization. Follow net["line"][id]["linecode"] to find the effective matrix; an authored LineCode name need not survive as an active electrical record.

A missing geometry or conductor still rejects canonical BMOPF conversion with BUILD.DIST.ELECTRICAL_INCOMPLETE; from_dss preserves that code in DSSConversionError.cause. It never fills unresolved lines with default sequence impedances. This protects the failure boundary in issue #381.

An effective electrical snapshot is not an editable WireData/LineGeometry model. Retain the source DSS or PowerIO module for those definitions. BMOPFTools can compile its own wire_data/line_geometry representation; that separate capability does not imply that intake populates it.

  • Transformer fidelity (from from_dss). The BMOPF export carries fixed taps, center-tap leakage, delta-wye leakage, neutral grounding, and the validated n_winding cases directly. BMOPFTools normalizes no-load shunts to its OPF convention.
  • Grounding reactors need phases=1 (gotcha). PowerIO silently drops a New Reactor.grnd ... bus2=X.0 neutral-grounding reactor unless it declares phases=1 (OpenDSS itself tolerates the omission). Without it the grounded neutral floats — a wye-load delta_wye then has no neutral reference and the power flow diverges. Always write grounding reactors with an explicit phases=1.
  • Ground references remain distinct from neutral conductors. from_dss maps a materialized OpenDSS node-0 reference to n_ref using its grounded declaration. It does not merge that reference with conductor neutral n. See The earth terminal.
  • Wye-wye three-phase transformers have no spec type. They are parked in single_phase with 3-phase terminal maps and flagged (W.SPEC.XFMR_TMAP_ARITY); the faithful decomposition into three single-phase units is future work.
  • RegControl / tap controllers: PowerIO retains the written static taps and reports ignored voltage regulation; it does not reproduce the control loop. Pairable single-phase regulator legs marked by RegControl can be emitted as one open_delta_regulator. The writer regression checks that from_dss restores its connection and both taps from the 0.1.0 extras representation. This recognition does not establish general AutoTrans/RegControl support. to_pmd does not emit the regulator subtypes. See conventions and the OPF reference for the numerical models and their domains.

DSS intake compatibility

The minimum supported PowerIO version is 0.11.11. PowerIO carries static DSS model-4 CVR exponents in its typed voltage model and emits them directly. from_dss verifies aligned finite exponents instead of restoring defaults from missing legacy extras. Explicit zero and negative exponents are preserved; unsupported time-varying CVRcurve remains rejected.

Multi-winding %R conversion using winding 1's power base (#356) remains an intake repair, recorded in _meta.powerio_intake_repairs. Ordinary BMOPF JSON intake is unchanged. Typed load voltage thresholds are retained separately in _meta.powerio_source_semantics, with their typed-model origin distinguished from legacy source extras. Each typed observation also retains the complete typed_voltage_model, including the low-voltage transition and exponents. They are behavior-domain observations, not bus operating bounds. PowerIO's EMIT.BMOPF.VALUE_SUBSTITUTED diagnostics remain visible: the nominal BMOPF load law does not reproduce bounded-law transitions, current limits or dropout outside its supported domain.

DSS sources now include explicit series impedances and internal EMF buses. Keep those branches when solving an imported case. Extra buses and branches, linecode identities/provenance and canonical DSS source-equivalent circuits can change dictionary structure across a round trip; compare physical bus voltages with explicit correspondence and inspect conversion diagnostics.

to_dss also emits explicit model-4/CVR parameters and first-winding-base multi-winding %R edits, preventing the repaired laws from disappearing again on export. Exponential exponents must be uniform across the phases of one DSS load; unsupported per-phase exponents are rejected. The PMD exporter is unchanged.