components — symbolic nonlinear-then-linearized models

Ports the pattern used throughout Functions/sym*.m: build a full nonlinear DAE symbolically with sympy (differential equations, algebraic constraints, outputs), then either linearize it around an operating point (what every MATLAB sym*.m file did, and all it did) or keep it nonlinear and lambdify it to a fast numeric callable (new — this is what makes EMT simulation possible without re-deriving any physics).

The shared machinery (components.base)

        classDiagram
    class ComponentDAE {
        Fx Fz Fu Gx Gz Gu Hx Hz Hu : sp.Matrix
        state_syms alg_syms input_syms : list~Symbol~
        diffeq_exprs algeq_exprs output_exprs : list~Expr~
        param_syms() list~Symbol~
        point_from_subs(subs) tuple
        linearize(subs) LinearComponent
        nonlinear_funcs() NonlinearFuncs
        nonlinear_jacobians() NonlinearJacobians
    }
    class LinearComponent {
        A B C D : ndarray
        n_us n_ug n_out_s n_out_g : int
        state_names input_names output_names
    }
    class NonlinearFuncs {
        f(x,z,u,p) ndarray
        g(x,z,u,p) ndarray
        h(x,z,u,p) ndarray
    }
    class NonlinearJacobians {
        Gz(x,z,u,p) Gx(x,z,u,p) Gu(x,z,u,p)
        Hz(x,z,u,p) Hx(x,z,u,p) Hu(x,z,u,p)
    }
    ComponentDAE --> LinearComponent : linearize(subs)
    ComponentDAE --> NonlinearFuncs : nonlinear_funcs()
    ComponentDAE --> NonlinearJacobians : nonlinear_jacobians()
    

build_dae() is the one function every sym*-ported module calls: given symbolic state/algebraic/input vectors and the raw nonlinear diffeq/algeq/output expressions, it takes every Jacobian (Fx = d(diffeq)/d(state), etc. — mirroring the jacobian(diffeqVec, stateVec) calls repeated in every MATLAB sym*.m file) and returns one ComponentDAE. Three consumers read from that same object:

  • linearize(subs) substitutes numeric parameter + operating-point values and eliminates the algebraic variables (A = Fx - Fz@inv(Gz)@Gx, etc.) — this is all the MATLAB source ever did.

  • nonlinear_funcs() lambdifies the raw diffeq/algeq/output expressions (unsubstituted) into (x, z, u, p) -> ndarray callables — the same equations, kept instead of discarded after differentiation.

  • nonlinear_jacobians() lambdifies Gz/Gx/Gu/Hz/Hx/Hu themselves (not substituted at one fixed point) into (x,z,u,p) -> ndarray callables — what a Newton solver needs to evaluate the algebraic Jacobian at whatever point it’s currently iterating on, not just once at a fixed operating point. This is the analytic Jacobian timedomain — EMT (nonlinear) time-domain simulation’s coupled Newton solve uses.

One deliberate deviation from the MATLAB source is worth calling out: the original toolbox substitutes numeric values before forming Ai = Fx - Fz*inv(Gz)*Gx (symbolic matrix inversion). Substitution and matrix algebra commute, so this port keeps Fx..Hu symbolic (cheap — just differentiation) and defers substitution to linearize(), where the Gz inverse runs numerically in numpy instead of symbolically in sympy — avoiding symbolic inversion of a 15×15+ matrix. Same result, much faster.

Per-component modules

Each ported component follows the same shape: a module-level *_dae() function (cached with @lru_cache, since building the symbolic DAE is the expensive part and every instance of a given component type shares the same equations), an *OperatingPoint class that turns power-flow results into substitution values, and linearize_*() / *_nonlinear_point() / *_nonlinear_funcs() / *_nonlinear_jacobians() wrapper functions.

        flowchart LR
    subgraph Sources["Functions/sym*.m"]
        symSG["symSG.m + SG_subs.m"]
        symGFM["symGFM_types.m + GFM_subs.m"]
        symGFL["symGFL.m + GFL_subs.m"]
        symIB["symIB.m + IB_subs.m"]
        symLine["symLine.m"]
        symNode["symNode.m"]
        symLoad["symLoad.m"]
    end
    subgraph Python["components/*.py"]
        sm["sm.py<br/>synchronous machine"]
        gfm["gfm.py<br/>grid-forming"]
        gfl["gfl.py<br/>grid-following"]
        ib["ib.py<br/>infinite bus"]
        frame["frame.py<br/>reference frame"]
        line["line.py"]
        node["node.py"]
        load["load.py"]
    end
    symSG --> sm --> DAE["build_dae() -> ComponentDAE"]
    symGFM --> gfm --> DAE
    symGFL --> gfl --> DAE
    symIB --> ib --> DAE
    symLine --> line --> DAE
    symNode --> node --> DAE
    symLoad --> load --> DAE
    

Coverage note: gfm.py implements only the 'Droop' branch of symGFM_types.m (the CIGRE preset’s GFM controller); 'Droop+filter', 'Droop+PLL', 'dVOC', 'VSM' and 'Matching' aren’t ported. ib.py (infinite bus) was built and tested in isolation for a while before being wired into interconnect — no MATLAB script in the source repo defines a complete infinite-bus DER case (preset_networks.m names SM_SMIB/GFM_SMIB/GFL_SMIB but their switch bodies are empty), so there was no real preset to validate it against until the SMIB presets (network) reconstructed one from the real topology data that does exist (SMIB_raw.m).

A major bug was found and fixed while building sm.py’s SmOperatingPoint: a double-rotation error in the terminal-voltage phasor (v0 * exp(j*(angle_terminal_rad - theta0)) instead of v0 * exp(-j*theta0)) silently corrupted every non-slack synchronous machine’s operating point, invisible on the slack unit only because pandapower pins its angle to exactly 0. See the SmOperatingPoint docstring below and its internal consistency assertion (comparing power-flow-derived id0/iq0 against independently-derived values from the flux-current relations) — that assertion is what caught it.

Physics: the full nonlinear equations

Everything below is the literal content of each *_dae() function — the same diffeqVec/algeqVec/outputeqVec sympy builds, transcribed to LaTeX rather than paraphrased, so this can be read as the model specification and cross-checked line-by-line against the source. Two notational conventions hold throughout, matching pu_base’s amplitude-invariant convention and the MATLAB source’s own dq-frame formulation:

  • Everything is per-unit on the network’s own sn_mva/bus vn_kv base (pu_base), and every state/algebraic/input symbol below is exactly the sympy symbol name used in the code, just set in math instead of snake_case (e.g. Dwr → \(\Delta\omega_r\), phi_d → \(\psi_d\), theta_g → \(\theta_g\)).

  • Two reference frames are in play. A global dq frame carries every node, line and load (their states already have a _g suffix), turning at \(\omega_g\) with angle \(\theta_g\). Each DER (SM/GFM/GFL/IB) instead has its own rotating frame, at its own angle \(\theta\) (SM, GFM), \(\theta_{pll}\) (GFL, PLL-locked) or \(\theta_{up}\) (IB) — the algebraic equations below rotate a DER’s own terminal quantities into and out of the global frame by the angle difference \(\theta_g - \theta\).

  • The global frame is a block of its own (frame.py): one state, the frame angle, following the speed of the unit its island is referenced to, and nothing else. It replaces the toolbox’s convention of reading \(\theta_g\)/\(\omega_g\) off the slack unit, which made that unit impossible to disconnect: with the frame standing apart, any unit can be tripped and a network can split into islands, each with its own frame and its own frequency. Network.frame_follows_slack restores the old wiring, where the slack unit itself is the frame and has to be a synchronous machine or an infinite bus. The two give the same modes (checked to 1e-5 relative in tests/test_breakers.py) and, being only a change of coordinates, the same nonlinear trajectories.

A state’s time derivative is written \(\dot x\) throughout; every equation below carries an explicit \(\omega_b\) (wb, the base angular frequency, \(2\pi f_n\)) factor exactly where the code has it — per-unit flux-linkage and current states pick up this factor because \(1\) per-unit flux corresponds to \(1/\omega_b\) seconds of a unit voltage integrated, the standard per-unit machine-equation convention.

Synchronous machine (sm.py)

A round-rotor synchronous machine with one field winding and 2 damper windings (1 \(d\)-axis, 2 \(q\)-axis), its own step-up transformer, and a set of regulators — 19 states in its default configuration, ported from Functions/symSG.m.

States \(x = [i_{gd}, i_{gq}, \psi_d, \psi_q, \psi_{fd}, \psi_{1d}, \psi_{1q}, \psi_{2q}, \Delta\omega_r, \theta, \ldots]\) — grid-side transformer current, stator/field/damper flux linkages, speed deviation and rotor angle, followed by whatever the fitted regulators carry (below).

