Reading UCTE-DEF Input
UCTE-DEF is the fixed-column text format the continental-European TSOs used to exchange network snapshots before CGMES. It is superseded, and it is still the format most published remedial-action and capacity-calculation test material is written in — which is the practical reason gridoxide reads it.
#![allow(unused)] fn main() { let net = gridoxide::ucte::read("TestCase12Nodes.uct")?; let report = gridoxide::run_power_flow( net.buses.clone(), &net.lines, &net.transformers, &net.shunts, PowerFlowOptions::default(), ); }
Behind the ucte feature. Unlike cgmes it costs nothing to enable: the format
is plain text, so the importer is pure std with no dependency, and it is built
and tested in CI.
What it brings that the other importers do not
Two things, and they are the reason this importer exists at all rather than a conversion script.
Branch ratings. A ##L record ends in a current limit and a ##T record
carries one too:
BBE1AA1 BBE2AA1 1 0 0.0000 10.000 0.000000 5000
// ^^^^ permanent rating, amperes
Nothing in [types::Line] or [types::Transformer] has anywhere to put that —
the only rating in the crate before this was the single rate_a in the
companion OPF document. It lands in
ratings::BranchLimits, indexed by the same flat
branch index as everything else:
#![allow(unused)] fn main() { let b = net.branch_ids.iter().position(|id| id.starts_with("BBE1AA1 BBE2AA1")).unwrap(); assert_eq!(net.limits[b].patl_a, Some(5000.0)); }
Tap tables. A ##R record is a tap changer:
BBE2AA1 BBE3AA1 1 -0.68 90.00 16 0 SYMM
// ^^^^^ ^^^^^ ^^ ^^ ^^^^
// du% theta n n' kind
so a phase shifter arrives as a TapChanger with all 33 positions, not as one
complex number:
#![allow(unused)] fn main() { let pst = net.tap_changers[0].as_ref().unwrap(); assert_eq!((pst.low, pst.high(), pst.position), (-16, 16, 0)); pst.angle_deg(16); // the shift at the top tap }
Conventions, and why each is worth stating
Every one of these is silent when wrong — the file still loads and the network still solves, just to a different answer.
Generation is negative. UCTE writes generation as a negative number in a
field called "active power generation". Net injection is (−generation) − load,
and the permissible-generation fields are negated the same way, which swaps
which of each pair is the lower bound.
Nominal voltage comes from the node code, not from the record. Character 7
of the 8-character code is a voltage class — 1 is 380 kV, 2 is 220 kV. The
voltage field in the record is the set-point a regulating node holds, commonly
400 kV on a 380 kV node. That is a per-unit target of 1.0526, not a different
base.
Transformers are reversed relative to the file. UCTE refers a transformer's
impedance to its node-1 side and puts the tap changer on node 2. gridoxide's
network::branch_calc_param — the MATPOWER convention — wants the series
admittance at to and the complex ratio at from. So from is UCTE node 2 and
to is node 1, which is the same swap powsybl's own importer makes. Getting it
wrong is invisible on a 400/400 phase shifter and a 3% error on a 400/225 unit.
Files are Latin-1 and columns are bytes. One accented character in a node name would shift every later field if the line were decoded as UTF-8 first.
The slack is chosen, not read. UCTE has a node type for it — 3, "U and θ
constant" — and none of the vendored fixtures uses it, because the tools that
wrote them let the load flow pick. gridoxide picks the largest generator,
preferring one that regulates voltage, ties broken on the node code so two
imports of one file agree. The choice is recorded in notes, and
SlackPolicy::Node overrides it.
What the importer decides, it says out loud
UcteImport::notes carries everything that was a decision rather than a
reading, and out_of_service names every branch left out:
note: 8 X-node(s) kept as ordinary buses; boundary-line semantics are not modelled
note: 2 closed busbar coupler(s) imported as near-zero-impedance branches
note: 3 branch(es) omitted as out of operation
note: no `U and theta constant` node in file; slack chosen as `BBE2AA1 ` (largest generation)
A silently dropped element is how an importer produces a plausible wrong answer, so nothing is dropped silently.
How far it agrees with pypowsybl
Every fixture is solved and compared against pypowsybl, element by element
(tests/ucte_test.rs). On networks whose transformers declare no magnetizing
admittance the agreement is exact to solver tolerance — below 1e-9 on
per-unit voltage, 1e-7 degrees on angle, and 1e-5 MW on every branch terminal —
including a case with 400/225 transformers, X-nodes, and a phase shifter parked
at tap 16 of 16.
Where a transformer does declare a magnetizing admittance, angles differ by
around 2e-4 degrees and reactive flows by a few tenths of a MVar. That is one
known model difference and nothing else: IIDM places the whole shunt on side 2,
while network::branch_calc_param splits it equally between both ends — a Γ
model against a π model. Zeroing just those fields drops every figure back to
2e-9 degrees and 1.1e-7 MW, which is how the cause was established rather than
assumed. The choice is the crate's, not this importer's: the CGMES and PGM paths
split the shunt the same way.
Not modelled
- X-nodes are ordinary buses. powsybl merges the two half-lines meeting at an X-node into one tie line; gridoxide keeps the X-node. On the buses the two have in common the answers agree, but gridoxide reports more buses and more branches, and an X-node carrying its own load or generation is not treated as a boundary injection.
- Out-of-service branches are omitted, not carried as openable elements.
They are listed in
out_of_serviceso a later phase can reconnect them. ##TT,##Eand any other block are skipped with a note. None appears in any vendored fixture.