interconnect — wiring components into one closed-loop system

Functionally ports Functions/assoc_matrices.m plus the concatenation/ closed-loop formula in script_generic.m’s Section IX — but instead of replicating ~300 lines of hand-rolled row/column index arithmetic (verified once against a MATLAB run, near-impossible to verify blind), this builds the same F/G/K/L interconnection from an explicit list of named-port wiring rules, checked against assoc_matrices.m’s Parts I–VII by inspection (each rule is documented in the module’s own docstring: reference-angle propagation, DG terminal voltage, node current balance, line/load voltage feed).

Block / Wiring / Topology

        classDiagram
    class PortSpec {
        <<protocol>>
        n_us n_ug n_out_s n_out_g : int
    }
    class Block {
        name kind : str
        comp : PortSpec
        state_off input_off output_off : int
        input_row(port) int
        output_col(port) int
    }
    class Wiring {
        input_block : Block
        input_port : str
        terms : list~(Block, str, float)~
    }
    class Topology {
        blocks : list~Block~
        n_x n_u n_y : int
        G : ndarray
        F : ndarray
        input_names : list~str~
    }
    class AssembledSystem {
        A B C D : ndarray
        state_names input_names output_names
    }
    Block --> PortSpec
    Wiring --> Block : reads/writes ports of
    Topology --> Block : offsets
    Topology ..> AssembledSystem : assemble() builds this from Topology + block A/B/C/D
    

PortSpec is a Protocol — just the four port-count attributes (n_us, n_ug, n_out_s, n_out_g). Both LinearComponent (from components) and the nonlinear wrapper in timedomain.emt satisfy it structurally, which is what lets compute_topology() be shared code between the linear closed-loop assembly here and the nonlinear coupled-Newton assembly in timedomain/emt.py — topology (which port connects to which) is identical either way; only what runs on top of it (linear algebra vs. a nonlinear solve) differs.

From wiring rules to a closed-loop state-space

        flowchart TD
    BW["build_blocks_and_wiring()<br/>(network_assembly.py)"] --> TOPO["compute_topology(blocks, wiring)"]
    TOPO --> G["G: full input = F @ u_exo + G @ y<br/>(interconnection selector matrix)"]
    TOPO --> F["F: selects each block's own (us) inputs"]
    G --> ASM["assemble(blocks, wiring)"]
    F --> ASM
    BLKDIAG["block-diagonal A_ol/B_ol/C_ol/D_ol<br/>(one per-component LinearComponent, concatenated)"] --> ASM
    ASM --> CLOSED["closed-loop elimination<br/>E_ol = inv(I - D_ol@G)<br/>A_tot = A_ol + B_ol@G@E_ol@C_ol<br/>B_tot, C_tot, D_tot similarly"]
    CLOSED --> OUT["AssembledSystem<br/>(A, B, C, D, state/input/output names)"]
    

network_assembly.build_blocks_and_wiring() is the network-specific part: given a Network and its already-linearized components, it decides which Blocks exist (one per SM/GFM/GFL/IB/node/line/load) and which Wiring rules connect them, then hands that to the kind-agnostic assemble() above. It raises NotImplementedError if the slack unit is anything other than a synchronous machine or an infinite bus — _SLACK_KIND_BY_UNIT_TYPE picks "sm_slack" or "ib_slack" accordingly; every other wiring rule in that module is already generic over “the slack block” by role, not by kind, so adding the IB slack kind needed no other change there.

Block names are per-type counters, not the DER’s raw id: "SM_1", "GFM_1", "GFM_2", "GFL_1" rather than "DG_1"/"DG_2"/"DG_3"/ "DG_4" — counted in network.der_units declaration order (every preset lists them in ascending id order already). Every state/input/output name downstream is built by interpolating a block’s name (e.g. f"dw_r_{{{block.name}}}" → "dw_r_{SM_2}"), so this one change is what makes a name say what kind of unit it is without cross-referencing the network definition — a single surgical change here propagates everywhere (AssembledSystem’s and NonlinearNetworkModel’s state_names/input_names/output_names alike), since both are built by the same build_blocks_and_wiring().

Physics: closed-loop elimination

Every component contributes its own open-loop linear state-space (its own \(A_i,B_i,C_i,D_i\) from components’s linearization). Stacking all \(n\) blocks block-diagonally gives the open-loop system

\[ A_{ol}=\operatorname{blkdiag}(A_1,\dots,A_n) \qquad B_{ol}=\operatorname{blkdiag}(B_1,\dots,B_n) \qquad C_{ol}=\operatorname{blkdiag}(C_1,\dots,C_n) \qquad D_{ol}=\operatorname{blkdiag}(D_1,\dots,D_n) \]

with the full input vector \(u\) split into each block’s own exogenous inputs \(u_{exo}\) (selected by \(F\), e.g. an SM’s \(P_{ref}\)) and the interconnection signals wired between blocks’ own outputs \(y\) (selected by \(G\), built directly from the wiring rules below):

\[ u = F\,u_{exo} + G\,y \qquad y = C_{ol}x + D_{ol}u \]