Selectable regulators

The exciter, the stabiliser and the governor are chosen per unit through DerUnit.exciter, DerUnit.pss and DerUnit.governor. They decide the machine’s states and its parameter set: each model brings its own parameters under its own names, so what is typed is what that model’s own diagram shows, and an override under a name the fitted model does not have is rejected rather than ignored.

Slot

Model

States

Parameters

Exciter

g2elin (default)

\(e_1, e_2, e_{fd}, e_3\)

\(T_r, K_a, T_a, K_e, T_e, K_{fd}, T_{fd}\)

kundur

\(e_1, e_{tgr}\)

\(T_R, K_A, T_A, T_B\)

PSS

g2elin (default)

\(\Delta\omega_1, v_1, v_2, v_{pss}\)

\(K_{PSS}, T_{LP}, T_{HP}, T_{1n}, T_{1d}, T_{2n}, T_{2d}\)

kundur

\(v_1, v_2, v_{pss}\)

\(K_{STAB}, T_W, T_1, T_2, T_3, T_4\)

none

—

—

Governor

g2elin (default)

\(P_m\)

\(m_p, T_G\)

none

—

—

The kundur pair is Kundur Fig. E12.9: a thyristor exciter — a terminal voltage transducer, a gain and transient gain reduction, with no exciter lag — and a stabiliser that is a washout and two lead-lag stages. Because a thyristor bridge has no time constant of its own, \(E_{fd}\) follows the regulator instantaneously and is algebraic there rather than a state, which is why that exciter is two states shorter rather than one.

none is equipment that is not fitted, not a gain turned down: the states, the parameters and the reduction group all go. A machine with no governor holds its mechanical power at the operating point, which is the constant-torque assumption most textbook small-signal examples are worked under — and note that a fleet with no governor anywhere has an unregulated common frequency, which the modal tools treat as marginal by construction rather than as an instability.

Algebraic variables \(z = [\omega_r, v_{ed}, v_{eq}, i_d, i_{1d}, i_{fd}, i_q, i_{1q}, i_{2q}, v_{gd}, v_{gq}, C_m, C_e, \Delta P, E_t]\).

Inputs: own \(u_s = [V_{ref}, P_{ref}, \omega_{ref}]\) — note that \(P_{ref}\) is the governor’s setpoint, so with no governor fitted its column of \(B\) is exactly zero and it moves nothing; grid-coupling \(u_g = [\theta_g, v_{gd,g}, v_{gq,g}]\) (just \([v_{gd,g}, v_{gq,g}]\) for the slack unit, which needs no \(\theta_g\) input since \(\theta_g \equiv \theta\) for it by definition).

Electrical dynamics (transformer current, stator/damper flux):

\[\begin{split} \begin{aligned} \dot i_{gd} &= \frac{\omega_b}{L_t}\left(v_{ed} - v_{gd} - R_t i_{gd} + \omega_r L_t i_{gq}\right) \\ \dot i_{gq} &= \frac{\omega_b}{L_t}\left(v_{eq} - v_{gq} - R_t i_{gq} - \omega_r L_t i_{gd}\right) \\ \dot\psi_d &= \omega_b\left(v_{ed} + R_a i_d + \omega_r \psi_q\right) \\ \dot\psi_q &= \omega_b\left(v_{eq} + R_a i_q - \omega_r \psi_d\right) \\ \dot\psi_{fd} &= \omega_b\frac{R_{fd}}{L_{ad}} e_{fd} - \omega_b R_{fd} i_{fd} \\ \dot\psi_{1d} &= -\omega_b R_{1d} i_{1d} \qquad \dot\psi_{1q} = -\omega_b R_{1q} i_{1q} \qquad \dot\psi_{2q} = -\omega_b R_{2q} i_{2q} \end{aligned} \end{split}\]

Mechanical (swing equation + first-order turbine/governor):

\[ \Delta\dot\omega_r = \frac{C_m - C_e - K_D\,\Delta\omega_r}{2H} \qquad \dot\theta = \omega_b\,\omega_r \qquad \dot P_m = \frac{P_{ref} - \Delta P - P_m}{T_G} \]

PSS (washout \(\to\) lead-lag cascade — note \(\dot v_1\) feeds directly into the \(\dot v_2\) equation and \(\dot v_2\) into \(\dot v_{pss}\), a transfer-function realization, not three independent first-order lags):

\[ \Delta\dot\omega_1 = \frac{\Delta\omega_r - \Delta\omega_1}{T_{LP}} \qquad \dot v_1 = \frac{T_{HP} K_{PSS}\,\Delta\dot\omega_1 - v_1}{T_{HP}} \]
\[ \dot v_2 = \frac{T_{1n}\dot v_1 + v_1 - v_2}{T_{1d}} \qquad \dot v_{pss} = \frac{T_{2n}\dot v_2 + v_2 - v_{pss}}{T_{2d}} \]

AVR (voltage transducer \(\to\) PI-ish comparator \(\to\) exciter \(\to\) field-current-derivative feedback):

\[ \dot e_1 = \frac{E_t - e_1}{T_r} \qquad \dot e_2 = \frac{K_a(V_{ref} - e_1 - e_3 + v_{pss}) - e_2}{T_a} \]
\[ \dot e_{fd} = \frac{K_e e_2 - e_{fd}}{T_e} \qquad \dot e_3 = \frac{K_{fd}\,\dot e_{fd} - e_3}{T_{fd}} \]

Algebraic constraints — frequency reference, stator-side aux-load Ohm’s law, flux/current inversion, torque/power, and the terminal-voltage magnitude:

\[ \omega_r = \Delta\omega_r + \omega_{ref} \qquad v_{ed} = (i_d - i_{gd})R_g \qquad v_{eq} = (i_q - i_{gq})R_g \]
\[\begin{split} \begin{bmatrix} i_d \\ i_{1d} \\ i_{fd} \end{bmatrix} = \begin{bmatrix} -L_{ad}-L_l & L_{ad} & L_{ad} \\ -L_{ad} & L_{1d}+L_{ad} & L_{ad} \\ -L_{ad} & L_{ad} & L_{fd}+L_{ad} \end{bmatrix}^{-1} \begin{bmatrix} \psi_d \\ \psi_{1d} \\ \psi_{fd} \end{bmatrix} \qquad \begin{bmatrix} i_q \\ i_{1q} \\ i_{2q} \end{bmatrix} = \begin{bmatrix} -L_{aq}-L_l & L_{aq} & L_{aq} \\ -L_{aq} & L_{1q}+L_{aq} & L_{aq} \\ -L_{aq} & L_{aq} & L_{2q}+L_{aq} \end{bmatrix}^{-1} \begin{bmatrix} \psi_q \\ \psi_{1q} \\ \psi_{2q} \end{bmatrix} \end{split}\]
\[ C_m = \frac{P_m}{\omega_r} \qquad C_e = \psi_d i_q - \psi_q i_d \qquad \Delta P = \frac{\omega_r - \omega_{ref}}{m_p} \qquad E_t = \sqrt{v_{ed}^2 + v_{eq}^2} \]

Frame rotation into the machine’s own dq frame (non-slack only — the slack sets \(v_{gd} = v_{gd,g}\), \(v_{gq} = v_{gq,g}\) directly, since its own frame is the global one):

\[\begin{split} \begin{bmatrix} v_{gd} \\ v_{gq} \end{bmatrix} = \begin{bmatrix} \cos(\theta_g-\theta) & -\sin(\theta_g-\theta) \\ \sin(\theta_g-\theta) & \cos(\theta_g-\theta) \end{bmatrix} \begin{bmatrix} v_{gd,g} \\ v_{gq,g} \end{bmatrix} \end{split}\]

Outputs: own \([P_e, Q_e, \omega_r, \theta, \Delta\omega_r, E_t]\) with \(P_e = v_{ed}i_{gd}+v_{eq}i_{gq}\), \(Q_e=-v_{ed}i_{gq}+v_{eq}i_{gd}\); grid current, rotated back to the global frame for a non-slack unit (the slack outputs \(i_{gd}, i_{gq}\) directly, being already in that frame):

\[\begin{split} \begin{bmatrix} i_{gd,g} \\ i_{gq,g} \end{bmatrix} = \begin{bmatrix} \cos(\theta-\theta_g) & -\sin(\theta-\theta_g) \\ \sin(\theta-\theta_g) & \cos(\theta-\theta_g) \end{bmatrix} \begin{bmatrix} i_{gd} \\ i_{gq} \end{bmatrix} \end{split}\]

Default electrical/mechanical/AVR/PSS parameter values are tabulated in operating_point.

Grid-forming converter (gfm.py)

