Reading CGMES Input

cargo build --features cgmes builds src/cgmes.rs, a third network-input path alongside the native JSON format and PGM-JSON, reading CGMES (Common Grid Model Exchange Standard) RDF/XML — the IEC 61970/61968 interchange format ENTSO-E and TSOs use.

It is built on cimoxide — a separate Rust project by the same author — for RDF/XML decoding, via its cimoxide-model crate (see Provenance and Licensing). The feature is opt-in since some users only need JSON input and shouldn't pay for cimoxide's dependency tree or build time.

#![allow(unused)]
fn main() {
use gridoxide::cgmes::{load_profiles, cgmes_to_buses_and_branches};
use gridoxide::network::{build_ybus, stamp_shunts};
use gridoxide::run_power_flow_analysis_from_ybus;

let ds = load_profiles(&[&eq_path, &ssh_path, &tp_path, &sv_path])?;
let (buses, lines, transformers, shunts) = cgmes_to_buses_and_branches(&ds, 100e6)?;
let mut ybus = build_ybus(buses.len(), &lines, &transformers);
stamp_shunts(&mut ybus, &shunts);
let result = run_power_flow_analysis_from_ybus(buses, ybus);
}

What the importer expects

The standard EQ+SSH+TP+SV "solved case" profile bundle:

  • TP is required. TopologicalNode is used directly as gridoxide's Bus, so switch-state topology processing is assumed already resolved upstream. See Ideal Switches and Zero-Impedance Branches for what that resolution involves and how cgmes::merge_closed_switches handles the node-breaker case.
  • SV must carry a populated TopologicalIsland.AngleRefTopologicalNode, used as the slack bus. See Multi-Island Power Flow for how reference buses are picked per island.

What is mapped

Loads — EnergyConsumer, ConformLoad, NonConformLoad, EquivalentInjection, ExternalNetworkInjection, and AsynchronousMachine. The last is converted like a plain load, with both P and Q negated.

Branches — ACLineSegment and SeriesCompensator, including ACLineSegment.gch, real shunt conductance, not just bch's reactive charging.

Transformers — 2- and 3-winding PowerTransformers, with RatioTapChanger (including its optional RatioTapChangerTable per-step override, falling back to the linear stepVoltageIncrement formula when absent) and all four PhaseTapChanger variants: Linear, Symmetrical, Asymmetrical, and Tabular.

Shunts — LinearShuntCompensator and NonlinearShuntCompensator.

Voltage-controlled buses — SynchronousMachine plus RegulatingControl, and the same mechanism for StaticVarCompensator and ExternalNetworkInjection, minus the active-power term for the former.

Validation

Validated end-to-end against four ENTSO-E conformance cases, with fixtures referenced via a git submodule (see tests/data/cgmes/README.md):

CaseTestNotes
MicroGrid-BE-MAStests/cgmes_microgrid_be_test.rs
MiniGridtests/cgmes_minigrid_test.rsFirst fixture with more than one 3-winding transformer, which exposed and fixed a real star-bus-indexing bug; also real AsynchronousMachine loads (~9 MW / ~5 MVAr)
PhaseTapChangerLinear PSTtests/cgmes_pst_phase_tap_changer_linear_test.rsMatches published SV values to ~1e-3
RealGridtests/cgmes_realgrid_test.rsLarge real transmission+distribution model, 6252 buses

MicroGrid-BE-MAS and MiniGrid converge cleanly but match their own published SV voltages only within a few percent. That gap was cross-checked (for MicroGrid-BE-MAS) against pypowsybl's own independent CGMES import and AC load flow on the same case, which shows a comparable deviation from the same published values (scripts/bench/cross_validate_cgmes_microgrid_be.py) — confirming it is inherent to solving a boundary-truncated area file with fixed-injection equivalents, not a correctness bug. One known, documented limitation contributes: types::Line has no tap ratio, so it can't absorb the small nominal-voltage mismatch CGMES explicitly allows at boundary tie points.

FullGrid is an import fixture, not a solve fixture

FullGrid is the richest conformity model in the tree — both TapChangerControl modes, two phase shifters sharing one target, a three-winding transformer, HVDC, a bus held by nine machines — and tests/cgmes_tap_table_test.rs, cgmes_tap_regulation_test.rs, cgmes_voltage_control_test.rs and cgmes_node_breaker_test.rs all use it for exactly those.

It does not converge, and it cannot. Its NonlinearShuntCompensatorPoint._7df4778f declares b = 0.99 S and g = 0.99 S for BE_SHUNT_1 at nomU = 225 kV. On a 100 MVA base, \(z_{base} = 506.25,\Omega\), so \(g = 501.19\) per-unit — a shunt compensator dissipating 50 GW on a network whose entire scheduled generation is 485 MW. The first Newton iteration reports a mismatch of 503 pu, which is that conductance and almost nothing else.

The conversion is arithmetically right; the input is not physical. 0.99 turns up across FullGrid as filler for several unrelated quantities — it is also the SVC's inductiveRating/ capacitiveRating, giving that device a ±511 pu reactive band. These are placeholders exercising a profile's classes, not a modelled network.

Worth separating from a different failure mode: FullGrid's published SvVoltage is consistent with its own EQ/SSH transformer data — scripts/bench/check_cgmes_sv_consistency.py flags zero of its ten two-winding transformers above 5%, where Svedala has one and RealGrid has many. The inconsistency here is between the fixture and physics, not between two of its own profiles.