Substituting the first equation into the second and solving for \(y\) (hence \(u\)) in terms of \(x\) and \(u_{exo}\) alone gives the algebraic loop’s closed form — the same elimination components uses per-component, applied once more at the network level:

\[ E = \left(I - D_{ol}G\right)^{-1} \]
\[ A_{tot} = A_{ol} + B_{ol}\,G\,E\,C_{ol} \qquad B_{tot} = \left(B_{ol}\,G\,E\,D_{ol} + B_{ol}\right)F \qquad C_{tot} = L\,E\,C_{ol} \qquad D_{tot} = L\,E\,D_{ol}\,F \]

(\(L\) selects each block’s own named (”\(s\)”) outputs — e.g. an SM’s \(P_e\)/\(Q_e\)/\(\omega_r\) — out of the full output vector \(y\), the same way \(F\) selects named inputs out of \(u\).) \(A_{tot}\) is exactly what modal analysis eigen-decomposes.

The wiring rules themselves — what actually populates \(G\) — are physical KVL/KCL statements in the dq frame, not free choices:

  • Reference-frame propagation: every non-slack DER’s own \(\theta_g\) input, and every node/line/load’s own \(\omega_g\) input, equals the slack’s own \(\theta\)/\(\omega_r\) output — one shared rotating reference frame for the whole network, defined by whichever unit is the slack.

  • DER terminal voltage: a DER’s \(v_{gd,g}/v_{gq,g}\) input equals its own transformer’s hv_bus node’s voltage output — a DER sees exactly the voltage of the grid bus its transformer connects to.

  • Node current balance (KCL): a node’s injected shunt current \(i_{sh}\) equals the signed sum of every incident branch’s current — \(+1\) for a DER whose transformer lands here, \(+1\) for an incoming line (\(k=\) this bus), \(-1\) for an outgoing line (\(j=\) this bus), \(-1\) for a load: $\( i_{sh,d} = \sum_{\text{DER here}} i_{gd} \;+\!\!\sum_{\text{lines }k=\text{here}}\!\! i_{ld} \;-\!\!\sum_{\text{lines }j=\text{here}}\!\! i_{ld} \;-\!\!\sum_{\text{loads here}} i_{cd} \)\( (and identically for the \)q\(-axis) — this is the same statement as \)Y_{bus}$’s row sum in power flow, just carried per-branch instead of pre-summed into an admittance matrix, since each branch here is its own dynamic state, not a static impedance.

  • Line/load voltage: a line’s two endpoint voltage inputs, and a load’s own voltage input, equal their respective node’s voltage output directly (KVL — a branch sees the potential difference of the nodes it connects).

Reference

Assembles per-component linear state-spaces into one closed-loop system.

Functionally ports Functions/assoc_matrices.m + the concatenation/ closed-loop formula in script_generic.m’s “Section IX” — but instead of replicating ~300 lines of hand-rolled row/column index arithmetic (verified once against a MATLAB run, near-impossible to verify blind), this builds the same F/G/K/L interconnection from an explicit list of named-port wiring rules, checked against assoc_matrices.m’s Parts I-VII by inspection:

  • Part II/IV (theta, omega): every non-slack unit’s theta_g input, and every node/line/load’s wg input, equals the slack unit’s own theta/wr output — the reference frame rotates at the slack’s speed.

  • Part III (DG voltage): each DG’s vgd_g/vgq_g input equals the voltage output of the raw node its transformer connects to.

  • Part V (node current balance): a node’s injected current equals the sum of incident DG currents (+), “to” line currents (+), “from” line currents (-), and load currents (-).

  • Part VI (line voltages): a line’s two vgdj_g/vgdk_g inputs equal its “from”/”to” node’s voltage output.

  • Part VII (load voltage): a load’s vgd_g/vgq_g input equals its node’s voltage output.

The closed-loop formula itself (A_tot0 = A_ol + B_ol*G*E_ol*C_ol etc.) is ported directly — it’s a single unambiguous line, not index bookkeeping.

class g2elin_core.interconnect.assemble.PortSpec(*args, **kwargs)[source]

Bases: Protocol

Just the sizes Block’s topology needs — both LinearComponent and the nonlinear-simulation wrapper in timedomain/ satisfy this structurally, so the same Block/Wiring/topology code works for linearized (assemble) and nonlinear (timedomain.emt) assembly.

n_us: int
n_ug: int
n_out_s: int
n_out_g: int
class g2elin_core.interconnect.assemble.Block(name: 'str', kind: 'str', comp: 'PortSpec', state_off: 'int' = 0, input_off: 'int' = 0, output_off: 'int' = 0)[source]

Bases: object

Parameters:
name: str
kind: str
comp: PortSpec
state_off: int = 0
input_off: int = 0
output_off: int = 0
input_row(port)[source]
Parameters:

port (str)

Return type:

int

output_col(port)[source]
Parameters:

port (str)

Return type:

int

class g2elin_core.interconnect.assemble.AssembledSystem(A: 'np.ndarray', B: 'np.ndarray', C: 'np.ndarray', D: 'np.ndarray', state_names: 'list[str]', input_names: 'list[str]', output_names: 'list[str]')[source]

Bases: object