A two-level VSC behind an LCL filter (\(R_f/L_f\) series, \(C_f\) shunt) and its own step-up transformer (\(R_t/L_t\)), with an outer power-control law, a voltage-control loop, a current-control loop, and a first-order DC-link model — 15 states running droop, ported from Functions/symGFM_types.m.

States \(x = [i_{sd}, i_{sq}, i_{gd}, i_{gq}, v_{dc}, i_{dc}, \ldots, M_{VLd}, M_{VLq}, M_{CLd}, M_{CLq}]\) — converter and grid-side filter currents, filter capacitor voltage, DC-link voltage and current, then the outer law’s own states (below), then the voltage-loop/current-loop PI integrator states.

Selectable power-control laws

All five of symGFM_types.m’s laws are chosen per unit through DerUnit.controller. They differ in exactly three things — which states the outer loop carries, how it forms the frequency deviation \(\Delta\omega\) that the angle integrates, and how it forms the voltage reference the cascade then tracks. Everything downstream is identical for all five.

Law

Outer states

\(\Delta\omega\)

\(v_{ed}^{ref}\)

Parameters

droop (default)

\(p_m, \theta, q_m\)

\(m_p(P_{ref}-p_m)\)

\(V_{ref} + (Q_{ref}-q_m)n_q\)

\(m_p, n_q, w_f\)

droop_filtered

\(p_m, \Delta\omega, \theta, q_m\)

a state

same

\(m_p, n_q, w_f, w_c\)

dvoc

\(p_m, \theta, q_m, v_{ed}^{ref}\)

\(\eta(P_{ref}/V_{ref}^2 - p_m/(v_{ed}^{ref})^2)\)

a state

\(\eta, \alpha, w_f\)

vsm

\(\Delta\omega, \theta, \Phi\)

a state (swing, \(J\)/\(D_p\))

\(\omega\Phi\)

\(J, D_p, K, D_q\)

matching

\(v_{dc,m}, \theta\)

\(K_\theta(v_{dc,m} - V_{dc,ref})\)

\(V_{ref}\)

\(K_\theta, w_f\)

A virtual synchronous machine and matching control carry no power filters at all. VSM measures \(p\) and \(q\) directly — its emulated inertia is what smooths the response — and matching takes its frequency from the DC link, which is what a machine’s speed does physically. So \(p_m\)/\(q_m\) are absent from those two rather than merely retuned.

The gains are script_generic.m’s, and every one is written in terms of the droop tuning (\(\eta = m_p\), \(J = 1/(m_p w_f)\), \(K_\theta = m_p K_{pdc}\), and so on). That is deliberate: the laws are meant to be comparable, tuned to the same equivalent inertia and reactive gain, so a study that swaps one for another sees what the law changes rather than what a different tuning changes.

Algebraic \(z = [m_d, m_q, \omega]\) (modulation index, own frequency). Inputs: \(u_s = [P_{ref}, Q_{ref}, V_{ref}, \omega_{ref}, V_{dc,ref}]\), \(u_g = [\theta_g, v_{gd,g}, v_{gq,g}]\) (a GFM is never the slack).

\[\begin{split} \begin{bmatrix} v_{gd} \\ v_{gq} \end{bmatrix} = \begin{bmatrix} \cos(\theta_g-\theta) & -\sin(\theta_g-\theta) \\ \sin(\theta_g-\theta) & \cos(\theta_g-\theta) \end{bmatrix} \begin{bmatrix} v_{gd,g} \\ v_{gq,g} \end{bmatrix} \end{split}\]

LCL filter + DC link:

\[\begin{split} \begin{aligned} \dot i_{sd} &= \frac{\omega_b}{L_f}\left(m_d v_{dc} - v_{ed} - R_f i_{sd} + \omega L_f i_{sq}\right) \\ \dot i_{sq} &= \frac{\omega_b}{L_f}\left(m_q v_{dc} - v_{eq} - R_f i_{sq} - \omega L_f i_{sd}\right) \\ \dot i_{gd} &= \frac{\omega_b}{L_t}\left(v_{ed} - v_{gd} - R_t i_{gd} + \omega L_t i_{gq}\right) \\ \dot i_{gq} &= \frac{\omega_b}{L_t}\left(v_{eq} - v_{gq} - R_t i_{gq} - \omega L_t i_{gd}\right) \\ \dot v_{ed} &= \frac{\omega_b}{C_f}\left(i_{sd} - i_{gd} + \omega C_f v_{eq}\right) \\ \dot v_{eq} &= \frac{\omega_b}{C_f}\left(i_{sq} - i_{gq} - \omega C_f v_{ed}\right) \\ \dot v_{dc} &= \frac{\omega_b}{C_{dc}}\left(i_{dc} - G_{dc} v_{dc} - m_d i_{sd} - m_q i_{sq}\right) \\ \dot i_{dc} &= \frac{1}{T_{dc}}\left(\frac{P_{ref}}{V_{dc,ref}} + K_{pdc}(V_{dc,ref}-v_{dc}) - i_{dc}\right) \end{aligned} \end{split}\]

Droop + voltage/current loops — \(P\)/\(\omega\) and \(Q\)/\(V\) droop, a low-pass power-measurement filter (\(\omega_f\)), then a voltage loop (PI + feed-forward, gains \(K_{p/i,VL}\)) whose output is the current reference, then a current loop (PI + feed-forward, gains \(K_{p/i,CL}\)):

\[ q = -v_{ed}i_{gq}+v_{eq}i_{gd} \qquad p = v_{ed}i_{gd}+v_{eq}i_{gq} \qquad \Delta\omega = m_p(P_{ref}-p_m) \]
\[ \dot p_m = \omega_f(p-p_m) \qquad \dot q_m = \omega_f(q-q_m) \qquad \dot\theta = \omega_b\,\omega \]
\[ v_{ed,ref} = V_{ref}+(Q_{ref}-q_m)n_q \qquad v_{eq,ref}=0 \]
\[ \dot M_{VLd} = K_{iVL}(v_{ed,ref}-v_{ed}) \qquad \dot M_{VLq} = K_{iVL}(v_{eq,ref}-v_{eq}) \]
\[ i_{sd,ref} = K_{pVL}(v_{ed,ref}-v_{ed}) + M_{VLd} + K_{ffi}i_{gd} - \omega_{ff}C_f v_{eq} \]
\[ i_{sq,ref} = K_{pVL}(v_{eq,ref}-v_{eq}) + M_{VLq} + K_{ffi}i_{gq} + \omega_{ff}C_f v_{ed} \]
\[ \dot M_{CLd}=K_{iCL}(i_{sd,ref}-i_{sd}) \qquad \dot M_{CLq}=K_{iCL}(i_{sq,ref}-i_{sq}) \]

Algebraic (modulation index, own frequency):

\[ m_d = \frac{1}{v_{dc}}\Big(K_{pCL}(i_{sd,ref}-i_{sd}) + K_{ffv}v_{ed} - \omega_{ff}L_f i_{sq} + M_{CLd}\Big) \]
\[ m_q = \frac{1}{v_{dc}}\Big(K_{pCL}(i_{sq,ref}-i_{sq}) + K_{ffv}v_{eq} + \omega_{ff}L_f i_{sd} + M_{CLq}\Big) \]
\[ \omega = \Delta\omega + \omega_{ref} \]

Outputs: \(p\), \(q\), \(\omega\), \(V_t=\sqrt{v_{ed}^2+v_{eq}^2}\), and the grid current rotated back to the global frame the same way as the SM’s non-slack case above (using \(\theta\) in place of the SM’s own angle).

Default filter/loop-tuning values (pole-placement formulas for \(K_{p/i,VL}\), \(K_{p/i,CL}\), \(K_{pdc}\)) are in operating_point.

Grid-following converter (gfl.py)

Structurally the same LCL-filter VSC as the GFM above, but current- (not voltage-) controlled, phase-locked to the grid via an SRF-PLL instead of self-clocking — 14 states, ported from Functions/symGFL.m. Never the slack.

States \(x = [i_{sd}, i_{sq}, i_{gd}, i_{gq}, v_{ed}, v_{eq}, v_{dc}, i_{dc}, M_d, M_q, M_{CLd}, M_{CLq}, M_{pll}, \theta_{pll}]\). Algebraic \(z=[m_d, m_q, \omega_{pll}]\). Inputs: \(u_s=[V_{dc,ref}, Q_{ref}, I_{dc,ref}]\), \(u_g=[\theta_g, v_{gd,g}, v_{gq,g}]\).

\[\begin{split} \begin{bmatrix} v_{gd} \\ v_{gq} \end{bmatrix} = \begin{bmatrix} \cos(\theta_g-\theta_{pll}) & -\sin(\theta_g-\theta_{pll}) \\ \sin(\theta_g-\theta_{pll}) & \cos(\theta_g-\theta_{pll}) \end{bmatrix} \begin{bmatrix} v_{gd,g} \\ v_{gq,g} \end{bmatrix} \end{split}\]

LCL filter + DC link (identical form to the GFM’s, \(\omega\) replaced by the PLL’s own tracked frequency \(\omega_{pll}\)):

\[\begin{split} \begin{aligned} \dot i_{sd} &= \frac{\omega_b}{L_f}\left(m_d v_{dc} - v_{ed} - R_f i_{sd} + \omega_{pll} L_f i_{sq}\right) \\ \dot i_{sq} &= \frac{\omega_b}{L_f}\left(m_q v_{dc} - v_{eq} - R_f i_{sq} - \omega_{pll} L_f i_{sd}\right) \\ \dot i_{gd} &= \frac{\omega_b}{L_t}\left(v_{ed} - v_{gd} - R_t i_{gd} + \omega_{pll} L_t i_{gq}\right) \\ \dot i_{gq} &= \frac{\omega_b}{L_t}\left(v_{eq} - v_{gq} - R_t i_{gq} - \omega_{pll} L_t i_{gd}\right) \\ \dot v_{ed} &= \frac{\omega_b}{C_f}\left(i_{sd} - i_{gd} + \omega_{pll} C_f v_{eq}\right) \\ \dot v_{eq} &= \frac{\omega_b}{C_f}\left(i_{sq} - i_{gq} - \omega_{pll} C_f v_{ed}\right) \\ \dot v_{dc} &= \frac{\omega_b}{C_{dc}}\left(i_{dc} - G_{dc} v_{dc} - m_d i_{sd} - m_q i_{sq}\right) \\ \dot i_{dc} &= \frac{1}{T_{dc}}\left(I_{dc,ref}-i_{dc}\right) \end{aligned} \end{split}\]

DC-voltage / reactive-power / current loops — a \(d\)-axis current reference regulates the DC-link voltage directly (no separate power loop); the \(q\)-axis current reference is the reactive-power loop’s own integrator state, fed straight into the current loop:

\[ i_{sd,ref} = K_{pd}(V_{dc,ref}-v_{dc}) + M_d \qquad \dot M_d = K_{id}(V_{dc,ref}-v_{dc}) \]
\[ q=-v_{ed}i_{gq}+v_{eq}i_{gd} \qquad p=v_{ed}i_{gd}+v_{eq}i_{gq} \qquad \dot M_q = K_{iq}(Q_{ref}-q) \]
\[ \dot M_{CLd}=K_{iCL}(i_{sd,ref}-i_{sd}) \qquad \dot M_{CLq}=K_{iCL}(M_q-i_{sq}) \]

SRF-PLL — the integral-of-\(v_{eq}\) term drives \(v_{eq}\to 0\), locking \(\theta_{pll}\) to the terminal-voltage vector angle:

\[ \dot M_{pll} = K_{i,pll}\,v_{eq} \qquad \dot\theta_{pll} = \omega_b\,\omega_{pll} \]

Algebraic:

\[ m_d = \frac{1}{v_{dc}}\Big(K_{pCL}(i_{sd,ref}-i_{sd}) + K_{ffv}v_{ed} - \omega_{ff}L_f i_{sq} + M_{CLd}\Big) \]
\[ m_q = \frac{1}{v_{dc}}\Big(K_{pCL}(M_q-i_{sq}) + K_{ffv}v_{eq} + \omega_{ff}L_f i_{sd} + M_{CLq}\Big) \]
\[ \omega_{pll} = M_{pll} + K_{p,pll}v_{eq} + \omega_{ff} \]

Outputs: \(p\), \(q\), \(\omega_{pll}\) (recomputed at output as \(M_{pll}+K_{p,pll}v_{eq}+\omega_{ff}\) — algebraically identical to the constraint above, kept as a separate output expression in the source), \(V_t\), and grid current rotated back to the global frame using \(\theta_{pll}\).

Infinite bus (ib.py)

The simplest DER: an ideal voltage source of fixed magnitude \(V_{up}\) and frequency \(\omega_{up}\) behind its own series impedance \(R_{up}/L_{up}\) (the DER’s own transformer) — 3 states, ported from Functions/symIB.m. No control loops, no algebraic variables at all. Always the slack (never wired as a non-slack unit — see interconnect), so its own frame is the global frame and its equations never rotate anything.

States \(x=[i_{gd,g}, i_{gq,g}, \theta_{up}]\). Inputs: \(u_s=[\omega_{up}, V_{up}]\), \(u_g=[v_{gd,g}, v_{gq,g}]\).

\[ \dot i_{gd,g} = \frac{\omega_b}{L_{up}}\left(V_{up} - v_{gd,g} - R_{up}i_{gd,g} + \omega_{up}L_{up}i_{gq,g}\right) \]
\[ \dot i_{gq,g} = \frac{\omega_b}{L_{up}}\left(-v_{gq,g} - R_{up}i_{gq,g} - \omega_{up}L_{up}i_{gd,g}\right) \]
\[ \dot\theta_{up} = \omega_b\,\omega_{up} \]

Outputs: own \([P_{up}, Q_{up}] = [V_{up}i_{gd,g},\; -V_{up}i_{gq,g}]\); grid-facing \([i_{gd,g}, i_{gq,g}, \theta_{up}, \omega_{up}]\) (echoed directly — an IB defines the global frame, so nothing needs rotating).

Transmission line (line.py)

A series \(R_l\)–\(L_l\) branch between two buses \(j\) (from_bus) and \(k\) (to_bus), living entirely in the global dq frame (no rotation, unlike every DER above) — 2 states, ported from Functions/symLine.m.

States \(x=[i_{ld,g}, i_{lq,g}]\). Inputs: \([\omega_g, v_{gdj,g}, v_{gqj,g}, v_{gdk,g}, v_{gqk,g}]\).

\[ \dot i_{ld,g} = \frac{\omega_b}{L_l}\left(v_{gdj,g}-v_{gdk,g} - R_l i_{ld,g} + \omega_g L_l i_{lq,g}\right) \]
\[ \dot i_{lq,g} = \frac{\omega_b}{L_l}\left(v_{gqj,g}-v_{gqk,g} - R_l i_{lq,g} - \omega_g L_l i_{ld,g}\right) \]

Output: \([i_{ld,g}, i_{lq,g}]\) itself (this current is what interconnect’s node current-balance wiring sums into the two endpoint buses, \(+\) at \(k\), \(-\) at \(j\)). \(L_l\) plays the role of the line’s reactance \(x_{pu}\) directly, per-unit convention throughout this codebase.

Network node (node.py)

The shunt line-charging capacitance \(C_l\) seen at one bus — its “voltage” state is that bus’s own dq voltage, so this component is what actually defines bus voltage dynamically in the closed-loop system (every other component treats its own bus voltage as an algebraic/input quantity) — 2 states, ported from Functions/symNode.m.

States \(x=[v_{gd,g}, v_{gq,g}]\). Input: \([\omega_g, i_{shd,g}, i_{shq,g}]\) — the net shunt current injected at this bus (KCL sum of every incident DER/line/load current, built by interconnect’s wiring rules).

\[ \dot v_{gd,g} = \frac{\omega_b}{C_l}\left(i_{shd,g} + \omega_g C_l v_{gq,g}\right) \qquad \dot v_{gq,g} = \frac{\omega_b}{C_l}\left(i_{shq,g} - \omega_g C_l v_{gd,g}\right) \]

Output: \([v_{gd,g}, v_{gq,g}]\) itself — every other component’s vgd_g/vgq_g input is wired to this output.

Constant-impedance load (load.py)

A series \(R_c\)–\(L_c\) branch to (implicitly) a zero-voltage reference, i.e. a constant-impedance load equivalent at its bus — 2 states, ported from Functions/symLoad.m. \(R_c\)/\(L_c\) (load_rx in operating_point) are derived from the load’s \(P\)/\(Q\)/\(V\) at the power-flow operating point, not free parameters.

States \(x=[i_{cd,g}, i_{cq,g}]\). Input: \([\omega_g, v_{gd,g}, v_{gq,g}]\).

\[ \dot i_{cd,g} = \frac{\omega_b}{L_c}\left(v_{gd,g} - R_c i_{cd,g} + \omega_g L_c i_{cq,g}\right) \qquad \dot i_{cq,g} = \frac{\omega_b}{L_c}\left(v_{gq,g} - R_c i_{cq,g} - \omega_g L_c i_{cd,g}\right) \]

Output: \([i_{cd,g}, i_{cq,g}]\) — subtracted (load convention) at its bus’s current balance, the same way a line’s current is added/subtracted at its two endpoints.

Linearization, in one place

Every component above shares one linearization step (linearize()), algebraic- variable elimination by direct substitution into the Jacobians formed once by build_dae():

