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
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):
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:
(\(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_busnode’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_ginput, and every node/line/load’swginput, equals the slack unit’s owntheta/wroutput — the reference frame rotates at the slack’s speed.Part III (DG voltage): each DG’s
vgd_g/vgq_ginput 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_ginputs equal its “from”/”to” node’s voltage output.Part VII (load voltage): a load’s
vgd_g/vgq_ginput 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:
ProtocolJust the sizes
Block’s topology needs — bothLinearComponentand the nonlinear-simulation wrapper intimedomain/satisfy this structurally, so the sameBlock/Wiring/topology code works for linearized (assemble) and nonlinear (timedomain.emt) assembly.
- 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
- 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:
- class g2elin_core.interconnect.assemble.Wiring(input_block, input_port, terms=<factory>)[source]
Bases:
objectOne row: an input port that must equal a (signed) sum of output ports.
- class g2elin_core.interconnect.assemble.Topology(blocks, n_x, n_u, n_y, G, F, input_names)[source]
Bases:
objectBlock 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:
- class g2elin_core.interconnect.assemble.AssemblyParts(blocks, topology, A_ol, B_ol, C_ol, D_ol, E_ol, A_tot)[source]
Bases:
objectThe pieces
assemblebuilds on the way toA_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:
- g2elin_core.interconnect.assemble.assembly_parts(blocks, wiring)[source]
assemble’s intermediates, for callers that need more than A_tot.- Parameters:
- Return type:
- class g2elin_core.interconnect.assemble.PerturbationProjector(parts, weights)[source]
Bases:
objectProjects 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. WithA_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_totis a weighted sum<S, dA_tot>for some matrixS– an eigenvalue sensitivity, for instance. PushingSthrough 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:
parts (AssemblyParts)
weights (np.ndarray)
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_componentsare the shunt reactors, keyed by their index innetwork.shunts. A capacitor bank never appears here: it is part of its bus’s own capacitance (network/breakers.node_capacitances).transformer_componentsare the branch transformers – those between two grid buses – keyed by their index innetwork.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(seecomponents/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:
network (Network)
- Return type: