Source code for hermess.devices.device

# © 2024-2026 ETH Zurich
# Original author: Milos Katanic
# Simulation-only fork & maintainer: Maitraya Avadhut Desai
#
# Licensed under the GNU General Public License v3.0 or later;
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at:
#
#     https://www.gnu.org/licenses/gpl-3.0.en.html
#
# This software is distributed "AS IS", WITHOUT WARRANTY OF ANY KIND,
# express or implied. See the License for specific language governing
# permissions and limitations under the License.
#
# Simulation-only fork of PowerDynamicEstimator
# (https://doi.org/10.5905/ethz-1007-842); dynamic state estimation removed.
# For inquiries, contact: mdesai@ethz.ch

from __future__ import annotations  # Postponed type evaluation
from typing import TYPE_CHECKING, Union, Any, Optional

if TYPE_CHECKING:
    from hermess.system import Dae, Grid, DaeSim, GridSim
import casadi as ca
import numpy as np
import scipy.sparse
import scipy.sparse.linalg
import logging

np.random.seed(30)
sin = np.sin
cos = np.cos
sqrt = np.sqrt


[docs] class Element: """Metaclass to be used for all elements to be added""" def __init__(self) -> None: self.n: int = 0 # number of devices self.u: list[bool] = [] # device status self.name: list[str] = [] # name of the device self._type: Optional[str] = None # Device type self._name: Optional[str] = None # Device name self.int: dict[ str, Union[str, int] ] = ( {} ) # Dictionary of unique identifiers for each device based on the variable "idx" self._data: dict[str, Any] = {"u": True} # Default entries for lists self._params: dict[str, float] = {} # Default entries for np.arrays params # NaN-sentinel parameters and the parameter each falls back to; the # parametric build resolves the sentinel numerically before creating # symbols (see hermess/parametric.py). self._param_sentinels: dict[str, str] = {} self._setpoints: dict[ str, float ] = {} # Default entries for np.arrays set points self._descr: dict[str, str] = {} # Parameter and data explanation self._mand: list[str] = [] # mandatory attributes self.properties: dict[str, bool] = { "gcall": False, "fcall": False, "xinit": False, "fgcall": False, "call": False, "xy_index": False, "finit": False, "save_data": False, "fplot": False, }
[docs] def add( self, idx: Optional[str] = None, name: Optional[str] = None, **kwargs ) -> None: """ Add an element device Args: idx (str, optional): Unique identifier for the device. Generated if not provided. name (str, optional): Name of the device. Generated if not provided. ``**kwargs``: Custom parameters to overwrite defaults. """ # Generate unique identifiers if not provided idx = idx or f"{self._type}_{self.n + 1}" name = name or f"{self._name}_{self.n + 1}" self.int[idx] = self.n self.name.append(name) # check whether mandatory parameters have been set up for key in self._mand: if key not in kwargs: raise Exception("Mandatory parameter <%s> has not been set up" % key) self.n += 1 # Set default values for key, default in {**self._params, **self._setpoints}.items(): if key not in self.__dict__: logging.warning( f"Attribute {key} not found in element - initializing as an empty array." ) self.__dict__[key] = np.array([], dtype=type(default)) self.__dict__[key] = np.append(self.__dict__[key], default) for key, default in self._data.items(): if key not in self.__dict__: logging.warning( f"Attribute {key} not found in element - initializing as an empty list." ) self.__dict__[key] = [] self.__dict__[key].append(default) # Overwrite custom values for key, value in kwargs.items(): if key not in self.__dict__: logging.info( "Element %s does not have parameter %s - ignoring" % (self._name, key) ) continue try: self.__dict__[key][-1] = value # Attempt to update the last element except IndexError: raise IndexError( f"Parameter/setpoint '{key}' not properly specified in the model definition. Check if it's properly listed and initialized when defining the class" ) logging.info(f"Element {name} (ID: {idx}) added successfully.")
[docs] class BusInit(Element): r"""Power-flow input data for one bus, used only by the initialization. Selected in a system file by the class name in the first column, one line per bus, e.g. ``BusInit, bus = "2", p = 100, q = 10, type = "PQ"``. **Model.** There are no equations attached to this element; it feeds the initial power flow that computes the operating point at t = 0 s. Each bus is declared as one of three types: ``"PQ"`` fixes the injected powers :math:`P` and :math:`Q`, ``"PV"`` fixes :math:`P` and the voltage magnitude :math:`|\bar{v}|`, and ``"slack"`` fixes the voltage magnitude and angle and balances the system; exactly one bus must be the slack. Positive :math:`P` and :math:`Q` correspond to consumption, matching the convention of the static loads. After the power flow, each device's initialization overwrites its own setpoints so that the network starts in steady state at exactly this operating point. **Symbols.** .. csv-table:: :header: Code, Symbol, Meaning, Default :widths: 14, 12, 58, 10 "``bus``", "", "name of the bus", "" "``p``", ":math:`P`", "injected active power [MW]; positive = consumption", "0" "``q``", ":math:`Q`", "injected reactive power [MVAr]; positive = consumption", "0" "``v``", ":math:`|\bar{v}|`", "voltage magnitude [p.u.] (used for PV and slack buses)", "1.0" "``type``", "", "bus type: ``""PQ""``, ``""PV""`` or ``""slack""``", "" """ def __init__(self) -> None: super().__init__() self._type = "Bus_init_or_unknwon" # Element type self._name = "Bus_init_or_unknown" # Element name self._data.update({"bus": None, "p": 0, "q": 0, "v": 1.0, "type": None}) self.bus: list[Optional[str]] = [] self.p: list[float] = [] self.q: list[float] = [] self.v: list[float] = [] self.type: list[Optional[str]] = []
[docs] class Disturbance(Element): r"""One scheduled disturbance event of the time-domain simulation. Declared in the system's ``sim_dist.txt``, one line per event, e.g. ``Disturbance, time = 7.0, type = "FAULT_LINE", bus_i = "5", bus_j = "8", y = 30``. **Model.** No equations of its own: events are applied at their scheduled times by ``hermess.system.DaeSim`` (``check_disturbance``), which modifies the network or load data and restarts the integrator from the reached state. Six event types are supported; each uses the subset of fields listed for it: * ``"FAULT_LINE"`` -- three-phase fault of admittance :math:`y` in the middle of line ``bus_i``--``bus_j`` (the line's T model is converted to an equivalent PI model with the fault shunt included); * ``"CLEAR_FAULT_LINE"`` -- removes the fault on line ``bus_i``--``bus_j``; * ``"FAULT_BUS"`` -- fault of admittance :math:`y` added to the shunt of bus ``bus``; * ``"CLEAR_FAULT_BUS"`` -- removes the fault at bus ``bus``; * ``"OPEN_LINE"`` -- opens line ``bus_i``--``bus_j`` (a faulted line is thereby neutralized); * ``"LOAD"`` -- load step of :math:`\Delta P`, :math:`\Delta Q` added to the consumption at bus ``bus``. Events are sorted chronologically before the simulation (:meth:`sort_chrono`). **Symbols.** .. csv-table:: :header: Code, Symbol, Meaning, Default :widths: 14, 12, 58, 10 "``time``", ":math:`t`", "time of the event [s]", "" "``type``", "", "event type, one of the six strings above", "" "``bus_i``", "", "sending-end bus of the affected line", "" "``bus_j``", "", "receiving-end bus of the affected line", "" "``y``", ":math:`y`", "fault admittance [p.u.] (fault events)", "10" "``bus``", "", "affected bus (bus-fault and load events)", "" "``p_delta``", ":math:`\Delta P`", "active-power step [MW] (``""LOAD""``)", "0" "``q_delta``", ":math:`\Delta Q`", "reactive-power step [MVAr] (``""LOAD""``)", "0" "``device``", "", "device id (``""SETPOINT""``)", "" "``param``", "", "setpoint name to step, e.g. ``Pref`` (``""SETPOINT""``)", "" "``value``", "", "new setpoint value, device p.u. (``""SETPOINT""``)", "0" A seventh event type, ``"SETPOINT"``, steps a device setpoint (a reference step, the standard control test signal):: Disturbance, time = 1.0, type = "SETPOINT", device = "GFM1", param = "Pref", value = 0.7 """ def __init__(self) -> None: super().__init__() self._type = "Disturbance" # Element type self._name = "Disturbance" # Element name # Default parameter values self._params.update( { "bus_i": None, "bus_j": None, "time": None, "type": None, "y": 10, "bus": None, "p_delta": 0, "q_delta": 0, "device": None, "param": None, "value": 0, } ) self.type = np.array([], dtype=str) self.time = np.array([], dtype=float) self.bus_i = np.array([], dtype=str) self.bus_j = np.array([], dtype=str) self.y: np.ndarray = np.array([], dtype=float) self.bus = np.array([], dtype=str) self.p_delta = np.array([], dtype=float) self.q_delta = np.array([], dtype=float) self.device = np.array([], dtype=str) self.param = np.array([], dtype=str) self.value = np.array([], dtype=float) #: The fields each event type uses (beyond ``time`` and ``type``); #: required first, then optional-with-default. _EVENT_FIELDS = { "FAULT_LINE": (("bus_i", "bus_j"), ("y",)), "CLEAR_FAULT_LINE": (("bus_i", "bus_j"), ()), "OPEN_LINE": (("bus_i", "bus_j"), ()), "LOAD": (("bus",), ("p_delta", "q_delta")), "FAULT_BUS": (("bus",), ("y",)), "CLEAR_FAULT_BUS": (("bus",), ()), "SETPOINT": (("device", "param", "value"), ()), } _FIELD_MEANING = { "time": "event time [s]", "bus_i": "sending-end bus name of the line", "bus_j": "receiving-end bus name of the line", "y": "fault admittance [p.u. on the system base] (default 10)", "bus": "bus name", "p_delta": "active-power step [MW], positive = more load (default 0)", "q_delta": "reactive-power step [MVAr] (default 0)", "device": "device id (idx) whose setpoint is stepped", "param": "setpoint name to step, e.g. Pref", "value": "new setpoint value [device p.u.]", } _EVENT_EXAMPLES = { "FAULT_LINE": 'Disturbance, time = 1.0, type = "FAULT_LINE", bus_i = "1", bus_j = "2", y = 30', "CLEAR_FAULT_LINE": 'Disturbance, time = 1.1, type = "CLEAR_FAULT_LINE", bus_i = "1", bus_j = "2"', "OPEN_LINE": 'Disturbance, time = 1.1, type = "OPEN_LINE", bus_i = "1", bus_j = "2"', "LOAD": 'Disturbance, time = 1.0, type = "LOAD", bus = "2", p_delta = 10, q_delta = 0', "FAULT_BUS": 'Disturbance, time = 1.0, type = "FAULT_BUS", bus = "2", y = 20', "CLEAR_FAULT_BUS": 'Disturbance, time = 1.1, type = "CLEAR_FAULT_BUS", bus = "2"', "SETPOINT": 'Disturbance, time = 1.0, type = "SETPOINT", device = "GFMI2", param = "Pref", value = 0.7', } @classmethod def _fields_help(cls, typ: str) -> str: required, optional = cls._EVENT_FIELDS[typ] lines = [f'A "{typ}" disturbance takes:'] for f in ("time",) + required: lines.append(f" {f:<8} {cls._FIELD_MEANING[f]}") for f in optional: lines.append(f" {f:<8} {cls._FIELD_MEANING[f]} [optional]") lines.append(f"Example: {cls._EVENT_EXAMPLES[typ]}") return "\n".join(lines)
[docs] def add(self, idx=None, name=None, **kwargs): """Validate one disturbance row before storing it: the type must be known, its required fields present, and no stray fields allowed (a typo in a field name must not silently change the event).""" typ = kwargs.get("type") if typ is None or typ not in self._EVENT_FIELDS: raise ValueError( f"unknown disturbance type {typ!r}; supported types: " + ", ".join(f'"{t}"' for t in self._EVENT_FIELDS) + ". Each is documented in the disturbances guide." ) required, optional = self._EVENT_FIELDS[typ] allowed = {"type", "time", "u", *required, *optional} unknown = sorted(k for k in kwargs if k not in allowed) if unknown: raise ValueError( f"unknown field(s) {', '.join(unknown)} on a \"{typ}\" " "disturbance row.\n" + self._fields_help(typ) ) missing = sorted( f for f in ("time",) + required if kwargs.get(f) is None ) if missing: raise ValueError( f"missing field(s) {', '.join(missing)} on a \"{typ}\" " "disturbance row.\n" + self._fields_help(typ) ) super().add(idx=idx, name=name, **kwargs)
[docs] def sort_chrono(self): sorted_indices = np.argsort(self.time) for key in self._params: setattr(self, key, getattr(self, key)[sorted_indices])
[docs] class Line(Element): r"""Transmission line or transformer branch (PI model with off-nominal tap). Selected in a system file by the class name in the first column, e.g. ``Line, bus_i = "1", bus_j = "2", r = 0.01, x = 0.08, g = 0.001, b = 0.03, trafo = 1``. All values are per unit on the system base, so a transformer is represented by the off-nominal tap ratio :math:`\tau` (``trafo``) of its branch rather than by a separate model. **Quasi-static model** (``line_dyn = False``). The branch is a PI section: series impedance :math:`z = r + jx` with series admittance :math:`\bar{y}_s = 1/z`, total shunt :math:`g + jb` split half per end, and the tap on the ``bus_i`` (from) side. The class only carries the parameters; ``hermess.system.Grid.build_y_sym`` assembles the bus admittance matrix from them, with :math:`z^{-1} = 1/(r^2 + x^2)` and entries (real, imaginary): .. math:: Y_{ij} = Y_{ji} &: \Bigl( -\frac{r z^{-1}}{\tau}, \; -\frac{x z^{-1}}{\tau} \Bigr) \\ Y_{ii} &: \Bigl( \frac{g/2 + r z^{-1}}{\tau^2}, \; \frac{-b/2 + x z^{-1}}{\tau^2} \Bigr) \\ Y_{jj} &: \Bigl( g/2 + r z^{-1}, \; -b/2 + x z^{-1} \Bigr) The imaginary entries are stored in the sign convention of the rectangular nodal current balance used throughout the code (the susceptance rows enter with the sign shown, i.e. a stored pair :math:`(a, b)` corresponds to the admittance :math:`a - jb` of the standard phasor convention). During a middle-of-line fault the branch's :math:`r, x, g, b` are replaced by the equivalent PI section of the faulted T model; an opened line is removed by setting its series impedance effectively infinite. **Dynamic (electromagnetic) model** (``line_dyn = True``). The series branch keeps its own current states :math:`\bar{i}_{ij} = i_{ij,re} + j \, i_{ij,im}` (in the line's reference frame, with the nodal voltages transformed into that frame): .. math:: \frac{d i_{ij,re}}{dt} &= \frac{\omega_b}{x} \Bigl( \frac{v_{i,re}}{\tau} - v_{j,re} - r \, i_{ij,re} \Bigr) + \omega_b \, \omega_{ref} \, i_{ij,im} \\ \frac{d i_{ij,im}}{dt} &= \frac{\omega_b}{x} \Bigl( \frac{v_{i,im}}{\tau} - v_{j,im} - r \, i_{ij,im} \Bigr) - \omega_b \, \omega_{ref} \, i_{ij,re} with :math:`\omega_b = 2 \pi f_n` and :math:`\omega_{ref}` the line's reference frequency (per the configured ``omega_mode``); here :math:`x` and :math:`b` act as the inductance and capacitance, being numerically equal at 1 p.u. frequency. The line switch of an opened line zeroes the derivatives, and the branch currents are accumulated into the nodal current balance transformed to the bus frame. **Symbols.** .. csv-table:: :header: Code, Symbol, Meaning, Default :widths: 14, 12, 58, 10 "``r``", ":math:`r`", "series resistance [p.u.]", "0.001" "``x``", ":math:`x`", "series reactance [p.u.]", "0.001" "``g``", ":math:`g`", "total shunt conductance [p.u.], split half per end", "0" "``b``", ":math:`b`", "total shunt susceptance [p.u.], split half per end", "0" "``trafo``", ":math:`\tau`", "off-nominal tap ratio on the ``bus_i`` side (real)", "1" "``bus_i``", "", "name of the sending-end (from) bus", "" "``bus_j``", "", "name of the receiving-end (to) bus", "" "``i_ijr``", ":math:`i_{ij,re}`", "series current, real part (state, ``line_dyn`` only) [p.u.]", "" "``i_iji``", ":math:`i_{ij,im}`", "series current, imaginary part (state, ``line_dyn`` only) [p.u.]", "" """ def __init__(self) -> None: super().__init__() self._type = "Transmission_line" # Element type self._name = "Transmission_line" # Element name self._params.update( { "r": 0.001, "x": 0.001, "g": 0, "b": 0, "bus_i": None, "bus_j": None, "trafo": 1, } ) self.states = ["i_ijr", "i_iji"] self.r = np.array([], dtype=float) self.x = np.array([], dtype=float) self.g = np.array([], dtype=float) self.b = np.array([], dtype=float) self.trafo = np.array([], dtype=float) self.bus_i = np.array([], dtype=object) self.bus_j = np.array([], dtype=object)
[docs] def fcall(self, grid: GridSim, dae: DaeSim) -> None: r"""Call all differential equations for all lines. This function should be used only in case lines are modeled dynamically. If they are modeled statically they are automatically incorporated within grid class""" # TODO create separate multiplication factors for re and im because of phase shifters self.i_ijr = [i for i in range(2 * grid.nb) if i % 2 == 0] self.i_iji = [i for i in range(2 * grid.nb) if i % 2 != 0] omega_b = [2 * np.pi * dae.fn] * self.n # Update Reference Frequencies omega_ref_vec = ( ca.SX.ones(self.n, 1) * dae.omega_net ) # initialization and fallback # Set reference frequency if getattr(dae, "omega_ref_lines", None) is not None: # Vector of reference frequencies at all lines omega_ref_vec = dae.omega_ref_lines # Transform nodal voltages into the line's reference frame so that all # quantities in each equation share one frame. Vi_re, Vi_im, Vj_re, Vj_im = grid.build_voltage_transformed_to_line_frame(dae) # d i_ijr / dt dae.fl[self.i_ijr] = (omega_b / (self.x * 1)) * ( ((Vi_re / self.trafo) - Vj_re) - ((self.r * 1) * dae.xl[self.i_ijr]) ) + omega_b * omega_ref_vec * dae.xl[self.i_iji] # d i_iji / dt dae.fl[self.i_iji] = (omega_b / (self.x * 1)) * ( ((Vi_im / self.trafo) - Vj_im) - ((self.r * 1) * dae.xl[self.i_iji]) ) - omega_b * omega_ref_vec * dae.xl[self.i_ijr] # Apply line switches: sl=0 deactivates opened lines (derivative → 0) dae.fl[self.i_ijr] *= dae.sl[self.i_ijr] dae.fl[self.i_iji] *= dae.sl[self.i_iji] # TODO add the rotation of current through phase shifts # Accumulate branch currents into the nodal current balance, transformed # into the bus frame. Handles multiple lines incident to one bus without # index-overwrite issues. The returned vector spans the 2*nn bus-voltage # block; device-private algebraics occupy dae.g[2*nn:] and are untouched. nv = 2 * grid.nn dae.g[:nv] += grid.build_linecurrents_transformed_to_bus_frame(dae)
[docs] class DeviceRect(Element): """ Dynamic or static device modeled in rectangular coordinates. Used as a parent class for all devices modeled in rectangular coordinates. Attributes: properties (dict[str, bool]): Flags for method calls (e.g., 'gcall', 'fcall'). xf (dict[str, np.ndarray]): Final state results for the simulation. xinit (dict[State, list[float]]): Initial state values for all devices of the instance. xmin (list[float]): Minimum limited state value. xmax (list[float]): Maximum limited state value. _params (dict[str, float]): Device parameters, such as rated voltage, power, and frequency and internal parameters. _data (dict[str, str]): Additional data which will be used in a list, one entry for each device of this object instance. bus (list[Optional[str]]): Buses where the device is connected. Each device has an entry in the list. states (list[States]): State variables. units (list[States]): Units of state variables. ns (float): Total number of states in the model. vre (list[float]): Order of the real voltage value for each device in the overall DAE model. vim (list[float]): Order of the real voltage value for each device in the overall DAE model. _algebs (list[str]): Algebraic variables ('vre', 'vim'). _descr (dict[str, str]): Descriptions for key parameters. """ #: Default initialization method for this device class. ``"joint"`` -> the #: one-shot Newton+LM solve in :meth:`_finit_joint` (e.g. the synchronous #: machine); ``"sequential"`` -> a staged init in :meth:`_finit_sequential` #: (e.g. the inverter). _init_method: str = "joint" def __init__(self) -> None: super().__init__() self.xf: dict[str, np.ndarray] = {} # final state results or sim/est self.yf_int: dict[str, np.ndarray] = {} # private algebraic trajectories self.xinit: dict[str, list[float]] = {} self.xmin: list[float] = [] self.xmax: list[float] = [] self._params.update({"Vn": 220, "fn": 50.0, "Sn": 100}) self.Vn = np.array([], dtype=float) self.fn = np.array([], dtype=float) self.Sn = np.array([], dtype=float) self._data.update({"bus": None}) self.bus: list[Optional[str]] = [] # at which bus self.states: list[str] = [] # list of state variables self.units: list[str] = [] # List of states' units self.ns: int = 0 # number of states self._algebs: list[str] = ["vre", "vim"] # list of algebraic variables self.vre = np.array([], dtype=float) self.vim = np.array([], dtype=float) # Device-private (internal) algebraic variables. Unlike the shared, # grid-mapped node voltages vre/vim, each private algebraic is owned by # this device, gets a fresh dae.y/dae.g slot in xy_index, and is defined # by its own equation 0 = g written in fgcall. Used for device-local # algebraic constraints that cannot be symbolically eliminated. self._algebs_int: list[str] = [] # ordered names of private algebraics self._algebs_int_x0: dict[str, float] = {} # init guess (Newton guess in finit) self._algebs_int_units: dict[str, str] = {} # units, for labels/plotting self._x0: dict[ str, float ] = ( {} ) # default initial states, will be used as initial guess for the simulation initialization self._mand.extend(["bus"]) self._descr = { "Sn": "rated power", "Vn": "rated voltage", "u": "connection status", "fn": "nominal frequency", } def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) # Merge class-level _descrs if subclass adds its own full_descr = dict(getattr(cls, "_descr", {})) for base in cls.__bases__: full_descr.update(getattr(base, "_descr", {})) # Format as docstring section doc_lines = ["\nAttributes", "----------"] for key, val in full_descr.items(): doc_lines.append(f"{key} : np.ndarray") doc_lines.append(f" {val}") # Inject into class docstring cls.__doc__ = (cls.__doc__ or "") + "\n" + "\n".join(doc_lines) def _init_data(self) -> None: self.xinit = {state: [] for state in self.states}
[docs] def xy_index(self, dae: Dae, grid: Grid) -> None: """Initializes indices for states, algebraic variables, unknown inputs, and switches. Args: dae (Dae): Object managing differential-algebraic equations. grid (Grid): Object managing the electrical grid and node indices. """ zeros = [0] * self.n for item in self.states: self.__dict__[item] = zeros[:] for item in self._algebs: self.__dict__[item] = zeros[:] for item in self._algebs_int: self.__dict__[item] = zeros[:] for var in range(self.n): for item in self.states: self.__dict__[item][var] = dae.nx # Init with current state init value dae.xinit.append(self.xinit[item][var]) # Add the corresponding state to the DAE dae.states.append(f"{self._name}_at_{self.bus[var]}_{item}") dae.nx += 1 # Retrieve min and max state limits item_min = getattr(self, item + "_min", None) item_max = getattr(self, item + "_max", None) self.xmin.append(item_min[var] if item_min is not None else -np.inf) self.xmax.append(item_max[var] if item_max is not None else np.inf) dae.xmin.append(item_min[var] if item_min is not None else -np.inf) dae.xmax.append(item_max[var] if item_max is not None else np.inf) if self.n: # assign indices to real and imaginary voltage algebraic variables; first real value self.__dict__["vre"] = grid.get_node_index(self.bus)[1] self.__dict__["vim"] = grid.get_node_index(self.bus)[2] # Device-private algebraic variables: append fresh slots after the 2*nn # voltage block. grid.nn is final here, so the global index is # grid.nn*2 + dae.n_priv (a running counter across all devices). The # symbolic dae.y/dae.g vectors are sized to 2*nn + dae.n_priv later, in # Grid.init_symbolic (which runs after every device's xy_index). if self._algebs_int: base = grid.nn * 2 for var in range(self.n): for item in self._algebs_int: self.__dict__[item][var] = base + dae.n_priv dae.algebs_int_names.append( f"{self._name}_at_{self.bus[var]}_{item}" ) dae.yinit_priv.append(self._algebs_int_x0.get(item, 0.0)) dae.n_priv += 1
[docs] def add(self, idx=None, name=None, **kwargs) -> None: super().add(idx, name, **kwargs) # initialize initial states with some default values for item in self.states: self.xinit[item].append(self._x0[item])
[docs] def save_data(self, dae: Dae) -> None: for item in self.states: self.xf[item] = np.zeros([self.n, dae.nts]) self.xf[item][:, :] = dae.x_full[self.__dict__[item][:], :] # Persist device-private algebraic trajectories (from dae.y_full) # alongside the state trajectories. for item in self._algebs_int: self.yf_int[item] = dae.y_full[self.__dict__[item][:], :]
[docs] def finit_anchor_residuals(self, dae: Dae) -> list: """Extra steady-state anchor residuals (each an n-vector that must equal 0) a device declares to keep its initialization Newton system square. The joint init pins each device's state ODEs (``f = 0``) plus two quantities through the bus-current balance (``g[vre]``/``g[vim]``), so a square system requires ``n_setpoints == 2`` (the SG: Pref + Vref). A device with more setpoints returns ``n_setpoints - 2`` anchor residuals to balance them. Empty by default. Example: the inverter has 3 setpoints (Pref/Qref/Vref) but the bus equations pin only 2; it returns one anchor (Qref = Qc) here. """ return []
[docs] def finit(self, dae: Dae) -> None: """Initialize the device, dispatching on :attr:`_init_method` (``"joint"`` -> :meth:`_finit_joint`, ``"sequential"`` -> :meth:`_finit_sequential`).""" if self._init_method == "sequential": self._finit_sequential(dae) elif self._init_method == "joint": self._finit_joint(dae) else: raise ValueError( f"Unknown _init_method {self._init_method!r} on {self._name}; " "expected 'joint' or 'sequential'." )
def _finit_sequential(self, dae: Dae) -> None: """Staged, device-specific initialization. Devices that declare ``_init_method = 'sequential'`` implement this; the default has none.""" raise NotImplementedError( f"{self._name} declares _init_method='sequential' but provides no " "_finit_sequential." )
[docs] def finit_guess(self, dae: Dae): """Newton seed for the joint init, machine-major ``[states of inst 0 | states of inst 1 | ...]``, or None for the default (class-level ``_x0`` repeated per instance). Overrides may also refresh setpoint arrays (e.g. ``Pref``), which the joint solve consumes as setpoint guesses.""" return None
def _finit_joint(self, dae: Dae) -> None: """One-shot initialization: solve the device's steady state (states + setpoints + private algebraics, balanced by the bus equations and any device-declared anchor residuals) as a single Newton system, with a Levenberg-Marquardt fallback. Initialize the device by setting up setpoints, initial states based on the power flow solution. Args: dae (Dae): The DAE object used to simulate the system. """ # Per-instance Newton seeds. The default guess repeats the class-level # _x0 for every instance; a device class may override finit_guess() to # compute operating-point-aware seeds (and refresh its setpoint arrays, # consumed as guesses just below). Operating-point-aware seeds keep # machines at large-angle buses off the mirror (saddle) equilibrium of # the machine equations. guess_x0 = self.finit_guess(dae) u = ca.SX.sym("", 0) u0 = [] for item in self._setpoints: # Set the initial guess for the setpoint u0.append(self.__dict__[item]) # Reset it to be a variable self.__dict__[item] = ca.SX.sym(item, self.n) # Stack the variable to a single vector u = ca.vertcat(u, self.__dict__[item]) u0 = [item for sublist in u0 for item in sublist] # flatten it # Now subtract the initial network currents from algebraic equations for alg in self._algebs: dae.g[self.__dict__[alg]] += dae.iinit[self.__dict__[alg]] # Fix algebraic variables to their init values, except this device's own # private algebraics, which stay symbolic so finit solves for their # consistent values together with the states. Build a mixed dae.y: # numeric voltages, symbolic privates. priv_global_idx / priv_syms / # priv_guess are item-major, instance-minor so the solution tail unpacks # in the same order below. priv_syms: list = [] priv_global_idx: list = [] priv_guess: list = [] if self._algebs_int: y_mixed = ca.SX(dae.yinit) # numeric voltages as SX constants for item in self._algebs_int: sym = ca.SX.sym(item, self.n) for var in range(self.n): gidx = self.__dict__[item][var] y_mixed[gidx] = sym[var] priv_global_idx.append(gidx) priv_guess.append(self._algebs_int_x0.get(item, 0.0)) priv_syms.append(sym) dae.y = y_mixed else: dae.y = dae.yinit.copy() dae.s = np.ones(dae.nx) self.fgcall(dae) # Find the indices of differential equations for this type of generator diff = [self.__dict__[arg] for arg in self.states] diff_index = [item for sublist in np.transpose(diff) for item in sublist] # Stack private symbols as additional unknowns (after the setpoints) and # their defining equations dae.g[private] as additional residuals. This # keeps the Newton system square: +n*n_priv unknowns, +n*n_priv residuals. priv_stack = ca.SX.sym("", 0) for sym in priv_syms: priv_stack = ca.vertcat(priv_stack, sym) residuals = ca.vertcat( dae.f[diff_index], dae.g[self.__dict__["vre"]], dae.g[self.__dict__["vim"]], ) if priv_global_idx: residuals = ca.vertcat(residuals, dae.g[priv_global_idx]) # Device-declared anchor residuals balance setpoints not pinned by the # two bus-current equations (e.g. the inverter's Qref=Qc gauge fix). # Empty by default. for anchor in self.finit_anchor_residuals(dae): residuals = ca.vertcat(residuals, anchor) inputs = [ca.vertcat(dae.x[diff_index], u, priv_stack)] outputs = [residuals] W = [ca.vertcat(dae.omega_ref, dae.omega_ref_buses, dae.omega_ref_lines)] W_one = [ca.SX.ones(1 + dae.grid.nn + dae.grid.nb, 1)] outputs = ca.substitute(outputs, W, W_one) device_init = ca.Function("h", inputs, outputs) newton_init = ca.rootfinder("G", "newton", device_init) if guess_x0 is not None: x0 = np.asarray(guess_x0, dtype=float) else: x0 = np.array([self._x0[s] for s in self.states] * self.n) guess = ca.vertcat(x0, u0) if priv_guess: guess = ca.vertcat(guess, priv_guess) try: solution = np.array(newton_init(guess)).flatten() except RuntimeError: solution = np.full(int(inputs[0].numel()), np.nan) if not np.isfinite(solution).all(): # Plain Newton can diverge when the per-device init Jacobian is # rank-deficient at the starting guess (a benign gauge in the d-axis # excitation/flux chain). Fall back to a damped Gauss-Newton # (Levenberg-Marquardt) iteration, which regularises the # rank-deficient Jacobian and converges to a consistent solution. h = ca.Function("h", inputs, outputs) Jh = ca.Function("Jh", inputs, [ca.jacobian(outputs[0], inputs[0])]) z = np.array(guess).flatten().astype(float) lam = 1e-3 r = np.array(h(z)).flatten() n_z = z.shape[0] ident = scipy.sparse.identity(n_z, format="csc") for _ in range(500): rn = np.linalg.norm(r) if rn < 1e-10: break # The init Jacobian is block-diagonal across device instances; # keep it sparse. Densifying and solving with dense LAPACK is # O((n·n_states)³) and dominates init time for large fleets. J_dm = Jh(z) J_sp = J_dm.sparsity() J = scipy.sparse.csc_matrix( (np.array(J_dm.nonzeros()), J_sp.row(), J_sp.colind()), shape=J_dm.shape, ) JtJ = (J.T @ J).tocsc() step = scipy.sparse.linalg.spsolve(JtJ + lam * ident, -(J.T @ r)) z_new = z + step r_new = np.array(h(z_new)).flatten() if np.linalg.norm(r_new) < rn: z, r = z_new, r_new lam = max(lam * 0.5, 1e-12) else: lam = min(lam * 2.0, 1e8) solution = z # Unpack the per-state init values from the machine-major solution # vector (states grouped per instance, n_states wide). index = 0 for s in self.states: locat = [] for n in range(self.n): locat.append(n * len(self.states) + index) self.xinit[s] = solution[locat] index += 1 for idx, s in enumerate(self._setpoints): setpoint_range_start = (len(self.states) + idx) * self.n changed_setpoints = ( u0[idx * self.n : (idx + 1) * self.n] != solution[setpoint_range_start : setpoint_range_start + self.n] ) for i in range(self.n): if changed_setpoints[i]: logging.info( f"Setpoint '{s}' - {self._descr[s]} - updated in device {self._name} at node {self.bus[i]} from {u0[idx * self.n + i]} to {solution[setpoint_range_start + i]} to match the initial power flow!" ) self.__dict__[s] = solution[ setpoint_range_start : setpoint_range_start + self.n ] # Load the initial states into the DAE class so the simulation starts # from those values. dae.xinit[diff_index] = solution[: len(self.states) * self.n] # Write converged private algebraic values back into dae.yinit so the # time-domain start sits on the constraint manifold. The private block of # the solution begins after the states and setpoints, item-major # instance-minor (the order in which priv_global_idx was built). if self._algebs_int: k = (len(self.states) + len(self._setpoints)) * self.n for gidx in priv_global_idx: dae.yinit[gidx] = solution[k] k += 1 # Clear the algebraic equations so fgcall can rebuild them from scratch. dae.g *= 0 # Restore the voltages and switches as symbolic variables. dae.y = ca.SX.sym("y", dae.ny) dae.s = ca.SX.sym("s", dae.nx)
[docs] def fgcall(self, dae: Dae) -> None: """ A method that executes the differential and algebraic equations of the model and adds them to the appropriate places in the Dae class :param dae: an instance of a class Dae :type dae: :return: None :rtype: None """ pass