\[ F_x = \frac{\partial f}{\partial x} \quad F_z = \frac{\partial f}{\partial z} \quad F_u = \frac{\partial f}{\partial u} \qquad G_x = \frac{\partial g}{\partial x} \quad G_z = \frac{\partial g}{\partial z} \quad G_u = \frac{\partial g}{\partial u} \qquad H_x = \frac{\partial h}{\partial x} \quad H_z = \frac{\partial h}{\partial z} \quad H_u = \frac{\partial h}{\partial u} \]
\[ A = F_x - F_z G_z^{-1} G_x \qquad B = F_u - F_z G_z^{-1} G_u \qquad C = H_x - H_z G_z^{-1} G_x \qquad D = H_u - H_z G_z^{-1} G_u \]

evaluated numerically at each component’s own operating point (all the \(0\)-subscripted values each *OperatingPoint class computes). This is exactly the small-signal linearization every sym*.m file in the MATLAB source performs — see the module’s own docstring above for the one deliberate implementation difference (symbolic-vs-numeric order of substitution and inversion, same result either way).

Reference

Shared machinery for linearized small-signal component models.

Ports the pattern used throughout Functions/sym*.m: build a full nonlinear DAE symbolically (differential equations diffeqVec, algebraic constraints algeqVec, outputs outputeqVec), take Jacobians to get Fx, Fz, Fu, Gx, Gz, Gu, Hx, Hz, Hu, then eliminate the algebraic variables:

A = Fx - Fz @ inv(Gz) @ Gx B = Fu - Fz @ inv(Gz) @ Gu C = Hx - Hz @ inv(Gz) @ Gx D = Hu - Hz @ inv(Gz) @ Gu

One deliberate deviation from the MATLAB source: the MATLAB toolbox substitutes numeric parameter/operating-point values before forming Ai = Fx - Fz*inv(Gz)*Gx (symbolic matrix inversion, feasible in MATLAB because it’s cached per case). Substitution and matrix algebra commute, so this module instead keeps Fx..Hu symbolic (cheap — just differentiation, no inversion) and defers substitution to ComponentDAE.linearize(), where the Gz inverse and matrix products run numerically in numpy. Same result, avoids symbolic inversion of a 15x15+ matrix.

class g2elin_core.components.base.LinearComponent(A, B, C, D, n_us, n_ug, n_out_s, n_out_g, state_names, input_names, output_names)[source]

Bases: object

A numeric linearized state-space, split into “own” (s) and “grid” (g) inputs/outputs the way the MATLAB toolbox splits nIP_* / nOP_*.

us inputs are the component’s own setpoints/references (e.g. a generator’s P_ref); ug inputs/outputs are the interconnection signals (node voltages, injected currents, the global reference angle) that g2elin_core.interconnect wires between components.

Parameters:
A: ndarray
B: ndarray
C: ndarray
D: ndarray
n_us: int
n_ug: int
n_out_s: int
n_out_g: int
state_names: list[str]
input_names: list[str]
output_names: list[str]
property n_states: int
class g2elin_core.components.base.NonlinearFuncs(f, g, h)[source]

Bases: object

Numeric (numpy-backed) callables for a component’s full nonlinear DAE, lambdified from the same symbolic equations the linearization was built from. All three take (x, z, u, p) — numpy arrays matching ComponentDAE.state_syms / alg_syms / input_syms / param_syms order — and return a 1-D numpy array.

This is what makes EMT (nonlinear) time-domain simulation possible without re-deriving physics: it’s the same diffeqVec/algeqVec/ outputeqVec every sym*.m file builds before linearizing, kept instead of discarded (see the migration plan’s “the nonlinear models already exist” finding).

Parameters:
f: object
g: object
h: object
class g2elin_core.components.base.NonlinearJacobians(Gz, Gx, Gu, Hz, Hx, Hu, Fx, Fz, Fu)[source]

Bases: object

Numeric Jacobian callables for f, g and h — see ComponentDAE.nonlinear_jacobians(). Each takes (x, z, u, p) and returns a 2-D numpy array.

Fx/Fz/Fu are what an implicit ODE solver needs: with them the total derivative df/dx along the constraint manifold can be assembled directly (see ode_jacobian()) instead of being finite-differenced, which costs one full coupled Newton solve per state.

Parameters:
Gz: object
Gx: object
Gu: object
Hz: object
Hx: object
Hu: object
Fx: object
Fz: object
Fu: object
class g2elin_core.components.base.ComponentDAE(Fx, Fz, Fu, Gx, Gz, Gu, Hx, Hz, Hu, n_us, n_ug, n_out_s, n_out_g, state_names=<factory>, input_names=<factory>, output_names=<factory>, state_syms=<factory>, alg_syms=<factory>, input_syms=<factory>, diffeq_exprs=<factory>, algeq_exprs=<factory>, output_exprs=<factory>, _cache=<factory>)[source]

Bases: object

Symbolic Jacobians for one component, not yet substituted with numbers.

Parameters:
  • Fx (MutableDenseMatrix)

  • Fz (MutableDenseMatrix)

  • Fu (MutableDenseMatrix)

  • Gx (MutableDenseMatrix)

  • Gz (MutableDenseMatrix)

  • Gu (MutableDenseMatrix)

  • Hx (MutableDenseMatrix)

  • Hz (MutableDenseMatrix)

  • Hu (MutableDenseMatrix)

  • n_us (int)

  • n_ug (int)

  • n_out_s (int)

  • n_out_g (int)

  • state_names (list[str])

  • input_names (list[str])

  • output_names (list[str])

  • state_syms (list[Symbol])

  • alg_syms (list[Symbol])

  • input_syms (list[Symbol])

  • diffeq_exprs (list[Expr])

  • algeq_exprs (list[Expr])

  • output_exprs (list[Expr])

  • _cache (dict)

Fx: MutableDenseMatrix
Fz: MutableDenseMatrix
Fu: MutableDenseMatrix
Gx: MutableDenseMatrix
Gz: MutableDenseMatrix
Gu: MutableDenseMatrix
Hx: MutableDenseMatrix
Hz: MutableDenseMatrix
Hu: MutableDenseMatrix
n_us: int
n_ug: int
n_out_s: int
n_out_g: int
state_names: list[str]
input_names: list[str]
output_names: list[str]
state_syms: list[Symbol]
alg_syms: list[Symbol]
input_syms: list[Symbol]
diffeq_exprs: list[Expr]
algeq_exprs: list[Expr]
output_exprs: list[Expr]
param_syms()[source]

Every free symbol in the equations that isn’t a state/alg/input — i.e. the physical parameters (wb, Ra, Lf, …). Sorted by name for a stable, reproducible ordering.

Return type:

list[Symbol]

point_from_subs(subs)[source]

Pulls (x0, z0, u0, p0) numeric arrays — in the exact symbol order nonlinear_funcs()’s callables expect — out of the same substitution dict linearize() takes. Lets callers reuse one dict to get both the linearized and the nonlinear operating point.

Parameters:

subs (dict)

Return type:

tuple[ndarray, ndarray, ndarray, ndarray]

nonlinear_funcs()[source]

Lambdify diffeqVec/algeqVec/outputeqVec into numeric callables. Not cached here (sympy Matrix isn’t reliably hashable) — component modules wrap this in their own @lru_cache``d accessor, the same way ``sm_dae() etc. are cached.

Return type:

NonlinearFuncs

nonlinear_jacobians()[source]

Lambdify Gz/Gx/Gu/Hz/Hx/Hu — the same Jacobians linearize() uses, but as numeric (x, z, u, p) -> matrix callables instead of being substituted with one fixed operating point. This is what a Newton solver needs for the nonlinear coupled algebraic system (see timedomain/emt.py): unlike linearize(), the Newton solve has to evaluate the Jacobian at whatever point the solver is currently at, not just once at an operating point.

Return type:

NonlinearJacobians

cached_jacobians()[source]

nonlinear_jacobians(), built once per DAE.

The component modules already cache each DAE, so caching the lambdified Jacobians on it means the (slow) lambdify happens once per model variant for the life of the process.

Return type:

NonlinearJacobians

linearize(subs)[source]

Substitute numeric parameter + operating-point values and eliminate z.

Evaluated through the lambdified Jacobians rather than by symbolic substitution. The two agree to ~1e-22 – they are the same expressions – but Matrix.subs on expressions this size is expensive enough to dominate everything built on it: measured on one synchronous machine, 952 ms against 0.74 ms, and a network’s linearisation calls this once per component.

Parameters:

subs (dict)

Return type:

LinearComponent

g2elin_core.components.base.build_dae(*, state_vec, alg_vec, input_vec, diffeq_vec, algeq_vec, output_vec, n_us, n_ug, n_out_s, n_out_g, state_names, input_names, output_names)[source]