Parameters:
A: ndarray
B: ndarray
C: ndarray
D: ndarray
state_names: list[str]
input_names: list[str]
output_names: list[str]
class g2elin_core.interconnect.assemble.Wiring(input_block, input_port, terms=<factory>)[source]

Bases: object

One row: an input port that must equal a (signed) sum of output ports.

Parameters:
input_block: Block
input_port: str
terms: list[tuple[Block, str, float]]
class g2elin_core.interconnect.assemble.Topology(blocks, n_x, n_u, n_y, G, F, input_names)[source]

Bases: object

Block offsets + the interconnection’s F/G selector matrices — the part of assembly that’s identical whether the blocks are linearized (assemble) or nonlinear (timedomain.emt): it only encodes which port connects to which, never any component’s actual dynamics.

Parameters:
blocks: list[Block]
n_x: int
n_u: int
n_y: int
G: ndarray
F: ndarray
input_names: list[str]
g2elin_core.interconnect.assemble.compute_topology(blocks, wiring)[source]
Parameters:
Return type:

Topology

g2elin_core.interconnect.assemble.assemble(blocks, wiring)[source]
Parameters:
Return type:

AssembledSystem

class g2elin_core.interconnect.assemble.AssemblyParts(blocks, topology, A_ol, B_ol, C_ol, D_ol, E_ol, A_tot)[source]

Bases: object

The pieces assemble builds on the way to A_tot.

Kept for callers that need to know how one component’s change reaches the closed-loop matrix without re-assembling the whole thing – see PerturbationProjector.

Parameters:
blocks: list[Block]
topology: Topology
A_ol: ndarray
B_ol: ndarray
C_ol: ndarray
D_ol: ndarray
E_ol: ndarray
A_tot: ndarray
block_slices(index)[source]

One block’s (states, inputs, outputs) spans in the stacked vectors.

Parameters:

index (int)

Return type:

tuple[slice, slice, slice]

g2elin_core.interconnect.assemble.assembly_parts(blocks, wiring)[source]

assemble’s intermediates, for callers that need more than A_tot.

Parameters:
Return type:

AssemblyParts

class g2elin_core.interconnect.assemble.PerturbationProjector(parts, weights)[source]

Bases: object

Projects a change in one component onto a weighted sum over A_tot.

Re-assembling to find out what a parameter did costs an n_y-square inverse – 0.34 s on a 118-bus model, which a scan over a thousand parameters cannot afford. It is also unnecessary. With

A_tot = A_ol + B_ol G E C_ol, E = inv(I - D_ol G)

and only one component’s (A, B, C, D) moving, differentiating gives

dA_tot = dA_c + dB_c P + Q dC_c + Q dD_c P, P = G E C_ol, Q = B_ol G E

and every quantity a caller actually wants from dA_tot is a weighted sum <S, dA_tot> for some matrix S – an eigenvalue sensitivity, for instance. Pushing S through each term turns that sum into four inner products over the component’s own blocks:

<S, dA_tot> = <S, dA_c> + <S P^T, dB_c> + <Q^T S, dC_c> + <Q^T S P^T, dD_c>

So the four weight matrices are built once and each parameter after that costs only its own small blocks. Exact, not an approximation.

Parameters:
project(index, dA, dB, dC, dD)[source]

<S, dA_tot> for a change confined to block index.

Parameters:

index (int)

Return type:

complex

Builds the Block list and wiring rules for a Network. An infinite bus (IB) is always the slack (see components/ib.py), so it gets its own "ib_slack" kind alongside "sm_slack" — see _slack_kind() below.

build_blocks_and_wiring is generic over anything satisfying PortSpec — both LinearComponent (used by assemble_network, for modal analysis) and the nonlinear wrapper in timedomain/emt.py (for EMT simulation) build the same topology from it, since the interconnection itself (which port connects to which) doesn’t care whether a block is linear or not.

g2elin_core.interconnect.network_assembly.build_blocks_and_wiring(network, *, der_components, node_components, line_components, load_components, frame_components=None, shunt_components=None, transformer_components=None)[source]

Wires one network’s components together.

shunt_components are the shunt reactors, keyed by their index in network.shunts. A capacitor bank never appears here: it is part of its bus’s own capacitance (network/breakers.node_capacitances).

transformer_components are the branch transformers – those between two grid buses – keyed by their index in network.transformers. A unit’s own step-up never appears here either: its impedance is inside that unit’s model, and its LV bus has no node of its own.

frame_components (see components/frame.py) are the dq reference frames everything is written in, one per island, keyed by the unit that island is referenced to. Each frame is a block of its own, following that unit’s speed, so no unit is load-bearing for the model: any of them can be disconnected, and islands each keep their own frequency. Without them the frame is the slack unit itself (Network.frame_follows_slack, the MATLAB toolbox’s convention), which then has to be a synchronous machine or an infinite bus and can never leave the model.

Parameters:
Return type:

tuple[list[Block], list[Wiring]]

g2elin_core.interconnect.network_assembly.assemble_network(network, *, der_components, node_components, line_components, load_components, frame_components=None, shunt_components=None, transformer_components=None)[source]
Parameters:
Return type:

AssembledSystem