CIGRE islanded 1 SM + 1 GFM + 1 GFL — a complete worked example

notebooks/tour.ipynb surveys every g2elin_core module generically, switching between several presets as convenient. This notebook instead picks one preset — cigre_islanded_1sm_1gfm_1gfl() — and follows it all the way through every module, end to end, reading and interpreting the actual numbers at each step rather than just calling the function and moving on. It’s the smallest network in this codebase that still has one of each major DER type (a synchronous machine, a grid-forming converter, a grid-following converter), which makes it the clearest possible case for comparing how three fundamentally different control philosophies behave side by side in one shared, islanded microgrid.

Why islanded matters here: there’s no upstream infinite bus to lean on — the SM, GFM, and GFL are the only three sources on this network, so the question every section below is really asking is “which one is actually holding this microgrid together, and how.”

Reading order: network definition -> topology -> power flow -> per-unit bases -> operating point -> per-component (isolated) dynamics -> interconnection -> modal analysis -> the rest of the modal toolbox -> nonlinear EMT simulation -> time-series load sweep. Each section’s own markdown explains what its result means for this network, not just what the function computed. The full underlying physics (every equation) is documented in this project’s Sphinx docs — see docs/sphinx/modules/*.md — this notebook is the “how it plays out on a real case” companion to that reference.

%matplotlib inline
import math

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

plt.rcParams["figure.dpi"] = 100
pd.set_option("display.precision", 4)
np.set_printoptions(precision=4, suppress=True)

Run-time toggles

Section 10 (nonlinear EMT simulation) is one of the slowest cells in this notebook – a full nonlinear coupled-Newton time integration, not just linear algebra. Defaults to off so “Restart Kernel and Run All” finishes quickly; flip the flag to True and re-run to actually exercise that section.

RUN_EMT_SECTION = False  # Section 10: nonlinear EMT time-domain simulation

1. Network definition

cigre_islanded_1sm_1gfm_1gfl() is ported from CIGRE/script_CIGRE_Islanded.m’s 'CIGRE_Islanded_1SM_1GFM_1GFL' case: the 14-bus CIGRE MV benchmark feeder (20 kV, islanded — no upstream connection), with its one normally-open tie line (buses 8-12) closed, and three DER units each on their own new 10 kV terminal bus behind their own step-up transformer (network_form.m’s convention, see docs/sphinx/modules/network.md).

from g2elin_core.network.presets import cigre_islanded_1sm_1gfm_1gfl

net = cigre_islanded_1sm_1gfm_1gfl()
print(net.name, f"-- {net.f_hz} Hz, {net.sn_mva} MVA base")
print(f"{len(net.buses)} buses, {len(net.lines)} lines, {len(net.transformers)} transformers, "
      f"{len(net.loads)} loads, {len(net.der_units)} DER units")

der_rows = []
for d in net.der_units:
    tr = next(t for t in net.transformers if t.lv_bus == d.bus)
    der_rows.append({
        "id": d.id, "type": d.unit_type.value, "bus_type": d.bus_type.value,
        "own_bus": d.bus, "dispatch_bus": tr.hv_bus,
        "V_set (pu)": d.v_set_pu, "P_set (MW)": d.p_set_mw,
        "controller": d.controller.value if d.controller else "-",
    })
pd.DataFrame(der_rows)
CIGRE_Islanded_1SM_1GFM_1GFL
 
-- 50.0 Hz, 2.5 MVA base

17 buses, 13 lines, 3 transformers, 13 loads, 3 DER units

id type bus_type own_bus dispatch_bus V_set (pu) P_set (MW) controller
0 1 sm slack 15 3 1.0 1.25 -
1 2 gfm pv 16 12 1.0 2.50 droop
2 3 gfl pq 17 9 1.0 2.50 -

Three units, three roles:

  • SM (id 1, bus 15, slack) — the synchronous machine is the network’s reference: it isn’t dispatched to a fixed power, it absorbs whatever active/reactive mismatch the other two units’ fixed dispatch leaves, the same role a slack bus plays in power flow.

  • GFM (id 2, bus 16, droop) — dispatched at a fixed \(P_{ref}=1.0\) pu (2.5 MW) but not rigidly fixed there: its droop outer loop lets \(P\) drift with local frequency the same way the SM’s own governor droop does, which is what makes a GFM able to sit at a pv-type bus and still participate in frequency regulation.

  • GFL (id 3, bus 17, PQ) — dispatched at a fixed \(P=1.0\) pu, \(Q=0\), current-controlled and phase-locked to the local voltage rather than self-clocking — it follows the grid’s frequency, it doesn’t help set it.

2. Topology

compute_topology_layout()’s Kamada-Kawai layout over the bus graph — the same one the web UI’s Network tab renders.

from g2elin_core.network.topology import compute_topology_layout

topo = compute_topology_layout(net)
fig, ax = plt.subplots(figsize=(7, 6))
pos = {n.id: (n.x, n.y) for n in topo.nodes}
for e in topo.edges:
    x1, y1 = pos[e.from_bus]; x2, y2 = pos[e.to_bus]
    style = "--" if e.kind == "transformer" else "-"
    ax.plot([x1, x2], [y1, y2], style, color="0.6", zorder=1)
colors = {"sm": "tab:blue", "gfm": "tab:orange", "gfl": "tab:green", None: "0.5"}
for n in topo.nodes:
    ax.scatter(*pos[n.id], s=280 if n.unit_type else 90, color=colors[n.unit_type], zorder=2)
    ax.annotate(f"{n.id}", pos[n.id], ha="center", va="center", fontsize=8, color="white", zorder=3)
ax.set_title(f"{net.name} -- topology (dashed = transformer)")
ax.set_axis_off()
plt.show()
../_images/42e58ca936449a16fab76b15097fcf9366aa8ef88775a0532ef7f73b6e653bef.png

The three DER terminal buses (15/16/17, larger markers) each sit one transformer away from a different point on the feeder (buses 3, 12, and 9) — spread across the network, not clustered, which is exactly what makes the tie line (8-12) load-bearing here: it’s what lets the GFM’s own power actually reach load buses 1-7 rather than only serving its own local corner of the feeder.

3. Power flow

Solves the steady-state AC power balance (Newton-Raphson via pandapower) at the dispatch above.

from g2elin_core.powerflow import run_power_flow

pf = run_power_flow(net)
print("converged:", pf.converged, " total losses:", round(pf.total_losses_mw(), 5), "MW")
bus_df = pf.bus_table()
bus_df
converged:
 
True
 
 total losses:
 
0.03569
 
MW

bus vm_pu va_degree p_net_gen_mw q_net_gen_mvar
0 1 0.9709 -2.0361 -1.2000 -0.3500
1 2 0.9769 -1.7395 -0.0000 -0.0000
2 3 0.9860 -1.2600 -0.5500 -0.2664
3 4 0.9844 -1.3281 -0.4450 -0.1115
4 5 0.9834 -1.3684 -0.3400 -0.0852
5 6 0.9816 -1.4317 -0.5700 -0.2761
6 7 0.9836 -1.1667 -0.6750 -0.4183
7 8 0.9862 -1.0973 -0.6050 -0.1516
8 9 0.9864 -1.0621 -0.0900 -0.0558
9 10 0.9847 -1.1510 -0.5650 -0.1416
10 11 0.9842 -1.1720 -0.7500 -0.1880
11 12 0.9898 -1.0568 -0.6050 -0.2930
12 13 0.9859 -1.1421 -0.0400 -0.0248
13 14 0.9799 -1.2797 -0.8000 -0.2050
14 15 1.0000 0.0000 2.2707 1.4690
15 16 1.0000 0.3276 2.5000 1.0691
16 17 0.9863 0.3515 2.5000 -0.0000
print(f"voltage range: {bus_df['vm_pu'].min():.4f} - {bus_df['vm_pu'].max():.4f} pu "
      f"(bus {bus_df.loc[bus_df['vm_pu'].idxmin(), 'bus']} lowest, "
      f"bus {bus_df.loc[bus_df['vm_pu'].idxmax(), 'bus']} highest)")
display(pf.trafo_table()[["name", "hv_bus", "lv_bus", "loading_percent"]])
display(pf.gen_table())      # the GFM, dispatched as a pandapower `gen` (fixed P, |V|)
display(pf.sgen_table())     # the GFL, dispatched as a pandapower `sgen` (fixed P, Q)
display(pf.ext_grid_table()) # the SM slack -- P/Q solved for, not dispatched
voltage range: 0.9709 - 1.0000 pu (bus 1 lowest, bus 15 highest)

name hv_bus lv_bus loading_percent
0 sm_xfmr 3 15 108.1767
1 gfm_xfmr 12 16 108.7599
2 gfl_xfmr 9 17 101.3902
name bus p_mw q_mvar va_degree vm_pu
0 der2 16 2.5 1.0691 0.3276 1.0
name bus p_mw q_mvar
0 der3 17 2.5 0.0
name bus p_mw q_mvar
0 der1 15 2.2707 1.469

Every bus sits comfortably within a normal +/-5% band even though this feeder is fully islanded — no upstream grid propping up the voltage profile, just the SM’s own AVR and the GFM’s own voltage-droop loop doing that job locally. The ext_grid_table() row is the SM’s own solved \(P\)/\(Q\): whatever the GFM’s and GFL’s fixed 2.5 MW each didn’t cover (plus feeder losses), the slack machine supplied — this is the power-flow-level version of the same “SM absorbs the mismatch” role described in Section 1.

4. Per-unit bases

Two different voltage levels share this network’s one sn_mva base: the 20 kV feeder and the 10 kV DER terminals. pu_base() is a pure function of (wb, un_kv, sn_mva), so it’s computed once per voltage level, not once for the whole network.

from g2elin_core.pu_base import pu_base

wb = 2 * math.pi * net.f_hz
base_mv = pu_base(wb=wb, un_kv=net.buses[0].vn_kv, sn_mva=net.sn_mva)   # feeder, 20 kV
base_lv = pu_base(wb=wb, un_kv=net.bus(15).vn_kv, sn_mva=net.sn_mva)   # DER terminals, 10 kV
print("Feeder (20 kV):", base_mv)
print("DER terminal (10 kV):", base_lv)
Feeder (20 kV):
 
PuBase(ub=16329.931618554521, ib=102.06207261596577, zb=159.99999999999997, lb=0.509295817894065, cb=1.989436788648692e-05, ub_dc=32659.863237109043, ib_dc=76.54655446197432, zb_dc=426.6666666666666, cb_dc=7.460387957432594e-06)

DER terminal (10 kV):
 
PuBase(ub=8164.965809277261, ib=204.12414523193155, zb=39.99999999999999, lb=0.12732395447351624, cb=7.957747154594768e-05, ub_dc=16329.931618554521, ib_dc=153.09310892394865, zb_dc=106.66666666666664, cb_dc=2.9841551829730377e-05)

5. Operating point

compute_operating_point() turns the solved power flow above into the exact per-unit dq-frame values each component’s linearization needs — see docs/sphinx/modules/operating_point.md for the full derivation.

from g2elin_core.operating_point import compute_operating_point

op = compute_operating_point(net, pf)
print("global reference angle theta_g_rad:", round(op.theta_g_rad, 5))

sm_op = op.sm_ops[1]
print("\nSM (id=1, slack):")
print(f"  theta0 (rotor angle, = theta_g since it's the slack) = {sm_op.theta0:.5f} rad")
print(f"  Et0 (terminal voltage magnitude)  = {sm_op.Et0:.4f} pu")
print(f"  Pm0 (mechanical power)            = {sm_op.Pm0:.4f} pu")
print(f"  id0, iq0 (stator dq current)      = {sm_op.id0:.4f}, {sm_op.iq0:.4f} pu")

gfm_op = op.gfm_ops[2]
print("\nGFM (id=2):")
print(f"  theta0 (own frame = terminal-voltage angle directly) = {gfm_op.theta0:.5f} rad")
print(f"  ved0, veq0 (filter-cap voltage)   = {gfm_op.ved0:.4f}, {gfm_op.veq0:.4f} pu")
print(f"  isd0, isq0 (converter current)    = {gfm_op.isd0:.4f}, {gfm_op.isq0:.4f} pu")
print(f"  md0, mq0 (steady-state modulation index) = {gfm_op.md0:.4f}, {gfm_op.mq0:.4f}")

gfl_op = op.gfl_ops[3]
print("\nGFL (id=3):")
print(f"  theta_pll0 (PLL-locked angle = terminal-voltage angle) = {gfl_op.theta_pll0:.5f} rad")
print(f"  ved0, veq0 (veq0 ~ 0 by construction: PLL-locked frame) = {gfl_op.ved0:.4f}, {gfl_op.veq0:.6f} pu")
print(f"  isd0, isq0 (converter current)    = {gfl_op.isd0:.4f}, {gfl_op.isq0:.4f} pu")
global reference angle theta_g_rad:
 
-0.90592

SM (id=1, slack):

  theta0 (rotor angle, = theta_g since it's the slack) = -0.90592 rad

  Et0 (terminal voltage magnitude)  = 1.0000 pu

  Pm0 (mechanical power)            = 0.9083 pu

  id0, iq0 (stator dq current)      = 1.0228, 0.3523 pu

GFM (id=2):

  theta0 (own frame = terminal-voltage angle directly) = 0.00572 rad

  ved0, veq0 (filter-cap voltage)   = 1.0000, 0.0000 pu

  isd0, isq0 (converter current)    = 1.0000, -0.3276 pu

  md0, mq0 (steady-state modulation index) = 1.0428, 0.0967

GFL (id=3):

  theta_pll0 (PLL-locked angle = terminal-voltage angle) = 0.00613 rad

  ved0, veq0 (veq0 ~ 0 by construction: PLL-locked frame) = 0.9863, 0.000000 pu

  isd0, isq0 (converter current)    = 1.0139, 0.1085 pu

Notice the difference in how each unit’s own angle is found. The SM’s \(\theta_0\) comes from the classical “\(E_q\) behind \(X_q\)” rotor-angle construction (it is the global reference here, being the slack) — a real physical rotor position. The GFM’s and GFL’s \(\theta_0\) are instead just their own terminal-voltage angle directly: neither has a rotor to locate, a GFM self-clocks its own angle from its droop law and a GFL locks its PLL to whatever angle the terminal voltage already has. The GFL’s \(v_{eq0}\approx 0\) is not a coincidence — a PLL’s entire job is to drive \(v_{eq}\to 0\), so by construction its own dq frame’s \(d\)-axis is always aligned with the terminal voltage vector.

6. Components — each unit’s own isolated dynamics

Before interconnection, each unit’s own linearize_*() gives its open-loop state-space — not yet physically meaningful on its own (its grid-side inputs aren’t closed into the rest of the network yet), but a direct look at how many states, and how stiff a system, each control philosophy brings to the table.

from g2elin_core.components.sm import linearize_sm
from g2elin_core.components.gfm import linearize_gfm
from g2elin_core.components.gfl import linearize_gfl

lin_sm = linearize_sm(sm_op)
lin_gfm = linearize_gfm(gfm_op)
lin_gfl = linearize_gfl(gfl_op)

for label, lin in [("SM", lin_sm), ("GFM", lin_gfm), ("GFL", lin_gfl)]:
    eigs = np.linalg.eigvals(lin.A)
    print(f"{label:4s}: {lin.A.shape[0]:2d} states, own (open-loop) eigenvalues span "
          f"{eigs.real.min():.3e} to {eigs.real.max():.3e} rad/s (real part)")
SM  : 19 states, own (open-loop) eigenvalues span -1.446e+07 to 0.000e+00 rad/s (real part)

GFM : 15 states, own (open-loop) eigenvalues span -2.995e+04 to 3.703e-01 rad/s (real part)

GFL : 14 states, own (open-loop) eigenvalues span -1.000e+03 to -2.258e+00 rad/s (real part)

19 states for the SM (electrical + mechanical + AVR + PSS), 15 for the GFM (LCL filter + DC link + droop + voltage/current loops), 14 for the GFL (the same LCL filter + DC link, current-controlled instead of voltage-controlled, plus a PLL instead of self-clocking) — see docs/sphinx/modules/components.md for exactly what each state is. The GFM/GFL eigenvalue spread already hints at the current-loop tuning’s much faster time constant (sub-millisecond, see docs/sphinx/modules/operating_point.md’s pole-placement table) compared to the SM’s mechanical/AVR states.

7. Interconnection + the closed-loop system

pipeline.linearize_network() is the one-call version of operating_point -> per-component linearize_*() -> interconnect.assemble_network().

from g2elin_core.pipeline import linearize_network
from collections import Counter

system = linearize_network(net, pf)
print(f"closed-loop system: {system.A.shape[0]} states total")

# Each state name is "<own name>_{<block name>}" (e.g. "dw_r_{SM_1}",
# "v_{g_d}_{Nd_4}") -- the block name is whatever's inside the LAST pair
# of braces; its own type is that block name's prefix before its trailing
# id ("Nd_4" -> "Nd", "SM_1" -> "SM").
block_type = lambda n: n.rsplit("_{", 1)[1].rstrip("}").rsplit("_", 1)[0]
block_counts = Counter(block_type(n) for n in system.state_names)
pd.Series(block_counts, name="n_states").sort_values(ascending=False)
closed-loop system: 128 states total

Nd     28
Ln     26
Ld     26
SM     19
GFM    15
GFL    14
Name: n_states, dtype: int64

\(19+15+14=48\) states from the three DERs alone, plus \(2\) states per plain node (its own dq voltage, Nd) and \(2\) per line/load (their own dq current, Ln/Ld) — all 14 raw feeder buses (\(14\times2=28\)), all 13 lines (\(13\times2=26\)), and all 13 loads (\(13\times2=26\)) each contribute their own dynamic state here, not just an algebraic constraint, since interconnect (see docs/sphinx/modules/interconnect.md) wires everything together as coupled ODEs, not a reduced admittance network: \(48+28+26+26=128\), exactly the total printed above.

9. The rest of the modal toolbox

modal.toolbox has four functions, all pure linear algebra on the eigendecomposition already computed above (no new solve) — this section exercises every one of them, not just a curated subset:

  • eigenvalue_sensitivity(modal, A, mode, n_top=8) — how sensitive one mode’s own eigenvalue is to each structurally-nonzero entry of \(A\).

  • mode_shape(modal, mode, n_top=5) — relative phase between a mode’s own top participating states.

  • free_response(modal, perturb_index, offset, t) — closed-form response to an initial-condition perturbation.

  • step_response(system, input_name, output_name, amplitude, t) — MIMO step response between one chosen input/output pair.

from g2elin_core.modal.toolbox import eigenvalue_sensitivity

mode_idx = int(sm_swing["mode"])
sens = eigenvalue_sensitivity(modal, system.A, mode_idx, n_top=8)
print(f"Top {len(sens.top)} entries A is most sensitive to, for mode {mode_idx} (the SM swing mode):")
for entry in sens.top:
    print(f"  d(lambda)/d(A[{entry.row_state}, {entry.col_state}]) = {entry.value:.4f}")
Top 8 entries A is most sensitive to, for mode 120 (the SM swing mode):

  d(lambda)/d(A[dw_r_{SM_1}, P_m_{SM_1}]) = 38.4196

  d(lambda)/d(A[dw_r_{SM_1}, psi_q_{SM_1}]) = 8.6522

  d(lambda)/d(A[dw_r_{SM_1}, psi_2q_{SM_1}]) = 6.5876

  d(lambda)/d(A[dw_r_{SM_1}, psi_d_{SM_1}]) = 6.5693

  d(lambda)/d(A[dw_r_{SM_1}, psi_1d_{SM_1}]) = 3.0617

  d(lambda)/d(A[dw_r_{SM_1}, psi_1q_{SM_1}]) = 1.2645

  d(lambda)/d(A[psi_fd_{SM_1}, e_fd_{SM_1}]) = 0.9544

  d(lambda)/d(A[theta_{GFM_1}, p_m_{GFM_1}]) = 0.7002

Each entry names the exact (row_state, col_state) pair in the closed-loop \(A\) matrix this mode’s own eigenvalue is most sensitive to — in Kundur’s own words, “where would a parameter change move this mode the most.” Masked to A’s structurally-nonzero entries only (a sensitivity value at a zero entry isn’t a sensitivity to any real physical parameter – see docs/sphinx/modules/pipeline.md’s own account of the formula, \(\partial\lambda_i/\partial A_{kj} = w_i[k]v_i[j]\)).

from g2elin_core.modal.toolbox import mode_shape, free_response

shape = mode_shape(modal, mode_idx, n_top=5)

fig, ax = plt.subplots(figsize=(5, 5), subplot_kw={"projection": "polar"})
angles = np.radians(shape.angles_deg)
ax.scatter(angles, [1] * len(angles), s=60, color="tab:blue", zorder=3)
for a, name in zip(angles, shape.states):
    ax.annotate(name, (a, 1), textcoords="offset points", xytext=(6, 6), fontsize=8)
ax.set_yticklabels([])
ax.set_title(f"Mode shape -- mode {mode_idx} ({sm_swing['damped_hz']:.3f} Hz, SM swing)")
plt.show()
../_images/2b958c12f024d8a79816c31b0a2de1f30ea6987b366b76fd5bd7b059267ab581.png
perturb_name = sm_swing["state1"]  # dw_r_{SM_1} -- a bounded speed deviation, not an absolute angle
perturb_idx = system.state_names.index(perturb_name)
t = np.linspace(0, 3.0, 400)
x_lin = free_response(modal, perturb_idx, offset=0.05, t=t)

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(t, x_lin[perturb_idx, :], color="tab:blue")
ax.axhline(0, color="0.85", lw=0.8)
ax.set_xlabel("t (s)"); ax.set_ylabel(perturb_name)
ax.set_title(f"Linear free-motion response -- {perturb_name}, 0.05 pu initial offset")
ax.grid(True, alpha=0.3)
plt.show()
../_images/70b8fd593287f5944dabbd97a4dab9e8de07ec2e0904332805b4064283719002.png

This is the closed-form linear prediction — no ODE integration, just \(x(t) = V(c\odot e^{\Lambda t})\) — and, being built from a mode at ~22% damping, it should decay back toward zero within the 3-second window plotted. Section 10 re-runs the same perturbation through the full nonlinear EMT model so the two can be compared directly.

(dw_r_{SM_1} is deliberately used here rather than the theta-drift state above precisely because it’s a bounded deviation, not an absolute angle — comparing a nonlinear trajectory’s raw value against a frozen operating-point constant only makes sense for a state that actually returns to that constant; theta-type states don’t, by construction, which is the entire content of the “theta problem” note above.)

from g2elin_core.modal.toolbox import step_response

sm_input = next(n for n in system.input_names if n.startswith("P_ref_{SM"))
sm_output = next(n for n in system.output_names if n.startswith("p_e_{SM"))
y_step = step_response(system, sm_input, sm_output, amplitude=0.05, t=t)

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(t, y_step, color="tab:green")
ax.set_xlabel("t (s)"); ax.set_ylabel(sm_output)
ax.set_title(f"Step response: {sm_input} (+0.05 pu) -> {sm_output}")
ax.grid(True, alpha=0.3)
plt.show()
../_images/6ae27c10999a0610fb0defdb444c8fa15ebb74240464113e39e00362fb5ee723.png

10. Nonlinear EMT simulation: does the linear prediction hold?

build_nonlinear_network() + simulate() integrate the network’s actual nonlinear differential-algebraic equations (a coupled Newton solve at every timestep) instead of the linearized \(A\) matrix above. Building the model (assembling blocks, no integration yet) is cheap, so it stays unconditional.

Every option simulate() takes, not just the ones this notebook happens to use:

Parameter

Meaning

t_span

(t0, t_final) — the integration window

x0

Initial state; defaults to the model’s own operating point

u_exo_fn

t -> exogenous-input vector — drives a disturbance by changing a reference over time, instead of (or in addition to) perturbing a state; defaults to holding it fixed

t_eval

Explicit sample times to return; defaults to the solver’s own adaptive steps

method

scipy.integrate.solve_ivp’s integrator — default "Radau" (implicit, L-stable — this system’s stiffest modes reach ~\(10^6\)–\(10^7\) rad/s, see docs/sphinx/modules/timedomain.md); "BDF"/"LSODA" are other implicit options, explicit methods ("RK45") are not recommended for a system this stiff

rtol / atol

Relative/absolute integration tolerance

first_step

Initial step size hint; a tiny value here (e.g. 1e-8) helps the solver get past the sharp initial algebraic-consistency transient every trajectory starts with

Two demonstrations follow: perturbing a state (an initial-condition offset, Section 9’s own comparison) and perturbing an input (a permanent step in an exogenous reference, held from \(t=0\)) — the req.perturb_kind choice the web UI’s EMT tab itself exposes.

from g2elin_core.timedomain.emt import build_nonlinear_network, simulate

model = build_nonlinear_network(net, pf)
print(f"nonlinear model: {len(model.state_names)} states, {len(model.input_names)} inputs, "
      f"{len(model.output_names)} outputs")
nonlinear model: 128 states, 11 inputs, 14 outputs

10a. Perturbing a state

Perturbing the exact same state by the exact same offset as Section 9 lets the closed-form linear prediction and the nonlinear trajectory be overlaid directly.

if RUN_EMT_SECTION:
    x0 = model.initial_state()
    xp = x0.copy()
    xp[perturb_idx] += 0.05

    t_eval = np.linspace(0, 1.5, 150)
    sim = simulate(model, (0.0, 1.5), x0=xp, t_eval=t_eval, method="Radau", rtol=1e-5, atol=1e-7, first_step=1e-8)
    nonlinear_trace = sim.x[perturb_idx, :] - x0[perturb_idx]  # deviation from equilibrium, matching x_lin's own convention

    fig, ax = plt.subplots(figsize=(7, 4))
    ax.plot(t, x_lin[perturb_idx, :], color="tab:blue", label="linear (free_response)")
    ax.plot(t_eval, nonlinear_trace, color="tab:orange", ls="--", label="nonlinear (EMT)")
    ax.axhline(0, color="0.85", lw=0.8)
    ax.set_xlabel("t (s)"); ax.set_ylabel(f"{perturb_name} (deviation from equilibrium)")
    ax.set_title("Linear prediction vs. actual nonlinear transient -- state perturbation")
    ax.legend()
    ax.grid(True, alpha=0.3)
    plt.show()

For this 0.05 pu perturbation the two traces track closely — a maximum absolute difference of about 0.0024 pu over the full 1.5 s window (under 5% of the initial offset), both decaying to essentially zero by the end, consistent with the swing mode’s own ~22% damping. This is exactly what “small-signal” means in “small-signal/modal analysis”: the linearization is a first-order Taylor approximation around the operating point, accurate for small enough deviations and only for small enough deviations. Re-running Section 9 and this cell with a much larger offset (say 0.3-0.5 pu) is a quick way to see that approximation start to visibly break down.

10b. Perturbing an input, via u_exo_fn

A state perturbation moves the starting point away from equilibrium but leaves every reference (P_ref, Q_ref, …) exactly where it was; an input perturbation instead moves the equilibrium itself — the system starts at its own operating point and is then asked to track a new one, the more standard “reference step” disturbance test (g2elin_api’s own EMT endpoint builds exactly this closure for perturb_kind="input").

if RUN_EMT_SECTION:
    input_name = sm_input  # P_ref_{SM_1} -- reuse Section 9's own step_response input
    input_idx = model.input_names.index(input_name)
    perturbed_u_exo = model.default_u_exo()
    perturbed_u_exo[input_idx] += 0.05
    u_exo_fn = lambda t: perturbed_u_exo  # held constant at the stepped value for all t -- noqa: E731

    t_eval_u = np.linspace(0, 3.0, 300)
    sim_u = simulate(
        model, (0.0, 3.0), u_exo_fn=u_exo_fn, t_eval=t_eval_u,
        method="Radau", rtol=1e-5, atol=1e-7, first_step=1e-8,
    )
    # p_e is an output, not a state -- solve_ivp only returns state
    # trajectories, so recover it from the already-integrated samples.
    _, outputs_u = model.recover_inputs_and_outputs(sim_u.t, sim_u.x, u_exo_fn=u_exo_fn)

    fig, ax = plt.subplots(figsize=(7, 4))
    ax.plot(t_eval_u, outputs_u[sm_output], color="tab:purple")
    ax.set_xlabel("t (s)"); ax.set_ylabel(sm_output)
    ax.set_title(f"Input-step response (nonlinear EMT): {input_name} (+0.05 pu, held) -> {sm_output}")
    ax.grid(True, alpha=0.3)
    plt.show()

This is the nonlinear counterpart of Section 9’s own step_response — same input, same amplitude, same output, but the actual coupled Newton solve instead of the linear SISO reduction. recover_inputs_and_outputs() is needed here because p_e is an output, not a state — solve_ivp only returns state trajectories, so the algebraic outputs are re-solved once per already-integrated sample (see docs/sphinx/modules/timedomain.md).

11. Time-series load sweep

No dynamics here — just power flow re-solved at three load levels (70% / 100% / 130% of the base network’s loads), the same sweep the web UI’s Time Series tab runs.

from g2elin_core.timeseries.scenario import scale_loads, run_time_series

snapshots = scale_loads(net, {"70%": 0.7, "100%": 1.0, "130%": 1.3})
ts = run_time_series(net, snapshots)
print("all converged:", ts.all_converged())

watch_buses = [15, 17, 1]  # SM terminal, GFL terminal, the feeder's heaviest load bus
fig, ax = plt.subplots(figsize=(6, 4.5))
for b in watch_buses:
    vals = [ts.bus_tables[snap.label].set_index("bus").loc[b, "vm_pu"] for snap in snapshots]
    ax.plot([s.label for s in snapshots], vals, marker="o", label=f"bus {b}")
ax.set_ylabel("V (pu)"); ax.set_xlabel("load level")
ax.set_title("Bus voltage vs. load level")
ax.grid(True, alpha=0.3)
ax.legend()
plt.show()
all converged:
 
True

../_images/4ba94478df1f512e0c9fbd84f58d7fe36cc8a3fd770d72a6d160c0acd6839c3d.png

Voltage droops with load at every bus, as expected, but not by the same amount everywhere — the GFL’s own terminal (bus 17, fixed \(P/Q\) dispatch, no voltage control of its own) moves more with load than the SM’s slack terminal (bus 15, whose AVR is actively holding voltage), a direct, visible consequence of the same “who’s actually regulating voltage here” question Section 1 raised in the abstract.

Summary

Module

What it told us about this network

network (schema/presets/topology)

The DER mix, dispatch, and physical layout

powerflow

A feasible, in-range voltage profile; the SM slack supplies ~2.27 MW (vs. the GFM/GFL’s fixed 2.5 MW each) to balance the feeder’s ~7.24 MW total load plus losses, from ext_grid_table()

pu_base

Two independent per-unit bases (20 kV feeder, 10 kV DER terminals) sharing one MVA base

operating_point

Three different ways of finding “the angle”: rotor-angle geometry (SM), self-clocked droop angle (GFM), PLL-locked angle (GFL)

components (isolated)

19/15/14 states for SM/GFM/GFL respectively, and how differently stiff each one’s own open-loop dynamics are

interconnect/pipeline

A 48-state DER core plus 28+26+26 states from 14 nodes/13 lines/13 loads – 128 states total for this 14-bus, 3-DER network

modal

Every mode stable except one structurally-zero reference-angle mode (the “theta problem,” not a real instability); the genuine SM swing mode sits at ~2.2 Hz, ~22% damping

modal.toolbox

Mode shape, closed-form free response, and step response – all from the same eigendecomposition, no extra solve

timedomain (EMT)

The nonlinear transient the linearization is only ever a small-signal approximation of

timeseries

Which bus’s voltage is most exposed to load swings, and why (whose control loop is actually holding it)

See notebooks/tour.ipynb for the same modules exercised more briefly across several different presets, and docs/sphinx/ for every equation behind every number computed above.