Jacobian bookkeeping shared by every sym* port — mirrors the Fx = jacobian(diffeqVec, stateVec) block repeated in every MATLAB file.

Parameters:
Return type:

ComponentDAE

g2elin_core.components.base.eq_symbol(sym)[source]

The <name>_0 equilibrium-point symbol for sym, matching the MATLAB toolbox’s sym(strcat(char(s),'_0')) convention.

Parameters:

sym (Symbol)

Return type:

Symbol

g2elin_core.components.base.mode_key(modes)[source]

A hashable, canonical form of a mode map, for the lru_cache on each component’s *_dae() builder.

Dynamic entries are dropped, so a fully-dynamic model hashes to () and shares its cache entry with a call that passed no modes at all – the full-order DAE is built once however it was asked for.

Parameters:

modes (Mapping[str, str] | None)

Return type:

tuple[tuple[str, str], …]

g2elin_core.components.base.apply_reduction(*, states, modes, alg_vec, algeq_vec, output_vec)[source]

Lower a component’s model order by reclassifying individual states.

states is the component’s full-order (symbol, d/dt expression, display name) list in its canonical order; modes maps a state symbol’s name to one of DYNAMIC, ALGEBRAIC or FROZEN (anything unnamed stays dynamic). Returns the six lists build_dae() takes, ready to pass straight through.

The two reductions are mathematically different and are not interchangeable, which is why they’re separate modes rather than one “drop this state” flag:

  • ALGEBRAIC (residualization / singular perturbation) replaces dx/dt = expr with 0 = expr and moves x into the algebraic vector. The state still takes whatever value the rest of the system demands of it, instantaneously. Valid for states much faster than the phenomena being studied – stator flux, filter currents, network branches, inner control loops.

  • FROZEN (truncation) substitutes x by its equilibrium value x_0 everywhere and drops its equation entirely. The state simply doesn’t move. Valid for states much slower than the window of interest – the field flux in the classical machine model, where E' is held constant.

Applying the fast-state treatment to a slow state (or the reverse) is the most common way a hand-rolled reduced model goes quietly wrong: a field flux with dpsi_fd/dt = 0 is an instantaneous field winding, the exact opposite of the classical model’s constant one.

A frozen state’s x_0 becomes an ordinary free symbol, so ComponentDAE.param_syms() picks it up as a parameter and ComponentDAE.point_from_subs() reads its value from the same operating-point substitution dict everything else comes from – which is why reduction needs no new initialization code anywhere.

Parameters:
Return type:

tuple[list[Symbol], list[Expr], list[str], list[Symbol], list[Expr], list[Expr]]

g2elin_core.components.base.equilibrium_subs(subs)[source]

subs plus an x_0 entry for every symbol in it – the values FROZEN states are substituted by.

Components call this on their own operating-point dict so that freezing any state works without the dict having to know which ones were frozen. The extra entries are harmless when nothing is frozen: param_syms only returns symbols that actually appear in the equations.

Parameters:

subs (dict)

Return type:

dict

class g2elin_core.components.base.RebuiltWithParams[source]

Bases: object

Mixin: an operating point that can be rebuilt with other parameters.

An operating point is built from the solved power flow – terminal voltage, dispatch, reference angle – plus the unit’s parameter set. Changing a machine constant leaves everything but the last of those alone, so the unit’s own operating point can be redone without redoing the network’s.

That matters for a parameter scan (g2elin_core.modal.parameters), which perturbs one parameter at a time: rebuilding one unit is a fraction of a millisecond where compute_operating_point on a 118-bus network is thirty.

One unit is not always enough. In the MATLAB-compatible frame (Network.frame_follows_slack) the slack machine’s rotor angle is the reference every other unit’s angle is measured from, so its Ra, Ll and Laq – the three constants that set that angle – change every other unit’s operating point too. Rebuilding that one unit is then not the same as recomputing the network’s, and a caller that needs those right has to go the long way round; the parameter scan does.

with_params(params)[source]

A copy of this operating point built from params instead.

Parameters:

params (dict)

Synchronous machine + transformer + AVR/PSS/governor, ported from Functions/symSG.m (nonlinear DAE + linearization) and Functions/SG_subs.m (numeric operating-point substitution).

Two known “use index 1 for all units” simplifications carry over unchanged from the MATLAB source (script_generic.m), preserved here rather than silently fixed, since fixing them would make results incomparable to a MATLAB-generated reference:

  • Every machine’s transformer impedance (Rt/Lt) comes from the first DG’s Y_DER TR_R/TR_XL columns, not its own — see script_generic.m’s SG_Data construction (Y_TR(1,4) Y_TR(1,5) for every row i). No longer the default: each unit now uses its own transformer (the one the power flow uses, see operating_point.unit_transformer_rx); set Network.units_use_first_transformer to reproduce the MATLAB behaviour.

  • Every node’s shunt susceptance (Cl, in g2elin_core.components.node) comes from the first line’s B column, not the sum of its own incident lines’ — see script_generic.m’s node-substitution loop (Cl = Y_line(1,6)).

class g2elin_core.components.sm.ExciterKind(*values)[source]

Bases: str, Enum

Which AVR/exciter this machine carries.

See docs/sphinx/design/controller-models.md for how these relate to the IEEE 421.5 model library.

G2ELIN = 'g2elin'
KUNDUR = 'kundur'
class g2elin_core.components.sm.PssKind(*values)[source]

Bases: str, Enum

Which power system stabiliser this machine carries, if any.

G2ELIN = 'g2elin'
KUNDUR = 'kundur'
NONE = 'none'

No stabiliser at all – no states, and nothing added at the regulator’s summing junction.

class g2elin_core.components.sm.GovernorKind(*values)[source]

Bases: str, Enum

Whether this machine’s prime mover responds to speed.

G2ELIN = 'g2elin'
NONE = 'none'

the mechanical power stays at whatever the operating point set, which is the “constant mechanical torque” assumption most textbook small-signal examples are worked under.

Type:

No governor

g2elin_core.components.sm.sm_dae(is_slack, modes=(), exciter=ExciterKind.G2ELIN, pss=PssKind.G2ELIN, governor=GovernorKind.G2ELIN)[source]

modes is a mode_key() tuple naming the states this machine gives up – see g2elin_core.reduction for the catalogue and the named orders it builds out of them. The default () is the full model.

exciter and pss choose the regulators. The defaults are the models this component has always had, so the default machine is the same 19 states it always was; kundur on both gives the 16-state machine of Kundur Fig. E12.9 (three states fewer: the exciter loses its amplifier, exciter lag and rate feedback but gains a TGR state, and the stabiliser loses its input filter).

Parameters:
Return type:

ComponentDAE

g2elin_core.components.sm.sm_nonlinear_funcs(is_slack, modes=(), exciter=ExciterKind.G2ELIN, pss=PssKind.G2ELIN, governor=GovernorKind.G2ELIN)[source]

Numeric callables for the full nonlinear SM DAE — see g2elin_core.components.base.ComponentDAE.nonlinear_funcs().

Parameters:
Return type:

NonlinearFuncs

g2elin_core.components.sm.sm_nonlinear_jacobians(is_slack, modes=(), exciter=ExciterKind.G2ELIN, pss=PssKind.G2ELIN, governor=GovernorKind.G2ELIN)[source]

Numeric Jacobian callables for the SM DAE’s algebraic/output equations — see g2elin_core.components.base.ComponentDAE.nonlinear_jacobians().

Parameters:
Return type:

NonlinearJacobians

class g2elin_core.components.sm.SmOperatingPoint(*, params, v_terminal_pu, angle_terminal_rad, p_terminal_pu, q_terminal_pu, v_grid_pu, angle_grid_rad, p_ref_pu, theta_g_rad, is_slack, exciter=ExciterKind.G2ELIN, pss=PssKind.G2ELIN, governor=GovernorKind.G2ELIN)[source]

Bases: RebuiltWithParams

Everything needed to linearize one SM instance, computed from the solved power flow the way script_generic.m’s “SM Initializations” section + SG_subs.m do.

Parameters:
g2elin_core.components.sm.EXCITER_SYMBOLS: dict[str, tuple[Symbol, ...]] = {ExciterKind.G2ELIN: (Tr, Ka, Ta, Ke, Te, Kfd, Tfd), ExciterKind.KUNDUR: (TR, KA, TA, TB)}

The parameter symbols each regulator model owns. The symbol name is the key in the unit’s parameter dict, so a model’s parameters are spelled the same in the equations, in the stored network and in the editor.

Kept per slot rather than in one dict: these enums are str enums, so ExciterKind.G2ELIN and PssKind.G2ELIN are the same dict key.