tests/cgmes_fullgrid_test.rs pins all of this, including the non-convergence itself, so a corrected fixture announces itself. This has cost effort before: scripts/bench/README.md records that network::dc_angle_guess was added specifically to make FullGrid converge, did not, and was removed after it broke case3120sp.

Not built or tested in CI — the same local/manual-verification posture as klu and pardiso.

The per-class pages

The remaining pages in this section each take one CIM class or attribute that needed real modeling work, and follow the same structure: why it matters, the concepts and formulas involved, where it sits in gridoxide today, and how other tools handle it.

One voltage base per galvanic level

An ACLineSegment has no ratio. Its two ends are one conductor at one physical voltage — which means per-unit across it is only meaningful if \(|V| = 1.0\) denotes the same volts at both. That is what a per-unit base is for.

CGMES does not guarantee it. The same physical level is declared 380 kV in Belgium and 400 kV in the Netherlands, 220 and 225 either side of another border, and a tie line between them is one wire carrying two different BaseVoltage.nominalVoltage values on its ends. Per-unitizing that line on one end's base — all a single base can do — leaves its two ends in different per-unit systems, so a flat \(1.0\) profile already contains a 5% step across a wire with nothing in it to make one. The solver pushes reactive power to sustain the step, and the whole area comes out high.

So the importer gives every galvanically-connected group of buses one base before it converts a single branch. The group is the connected component over plain conductors — ACLineSegment and SeriesCompensator — and the base is the nominal the most of its members declare, ties going to the larger.

Moving a base is safe, and moving a branch is not

A per-unit base is a choice, not a measurement. Moving one changes the per-unit numbers and leaves the volts alone, because everything that converts between the two reads u_rated: a reported voltage is \(|V| \cdot u_{rated}\), a voltage setpoint is imported as \(target / u_{rated}\), and — the load-bearing one — transformers self-correct, because transformer_tap already takes the node ratings and computes

\[ k = \frac{u_1 / u_2}{u_{1,rated} / u_{2,rated}} \]

so a moved node base is absorbed by the off-nominal ratio. That is why this runs before any branch is converted rather than after.

The one thing it is not invariant to

That invariance holds wherever u_rated is used as a base. It fails in the one place the importer uses it as a stand-in for the actual operating voltage: the StaticVarCompensator rating, which turns an ohmic reactance into a reactive limit through \(Q \approx V^2 / x\) evaluated at \(|V| = 1.0\). Move the base and \(1.0\) p.u. denotes different volts, so the limit moves with it — by \((400/380)^2 = 10.8%\), or \((225/220)^2 = 4.6%\).

The shunt compensators converted a few lines away look identical and are not affected, which is worth seeing clearly: they convert siemens to per-unit, and \(Q_{pu} = |V|{pu}^2 \cdot B{pu}\) recovers the same physical MVAr on any base. Only pinning \(|V| = 1\) makes the base stand for a voltage.

No vendored fixture exercises it. MicroGrid-Type1's single SVC sits at 225 kV — the base that is kept — so its rating is ±0.1 p.u. either way. A document with an SVC on the other side of such a boundary would see its rating shift, and nothing would flag it.

It adjudicates, and the alternative does not

Harmonizing has to choose which of two declarations to keep. That is a judgement about the document, not arithmetic, and it rests on an inference: an ACLineSegment has no ratio, therefore its two ends are one level, therefore a differing declaration is a naming convention rather than a fact.

For 380/400 and 220/225 that inference is right, and the error figures below say so. Where it would be wrong is a document that is itself wrong — a line written where a transformer belongs. Then harmonizing forces the two ends onto one base and the level difference disappears into a model that looks entirely reasonable, where powsybl's branch-side treatment would carry both declarations through and let the discrepancy reach the answer. Neither is correct, because the input is not; the difference is whether to trust the declaration or the topology.

What this does instead of choosing silently is report: CgmesNetwork::base_harmonization names every nominal it merged and how many buses moved, and examples/base_mismatch_probe.rs prints it. That is weaker than not having to choose — it needs someone to look — but it is what makes a mis-declared nominal noticeable rather than absorbed.

What it was worth

Measured against the fixtures' own published SvVoltage:

fixturelines spanning two basesworst error beforeafter
MicroGrid-Type15 of 134.4%1.3%
FullGrid5 of 13— (does not converge)—
SmallGrid, MiniGrid, RealGrid, Svedala0unchanged, bit for bit

On MicroGrid-Type1 the median error falls from 0.96% to 0.01%. With remote voltage control also enabled — the same fixture needs it — the worst error reaches 0.09%, and the machine's own reactive output comes within 2% of the published one. Neither fix is visible underneath the other: the base defect was larger than the reactive power remote control moves, which is why that fixture could not referee remote control until this landed.

What powsybl does

The same problem, solved in the branch rather than the bus, and enough of a real modelling question there to be a configuration option. LinePerUnitMode is either IMPEDANCE — per-unitize on the geometric mean \(n_1 n_2 / S_B\) and fold correction terms into the two end shunts — or RATIO, an ideal transformer of \(n_1/n_2\) on an otherwise ordinary line (LfBranchImpl.createLine).

Both need a branch model with per-terminal shunts or a ratio. types::Line has neither, deliberately: its shunt is one total split equally, which is what every line in every other fixture needs. Doing this in the bus base reaches the same coherent per-unit system without giving every line in the crate a field that ten lines in two fixtures would ever use — and, unlike RATIO, without moving a line into the transformer list and shifting the flat branch index that ratings, terminal_branch and the RAO all address branches by.