2. Data model and per-unit conventions¶
This chapter specifies the in-memory network model that every solver and study reads, and the per-unit system its
numbers are expressed in. The whole model is defined in one file, src/GridSim.Core/Model/Model.cs: GridModel
and its element records Bus, Generator, Branch, Oltc, Boundary, ZipLoad and GeneratorCost. The chapter
gives every field with its unit, default, validation rule and the loaders that set it; the per-unit base system; the
branch pi-model with tap and phase shift exactly as AdmittanceStamp implements it; the three Y-bus builders and how
they stay bit-identical; the unit normalisation that rescues raw-unit distribution files; the immutability rules; and
the invariants with the tests that pin them. The solvers that consume the model are specified in
ch. 3, Power flow, the file formats that fill it in
ch. 10, File and wire formats, and the JSON v2 case schema in
ch. 28, The v2 JSON schema.
Model¶
Shape of the model¶
GridModel is a sealed class whose properties are init-only. Its elements are sealed records. By convention no code
changes a built model (see "Immutability and the copy helpers"). There is no
separate load type: demand is a property of the bus (the MATPOWER convention), and shunts are properties of the bus.
GridModel
BaseMva system MVA base (required)
Buses : Bus[] required; file order is the matrix order
Generators : Generator[] required; the IN-SERVICE fleet (see "The generator fleet")
AllGenerators / OutOfServiceGenerators read-only views of the whole fleet
Branches : Branch[] required; lines and transformers, file order
Oltcs : Oltc[] default empty
Boundaries : Boundary[] default empty
FrequencyHz : double default 50.0
Name : string default "grid"
AssetDynamics: AssetDynamicsModel? default null (asset classes and bindings)
Protections : ProtectionModel? default null (placed protective devices)
BusCount, SystemInertiaGvaS computed
Result row i of every solver corresponds to Buses[i]; the solvers build an id-to-index map from Bus.Id, and
results carry BusIds so an external id can always be recovered. Bus ids are arbitrary integers and need not be
contiguous or sorted. They must be unique.
Which loader sets what¶
Five paths build a GridModel: the MATPOWER parser (src/GridSim.Core/IO/Matpower/MatpowerParser.cs), the JSON v1
loader (src/GridSim.Core/IO/Json/JsonGridLoader.cs), the JSON v2 loader
(src/GridSim.Core/IO/Json/V2CaseLoader.cs, which maps a v2 case onto the v1 data carriers and builds through
JsonGridLoader.Build, so a v2 case and its v1 equivalent give the same model bit for bit, as the invariants pin), the CIM bridge
(src/GridSim.Core/IO/Cim/CimGridModelBridge.cs) and the GDA builder (src/GridSim.Gda/GdaModelBuilder.cs). A sixth,
BridgeProtocol.BuildModel (src/GridSim.Core/IO/BridgeProtocol.cs), rebuilds a model from a bridge topology
message or a solve export. It reads a subset of the fields, listed under each element table, and is the only loader that
sets a branch InService false and the winding data. The tables below name the loader behaviour field by field. "v1" and "v2" share one builder, so a field set by "JSON" is set by both unless the entry
says otherwise.
Bus¶
Bus(Id, Type, Pd, Qd, Gs, Bs, BaseKv, Vm, VaDeg, Name, X, Y, Zip, VminPu, VmaxPu).
| Field | Unit | Default | Validation (GridModel.Validate) |
Meaning |
|---|---|---|---|---|
Id |
- | required | duplicate ids are an error, reported once per id | External bus number. |
Type |
BusType |
required | no slack bus anywhere is an error; several are a warning | Slack: Vm and Va fixed, absorbs the balance. Pv: P and Vm fixed, Q solved. Pq: P and Q fixed, V solved. |
Pd, Qd |
MW, MVAr | 0 | must be finite | Demand at 1.0 pu voltage. |
Gs, Bs |
MW, MVAr at 1.0 pu | 0 | must be finite | Fixed shunt, entered as the power it draws at 1.0 pu. Positive Gs draws MW; positive Bs injects MVAr (a capacitor bank). |
BaseKv |
kV | 0 | none | Nominal bus voltage. Never enters the solve; it converts per-unit voltage back to kV. |
Vm |
pu | 1.0 | none | Initial voltage magnitude. At a Slack or Pv bus that has a generator the generator's Vg takes precedence; FlatStart replaces the rest with 1.0. |
VaDeg |
degrees | 0 | none | Initial angle. Always honoured at a Slack bus. |
Name |
- | null | none | Asset identity carried through exports. |
X, Y |
0..1 | 0 | none | Layout coordinates for drawing. No solver reads them. |
Zip |
ZipLoad? |
null | JSON: all six coefficients present and finite, each triple sums to 1 within 1e-9 |
Voltage-dependent load; null is constant power. |
VminPu, VmaxPu |
pu | null | each given limit finite and above 0; VminPu not above VmaxPu (equal is allowed) |
The bus's own voltage limits; null means the global operational band applies. |
Loaders:
| Field | MATPOWER | JSON v1 / v2 | CIM | GDA |
|---|---|---|---|---|
Id |
mpc.bus column 1 |
id |
1-based position in the CIM bus list; three-winding star points are numbered after the CIM nodes | 0-based sequence in order of first use |
Type |
column 2: 3 is Slack, 2 is Pv, anything else Pq; type 4 (isolated) is dropped with its elements; a type 2 bus with no in-service generator is demoted to Pq and a note says so |
type (pq; pv or gen; slack, ref or swing; any letter case; an unknown word or JSON null is refused); an in-service generator raises its bus to Pv, and a slack role makes its bus Slack; a pq role leaves the bus alone |
the first generator's bus is Slack, later generators on other buses are Pv, everything else Pq; a network with no generator makes bus 1 the slack |
Slack for each grid supply point infeed, Pq for the rest; an island left with no slack gets one at its highest-kV real bus (never a transformer star node) |
Pd, Qd |
columns 3, 4 | demand.json rows summed per bus: pMw, and qMvar or, when absent, pMw * tan(acos(powerFactor)) with powerFactor defaulting to 0.97 and clamped to [0.01, 1] |
summed P, Q of the CIM loads |
demand rows summed per bus |
Gs, Bs |
columns 5, 6 | gs, bs |
not set (0) | not set (0) |
BaseKv |
column 10 | baseKv, required: a bus without it is refused naming up to twenty buses |
the CIM bus base voltage | the voltage of the merged node |
Vm, VaDeg |
columns 8, 9 | vm, vaDeg |
1.0, 0 | 1.0, 0 |
Name |
null | name |
CIM name | substation name |
X, Y |
0 | x, y |
0 | set only by the optional geolocation step |
Zip |
null | zip |
null | null |
VminPu, VmaxPu |
columns 13, 12; a row of fewer than 13 columns leaves both null with no note; a longer row whose pair is non-finite, not above 0 or has VMIN above VMAX leaves both null and is counted in a parse note |
vminPu, vmaxPu (refused naming the bus when unusable) |
null | null |
BridgeProtocol.BuildModel reads Type, Pd, Qd, Gs, Bs, BaseKv, Vm, Name, X, Y and Zip; it does not
read VaDeg or the voltage limits. A bus type name that does not parse silently becomes Pq; only winding names are
refused.
Actual kV recovery. A solved result stores Vm in per-unit. The physical voltage at result index i is
Result.Vm[i] * Buses[i].BaseKv. No other place applies a voltage base.
ZipLoad¶
ZipLoad(Ap, Bp, Cp, Aq, Bq, Cq) with defaults (0, 0, 1, 0, 0, 1):
P(V) = Pd * (Ap*V^2 + Bp*V + Cp) V in pu
Q(V) = Qd * (Aq*V^2 + Bq*V + Cq)
dP/dV = Pd * (2*Ap*V + Bp) Jacobian contribution
dQ/dV = Qd * (2*Aq*V + Bq)
VariesWithVoltage is true when any of Ap, Bp, Aq, Bq is non-zero; a bus whose ZIP does not vary is treated as
constant power, exactly as a bus with Zip null. ZipLoad.FromInverterFraction(f, nonInverter)
blends a constant-power inverter share f (clamped to [0, 1]) with a base composition:
f*(0,0,1) + (1-f)*nonInverter, so each triple still sums to 1 when nonInverter's does. The Newton engines apply the
ZIP as a deviation from nominal in the mismatch and add its derivatives to the Jacobian diagonal. The drawn power at a
solved voltage comes from one helper, LoadAtVoltage (src/GridSim.Core/PowerFlow/LoadAtVoltage.cs), shared by the
reactive-limit check and slack reactive diagnostic (NewtonShell), the backward/forward sweep, the distributed-slack
power flow, the physics audit and feasibility check (PhysicsLaws, PhysicsFeasibility) and power-flow tracing. JSON zip blocks, the bridge zip block and the JSON v2 zip member set it; MATPOWER, CIM and GDA do not,
and the MATPOWER writer reports a ZIP bus as a loss because MATPOWER cannot hold one.
Generator¶
Generator(Bus, Pg, Qg, Vg, Qmin, Qmax, Pmin, Pmax, MBase, H, Name, Type, DynamicsClass, SubtransientXdPu,
NegativeSeqXPu, ConverterInterfaced, ConverterFaultCurrentPu, PowerFlowOnly, UnitTransformerXPu, GridForming,
InService, Cost, ReactiveCost).
| Field | Unit | Default | Validation | Meaning |
|---|---|---|---|---|
Bus |
- | required | the bus must exist (error), for in-service and out-of-service units alike | The bus it sits on. Several generators on one bus are summed by the solvers. |
Pg, Qg |
MW, MVAr | Qg 0 |
finite (in-service units) | Dispatched output. At Pv and Slack buses the solve determines the actual Q. |
Vg |
pu | 1.0 | finite (in-service units) | Voltage setpoint at Slack and Pv buses. When several generators share a bus the last one listed sets it (VoltageSetpoint.ForBus, src/GridSim.Core/PowerFlow/VoltageSetpoint.cs), matching MATPOWER. |
Qmin, Qmax |
MVAr | -Infinity, +Infinity |
none: infinity means unlimited and is exempt from the finite check | Reactive capability; the per-bus sums feed the reactive-limit loop. |
Pmin, Pmax |
MW | 0, +Infinity |
none in Validate; the JSON loader refuses a non-finite pMinMw, a -Infinity pMaxMw, a +Infinity qMinMvar and a -Infinity qMaxMvar |
Real-power capability, read by the dispatch and optimal-power-flow studies. The load flow does not read it. |
MBase |
MVA | 100 | none | Machine base for H and the machine reactances. |
H |
MW.s/MVA on MBase |
0 | none | Inertia constant. System inertia is sum(H * MBase); GridModel.SystemInertiaGvaS divides by 1000. |
Name, Type |
- | null, "" |
JSON refuses a null type |
Identity and fuel or technology tag read by the fuel taxonomy. |
DynamicsClass |
- | null | none | Inline asset-dynamics class; wins over a binding rule. |
SubtransientXdPu, NegativeSeqXPu |
pu on MBase |
0 | none | X''d and X2 for short-circuit studies; at or below 0 selects the synthesised default (X''d) and the round-rotor rule (X2 = X''d). |
ConverterInterfaced, ConverterFaultCurrentPu |
bool?, x rated | null | none | Full-converter plant flag (null is inferred from Type by ConverterPlant.IsConverterType) and its fault-current multiple (null means 1.2, DefaultConverterFaultCurrentPu; 0 means no fault current). |
PowerFlowOnly |
bool | false | none | A balancing or voltage-control device, not physical plant. |
UnitTransformerXPu |
pu on MBase |
0 | none | Step-up transformer reactance added in series with the machine in fault studies. |
GridForming |
bool | false | none | Converter plant with grid-forming control. |
InService |
bool | true | none | False keeps the unit in the model but never solves it. |
Cost, ReactiveCost |
GeneratorCost? |
null | MATPOWER: see below | The MATPOWER gencost rows. Cost is read by the costed dispatch (src/GridSim.Core/Analysis/DcOpfCosted.cs, dcopf --costs, and acopf --costs, whose DC passes call it); ReactiveCost is carried and written back by the MATPOWER writer, and no solver reads it. |
GeneratorCost(Model, StartupCost, ShutdownCost, Coefficients) holds a MATPOWER cost row: Model 1 is piecewise
linear (Coefficients are the points x1, y1, x2, y2, ... as (MW, currency per hour)); Model 2 is a polynomial
(Coefficients are the NCOST coefficients, highest order first). PointCount is NCOST. The MATPOWER parser refuses a
gencost matrix that has neither one row per generator nor two, a row shorter than MODEL STARTUP SHUTDOWN NCOST, a
MODEL other than 1 or 2, an NCOST that is not a whole number of at least 1, a row too short for its NCOST, and any
non-finite cost number. The row-count refusal names the counts; every other refusal names the row.
Loaders:
| Field | MATPOWER (mpc.gen) |
JSON (generation.json) |
CIM | GDA |
|---|---|---|---|---|
Bus |
column 1 | bus |
the generator's node | the matched bus |
Pg |
column 2 | outputMw, else capacityMva * capacityFactor, with the factor defaulting by type (Dispatcher.DefaultCapacityFactor); the case is then balanced by Dispatcher.Balance unless system.json sets "dispatch": false |
CIM P |
capacity times the fuel capacity factor (FuelMap.CapacityFactor); with GdaBuildOptions.EmbeddedGenAsNegativeLoad the output is subtracted from the bus Pd and no Generator is created |
Qg |
column 3 | qOutputMvar, default 0 |
0 | 0 |
Vg |
column 6 | voltageSetpoint, default 1.0 |
1.0 | 1.0 |
Qmin, Qmax |
columns 5, 4 (absent columns give -Infinity, +Infinity) |
qMinMvar, qMaxMvar, defaulting to -capacityMva, +capacityMva; the strings "Infinity" and "-Infinity" are the only strings accepted |
-Infinity, +Infinity |
plus or minus 0.4 times capacity (GdaBuildDefaults.EmbeddedGenQRatio) |
Pmin, Pmax |
columns 10, 9 (Pmax defaults to +Infinity on a short row) |
pMinMw default 0; pMaxMw default capacityMva |
0, +Infinity |
0, capacity |
MBase |
column 7 when above 0, else baseMVA |
capacityMva |
100 | capacity |
H |
0 | inertiaH |
0 | the fuel inertia constant (kind.H) |
InService |
column 8 above 0; a status 0 unit is kept, marked out of service, unless its bus is dropped or undefined | inService, default true |
true | true |
Cost, ReactiveCost |
mpc.gencost rows i and ng+i |
not read by the loader (a cost file is read separately by src/GridSim.Core/IO/Json/GeneratorCostFile.cs) |
null | null |
Type, DynamicsClass, machine and converter fields |
not read | type (default ccgt), dynamicsClass, converterInterfaced, converterFaultCurrentPu, powerFlowOnly, unitTransformerXPu, gridForming; subtransientXdPu and negativeSeqXPu load from v2 (N234) and are absent from v1 |
not read | fuel type |
BridgeProtocol.BuildModel reads Pg, Qg, Vg, Qmin, Qmax, Pmin, Pmax, MBase, H, Name and Type.
The generator fleet. GridModel.Generators is the in-service fleet in the order given; every engine, analysis and
report reads it, so an out-of-service unit is invisible to them. GridModel.AllGenerators is every unit in the order
given, which is what the writers save, and GridModel.OutOfServiceGenerators is the units marked out of service.
Setting Generators takes the whole fleet and holds the out-of-service units back from the in-service list. When every
unit is in service the list given is returned by reference. An out-of-service unit does not raise its JSON bus to Pv
and does not make it the slack; a JSON case whose only slack unit is out of service is refused with "No slack bus". A
Pv bus with no in-service generator is solved as Pq.
Branch¶
Branch(From, To, R, X, B, TapRatio, PhaseShiftDeg, RateAMva, Name, IsTransformer, DynamicsClass, InService,
LengthKm, Equipment, R0, X0, FromWinding, ToWinding, FromNeutralXPu, ToNeutralXPu, RateBMva, RateCMva). A branch is a
line or a transformer in one pi-model.
| Field | Unit | Default | Validation | Meaning |
|---|---|---|---|---|
From, To |
- | required | both must be existing buses (error) | Terminals. From is the tap side. |
R, X |
pu on BaseMva |
required | finite | Series impedance. |
B |
pu on BaseMva |
0 | finite | TOTAL line-charging susceptance; the stamp puts half at each end. |
TapRatio |
ratio | 1.0 | finite | Off-nominal tap magnitude on the From side. A value at or below 0 is treated as 1.0 by every stamp. |
PhaseShiftDeg |
degrees | 0 | finite | Phase-shifting angle, combined with the tap into one complex tap. |
RateAMva |
MVA | 0 | none | Continuous thermal rating. 0 is unrated: loading is reported as 0 percent. |
RateBMva, RateCMva |
MVA | 0 | none | Short-term and emergency ratings. Read only by a contingency screen asked to judge post-fault loading against them (--post-fault-rating B or C); a branch with 0 uses rating A. |
Name |
- | null | an Oltc and a BoundaryBranch find the branch by exact (ordinal) name |
Identity. |
IsTransformer |
bool | false | none | Classification flag. The electrical model is only TapRatio and PhaseShiftDeg. |
InService |
bool | true | none | False is an open branch (a recloser or switch): every Y-bus builder skips it and its LineFlow is a zero placeholder. |
LengthKm, Equipment |
km, "ohl" or "cable" |
0, null | none | Circuit length and conductor type for frequency-dependent (skin-effect) modelling in the harmonic engine. |
R0, X0 |
pu on BaseMva |
0 | none | Zero-sequence series impedance for fault studies; both 0 means not supplied (a line then uses the standard ratio times R + jX; a transformer uses R + jX). |
FromWinding, ToWinding |
WindingConnection? |
null | the bridge refuses an unknown name | Transformer winding connection (Delta, WyeGrounded, WyeUngrounded) for the zero-sequence network; null on either side makes the branch a line in that network. |
FromNeutralXPu, ToNeutralXPu |
pu | 0 | none | Grounding reactance in the neutral; adds 3 * Xn to the zero-sequence path. |
DynamicsClass |
- | null | none | Inline passive-plant dynamics class. |
Loaders:
| Field | MATPOWER (mpc.branch) |
JSON (transmission.json, transformer.json) |
CIM | GDA |
|---|---|---|---|---|
R, X, B |
columns 3, 4, 5 | line: rPu / circuits, xPu / circuits, bPu * circuits; transformer: rPu, xPu, B = 0 |
line: ohm and siemens converted on the from bus base (a from bus with no base voltage takes the values as already per-unit); transformer: ohm on each end's rated voltage (a ratedU of 0 or less takes the value as per-unit), the two ends added, B = 0; a three-winding unit becomes a star point with one branch per winding |
circuit and transformer values converted to the model base; transformer B = 0 |
TapRatio |
column 9; 0 means a line (1.0) | tapRatio (a line may carry one, so a converted case keeps its branch order) |
the tap changer, referred to the from side (each winding of a three-winding unit carries its own) | 1.0 |
PhaseShiftDeg |
column 10 | phaseShiftDeg |
the tap changer | 0 |
RateAMva |
column 6 | ratingMva * circuits (transformer: ratingMva) |
not set | winter rating (line), nominal rating (transformer) |
RateBMva, RateCMva |
columns 7, 8 | ratingBMva, ratingCMva (times circuits on a line) |
not set | not set |
IsTransformer |
true when the ratio column is non-zero, even if it is 1 | isTransformer; always true for a transformer.json row |
true | true for a transformer |
InService |
a status 0 branch is dropped, so the parsed branch is always in service | no v1 key; v2 refuses "inService": false |
true | true |
LengthKm, Equipment, R0, X0 |
not read | lengthKm, equipment, rPu0 / circuits, xPu0 / circuits (lines only) |
not set | not set |
| windings, neutral reactances | not read | not read; v2 windings, neutralXPu, r0Pu and x0Pu on a transformer (N234) |
not set | not set |
BridgeProtocol.BuildModel reads every branch field except LengthKm, Equipment, DynamicsClass, RateBMva and
RateCMva; it is the only loader that sets InService false and the winding fields.
Oltc¶
Oltc(Transformer, ControlsBus, TargetVpu, MinTap, MaxTap, StepPu, DeadbandPu) is an on-load tap changer on the branch
named Transformer.
| Field | Unit | Default | Meaning |
|---|---|---|---|
Transformer |
- | required | Branch.Name of the transformer it moves. |
ControlsBus |
- | required | The regulated bus. |
TargetVpu |
pu | 1.0 | Voltage target. |
MinTap, MaxTap |
ratio | 0.9, 1.1 | Tap range. |
StepPu |
ratio | Oltc.DefaultStepPu = 0.0125 |
One tap step (1.25 percent). |
DeadbandPu |
pu | 0.01 | No movement while |Vm - Target| <= Deadband. |
Validate gives a warning, never an error, for an OLTC whose ControlsBus does not exist, whose Transformer names no
branch, that controls the higher-BaseKv winding, or that controls a bus that is not an end of the transformer. The
winding test is skipped only when the two end voltages are equal, both unknown included. If exactly one end has no
BaseKv, the unknown end counts as the low-voltage side and the test can fire.
SteadyStateSolver (ch. 3, Power flow) applies these rules on each pass:
- A record drives the first branch with its
Transformername that hasControlsBusat one end. When no same-named branch touches that bus, it falls back to the first branch of that name. - A record naming no branch, or whose
ControlsBusis not in the result, is skipped. Those are the only two warned cases that stop regulation. A warned high-voltage-side OLTC, and one whose bus is not an end, still regulate. - A record whose controlled bus is generator-regulated as solved (a
PvorSlackbus that the reactive-limit loop has not pinned at a limit) holds its tap. - The step direction depends on the end. With the controlled bus on the
To(untapped) end, lowering the tap raises its voltage. With the controlled bus on theFrom(tapped) end, raising the tap raises it. A bus on neither end uses theTorule.
JSON reads onloadtapchanger.json and the v2 oltcs array. The GDA builder creates one per transformer only when
GdaBuildOptions.CreateOltcs is set, the two kV values differ and the published tap range is valid; a 0 %/0 % range or
an invalid one gives none and is counted in the build notes. The bridge reads them, and MATPOWER and CIM carry none.
Boundary and BoundaryBranch¶
BoundaryBranch(Branch, Direction = 1) names one branch's contribution to a cut and the sign that counts as positive
transfer. Boundary(Name, LimitMw, Branches) is the cut: the flow across it is the signed sum of the named branches'
solved flows, compared against LimitMw by src/GridSim.Core/Analysis/BoundaryAnalysis.cs. Each branch name must
name exactly one branch of the model, compared ordinally (Boundary.BranchNameProblems). A name that matches nothing,
or that several branches share, is an error in GridModel.Validate, is refused by the JSON loader and the bridge model
reader, and makes BoundaryAnalysis.Evaluate, LossAllocation.Allocate, PowerFlowTracing.BoundaryTransfers and the
contingency screen throw InvalidDataException naming the boundary and the name. Out-of-service branches count as
real names. In the reduced post-outage model of the contingency screen an outaged boundary branch is absent and carries
no flow. JSON reads boundaries.json and the v2 boundaries array; the bridge reads boundaries and drops one with no
branches; MATPOWER, CIM and GDA carry none.
Frequency, base MVA, name¶
GridModel.FrequencyHz defaults to GridSim.Core.Dynamics.FrequencyLimits.NominalHz (50 Hz), the same value the JSON
defaults and the bridge reader take. MATPOWER files carry no frequency:
the parser takes the caller's value, else MatpowerParser.AssumedFrequencyHz = 50.0 and writes a parse note saying
the value is assumed; a frequency that is not positive and finite is refused. JSON takes system.frequencyHz
(default 50.0 in v1; read from the system block in v2); the bridge takes frequencyHz and refuses a value that is not positive and finite; the
GDA builder uses GdaBuildDefaults.SystemFrequencyHz = 50.0; the CIM bridge leaves the default.
BaseMva is required. MATPOWER takes mpc.baseMVA, defaulting to 100 when the scalar is absent; JSON takes
system.baseMva, which becomes GridModel.DefaultBaseMva = 100 only when it is absent; a present value that is zero,
negative or not finite is refused by Validate (below); the CIM bridge takes its baseMva argument (100); the GDA
builder takes GdaBuildOptions.BaseMva (100); the bridge takes baseMva, reads an absent value as
GridModel.DefaultBaseMva, and refuses a present value that is not finite and above 0. Name for MATPOWER is the
caller's name, else the function mpc = <name>, else "matpower"; the file loader
passes the file stem, so the stem wins over the function name, and a built-in case passes a display name such as
"IEEE 14-bus". For JSON it is system.name or the folder name, for CIM the caller's argument, and for GDA the dataset and
filter description. "grid" is only the GridModel.Name default.
Asset bindings and protections¶
GridModel.AssetDynamics is an AssetDynamicsModel(Library, Bindings): an AssetClassLibrary of authored asset
classes and an AssetBindingSet of AssetBinding(MatchType, ClassName) rules. AssetBindingSet.Resolve(inlineClass,
assetType) returns the inline DynamicsClass of the element when it has one; otherwise the first rule whose
MatchType equals the element's Type (case-insensitive, exact rules first); otherwise the first prefix rule, a
MatchType ending in * (wind* matches wind-onshore and wind-offshore); otherwise null, and the element stays
passive. GridModel.Protections is a ProtectionModel(Library, Instances) of placed ProtectionInstance(Name,
ClassName, MonitorBus, CapacityMw, TargetBranch) devices. Both are null for a case that ships no asset-classes.json
or protections.json. The JSON loader builds them from asset-classes.json, asset-bindings.json and
protections.json and fails loud on a binding to an unknown class, bindings with no classes, a class kind or provenance
tag it does not know, and an instance whose class is not a protection class; after the model is built it checks every
placed device against the network. The classes are specified in
ch. 13, Asset dynamics and data grounding and
ch. 15, Protections.
Per-unit conventions¶
- One system base. All power is converted with
GridModel.BaseMvaalone. The specified injection at a bus isPspec = (sum Pg - Pd) / BaseMvaandQspec = (sum Qg - Qd) / BaseMva(NewtonShell.BuildBusSpec,src/GridSim.Core/PowerFlow/NewtonShell.cs).PspecandQspechold the nominal load; a ZIP load is applied as a deviation inside the iteration. - Voltage bases.
Bus.BaseKvis the nominal line-to-line voltage of the bus. The solve never reads it. A branch'sR,XandBare entered per-unit onBaseMvaand on the voltage bases of its ends, so no impedance conversion happens inside the solvers. Converting an impedance given in ohms is a loader job and uses the base impedance of the bus voltage:
Zbase = BaseKv^2 / BaseMva ohm (BaseKv in kV, BaseMva in MVA)
r_pu = r_ohm / Zbase, x_pu = x_ohm / Zbase
b_pu = b_siemens * Zbase a susceptance converts the other way
The CIM bridge applies this to a line on the from-bus base and to each transformer winding on that winding's own rated
voltage ratedU (the per-unit impedance is invariant across the ideal ratio, so the two windings' values add with no
referral factor). The GDA builder converts percent data in three ways. A circuit impedance is
pu = percent / 100 * (BaseMva / 100) and a circuit susceptance is pu = percent / 100 / (BaseMva / 100). A
transformer impedance is pu = percent / 100 * (BaseMva / txBase), where txBase is the transformer rating
(GdaBuildDefaults.TransformerRatingFallbackMva when the rating is 0 or less) if the dataset gives percent on rating,
else 100. Ohm data is divided by the base impedance of RefZBase, which uses GdaBuildOptions.RefKv when it is set and
otherwise the grid supply point group voltage. The MATPOWER unit normaliser (below) applies the formula above to raw
distribution files.
- Shunts are entered in engineering units (MW and MVAr at 1.0 pu) and divided by BaseMva at stamp time, the only
place a bus field is converted inside the Y-bus builders.
- Voltages are per-unit throughout; BaseKv converts back to kV for display.
- Angles are degrees in Bus.VaDeg and PowerFlowResult.VaDeg and radians inside every solver loop.
- Results return to engineering units at assembly: PInjMw = P_pu * BaseMva, and line flows likewise
(NewtonShell.AssembleResult, NewtonShell.ComputeLineFlows).
- Convergence tolerances are per-unit power mismatches; the default 1e-8 is 1 W on a 100 MVA base.
Transformer conventions¶
A transformer is one Branch. Its off-nominal ratio and phase shift form one complex tap on the from side:
a = TapRatio (a <= 0 is replaced by 1.0)
phi = PhaseShiftDeg * pi / 180
tap = a * e^(j*phi) DetMath.FromPolar(a, phi)
The from terminal sees the pi-model through an ideal transformer of complex ratio tap : 1, as in MATPOWER; the
to terminal connects to the pi-model directly. A tap of 1.0 with zero shift is a plain line. Oltc limits (MinTap,
MaxTap) are tap ratios in the same convention. The phase shift acts only through the asymmetric mutual terms below.
The magnetising branch is not modelled: no loader sets a shunt on a transformer branch, and B is 0 for every transformer a loader builds.
Y-bus construction¶
The branch stamp¶
The branch stamp has one source, AdmittanceStamp.Branch
(src/GridSim.Core/PowerFlow/AdmittanceStamp.cs), which returns a BranchStamp(Yff, Ytt, Yft, Ytf). Every builder of
the network matrix uses it: the dense YBus.Build, its cell-only twin YBus.BuildSparse, and the sparse power-flow Y
SparseNewtonRaphsonPowerFlow.BuildY. So do the branch flows (NewtonShell.ComputeLineFlows), the estimator's flow
measurement model, the fault studies, the physics audit and the harmonic Y(h), which at h = 1 agrees with the fundamental
Y-bus to within 1e-9 per cell (FrequencyDependentYBusTests); that agreement is a tolerance, not a bit-identity claim.
ys = 1 / (R + jX) series admittance
bc = j * B / 2 half the line charging, per end
a = TapRatio (<= 0 treated as 1.0)
tap = a * e^(j * PhaseShiftDeg * pi/180) complex tap, on the FROM side
Yff = (ys + bc) / a^2 from-end self term
Ytt = ys + bc to-end self term
Yft = -ys / conj(tap) from-to mutual
Ytf = -ys / tap to-from mutual
These are the terms of the branch current equations
Points to audit:
- The ideal transformer sits on the from side. The from-end self term, including the from half of the line charging, is
referred through the tap by
1/a^2; the to-end self term is unscaled. - The phase shift never appears in the self terms, because
|tap|^2 = a^2. It rotates only the mutual terms, and asymmetrically (conj(tap)againsttap), which is what makes a phase shifter move flow:Yft != Ytfwhenever the shift is not a multiple of 180 degrees. - With no shift the stamp is symmetric:
Yft = Ytf = -ys / a. - A branch with
R = 0andX = 0has no finite series admittance;Validaterefuses it (below), so no loaded model reaches the stamp with one. DetMath.FromPolarisa * (cos phi + j sin phi)over the deterministic fdlibmDetMath.SinCos, not the platform'sComplex.FromPolarCoordinates, so the tap has the same bits on every operating system.- The harmonic and long-line builders call
AdmittanceStamp.FromAdmittancewith an already computed series admittance and per-end shunt; the tap arithmetic is the same lines.
The bus matrix¶
For every in-service branch the builder adds the four stamp terms to Y[f,f], Y[t,t], Y[f,t] and Y[t,f], where
f and t are the matrix indices of From and To (their positions in Buses). Branches are visited in model
order. Then every bus with Gs != 0 or Bs != 0 adds (Gs + jBs) / BaseMva to its diagonal, in bus order. Cells start
at +0.0 and accumulate by complex addition, so the result is a fixed sum in a fixed order:
Y[i,i] = sum over in-service branches with From = i of Yff
+ sum over in-service branches with To = i of Ytt
+ (Gs_i + j*Bs_i) / BaseMva added last
Y[i,k] = sum over in-service branches From = i, To = k of Yft
+ sum over in-service branches From = k, To = i of Ytf (i != k)
with each sum taken in branch order. Parallel circuits between one pair of buses accumulate, so duplicated branches
are summed admittances. With Y = G + jB, the shunt sign gives a drawn complex power |V|^2 * (Gs - jBs) at voltage
V pu: positive Gs draws MW, positive Bs injects MVAr. Line charging is the only shunt on a branch.
Out-of-service branches and islands¶
An out-of-service branch (InService false) is skipped by every builder and contributes no admittance. A model with
every branch in service builds the same matrix as a model with no such flag. NewtonShell.ComputeLineFlows emits a
zero-flow LineFlow for the skipped branch, so LineFlows[i] stays aligned with Branches[i].
The Y-bus builders do not partition the network. A split network gives a block-diagonal Y, and a block with no
reference bus is singular. Two rules keep that from reaching the solver. First, a model may carry several Slack
buses, one reference per island, each held at its own setpoint (Validate warns, it does not refuse). Second,
IslandPartition.Prepare (src/GridSim.Core/PowerFlow/IslandPartition.cs), applied by TopologyOps.Apply for the live
simulator's recloser-opened branches, finds the connected components over in-service branches and, per island: keeps
an island that has a slack; elects as slack the bus with the largest single |Pg| in an island that has generation
(a bus counts as generating when its units' |Pg| sum is above 0) and no slack; and blacks out an island with no
generation, removing its buses, branches and generators from the model and booking the lost demand (Pd) and lost
generation (Pg) in the IslandReport. A model that is one connected component with a slack is returned unchanged
(the same object). The pre-pass keeps the out-of-service generators of live buses (AllGenerators).
Dense and sparse builders, and how they stay identical¶
| Builder | Output | Used by |
|---|---|---|
YBus.Build (src/GridSim.Core/PowerFlow/YBus.cs) |
Complex[n,n] |
the dense Newton engine, the distributed-dynamics initialisation and the fault network system |
YBus.BuildSparse |
SparseYBus: a dictionary of stamped cells (a cell that sums to exactly zero is kept) keyed (row, col) by bus index, no dense array |
the estimator's measurement model, the fault analysis and the subtransient initialisation of a large model |
SparseNewtonRaphsonPowerFlow.BuildY (src/GridSim.Core/PowerFlow/SparseNewtonRaphsonPowerFlow.cs) |
ComplexCsr (src/GridSim.Core/Numerics/Sparse.cs): RowPtr, ColIdx, separate G and B arrays, a diagonal pointer per row |
the sparse Newton engine |
All three read the same AdmittanceStamp.Branch, visit branches and then shunts in the same order, and begin every
cell from +0.0. Three details make the sums bit-identical:
- Signed zeros. The dense array starts every cell at +0.0, so the first stamp is
0.0 + v, which turns a-0.0component into+0.0.BuildSparseaddsComplex.Zeroto the first stamp of a cell for the same reason, andComplexCsr.Builder.Buildwrites0.0 + g,0.0 + bfor the first entry of a cell. - Summation order.
ComplexCsr.Builder.Buildsorts each row by(column, insertion index), a unique key, so the entries of one cell keep their insertion order whatever the sort algorithm does. That order is the dense order (branches, then shunts). An unstable sort would sum the diagonal of a bus with many branches in a different order. - Shunt division. The dense builder divides the complex number
(Gs, Bs)by the realBaseMva; the sparse builder dividesGsandBsseparately. The dense division uses theComplex / doubleoperator, which divides each component, so the values are equal; the dense-sparse parity tests pin that on the cases with shunts (case14, case30, case118).
ComplexCsr.Builder.Add skips a stamp whose real and imaginary parts are both exactly 0; a skipped stamp changes no
sum. The CSR keeps a diagonal entry in every row, stored as zero when no stamp lands there.
Unit normalisation for distribution MATPOWER files¶
Four published radial feeders (case22, case33bw, case69, case85 in data/matpower/dist) declare baseMVA and
per-bus baseKV but give branch impedance in ohms and bus demand in kW and kVAr. Read as per-unit and MW, a 3.715 MW feeder
would be 3715 MW. MatpowerParser therefore passes every parse through MatpowerUnitNormalizer
(src/GridSim.Core/IO/Matpower/MatpowerUnitNormalizer.cs) by default (MatpowerUnitHandling.NormaliseRawFeeders).
Detection. LooksRawUnits(model) is true only when all three tells agree; unit-ness is not recorded in the file,
so the test errs towards leaving a file alone.
| Tell | Rule | Constant |
|---|---|---|
| Distribution scale | at least one BaseKv above 0, and no bus above DistributionKvCeiling = 35 kV |
DistributionKvCeiling |
| Load magnitude | sum(abs(Pd)) / BaseMva at least LoadToBaseRatioTrigger = 30 |
LoadToBaseRatioTrigger |
| Ohmic impedance | the median sqrt(R^2 + X^2) as given is at least OhmicImpedanceFloor = 0.05, and the median of that value divided by the from-bus Zbase is below ConvertedPuImpedanceCeiling = 1.0 |
OhmicImpedanceFloor, ConvertedPuImpedanceCeiling |
A model with BaseMva at or below 0, no buses or no branches is never flagged. A 110 kV source bus vetoes the file,
which is what keeps the already-per-unit cigre_mv case untouched.
Conversion. Normalize(model) returns the model unchanged when it does not look raw; otherwise it returns a copy
(built through CopyWith) in which:
R, X <- R, X / Zbase(from bus) Zbase = BaseKv^2 / BaseMva
Pd, Qd, Gs, Bs <- value / 1000 kW, kVAr -> MW, MVAr
B and the generators are not changed: the known raw feeders carry no line charging, and their only generator is the
slack, whose output the solve computes. A branch whose from bus has no BaseKv keeps its raw impedance.
Guards. MatpowerParser.ParseWithDiagnostics adds a parse note naming the conversion and sets
MatpowerParseResult.RawUnitsConverted. It then checks that the converted model no longer looks raw and throws
InvalidDataException if it does, because a caller that wraps Normalize again would otherwise convert a second time.
MatpowerUnitHandling.AsWritten keeps the numbers as the file writes them and adds a note that the case looks raw and
was not converted. An undefined MatpowerUnitHandling value throws ArgumentOutOfRangeException. A file that does not
look raw is returned as the same result object, so every other bundled file parses exactly as written.
Immutability and the copy helpers¶
The element types are records. A "modified" element is a with copy; Normalize builds its buses with
b with { Pd = ..., Qd = ... } and TopologyOps.Apply opens a branch with b with { InService = false }. Record
equality compares fields, and compares the list-valued fields (Boundary.Branches, GeneratorCost.Coefficients) by
reference. GridModel is a class with init-only properties and IReadOnlyList<T> collections, and no code changes a model after construction, so it is shared across parallel N-1 solves
without a defensive copy. The language does not enforce this for the list contents: the loaders pass List<T> instances.
A "modified" network is a new GridModel.
GridModel.CopyWith(buses, generators, branches, oltcs, boundaries, name, frequencyHz, assetDynamics) is the one copy
method. Every argument left null is carried across unchanged, and every property it does not take (BaseMva,
Protections) is carried too. Its generator rule:
- With
generatorsnull, the whole fleet is carried, out-of-service units included, and the copy'sGeneratorsis the same in-service list. - With a
generatorslist that holds no out-of-service unit (a redispatch builds it fromGenerators), this model's out-of-service units are appended after it; the copy'sGeneratorsis exactly the list given. - With a
generatorslist that itself marks a unit out of service, that list is taken as the complete fleet.
Other helpers build on it: Dispatcher.Clone (src/GridSim.Core/Dispatch/Dispatcher.cs) passes through CopyWith;
TopologyOps.Apply copies with an opened branch set; MatpowerUnitNormalizer.Normalize copies with replaced buses and
branches. AssetEnvelope, which swaps the asset-dynamics bundle per sweep corner, copies through CopyWith too
(assetDynamics, N314), so no copy is built by hand.
Validation¶
GridModel.Validate() returns a list of GridDiagnostic(Severity, Message) and never throws; ToString() renders
error: ... or warning: ..., the format the CLI prints. Entry-point loaders throw InvalidDataException on any error
(the MATPOWER parser and the JSON loader run it after building); the GDA merge pipeline collects the findings as notes.
Errors:
- A
BaseMvaor aFrequencyHzthat is not a finite number above 0. - Duplicate bus id, once per id.
- No slack bus.
- A branch
FromorTothat is not a bus. - A branch whose
Fromequals itsTo(a self-loop), whatever its service state. - A branch with
RandXboth 0 (either sign of zero), whatever its service state: its admittance1 / (R + jX)is not finite. A real zero-impedance tie is modelled by merging its two buses or by a small reactance. - An island (a set of buses joined by in-service branches) with no slack bus of its own, reported with its size and its lowest-id buses. Checked only when the model has a slack and no duplicate bus id.
- An in-service generator, or an out-of-service generator, on a bus that does not exist.
- A bus voltage limit that is not finite and above 0 pu, or a
VminPuabove itsVmaxPu. - A boundary branch name that matches no branch or more than one.
- A non-finite
Pd,Qd,GsorBsper bus;R,X,B,TapRatioorPhaseShiftDegper branch;Pg,QgorVgper in-service generator. A NaN or infinity here would feed the Y-bus and Jacobian and poison the solve, so it is caught as a model fault. GeneratorQmin,Qmax,PminandPmaxare excluded because infinity means no limit.
Warnings: more than one slack bus (a multi-island system); an OLTC whose ControlsBus does not exist, whose
Transformer names no branch, that controls the higher-BaseKv winding, or that controls a bus that is not an end of
its transformer.
Parameters and defaults¶
Constants that fix the data-model defaults and thresholds:
| Name | Value | Unit | Meaning | Source symbol |
|---|---|---|---|---|
| Default system base | 100 | MVA | Used where the JSON system file gives no baseMva (absent), and where the bridge model block gives a value at or below 0; MATPOWER, CIM and GDA use their own literal 100.0 |
GridModel.DefaultBaseMva |
| Default frequency | 50.0 | Hz | The GB nominal and the GridModel.FrequencyHz default (a literal 50.0 there); the JSON defaults and bridge reader take it from FrequencyLimits.NominalHz |
GridModel.FrequencyHz, FrequencyLimits.NominalHz |
| Assumed MATPOWER frequency | 50.0 | Hz | Taken when the caller gives none; noted in the parse result | MatpowerParser.AssumedFrequencyHz |
| Default OLTC step | 0.0125 | ratio | One tap step | Oltc.DefaultStepPu |
| Default OLTC range | 0.9 to 1.1 | ratio | MinTap, MaxTap |
Oltc constructor defaults |
| Default OLTC deadband | 0.01 | pu | No movement inside it | Oltc.DeadbandPu |
| JSON load power factor | 0.97 | - | Used when a demand row gives no qMvar |
JsonDefaults.LoadPowerFactor |
| JSON minimum power factor | 0.01 | - | Lower clamp on a demand row's power factor | JsonDefaults.MinPowerFactor |
| ZIP sum tolerance | 1e-9 |
- | How far a coefficient triple may sum from 1 | JsonGridLoader.ZipSumTolerance |
| Converter fault current | 1.2 | x rated | Default k of a converter-interfaced unit |
Generator.DefaultConverterFaultCurrentPu |
| Operational voltage band | 0.94 to 1.06 | pu | Applies on a side a bus leaves null | VoltageLimits.OperationalLowPu, OperationalHighPu |
| Distribution kV ceiling | 35 | kV | Raw-feeder tell 1 | MatpowerUnitNormalizer.DistributionKvCeiling |
| Load-to-base trigger | 30 | x BaseMva |
Raw-feeder tell 2 | MatpowerUnitNormalizer.LoadToBaseRatioTrigger |
| Ohmic impedance floor | 0.05 | as given | Raw-feeder tell 3 | MatpowerUnitNormalizer.OhmicImpedanceFloor |
| Converted impedance ceiling | 1.0 | pu | Raw-feeder tell 3 | MatpowerUnitNormalizer.ConvertedPuImpedanceCeiling |
| Default tap | 1.0 | ratio | Branch.TapRatio default; a MATPOWER ratio of 0 and any ratio at or below 0 at stamp time mean 1.0 |
Branch.TapRatio, AdmittanceStamp.FromAdmittance |
The per-field defaults are in the element tables above.
The per-bus limits are used by the contingency verbs nminus1, nminus2 and n-1-1 by default, through
VoltageLimits.OperationalLowFor and OperationalHighFor, which fall back to the operational band on a side the bus
leaves null; --global-vlimits restores the single band. The API default, PowerFlowOptions.PerBusVoltageLimits, is
false, and the CLI sets it unless --global-vlimits is given.
Invariants¶
| Invariant | Test |
|---|---|
| The branch stamp equals a separately written reference of the textbook pi-model with a from-side tap, bit for bit (the reference repeats the production formulas, so it guards drift, not the convention), for a plain line, a lossless reactance, an off-nominal tap, a phase shifter and a tap at or below 0 | AdmittanceStampPinTests.Branch_stamp_matches_an_independent_textbook_derivation_bit_for_bit |
YBus.BuildSparse cells equal YBus.Build cells bit for bit, and an absent cell is exactly zero in the dense matrix (case9, case30, case118, case300) |
YBusSparseEquivalencePinTests.BuildSparse_cells_are_bit_for_bit_the_dense_build |
The CSR builder is byte-identical to a reference for shuffled and duplicate entries (a six-row case with at most three entries in a row, so the order above 16 entries is not exercised there), and the fundamental Y(h) build agrees with the dense Y-bus to within 1e-9 |
ComplexCsrBuilderPinTests.Build_is_byte_identical_to_a_reference_for_shuffled_and_duplicate_entries, Fundamental_Y_h_build_is_unchanged_by_the_sort_port |
| The dense and sparse Newton engines give the same bits on built-in, MATPOWER and JSON cases | DenseSparseParityTests.Built_in_case_is_bit_identical_dense_vs_sparse, Matpower_file_is_bit_identical_dense_vs_sparse, Json_case_is_bit_identical_dense_vs_sparse |
Rows above 16 entries sum in dense order: the largest bundled buses (17 incident branches in case1354pegase.m and case2869pegase.m) solve bit-identically on both engines |
DenseSparseParityTests.Matpower_file_is_bit_identical_dense_vs_sparse |
| An out-of-service branch is skipped by the sparse Y as by the dense one | SparsePowerFlowTests.Matches_dense_when_a_branch_is_out_of_service |
| The island pre-pass is a no-op with every branch in service, elects a slack for a generating island, blacks out a dead island and books its lost load, and the adjusted model solves | IslandPartitionTests.All_in_service_is_a_no_op, Islanding_a_generating_region_elects_a_slack, Islanding_a_dead_region_blacks_it_out_and_books_the_lost_load, The_adjusted_islanded_model_solves |
CopyWith knows every settable GridModel property, so a new property cannot be silently dropped |
GridModelCopyTests.CopyWith_knows_every_settable_property |
CopyWith() carries every property by reference, and replaces only what it is given |
GridModelCopyTests.CopyWith_with_no_arguments_carries_every_property_by_reference, CopyWith_replaces_only_what_it_is_given |
A MATPOWER status 0 generator is kept and marked out of service; solving with it present is bit-identical to solving without it; an all-in-service fleet is returned by reference; CopyWith keeps the out-of-service units |
GeneratorInServiceTests.A_status_0_matpower_generator_is_kept_and_marked_out_of_service, Solving_a_model_with_out_of_service_units_is_bit_identical_to_solving_without_them, An_all_in_service_fleet_is_returned_by_reference, CopyWith_carries_out_of_service_units_and_keeps_the_in_service_list |
| A JSON unit marked out of service is kept, not solved and does not type its bus; a case whose only slack unit is out of service is refused | GeneratorInServiceTests.A_json_unit_marked_out_of_service_is_kept_but_not_solved_and_does_not_type_its_bus, A_json_case_whose_only_slack_unit_is_out_of_service_is_refused |
| Derived models (scaled loads, warm-started copies, scenarios, laid-out copies, opened branches) keep the out-of-service units | DerivedModelsKeepOutOfServiceUnitsTests |
| Duplicate bus ids, a missing slack, dangling branch or generator references and a non-finite branch reactance are errors; several slacks and infinite Q limits are not; case9 validates clean. The other non-finite checks and the OLTC "nonexistent bus" and "not an endpoint" warnings have no test | GridModelValidateTests (Duplicate_bus_id_is_an_error, Missing_slack_is_an_error, Multiple_slacks_is_a_warning_not_an_error, Dangling_branch_endpoint_is_an_error, Generator_on_nonexistent_bus_is_an_error, Non_finite_branch_reactance_is_an_error, Infinite_generator_q_limits_are_not_flagged, Clean_case9_produces_no_diagnostics) |
| An out-of-service generator on a missing bus is an error | GeneratorInServiceTests.Validate_refuses_an_out_of_service_unit_on_a_missing_bus |
| OLTC warnings: missing transformer, HV-side control, clean LV-side control | GridModelValidateTests.Oltc_naming_a_nonexistent_transformer_warns, Oltc_on_the_hv_side_warns, Oltc_on_the_lv_side_is_clean |
Per-bus limits are read from MATPOWER columns 12 and 13, a row too short to carry them leaves the global band silently, an unusable pair leaves it with a note, and Validate refuses unusable limits |
PerBusVoltageLimitTests.Matpower_bus_columns_12_and_13_are_read_as_vmax_and_vmin, A_short_or_unusable_limit_pair_leaves_the_bus_on_the_global_band_with_a_note, Validate_refuses_unusable_per_bus_limits |
| A ZIP block loads exactly, a missing or non-finite coefficient or a triple not summing to 1 is refused, and a decimal triple with binary rounding is accepted | ZipLoadWiringTests.A_valid_zip_block_loads_onto_the_bus_exactly, A_zip_missing_a_coefficient_fails_loud_naming_the_bus_and_key, A_zip_real_triple_not_summing_to_one_fails_loud, A_decimal_triple_with_binary_rounding_is_accepted |
Boundary branch names must resolve to exactly one branch in code, JSON, the bridge, Validate and the studies |
BoundaryNamesInCodeTests |
A raw feeder parses to exactly the model the product paths built, every other bundled file parses as before, case33bw solves to the published Baran and Wu values, an undefined unit handling is refused, and a file that still looks raw after one conversion is refused |
N165MatpowerUnitDefaultTests |
Export and import round trip, for one decorated case9 model, the ZIP load, a generator Pmin, branch R0 and X0, the windings, ToNeutralXPu, the OLTCs and one boundary; an opened branch stays out of service; and that model re-solves to the bit-identical state. VaDeg and the voltage limits are not carried (see Limits) |
SolveExportRoundTripTests.Every_carried_network_field_round_trips_exactly, An_opened_branch_stays_out_of_service_through_export_and_import, The_reimported_model_resolves_to_the_bit_identical_state |
| A bundled case loaded through its v2 projection gives the v1 model and solve bit for bit | V2CaseLoaderTests.A_bundled_case_loaded_through_its_v2_projection_gives_the_v1_model_and_solve_bit_for_bit |
| The MATPOWER parser classifies a branch as line or transformer by its ratio, drops isolated buses and out-of-service branches, handles non-contiguous ids, and refuses a case with no reference bus | MatpowerParserTests.Line_vs_transformer_by_ratio, Drops_isolated_buses_and_incident_elements, Skips_out_of_service_elements, Handles_noncontiguous_bus_ids, Throws_when_no_reference_bus |
Worked example¶
The example builds by hand the Y-bus entries of one line and one transformer of case14 and compares them with the
Y-bus triplets of a real run. The run writes a solve export, which holds the model as solved and the Y-bus as
ybus triplets (I, J are zero-based matrix indices, G and B the real and imaginary parts, per-unit):
$ gridsim case14 --once --enableExport
[export] dense-newton: converged=True iters=2 maxMis=1.32E-010 -> results\exports\case14-solve.json
[exit 0]
The iteration count and mismatch are as captured; they and any timing differ by machine and run. The captures show the
export path relative to the repository; the command prints the absolute path of the file. The model rows used
below, from the export's model block (baseMva is 100):
$ python -c "import json;m=json.load(open('results/exports/case14-solve.json'))['model'];print(m['baseMva']);[print(json.dumps(b)) for b in m['branches'] if (b['from'],b['to']) in {(1,2),(4,7)}];print(json.dumps(m['buses'][8]))"
100
{"from": 1, "to": 2, "r": 0.01938, "x": 0.05917, "b": 0.0528, "tap": 1, "phaseShiftDeg": 0, "rateAMva": 0, "isTransformer": false, "inService": true, "r0": 0, "x0": 0, "fromNeutralXPu": 0, "toNeutralXPu": 0, "lengthKm": 0, "rateBMva": 0, "rateCMva": 0}
{"from": 4, "to": 7, "r": 0, "x": 0.20912, "b": 0, "tap": 0.978, "phaseShiftDeg": 0, "rateAMva": 0, "isTransformer": true, "inService": true, "r0": 0, "x0": 0, "fromNeutralXPu": 0, "toNeutralXPu": 0, "lengthKm": 0, "rateBMva": 0, "rateCMva": 0}
{"id": 9, "type": "Pq", "baseKv": 0, "pd": 29.5, "qd": 16.6, "gs": 0, "bs": 19, "vm": 1.056, "vaDeg": -14.94, "x": 0, "y": 0, "vminPu": 0.94, "vmaxPu": 1.06}
[exit 0]
case14 is the built-in IEEE case, so every baseKv is 0: the per-unit solve does not need it. The exported Y-bus
cells for the buses involved (bus k is matrix index k - 1):
$ python -c "import json;[print(json.dumps(t)) for t in json.load(open('results/exports/case14-solve.json'))['solve']['ybus'] if (t['I'],t['J']) in {(0,0),(0,1),(3,3),(3,6),(6,3),(6,6),(8,8)}]"
{"I": 0, "J": 0, "G": 6.025029055768224, "B": -19.447070205514382}
{"I": 0, "J": 1, "G": -4.999131600798035, "B": 15.263086523179553}
{"I": 3, "J": 3, "G": 10.512989522036175, "B": -38.654171207607796}
{"I": 3, "J": 6, "G": 0, "B": 4.889512660317341}
{"I": 6, "J": 3, "G": 0, "B": 4.889512660317341}
{"I": 6, "J": 6, "G": 0, "B": -19.549005948264654}
{"I": 8, "J": 8, "G": 5.3260550394673585, "B": -24.092506375267877}
[exit 0]
The line, bus 1 to bus 2. R = 0.01938, X = 0.05917, B = 0.0528, tap 1.0, shift 0.
|z|^2 = 0.01938^2 + 0.05917^2 = 0.0038766733
ys = 1/(0.01938 + j0.05917) = 4.999131600798035 - j15.263086523179553
bc = j * 0.0528 / 2 = j0.0264
a = 1, tap = 1
Yff = Ytt = ys + bc = 4.999131600798035 - j15.236686523179552
Yft = Ytf = -ys = -4.999131600798035 + j15.263086523179553
Y[1,2] is only this branch, so the exported cell is the stamp: G = -4.999131600798035,
B = 15.263086523179553, equal to the last printed digit. Y[1,1] collects the from end of the two branches that leave
bus 1 (1-2 and 1-5; bus 1 has no shunt). Branch 1-5 gives 1.025897454970189 - j4.210383682334831, so
Y[1,1] = (4.999131600798035 - j15.236686523179552) + (1.025897454970189 - j4.210383682334831)
= 6.025029055768224 - j19.447070205514382
which is the exported I = 0, J = 0 cell.
The transformer, bus 4 to bus 7. R = 0, X = 0.20912, B = 0, TapRatio = 0.978, shift 0, so
tap = 0.978 and a^2 = 0.956484.
ys = 1/(j0.20912) = -j4.781943381790359
bc = 0
Yff = ys / a^2 = ys / 0.956484 = -j4.999501697665993 stamped on Y[4,4]
Ytt = ys = -j4.781943381790359 stamped on Y[7,7]
Yft = -ys / conj(tap) = +j4.889512660317341 = ys * (-1/0.978), Y[4,7]
Ytf = -ys / tap = +j4.889512660317341 Y[7,4]
The exported cells I = 3, J = 6 and I = 6, J = 3 hold G = 0, B = 4.889512660317341, equal to Yft and Ytf.
The off-diagonal terms are scaled by 1/a and the from self term by 1/a^2: |Yft| = 4.78194 / 0.978, and Yff is
larger than Ytt by the factor 1/0.956484. Bus 7 has no shunt and three branches, 4-7 (to end), 7-8 and 7-9 (from end,
both untapped, no charging), which contribute -j4.781943381790359, -j5.676979846721544 and -j9.09008271975275; the
sum, added in branch order, is -j19.549005948264654, the exported I = 6, J = 6 cell. Bus 4 collects five stamps in
branch order:
2-4 Ytt 1.686033150614943 - j5.098838325872083
3-4 Ytt 1.9859757099255606 - j5.06241697759392
4-5 Yff 6.840980661495671 - j21.578553981691588
4-7 Yff 0 - j4.999501697665993 (the tapped from end, 1/a^2)
4-9 Yff 0 - j1.914860224784211 (tap 0.969, 1/a^2)
sum 10.512989522036175 - j38.654171207607796 = exported I = 3, J = 3
The shunt. Bus 9 has Bs = 19 MVAr, so 19 / 100 = j0.19 pu is added last to Y[9,9]. The branch stamps at bus 9 are
the to end of 4-9 (-j1.7979790715236075), the to end of 7-9 (-j9.09008271975275) and the from ends of 9-10
(3.9020495524474277 - j10.365394127060915) and 9-14 (1.424005487019931 - j3.0290504569306034). Their sum plus the
shunt is 5.3260550394673585 - j24.092506375267877, the exported I = 8, J = 8 cell. Without the shunt the imaginary
part would be -24.282506375267877.
Every hand-computed cell equals the exported cell to the printed digits. AdmittanceStampPinTests and
YBusSparseEquivalencePinTests assert the stamp and the sparse-dense equality in IEEE doubles, not to printed digits.
Unit normalisation of a raw feeder¶
case33bw stores its branch impedance in ohms and its demand in kW and kVAr. The parse converts it and says so:
$ gridsim data/matpower/dist/case33bw.m --once --enableExport
[export] bfs: converged=True iters=8 maxMis=6.82E-011 -> results\exports\case33bw-solve.json
note: case33bw.m: nominal frequency not given (MATPOWER files carry none); assumed 50 Hz - pass the real value if this is, for example, a 60 Hz system (gridsim: --frequency-hz 60).
note: case33bw.m: the file has 2 indexed assignment(s) after the matrices (mpc.branch, mpc.bus); MATLAB statements are not run, so they are not applied (a raw-unit distribution feeder is converted by the unit normaliser instead).
note: case33bw.m: read mpc.gencost (1 row(s)); the costs are carried on the generators and used by dcopf / acopf when asked (--costs case); without it the OPF uses the fuel-type merit order.
note: case33bw.m: skipped 5 out-of-service branch(es).
note: case33bw.m: raw engineering units detected (branch impedance in ohms, demand in kW / kVAr, as in the published distribution feeders); converted to per-unit and MW: R and X divided by Zbase = baseKV^2 / baseMVA of the from bus, Pd, Qd, Gs and Bs divided by 1000.
[exit 0]
The raw rows, then the same rows in the model the solver received:
$ grep -nE '^\s(2\s1\s100|1\s2\s0.0922)' data/matpower/dist/case33bw.m
23: 2 1 100 60 0 0 1 1 0 12.66 1 1.1 0.9;
66: 1 2 0.0922 0.0470 0 0 0 0 0 0 1 -360 360;
[exit 0]
$ python -c "import json;m=json.load(open('results/exports/case33bw-solve.json'))['model'];print(m['baseMva']);print(json.dumps(m['buses'][1]));print(json.dumps(m['branches'][0]))"
10
{"id": 2, "type": "Pq", "baseKv": 12.66, "pd": 0.1, "qd": 0.06, "gs": 0, "bs": 0, "vm": 1, "vaDeg": 0, "x": 0, "y": 0, "vminPu": 0.9, "vmaxPu": 1.1}
{"from": 1, "to": 2, "r": 0.005752591161723931, "x": 0.002932448856844086, "b": 0, "tap": 1, "phaseShiftDeg": 0, "rateAMva": 0, "isTransformer": false, "inService": true, "r0": 0, "x0": 0, "fromNeutralXPu": 0, "toNeutralXPu": 0, "lengthKm": 0, "rateBMva": 0, "rateCMva": 0}
[exit 0]
With BaseMva = 10 and BaseKv = 12.66, Zbase = 12.66^2 / 10 = 16.02756 ohm, so R = 0.0922 / 16.02756 =
0.005752591161723931 pu and X = 0.0470 / 16.02756 = 0.002932448856844086 pu; the 100 kW, 60 kVAr load of bus 2 is
0.1 MW and 0.06 MVAr. The five skipped branches are the file's status 0 tie switches. The export solved this radial
feeder with the backward/forward sweep (bfs).
Source map¶
| File | Type or function | Responsibility |
|---|---|---|
src/GridSim.Core/Model/Model.cs |
GridModel, Bus, Generator, Branch, Oltc, Boundary, BoundaryBranch, ZipLoad, GeneratorCost, WindingConnection, ConverterPlant, GridDiagnostic |
The model, Validate, CopyWith, the generator fleet split |
src/GridSim.Core/PowerFlow/AdmittanceStamp.cs |
AdmittanceStamp.Branch, FromAdmittance, BranchStamp |
The single pi-model stamp with tap and phase shift |
src/GridSim.Core/PowerFlow/YBus.cs |
YBus.Build, YBus.BuildSparse, SparseYBus |
Dense and cell-only Y-bus |
src/GridSim.Core/PowerFlow/SparseNewtonRaphsonPowerFlow.cs |
BuildY |
Sparse (CSR) Y-bus for the sparse engine |
src/GridSim.Core/Numerics/Sparse.cs |
ComplexCsr, ComplexCsr.Builder |
CSR storage and the order-preserving summation |
src/GridSim.Core/PowerFlow/NewtonShell.cs |
BuildBusSpec, ComputeLineFlows, AssembleResult |
Per-unit injections, start point, flows back in MW |
src/GridSim.Core/PowerFlow/VoltageSetpoint.cs |
VoltageSetpoint.ForBus |
The last-generator-wins setpoint rule |
src/GridSim.Core/PowerFlow/LoadAtVoltage.cs |
LoadAtVoltage |
The one ZIP load rule |
src/GridSim.Core/PowerFlow/IslandPartition.cs, TopologyOps.cs |
IslandPartition.Prepare, TopologyOps.Apply |
Island election and blackout, opened-branch application |
src/GridSim.Core/IO/Matpower/MatpowerParser.cs |
MatpowerParser, MatpowerUnitHandling |
MATPOWER import, gencost, dcline, unit handling |
src/GridSim.Core/IO/Matpower/MatpowerUnitNormalizer.cs |
LooksRawUnits, Normalize |
Raw ohm and kW feeder detection and conversion |
src/GridSim.Core/IO/Matpower/MatpowerWriter.cs |
MatpowerWriter |
MATPOWER export and its loss notes |
src/GridSim.Core/IO/Json/JsonGridLoader.cs, JsonDtos.cs |
JsonGridLoader.Build, DTOs |
JSON v1 import and the shared builder |
src/GridSim.Core/IO/Json/V2CaseLoader.cs |
V2CaseLoader |
JSON v2 case import onto the v1 builder |
src/GridSim.Core/IO/Json/JsonCaseWriter.cs |
JsonCaseWriter |
JSON v1 export and its loss notes |
src/GridSim.Core/IO/Cim/CimGridModelBridge.cs |
ToGridModel |
CIM to model, ohm to per-unit |
src/GridSim.Gda/GdaModelBuilder.cs |
GdaModelBuilder |
GDA dataset to model |
src/GridSim.Core/IO/BridgeProtocol.cs |
BuildModel |
Bridge and solve-export model reader |
src/GridSim.Core/Dispatch/Dispatcher.cs |
Clone, Balance |
Copy through CopyWith, dispatch of the in-service fleet |
src/GridSim.Core/Assets/AssetBinding.cs, AssetDynamicsFleet.cs |
AssetBindingSet.Resolve, AssetDynamicsModel |
Asset class bindings |
src/GridSim.Core/Assets/Protection/ProtectionModel.cs |
ProtectionModel, ProtectionInstance |
Placed protective devices |
src/GridSim.Core/Analysis/VoltageLimits.cs |
OperationalLowFor, OperationalHighFor |
Per-bus limits with band fallback |
Limits¶
- A transformer has no magnetising shunt:
Branchcarries oneB, and every loader sets it to 0 for a transformer. - JSON v1 and v2 cannot hold an out-of-service branch (the v2 loader refuses
inServicefalse on a line or transformer rather than dropping it). JSON v1 cannot hold transformer winding connections, neutral reactances, transformer zero-sequence data or generator subtransient and negative-sequence reactance; JSON v2 carries them into the model, as the bridge and solve-export reader do. - MATPOWER drops status 0 branches and isolated (type 4) buses at parse time, so a parsed case never holds an open branch; MATPOWER, CIM and GDA carry no windings, zero-sequence data or ZIP load, and MATPOWER and CIM carry no OLTC.
- The bridge and solve-export reader does not read a bus
VaDegor its voltage limits, so a re-imported slack with a non-zero angle, or a case with per-bus limits, does not round trip those fields. Generator.ReactiveCostis carried and written by the MATPOWER writer, and no solver reads it.MatpowerParserkeepsmpc.dclinein the parse result only; no solver models a DC line, so a case with DC lines solves differently from MATPOWER.ProtectionCascade.WithBranchesandGridInertiaTransforms.Withbuild their copies by hand from the in-service generator list, so those copies do not hold the out-of-service units of the model they copy.- The island pre-pass is applied only by
TopologyOps.Apply, which the live simulator calls for its opened branches.
See also¶
- ch. 3, Power flow - how the solvers consume these fields.
- ch. 4, Sparse and GPU numerics - the sparse Y-bus and Jacobian storage.
- ch. 10, File and wire formats - the schemas that fill the model.
- ch. 28, The v2 JSON schema - the v2 case and network files.
- ch. 1, Architecture - where models come from and go.
- Loading networks - the case formats in practice.
- Exports and formats - how model fields appear in exports.