g2elin_core.components.sm.EXCITER_GAIN_KEY = {ExciterKind.G2ELIN: 'Ka', ExciterKind.KUNDUR: 'KA'}

Each exciter’s regulator gain, whatever it is called in that model. The reference voltage is solved from it at initialisation – V_ref is whatever makes the regulator ask for the field voltage the power flow implies.

g2elin_core.components.sm.EXCITER_PARAM_NAMES = {ExciterKind.G2ELIN: ('Tr', 'Ka', 'Ta', 'Ke', 'Te', 'Kfd', 'Tfd'), ExciterKind.KUNDUR: ('TR', 'KA', 'TA', 'TB')}

{model id: (parameter name, ...)} for whoever needs the names alone.

g2elin_core.components.sm.linearize_sm(op, modes=None)[source]
Parameters:
Return type:

LinearComponent

g2elin_core.components.sm.sm_nonlinear_point(op, modes=None)[source]

(x0, z0, u0, p0) for sm_nonlinear_funcs(), at the same operating point linearize_sm() uses.

Parameters:
Return type:

tuple

Grid-forming converter, ported from Functions/symGFM_types.m + Functions/GFM_subs.m.

All five of that file’s outer power-control laws are here – droop, droop behind a filter, dVOC, VSM and matching control – selected per unit through DerUnit.controller. They differ only in the outer loop (see _outer_loop()); the cascaded voltage and current loops, the LC filter and the DC link are shared.

Only the non-slack variant and the Droop outer-loop are implemented — CIGRE’s islanded preset uses GFM_P_control = 'Droop' and its GFM units are never the slack. Droop+filter/Droop+PLL/dVOC/VSM/ Matching and the slack (i_DG == 1) branch follow the exact same build_dae/linearize pattern (see symGFM_types.m’s other switch cases) and would extend gfm_dae/GfmOperatingPoint the same way sm.py handles is_slack — not done here to keep scope to what CIGRE’s preset actually needs.

class g2elin_core.components.gfm.GfmControlKind(*values)[source]

Bases: str, Enum

Which outer power-control law a grid-forming converter runs.

These are symGFM_types.m’s P_type cases. They differ in three things and nothing else: which states the outer loop carries, how it forms the frequency deviation Dw, and how it forms the voltage reference the cascaded loops then track. Everything downstream – the voltage loop, the current loop, the filter, the DC link – is identical.

DROOP = 'droop'
DROOP_FILTERED = 'droop_filtered'
DVOC = 'dvoc'
VSM = 'vsm'
MATCHING = 'matching'
g2elin_core.components.gfm.OUTER_SYMBOLS: dict[str, tuple[Symbol, ...]] = {GfmControlKind.DROOP: (mp, nq, wf), GfmControlKind.DROOP_FILTERED: (mp, nq, wf, wc), GfmControlKind.DVOC: (eta, alfa, wf), GfmControlKind.MATCHING: (K_theta, wf), GfmControlKind.VSM: (J, Dp, K, Dq)}

The parameters each control law owns, under symGFM_types.m’s own names. A converter runs one law, so only its parameters are in its dict.

g2elin_core.components.gfm.gfm_dae(modes=(), controller=GfmControlKind.DROOP)[source]

modes is a mode_key() tuple naming the states this converter gives up – see g2elin_core.reduction.

controller chooses the outer power-control law. The default is plain droop, the 15-state model this component has always built. The others carry different outer states: droop-behind-a-filter and dVOC add one to droop’s three, VSM and matching drop the power filters entirely for two and three states respectively.

Parameters:
Return type:

ComponentDAE

g2elin_core.components.gfm.gfm_nonlinear_funcs(modes=(), controller=GfmControlKind.DROOP)[source]
Parameters:
Return type:

NonlinearFuncs

g2elin_core.components.gfm.gfm_nonlinear_jacobians(modes=(), controller=GfmControlKind.DROOP)[source]
Parameters:
Return type:

NonlinearJacobians

class g2elin_core.components.gfm.GfmOperatingPoint(*, params, v_terminal_pu, angle_terminal_rad, p_terminal_pu, q_terminal_pu, v_grid_pu, angle_grid_rad, theta_g_rad, controller=GfmControlKind.DROOP)[source]

Bases: RebuiltWithParams

Mirrors GFM_subs.m + the “GFM Initializations” block of script_generic.m.

Parameters:
g2elin_core.components.gfm.linearize_gfm(op, modes=None)[source]
Parameters:
Return type:

LinearComponent

g2elin_core.components.gfm.gfm_nonlinear_point(op, modes=None)[source]

(x0, z0, u0, p0) for gfm_nonlinear_funcs().

Parameters:
Return type:

tuple

Grid-following converter (LCL filter + PLL), ported from Functions/symGFL.m + Functions/GFL_subs.m. A GFL is never the slack DG, so unlike SM/GFM there’s only one variant.

g2elin_core.components.gfl.gfl_dae(modes=())[source]

modes is a mode_key() tuple naming the states this converter gives up – see g2elin_core.reduction. The default () is the full 14-state model.

Parameters:

modes (tuple[tuple[str, str], ...])

Return type:

ComponentDAE

g2elin_core.components.gfl.gfl_nonlinear_funcs(modes=())[source]
Parameters:

modes (tuple[tuple[str, str], ...])

Return type:

NonlinearFuncs

g2elin_core.components.gfl.gfl_nonlinear_jacobians(modes=())[source]
Parameters:

modes (tuple[tuple[str, str], ...])

Return type:

NonlinearJacobians

class g2elin_core.components.gfl.GflOperatingPoint(*, params, v_terminal_pu, angle_terminal_rad, p_terminal_pu, q_terminal_pu, v_grid_pu, angle_grid_rad, theta_g_rad)[source]

Bases: RebuiltWithParams

Mirrors GFL_subs.m. Eg0/Ig0/Is0/Vm0 physics are identical to g2elin_core.components.gfm.GfmOperatingPoint (same LCL-filter converter structure) — see there for the derivation. M_d0/M_q0/ M_CLd0/M_CLq0/M_pll0 are derived from the inner-loop integrators’ own equilibrium conditions (dM_x = 0 at the operating point), since GFL_subs.m reads them from data columns this port doesn’t have an upstream initializer for; the resulting formulas match GFM’s structurally-identical ones exactly, as a cross-check.

Parameters:
g2elin_core.components.gfl.linearize_gfl(op, modes=None)[source]
Parameters:
Return type:

LinearComponent

g2elin_core.components.gfl.gfl_nonlinear_point(op, modes=None)[source]

(x0, z0, u0, p0) for gfl_nonlinear_funcs().

Parameters:
Return type:

tuple

Upstream infinite bus, ported from Functions/symIB.m + IB_subs.m.

The original toolbox made the IB the slack DG and the reference frame both (per Functions/network_form.m: “an Infinite bus can never be a normal DG, it is always assumed to be the slack DG”) – that is is_slack=True here: the source sits at angle 0 of a frame turning with its own speed, and the block hands that angle and speed out for the rest of the network to use.

is_slack=False is the same source in a frame it does not own (see components/frame.py): it reads the frame angle theta_g and speed wg like any other unit, and its source voltage enters at theta_up - theta_g. The two coincide whenever the frame turns at the source’s own speed, which is the default – what it buys is that the infinite bus is no longer load-bearing for the model: it can be disconnected, leaving the rest of the network islanded.

g2elin_core.components.ib.ib_dae(is_slack=True)[source]
Parameters:

is_slack (bool)

Return type:

ComponentDAE

g2elin_core.components.ib.linearize_ib(*, wb_val, r_pu, x_pu, v_pu, p_mw, q_mvar, sn_mva, delta_rad=0.0, is_slack=True)[source]

Linearize the infinite bus at its power-flow operating point.

r_pu/x_pu are the DG’s own interconnecting-transformer impedance (Y_DER TR_R/TR_XL, in the slot the toolbox calls Rup/Lup). p_mw/q_mvar are the power flowing out of the IB at its own bus, v_pu its voltage magnitude there (angle is the local reference, 0).

Parameters:
Return type:

LinearComponent

g2elin_core.components.ib.ib_nonlinear_funcs(is_slack=True)[source]
Parameters:

is_slack (bool)

Return type:

NonlinearFuncs

g2elin_core.components.ib.ib_nonlinear_jacobians(is_slack=True)[source]
Parameters:

is_slack (bool)

Return type:

NonlinearJacobians

g2elin_core.components.ib.ib_nonlinear_point(*, wb_val, r_pu, x_pu, v_pu, p_mw, q_mvar, sn_mva, delta_rad=0.0, is_slack=True)[source]
Parameters:
Return type:

tuple

The common dq reference frame the whole network model is written in.

