Reading IIDM Input

IIDM is the native serialization of powsybl-core's network model — .xiidm files — and the second of the two formats published remedial-action test material comes in, the other being UCTE-DEF.

#![allow(unused)]
fn main() {
let net = gridoxide::iidm::read("nordic32.xiidm")?;
let report = gridoxide::run_power_flow(
    net.buses.clone(), &net.lines, &net.transformers, &net.shunts,
    PowerFlowOptions::default(),
);
}

Behind the iidm feature. It costs one crate (quick-xml, already in the tree as a transitive dependency of the CGMES decoder) and no system library, so like ucte and opf it is built and tested in CI.

Version tolerance is the design constraint

The IIDM fixtures available for testing span eleven schema versions, 1_0 through 1_16, and pypowsybl 1.16 writes 1_17. The model moved underneath them. Limits are the clearest case:

<!-- older -->                     <!-- newer -->
<iidm:currentLimits1               <iidm:operationalLimitsGroup1 id="DEFAULT">
    permanentLimit="721.7"/>           <iidm:currentLimits permanentLimit="5000.0"/>
                                   </iidm:operationalLimitsGroup1>

and danglingLine became boundaryLine along the way.

So the importer accepts any 1_* namespace, understands both spellings, and skips elements it does not recognise rather than failing. A parser pinned to one version reads a fifth of the available material and breaks against the next powsybl release.

What it does not do is skip silently. Every unrecognised element type is counted and named:

note: skipped 4 `hvdcLine` element(s)
note: skipped 2 `slackTerminal` element(s)
note: 1 branch(es) omitted as disconnected

One topology, two spellings

IIDM voltage levels come in two kinds, and this importer folds them into one representation. A busBreakerTopology names its buses; a nodeBreakerTopology numbers its nodes and connects them with switches. Both become nodes of a single NodeBreakerTopology, and the bus view is whatever [bus_view] makes of it — for a bus-breaker file with no switches, exactly the buses the file declared; for a node-breaker file, the connected components of its closed switches.

That is not merely tidy. It is why a topological remedial action will be expressible on an IIDM network at all: the switches survive import as first-class objects instead of being resolved away, so RetentionPolicy picks the view rather than the parser fixing it.

#![allow(unused)]
fn main() {
let options = IidmOptions { retention: RetentionPolicy::RetainAll, ..Default::default() };
let net = gridoxide::iidm::read_with(path, &options)?;
net.topology.switches.len();     // 17 on the node-breaker fixture
net.view.n_buses();              // more than under MergeAll — the switches now split buses
}

Boundary nodes

A dangling (boundary) line runs from a real bus to a boundary node — what UCTE calls an X-node. gridoxide keeps that boundary as a real bus rather than collapsing the half-line into an injection, for two reasons: it is what the UCTE importer does with an X-node, so the two agree structurally on the same network; and it makes tie lines fall out for free. The two halves of a tie line share a pairingKey, so they land on the same boundary bus with no pairing logic at all — which covers both the newer form (a tieLine naming two danglingLines) and the older one (both halves inline as _1/_2 attributes).

A boundary node belongs to no declared voltage level and so has no nominalV. It adopts the voltage of whatever it attaches to; without that the per-unit base would default to 1 V and every quantity through the boundary would be nonsense.

How far it agrees

Two gates, and the first is the stronger.

Cross-format. Three fixtures are pypowsybl exports of .uct files the UCTE suite already checks against pypowsybl. gridoxide reads the same network through two parsers sharing no code and the answers must agree. On the twelve-node case, with and without the phase shifter at tap 16 of 16, they are bit-identical — zero deviation across all 16 branches. On the case with 400/225 transformers and X-nodes the worst deviation is 6e-12 MW, once the seven boundary half-lines whose terminals the two formats declare in opposite order are accounted for.

That last point is a labelling difference, not a physical one: a UCTE ##L record may name the X-node first where the IIDM boundary line names the real bus first, which flips the reported sign of a flow without changing anything.

Against pypowsybl, on fixtures written as IIDM in the first place — which is what covers node-breaker topology, temporary limits and the older spellings, none of which UCTE can express. nordic32 (52 buses, 80 branches) agrees to 1.2e-3 MVar on quantities of several hundred MVar. The residual is the same known model difference the UCTE page quantifies: IIDM places a transformer's magnetizing shunt entirely on side 2 while network::branch_calc_param splits it equally between both ends.

Not modelled

  • HVDC. hvdcLine, vscConverterStation and lccConverterStation are counted and then skipped with a note. gridoxide has a real DC-side network (crates/gridoxide-core/src/dc.rs) reachable from CGMES, and wiring IIDM into it is its own piece of work — one no fixture in the tree calls for, since none contains an HVDC element.

    This page used to say the same thing while the importer did something else: a vscConverterStation shared the generator arm, and since a converter station states no targetP it became a generator producing zero, silently losing the transfer. Counting it is what the page claimed and what it now does.

  • Extensions. slackTerminal, referenceTerminal, mergedXnode, busbarSectionPosition, virtual hubs — all named in notes and skipped. Notably slackTerminal is where powsybl records a chosen slack, so gridoxide currently picks its own (largest generation, preferring a voltage-regulating bus, ties broken on the bus label) and says so in notes.

  • A generator's reactive capability as a curve. reactiveCapabilityCurve states minQ/maxQ as a function of P; types::Bus carries one pair. The importer takes the curve's envelope and reports how many generators it did that for. It is an outer approximation — never forbidding an output the machine can produce, possibly permitting one it cannot at its dispatched P — and it replaces no limit at all, which was the same kind of approximation and a worse one. A minMaxReactiveLimits pair is read exactly.

  • Asymmetric line shunts. IIDM states g1/b1 and g2/b2 separately; Line carries one total that the π-model splits equally. Every line in every vendored fixture is symmetric, and any that is not is counted in notes rather than silently averaged.

Individual generators and loads stay addressable

The importer folds every injection into its bus's net p_spec/q_spec, which is all a power flow needs and is not reversible afterwards. IidmImport::injections keeps the correspondence back: each generator and load by its own IIDM id, the bus it sits on, and the injection the file states.

That is what lets something address an individual machine rather than a bus. A Dynawo dynamic model attaches to a generator by staticId, and a dynamic study needs each machine's own terminal power rather than its bus's total — see Reading Dynamic Data.