"""Typed network description for G2ELin.
Replaces the MATLAB toolbox's numeric-column matrix convention
(``Y_network`` / ``Y_line`` / ``Y_TR`` / ``Y_DER``) with named, validated
fields. See ``legacy.py`` for a converter from the original convention,
used to import existing G2ELin cases.
Per-unit convention: everything is in system per-unit on ``Network.sn_mva``,
matching the original tool (``Power_Fl.m`` / ``Load_flow.m``). Bus nominal
voltages (``Bus.vn_kv``) are physical, used only for display and for
transformer turns-ratio calculations.
"""
from __future__ import annotations
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field, PrivateAttr, model_validator
from g2elin_core import reduction
[docs]
class BusType(str, Enum):
"""Load-flow bus type, matching ``Y_network`` column 4 / ``Y_DER`` column 4."""
SLACK = "slack" # type 1
PV = "pv" # type 2
PQ = "pq" # type 3
[docs]
class UnitType(str, Enum):
"""DER unit type, matching ``Y_DER`` column 2 (``Y_network`` column 2 for raw nodes)."""
NONE = "none" # 0 - plain network node
INFINITE_BUS = "infinite_bus" # 1
GFM = "gfm" # 2 - grid-forming converter
GFL = "gfl" # 3 - grid-following converter
SYNCHRONOUS_MACHINE = "sm" # 4
[docs]
class GfmController(str, Enum):
"""Outer power-control law for a GFM unit, matching ``GFM_P_control`` in script_generic.m."""
DROOP = "droop"
DROOP_FILTERED = "droop_filtered"
DVOC = "dvoc"
VSM = "vsm"
MATCHING = "matching"
[docs]
class ExciterModel(str, Enum):
"""Which AVR/exciter a synchronous machine carries.
See ``docs/sphinx/design/controller-models.md`` for the equations and for
how these relate to the IEEE 421.5 library.
"""
#: The regulator this tool has always had: terminal-voltage transducer,
#: first-order amplifier, first-order exciter and a rate feedback.
G2ELIN = "g2elin"
#: Kundur Fig. E12.9: a thyristor exciter -- transducer, gain and
#: transient gain reduction, with no exciter lag. IEEE ST1A family.
KUNDUR = "kundur"
[docs]
class PssModel(str, Enum):
"""Which power system stabiliser a synchronous machine carries, if any."""
#: The stabiliser this tool has always had: an input low-pass, a washout
#: and two lead-lag stages.
G2ELIN = "g2elin"
#: Kundur Fig. E12.9: gain, washout and two lead-lag stages, taking the
#: speed deviation unfiltered.
KUNDUR = "kundur"
#: No stabiliser fitted.
NONE = "none"
[docs]
class GovernorModel(str, Enum):
"""Whether a synchronous machine's prime mover responds to speed."""
#: Droop into a first-order lag, the governor this tool has always had.
G2ELIN = "g2elin"
#: No governor: constant mechanical power, which is what most textbook
#: small-signal examples assume.
NONE = "none"
[docs]
class Bus(BaseModel):
"""A network node. Corresponds to one row of ``Y_network``."""
id: int
name: str = ""
vn_kv: float = Field(gt=0, description="Nominal line-to-line voltage (kV)")
[docs]
class Line(BaseModel):
"""A pi-model transmission line, in per unit on the network's ``sn_mva`` base.
Corresponds to one row of ``Y_line`` (excluding transformer rows, which
``network_form.m`` appends to the same matrix but are kept separate here
as :class:`Transformer`).
"""
from_bus: int
to_bus: int
r_pu: float = Field(ge=0)
x_pu: float = Field(gt=0)
b_pu: float = Field(ge=0, description="Total shunt susceptance (charging), per unit")
length_km: float = Field(default=1.0, gt=0)
name: str = ""
from_closed: bool = Field(default=True, description="Breaker at the from-bus end (open = line out of service)")
to_closed: bool = Field(default=True, description="Breaker at the to-bus end (open = line out of service)")
[docs]
class Load(BaseModel):
"""A constant-power load. Corresponds to the P_cons/Q_cons columns of ``Y_network``."""
bus: int
p_mw: float
q_mvar: float
name: str = ""
closed: bool = Field(default=True, description="Breaker between the load and its bus")
[docs]
class Shunt(BaseModel):
"""A shunt compensation device at a bus: a capacitor bank or a reactor.
The sign convention is :class:`Load`'s (and pandapower's): ``q_mvar > 0``
absorbs reactive power (a reactor), ``q_mvar < 0`` generates it (a
capacitor bank).
The two are modelled very differently, because a bus's own dynamic model
(``components/node.py``) already *is* a capacitance to ground --
``C dv/dt = i - jwC v``. A capacitor bank is simply more of that
capacitance, so it adds no states at all. A reactor is an inductor to
ground and cannot be folded in: a negative capacitance would flip the
sign of the node's dynamics, and netting the two against each other would
only be right at exactly the nominal frequency. It therefore gets a
branch of its own, the same RL branch a line uses with its far end at
zero volts.
"""
bus: int
q_mvar: float = Field(
description="Reactive power at 1.0 pu voltage: > 0 absorbs (reactor), < 0 generates (capacitor bank)"
)
r_pu: float | None = Field(
default=None, ge=0,
description="Series resistance of a *reactor*, per unit on the network base. None derives one from "
"X/R = 50 (a typical shunt reactor); exactly 0 leaves its resonance with the bus capacitance "
"undamped. Ignored for a capacitor bank, which has no branch of its own.",
)
closed: bool = Field(default=True, description="Breaker between the device and its bus")
name: str = ""
[docs]
class StateMode(str, Enum):
"""What becomes of one state group when the model order is lowered --
see :func:`g2elin_core.components.base.apply_reduction`."""
DYNAMIC = reduction.DYNAMIC
ALGEBRAIC = reduction.ALGEBRAIC
FROZEN = reduction.FROZEN
def _mode_values(states: dict) -> dict[str, str]:
"""``{group: mode string}`` from a ``dict[str, StateMode]``.
Tolerates a plain string as well: ``model_copy(update=...)`` skips
validation, so a field set that way holds whatever it was given, and a
bare ``AttributeError`` here would say nothing about why.
"""
return {k: (v.value if isinstance(v, StateMode) else str(v)) for k, v in states.items()}
def _check_requirements(kind: str, modes: dict[str, str]) -> None:
"""Refuse a combination whose algebraic groups depend on ones that are
still dynamic.
Without this the model still *builds* and then fails deep inside the
linearisation with "singular algebraic Jacobian", which is true and
tells the user nothing about which switch caused it.
"""
unmet = reduction.element(kind).unmet_requirements(modes)
if not unmet:
return
parts = [
f"{group!r} needs {' and '.join(repr(m) for m in missing)} to be algebraic too"
for group, missing in unmet
]
raise ValueError(
f"this {kind} model order isn't solvable: " + "; ".join(parts) + ". "
"A control loop's integrator doesn't appear in its own equation, so it can only be made "
"algebraic once what it regulates is an unknown as well."
)
[docs]
class ModelOptions(BaseModel):
"""Which dynamics this network's models keep.
Lives on the ``Network`` rather than on each analysis request on
purpose: the level is part of what a saved case *is*, it travels with a
network that is exported and re-imported, and the analysis layer's model
cache is keyed on the network's own JSON, so changing a level correctly
invalidates every cached model without any extra plumbing.
``*_level`` names a preset from :mod:`g2elin_core.reduction`;
``*_states`` overrides individual state groups on top of it. A unit can
override both again through ``DerUnit.level`` / ``DerUnit.states``.
"""
network_level: str = Field(
default="full",
description="Passive-element dynamics: 'full' (EMT) or 'quasi_stationary' (RMS). "
"See g2elin_core.reduction for the catalogue.",
)
network_states: dict[str, StateMode] = Field(
default_factory=dict,
description="Per-element-kind overrides (nodes/lines/loads/shunts/transformers) "
"applied on top of network_level",
)
network_frequency: Literal["frame", "nominal"] = Field(
default="frame",
description="Whether the w*L / w*C speed terms in the passive elements follow the "
"reference frame's own speed (the default, and what an EMT model does) or are pinned "
"to nominal, which is the convention phasor tools use.",
)
sm_level: str = Field(default="full", description="Default synchronous-machine level")
sm_states: dict[str, StateMode] = Field(default_factory=dict)
gfm_level: str = Field(default="full", description="Default grid-forming converter level")
gfm_states: dict[str, StateMode] = Field(default_factory=dict)
gfl_level: str = Field(default="full", description="Default grid-following converter level")
gfl_states: dict[str, StateMode] = Field(default_factory=dict)
@model_validator(mode="after")
def _levels_and_groups_exist(self) -> "ModelOptions":
for kind in ("network", "sm", "gfm", "gfl"):
level = getattr(self, f"{kind}_level")
overrides = _mode_values(getattr(self, f"{kind}_states"))
# Raises with the available levels / allowed modes named.
modes = reduction.element(kind).modes_by_group(level, overrides)
_check_requirements(kind, modes)
return self
[docs]
def modes_for(self, kind: str) -> dict[str, str]:
"""``{symbol name: mode}`` for one element type."""
overrides = _mode_values(getattr(self, f"{kind}_states"))
return reduction.resolve_modes(kind, getattr(self, f"{kind}_level"), overrides)
[docs]
def group_modes_for(self, kind: str) -> dict[str, str]:
"""``{group id: mode}`` for one element type -- what the UI shows."""
overrides = _mode_values(getattr(self, f"{kind}_states"))
return reduction.element(kind).modes_by_group(getattr(self, f"{kind}_level"), overrides)
@property
def network_is_dynamic(self) -> bool:
"""True when any passive element still integrates something -- the
property that decides whether a run is electromagnetic-transient."""
return reduction.DYNAMIC in self.group_modes_for("network").values()
@property
def fixed_network_frequency(self) -> bool:
return self.network_frequency == "nominal"
[docs]
class DerUnit(BaseModel):
"""A dispatchable unit (infinite bus, GFM, GFL or synchronous machine).
Corresponds to one row of ``Y_DER``. Sits on its own bus (``bus``),
connected to the rest of the network through ``transformer`` — matching
``network_form.m``, which always places DER units behind a step-up
transformer on a new node.
"""
id: int
bus: int
unit_type: UnitType
bus_type: BusType
v_set_pu: float = Field(gt=0, description="Voltage setpoint (slack/PV) or initial guess (PQ)")
angle_set_deg: float = Field(
default=0.0,
description="Voltage angle this unit's bus is held at, in degrees. Only the island's "
"reference (slack) unit has one -- every other bus angle is solved for. It is the origin "
"the whole solution is measured from, so moving it rotates every angle together and "
"changes no power flow, no current and no eigenvalue. Set it when published bus angles "
"are quoted against some other reference: Kundur's two-area case puts G1 at 20.2 degrees, "
"and with that set its bus angles can be read against the book directly.",
)
p_set_mw: float = Field(description="Dispatched active power (generation positive)")
q_set_mvar: float = 0.0
p_cons_mw: float = Field(
default=0.0, description="Small parallel load at the unit's own bus (Y_DER P_cons column)"
)
q_cons_mvar: float = 0.0
controller: GfmController | None = Field(
default=None, description="Outer power-control law, only meaningful for GFM units"
)
exciter: ExciterModel | None = Field(
default=None,
description="Which AVR/exciter model this machine carries; only meaningful for SM units. "
"None = this tool's original regulator, which is also what every saved network predating "
"the choice keeps.",
)
pss: PssModel | None = Field(
default=None,
description="Which power system stabiliser model this machine carries, or 'none' for a machine "
"without one; only meaningful for SM units. None = this tool's original stabiliser.",
)
governor: GovernorModel | None = Field(
default=None,
description="Whether this machine has a governor, and which model; 'none' holds the mechanical "
"power constant. Only meaningful for SM units. None = this tool's original governor.",
)
xd_pu: float | None = Field(
default=None, description="Equivalent transient reactance, used for SCR calculations only"
)
sn_mva: float | None = Field(
default=None, gt=0,
description="The unit's own rating, i.e. the base this unit's `params` are given on. Published "
"machine data is per unit of the machine's rating (Kundur's two-area machines are 900 MVA, while "
"the network base is 100 MVA), so with this set those overrides are converted to the network base "
"-- impedances x Sn_network/Sn_unit, inertia the other way. Leave it unset (the default) when the "
"overrides are already on the network base. It rebases `params` only: the built-in defaults and "
"the transformer impedance are on the network base either way.",
)
closed: bool = Field(default=True, description="Breaker between the unit and its bus")
params: dict[str, float] = Field(
default_factory=dict,
description="Overrides of this unit's electrical/control parameters (e.g. KpCL, H, Ka), by the "
"names sm_params()/gfm_params()/gfl_params() use; anything not listed keeps its default",
)
level: str | None = Field(
default=None,
description="This unit's own model level, overriding the network's default for its type "
"(ModelOptions.sm_level / gfm_level / gfl_level). None = follow the network.",
)
states: dict[str, StateMode] = Field(
default_factory=dict,
description="Per-state-group overrides for this unit alone, applied on top of its level",
)
@model_validator(mode="after")
def _controller_only_for_gfm(self) -> "DerUnit":
if self.controller is not None and self.unit_type is not UnitType.GFM:
raise ValueError("controller is only meaningful for GFM units")
return self
@model_validator(mode="after")
def _regulators_only_for_sm(self) -> "DerUnit":
if self.unit_type is not UnitType.SYNCHRONOUS_MACHINE:
for name in ("exciter", "pss", "governor"):
if getattr(self, name) is not None:
raise ValueError(f"{name} is only meaningful for synchronous machines")
return self
@property
def controller_model(self) -> str:
"""This converter's outer power-control law, defaulted."""
return (self.controller or GfmController.DROOP).value
@property
def exciter_model(self) -> str:
"""This machine's exciter, defaulted -- what the model builders take."""
return (self.exciter or ExciterModel.G2ELIN).value
@property
def pss_model(self) -> str:
"""This machine's stabiliser, defaulted."""
return (self.pss or PssModel.G2ELIN).value
@property
def governor_model(self) -> str:
"""This machine's governor, defaulted."""
return (self.governor or GovernorModel.G2ELIN).value
@model_validator(mode="after")
def _level_and_groups_exist(self) -> "DerUnit":
kind = self.unit_type.value
if kind not in reduction.ELEMENTS:
# An infinite bus has no reducible dynamics of its own.
if self.level or self.states:
raise ValueError(f"a {kind} unit has no model levels to choose from")
return self
if self.level is not None or self.states:
overrides = _mode_values(self.states)
modes = reduction.element(kind).modes_by_group(self.level, overrides)
# Only checkable here against this unit's own settings; the
# combination with the network's defaults is checked again in
# Network.unit_group_modes' caller (validation.py), since a unit
# can be valid on its own and not on top of the network's level.
_check_requirements(kind, modes)
return self
[docs]
class Network(BaseModel):
"""A complete network case: base values, topology, loads and DER dispatch."""
name: str
f_hz: float = 60.0
sn_mva: float = 100.0
units_use_first_transformer: bool = Field(
default=False,
description="MATLAB-compatible mode: every unit's dynamic model uses the network's *first* "
"transformer impedance (script_generic.m's Y_TR(1,:) convention) instead of its own. Off by "
"default, so power flow and the dynamic models see the same transformer.",
)
nodes_share_first_line_b: bool = Field(
default=False,
description="MATLAB-compatible mode: every bus's dynamic model uses the *first* line's charging "
"susceptance as its own capacitance, instead of the half-charging of the lines actually connected "
"to it plus its capacitor banks. Off by default, so each bus gets its own; the ported presets set "
"it so their numbers still match the MATLAB toolbox's (see network/breakers.node_capacitances).",
)
min_node_b_pu: float | None = Field(
default=None, gt=0,
description="The capacitance to give a bus whose in-service lines declare no charging at all, as "
"distribution-feeder data often does (IEEE 33/69). Without it such a network is refused rather "
"than run on an invented number, since a bus's dynamic model divides by its capacitance.",
)
frame_follows_slack: bool = Field(
default=False,
description="MATLAB-compatible mode: the dynamic models' common dq frame turns with the slack "
"unit's own speed (script_generic.m's convention) instead of standing on its own at nominal speed. "
"Off by default, so any unit -- the slack included -- can be disconnected and a network can be "
"split into islands (see components/frame.py).",
)
models: ModelOptions = Field(
default_factory=ModelOptions,
description="Which dynamics the element models keep -- the model-order reduction "
"settings. The default keeps everything, i.e. the full EMT model.",
)
buses: list[Bus]
lines: list[Line] = Field(default_factory=list)
transformers: list[Transformer] = Field(default_factory=list)
loads: list[Load] = Field(default_factory=list)
shunts: list[Shunt] = Field(default_factory=list)
der_units: list[DerUnit] = Field(default_factory=list)
# Set only on the reduced networks network.breakers.energized_network()
# builds: the element numbering/labels of the network they came from, so
# model names ("Ln_3", "SM_2", ...) don't shift when elements drop out.
_labels: object = PrivateAttr(default=None)
@model_validator(mode="after")
def _bus_refs_exist(self) -> "Network":
bus_ids = {b.id for b in self.buses}
missing: set[int] = set()
for ln in self.lines:
missing |= {ln.from_bus, ln.to_bus} - bus_ids
for tr in self.transformers:
missing |= {tr.hv_bus, tr.lv_bus} - bus_ids
for ld in self.loads:
missing |= {ld.bus} - bus_ids
for sh in self.shunts:
missing |= {sh.bus} - bus_ids
for der in self.der_units:
missing |= {der.bus} - bus_ids
if missing:
raise ValueError(f"references to undefined bus ids: {sorted(missing)}")
return self
@model_validator(mode="after")
def _exactly_one_slack(self) -> "Network":
slacks = [d for d in self.der_units if d.bus_type is BusType.SLACK]
if len(slacks) != 1:
raise ValueError(f"expected exactly one slack DER unit, found {len(slacks)}")
return self
[docs]
def bus(self, bus_id: int) -> Bus:
for b in self.buses:
if b.id == bus_id:
return b
raise KeyError(bus_id)
[docs]
def unit_group_modes(self, der: DerUnit) -> dict[str, str]:
"""``{group id: mode}`` for one unit: the network's default for its
type, with the unit's own ``level``/``states`` applied on top."""
kind = der.unit_type.value
level = der.level or getattr(self.models, f"{kind}_level")
overrides = _mode_values(getattr(self.models, f"{kind}_states"))
overrides.update(_mode_values(der.states))
modes = self.unit_element(der).modes_by_group(level, overrides)
# A unit's own settings and the network's defaults are each valid on
# their own; only their combination can be unsolvable, so this is
# where that is caught.
_check_requirements(kind, modes)
return modes
[docs]
@staticmethod
def unit_element(der: DerUnit) -> "reduction.ElementModel":
"""The reduction catalogue for one unit. A machine's AVR and PSS
groups depend on which regulator models it carries, so the catalogue
is per unit rather than per type."""
if der.unit_type is UnitType.SYNCHRONOUS_MACHINE:
return reduction.element("sm", exciter=der.exciter_model, pss=der.pss_model,
governor=der.governor_model)
if der.unit_type is UnitType.GFM:
return reduction.element("gfm", controller=der.controller_model)
return reduction.element(der.unit_type.value)
[docs]
def frequency_is_regulated(self) -> bool:
"""Whether anything in this network holds the system frequency.
A machine's governor does, a grid-forming converter's droop law
does, and an infinite bus pins it outright. A grid-following
converter does not -- it follows whatever frequency it is given --
and the loads here are constant-power, so they contribute nothing
either.
With none of them the common frequency is a *free integrator*:
nudge every machine's speed together and nothing brings it back.
That leaves the model a marginal direction which is not an
instability but the absence of a control loop, and it is why a
stability verdict has to know (see
:func:`g2elin_core.modal.analysis.unregulated_frequency_mode`).
"""
for der in self.der_units:
if not der.closed:
continue
kind = der.unit_type.value
if kind in ("infinite_bus", "gfm"):
return True
if kind == "sm" and der.governor_model != "none":
return True
return False
[docs]
def unit_modes(self, der: DerUnit) -> dict[str, str]:
"""``{symbol name: mode}`` for one unit -- what its ``*_dae()``
builder takes."""
e = self.unit_element(der)
by_group = self.unit_group_modes(der)
return {sym: by_group[g.id] for g in e.groups for sym in g.symbols}
[docs]
def unit_level(self, der: DerUnit) -> str | None:
"""The named level this unit's settings correspond to, or None when
its per-group overrides don't match any."""
return self.unit_element(der).matching_level(self.unit_group_modes(der))