Every node, line and load equation uses the frame’s speed wg (the rotational terms wg*L*i, wg*C*v), and every unit transforms between its own dq frame and the common one through theta - theta_g. Which frame that is, is a modelling choice, not physics: any frame gives the same physical trajectories, expressed differently.

This component is the frame itself: one state, the frame angle, with no electrical connection to anything, turning either at a fixed speed

dtheta_f/dt = wb * w_ref, outputs: (theta_f, w_ref)

or with a machine it follows (driven=True), which is the usual case:

dtheta_f/dt = wb * w_in, outputs: (theta_f, w_in)

Either way it feeds exactly the theta/wr ports a slack unit used to feed (see interconnect/network_assembly.py). The fixed variant is the angle part an infinite bus already carries (components/ib.py: dtheta_up = wb*wup with wup at 1 pu) without the voltage source behind it – which is why every infinite-bus preset in this repository has effectively been running on a frame of its own all along.

Why a block of its own: with the frame taken from the slack unit, that unit could never be disconnected – the frame would go with it. Standing apart, every unit is just a unit: any of them can be tripped, a network can be split into islands (one frame each, following that island’s own reference machine), and the slack keeps only its steady-state meaning, the power flow’s reference. EMT tools have no slack at all for the same reason.

Why it follows a machine rather than simply running at nominal speed. Both give identical trajectories – the nonlinear models agree to solver tolerance, since a frame is only a choice of coordinates. But governor and droop response leave the system at a slightly different frequency after a disturbance, and in a frame running at exactly nominal speed the network’s dq vectors then turn slowly forever: there is no steady state to linearise about, and modal analysis and the linearised overlay degrade the longer the horizon (measured on WSCC-9: 5.6 % error at 0.3 s, 17.9 % at 1.5 s, against 4.4 % and 5.1 % for a frame that follows the system). A frame carried by a machine settles with it, so the equilibrium exists and small-signal analysis stays sharp.

One free angle remains: nothing pins the frame’s absolute position, so a network with no infinite bus has a zero eigenvalue – the familiar reference-angle mode, a property of the formulation, not an instability.

g2elin_core.components.frame.frame_dae(driven=True)[source]

driven: the frame turns with a machine’s speed (its w_in port, wired to that machine’s own speed output); otherwise at the fixed w_ref, which is what an island referenced to an infinite bus needs.

Parameters:

driven (bool)

Return type:

ComponentDAE

g2elin_core.components.frame.frame_nonlinear_funcs(driven=True)[source]
Parameters:

driven (bool)

Return type:

NonlinearFuncs

g2elin_core.components.frame.frame_nonlinear_jacobians(driven=True)[source]
Parameters:

driven (bool)

Return type:

NonlinearJacobians

g2elin_core.components.frame.linearize_frame(*, wb_val, theta0, w0=1.0, driven=True)[source]

The frame as a linear block: an integrator of the speed it follows.

theta0 is where the frame starts, i.e. the operating point’s own reference angle (NetworkOperatingPoint.theta_g_rad), so every unit’s theta - theta_g starts at the value the operating point computed.

Parameters:
Return type:

LinearComponent

g2elin_core.components.frame.frame_nonlinear_point(*, wb_val, theta0, w0=1.0, driven=True)[source]

(x0, z0, u0, p0) for frame_nonlinear_funcs().

Parameters:
Return type:

tuple

RL-series transmission line, ported from Functions/symLine.m.

Also serves shunt reactors and branch transformers – both are the same RL branch, differing only in how interconnect/network_assembly.py wires their two ends (see that module).

With mode=ALGEBRAIC the branch’s L di/dt is dropped and the current becomes an algebraic variable of the series impedance: the quasi-stationary branch a phasor tool uses. Nothing else about the model changes – see g2elin_core.components.base.apply_reduction().

g2elin_core.components.line.line_dae(mode='dynamic', fixed_frequency=False)[source]

mode is this branch’s single state group – see g2elin_core.reduction. fixed_frequency pins the w*L speed-voltage term to nominal instead of following the frame, which is the classic phasor-network convention.

Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

ComponentDAE

g2elin_core.components.line.line_nonlinear_funcs(mode='dynamic', fixed_frequency=False)[source]
Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

NonlinearFuncs

g2elin_core.components.line.line_nonlinear_jacobians(mode='dynamic', fixed_frequency=False)[source]
Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

NonlinearJacobians

g2elin_core.components.line.linearize_line(*, wb_val, r_pu, x_pu, wg0, ild_g0, ilq_g0, mode='dynamic', fixed_frequency=False)[source]

Linearize a line at an operating point.

x_pu plays the role of Ll in (wb/Ll)*(...), matching the toolbox’s convention of using per-unit reactance directly in place of an inductance. vgdj_g/vgqj_g/vgdk_g/vgqk_g (the two end voltages) don’t appear in the linear system’s coefficients (only wg does, since it multiplies the states), so they’re left at the _line_operating_subs() default of 0 here — harmless for linearization, but see line_nonlinear_point() for where the real values are needed (a nonlinear equilibrium check needs the true voltages).

Parameters:
Return type:

LinearComponent

g2elin_core.components.line.line_nonlinear_point(*, wb_val, r_pu, x_pu, wg0, ild_g0, ilq_g0, vgdj_g0, vgqj_g0, vgdk_g0, vgqk_g0, mode='dynamic', fixed_frequency=False)[source]

(x0, z0, u0, p0) for line_nonlinear_funcs(), at a line’s true operating point (unlike linearize_line(), the end voltages matter here).

Parameters:
Return type:

tuple

Network node (line-charging capacitance), ported from Functions/symNode.m.

With mode=ALGEBRAIC the bus’s C dv/dt is dropped, leaving 0 = i_sh + w*C*J*v: the injected current equals the shunt’s own steady-state current, which is the bus row of a phasor tool’s admittance matrix. The capacitance does not disappear from the model – it is still the bus’s shunt admittance – it simply stops being integrated.

See g2elin_core.components.base.apply_reduction().

g2elin_core.components.node.node_dae(mode='dynamic', fixed_frequency=False)[source]

mode is this bus’s single state group – see g2elin_core.reduction. fixed_frequency pins the w*C term to nominal instead of following the frame.

Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

ComponentDAE

g2elin_core.components.node.node_nonlinear_funcs(mode='dynamic', fixed_frequency=False)[source]
Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

NonlinearFuncs

g2elin_core.components.node.node_nonlinear_jacobians(mode='dynamic', fixed_frequency=False)[source]
Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

NonlinearJacobians

g2elin_core.components.node.linearize_node(*, wb_val, b_pu, wg0, vgd_g0, vgq_g0, mode='dynamic', fixed_frequency=False)[source]

Linearize a node’s shunt (line-charging) capacitance at an operating point.

b_pu (total line-charging susceptance seen at this node) plays the role of Cl in (wb/Cl)*(...).

Parameters:
Return type:

LinearComponent

g2elin_core.components.node.node_nonlinear_point(*, wb_val, b_pu, wg0, vgd_g0, vgq_g0, mode='dynamic', fixed_frequency=False)[source]

(x0, z0, u0, p0) for node_nonlinear_funcs().

Parameters:
Return type:

tuple

Constant-impedance (RL) load branch, ported from Functions/symLoad.m.

With mode=ALGEBRAIC the branch’s L di/dt is dropped and the load draws the current its own impedance demands at the bus voltage, instantly – the constant-impedance load of a phasor tool.

See g2elin_core.components.base.apply_reduction().

g2elin_core.components.load.load_dae(mode='dynamic', fixed_frequency=False)[source]

mode is this load’s single state group – see g2elin_core.reduction. fixed_frequency pins the w*L term to nominal instead of following the frame.

Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

ComponentDAE

g2elin_core.components.load.load_nonlinear_funcs(mode='dynamic', fixed_frequency=False)[source]
Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

NonlinearFuncs

g2elin_core.components.load.load_nonlinear_jacobians(mode='dynamic', fixed_frequency=False)[source]
Parameters:
  • mode (str)

  • fixed_frequency (bool)

Return type:

NonlinearJacobians

g2elin_core.components.load.linearize_load(*, wb_val, r_pu, x_pu, wg0, vgd_g0, vgq_g0, mode='dynamic', fixed_frequency=False)[source]

Linearize a constant-impedance load at an operating point.

x_pu (the load’s equivalent reactance, V_pu^2/S_pu * sin(acos(PF))) plays the role of Lc, matching the toolbox’s per-unit convention.

Parameters:
Return type:

LinearComponent

g2elin_core.components.load.load_nonlinear_point(*, wb_val, r_pu, x_pu, wg0, vgd_g0, vgq_g0, mode='dynamic', fixed_frequency=False)[source]

(x0, z0, u0, p0) for load_nonlinear_funcs().

Parameters:
Return type:

tuple