pipeline and modal — linear small-signal analysis end to end

pipeline.linearize_network() is the one function that chains everything in components, operating_point and interconnect into a single closed-loop state-space; modal.analyze() turns that state-space into eigenvalues, damping/frequency, and participation factors.

linearize_network()

        flowchart TD
    N["Network"] --> OP["compute_operating_point()"]
    PF["PowerFlowResult"] --> OP
    OP --> SM["linearize_sm() per SM unit"]
    OP --> GFM["linearize_gfm() per GFM unit"]
    OP --> GFL["linearize_gfl() per GFL unit"]
    OP --> IB["linearize_ib() for an IB slack"]
    OP --> NODE["linearize_node() per plain bus<br/>(uses line #1's b_pu — MATLAB quirk)"]
    OP --> LINE["linearize_line() per line"]
    OP --> LOAD["linearize_load() per load"]
    SM --> AN["assemble_network()"]
    GFM --> AN
    GFL --> AN
    IB --> AN
    NODE --> AN
    LINE --> AN
    LOAD --> AN
    AN --> SYS["AssembledSystem<br/>(A, B, C, D)"]
    

Raises NotImplementedError if any DER unit isn’t SM/GFM/GFL/IB, and the slack isn’t SM or IB specifically (components.ib is always the slack — see its own docstring). Wiring the infinite bus in (interconnect.assemble’s "ib_slack" port kind, interconnect.network_assembly’s _SLACK_KIND_BY_UNIT_TYPE) was built alongside the SMIB presets (network) — it existed earlier but had no real preset to validate against until then.

Physics: modal analysis

Every mode of the closed-loop linear system \(\dot x = A_{tot}x\) is an eigenpair \((\lambda_i, v_i)\) of \(A_{tot}\): \(A_{tot}v_i = \lambda_i v_i\). Complex \(A_{tot}\) eigenvalues always come in conjugate pairs \(\lambda=\sigma\pm j\omega_d\) for a real system, each pair describing one oscillatory mode:

\[ f_n = \frac{|\lambda|}{2\pi} \qquad \zeta = \frac{-\operatorname{Re}(\lambda)}{|\lambda|} \qquad f_d = f_n\sqrt{1-\zeta^2} \]

— undamped natural frequency, damping ratio, and damped (actually observed oscillation) frequency; \(\zeta>0\) (equivalently \(\operatorname{Re}(\lambda)<0\)) is the stability condition for that mode. analyze() computes both the right eigenvectors \(V\) (\(A_{tot}V = V\Lambda\)) and left eigenvectors \(W\) (\(WA_{tot}=\Lambda W\)) from one scipy.linalg.eig(..., left=True, right=True) call (see the module’s own “bug fixed here” note below for why not two separate calls), normalized so \(WV=I\). The participation factor of state \(i\) in mode \(j\) — Kundur’s standard measure of how much a given state actually participates in a given oscillatory mode, dimensionless and normalized so each mode’s column sums to 1 — is

\[ p_{ij} = \frac{|V_{ij}|\,|W_{ji}|}{\sum_k |V_{kj}|\,|W_{jk}|} \]

Reference

End-to-end: solved power flow -> linearized closed-loop small-signal model.

A synchronous-machine or infinite-bus slack is wired up (see interconnect/network_assembly.py); GFM and GFL are supported as non-slack units.

g2elin_core.pipeline.linearize_network(network, result)[source]

The closed-loop small-signal model of one network at one operating point.

Parameters:
Return type:

AssembledSystem

g2elin_core.pipeline.linear_components(network, result, *, op=None)[source]

Every element’s own linearised state-space, before they are wired together – what linearize_network() assembles, and what a caller needs to know which block is which (see g2elin_core.modal.parameters).

op is the network’s operating point, computed here when not given. A caller that needs the operating point in its own right can pass the one it already has rather than have it built a second time.

Parameters:
Return type:

dict

Eigenvalue / participation-factor modal analysis, ported from the numeric core of Functions/modal_analysis.m (plotting and the sensitivity tensor aren’t ported yet — this covers the eigenvalues, damping/frequency table, and the participation-factor matrix).

class g2elin_core.modal.analysis.ModalAnalysisResult(eigenvalues: 'np.ndarray', right_eigenvectors: 'np.ndarray', left_eigenvectors: 'np.ndarray', participation: 'np.ndarray', state_names: 'list[str]')[source]

Bases: object

Parameters:
eigenvalues: ndarray
right_eigenvectors: ndarray
left_eigenvectors: ndarray
participation: ndarray
state_names: list[str]
summary_table()[source]

One row per mode: eigenvalue, frequency/damping, and the top 3 participating states — mirrors the table modal_analysis.m prints.

Return type:

DataFrame

g2elin_core.modal.analysis.reference_angle_modes(result)[source]

The modes that are only the model’s own reference angles.

Nothing pins the absolute position of the dq frame, so the model always has one marginal direction: turn every angle by the same amount and nothing physical changes. With a frame of its own (components/frame.py) there is a second one, since the frame and the unit it follows turn at the same speed and their difference is therefore conserved – a redundancy of the coordinates, not a mode of the system.

Both show up as eigenvalues at the origin (numerically a hair either side of it, which is why a plain max(Re) < 0 test on the raw spectrum is the wrong question to ask) and both are made entirely of angle states, which is how they are told apart from a genuinely slow mode sitting near the origin.

Parameters:

result (ModalAnalysisResult)

Return type:

list[int]

g2elin_core.modal.analysis.unregulated_frequency_mode(result, exclude=None)[source]

The mode that is the whole fleet’s frequency drifting together.

Only call this when nothing in the network regulates frequency (g2elin_core.network.schema.Network.frequency_is_regulated()). Then the model has one more marginal direction than the reference angles account for: with every machine’s mechanical power fixed, a uniform speed change meets no restoring torque, so dtheta/dt = w and dw/dt = 0 along it. That is a defective zero – a Jordan block, not a plain one – which is why it lands further from the origin numerically than the reference angles do: its error grows like the square root of machine precision times the matrix norm, and on a two-area model that is a few times 1e-4 rather than a few times 1e-11.

Chasing that with a tolerance would be guesswork, so this does not use one. Exactly one such mode exists whenever the caller’s precondition holds, so it is identified as the real eigenvalue nearest the origin whose participation lies wholly in the angle and speed states, with the reference angles set aside. Returns None if no candidate qualifies, which would mean the precondition did not really hold.

Parameters:
Return type:

int | None

g2elin_core.modal.analysis.analyze(A, state_names)[source]

Eigen-decompose A and compute the participation-factor matrix.

Right eigenvectors V and left eigenvectors W are computed by a single scipy.linalg.eig(A, left=True, right=True) call so they come back consistently paired and ordered — computing them from two separate eig() calls (once on A, once on A.conj().T) and assuming a shared ordering is a real bug: nothing guarantees two independent LAPACK calls return eigenvalues in matching order, and mismatched pairing shows up as near-zero (or exactly zero) W @ V normalization coefficients. They’re normalized so W @ V = I (modal_analysis.m’s coef = diag(W'*V) normalization), then the participation factor of state i in mode j is |V[i,j]| * |W[j,i]| / sum_k(|V[k,j]|*|W[j,k]|).

Parameters:
Return type:

ModalAnalysisResult

The rest of Functions/modal_analysis.m’s toolbox: eigenvalue sensitivity, mode shapes, closed-form free-motion response, and MIMO step response. modal/analysis.py’s own docstring already flagged these as “not ported yet” – this is that follow-up, read directly from the MATLAB source (not guessed) to keep the math and the plotting choices faithful.

Everything here is pure linear algebra on the eigendecomposition analyze() already computes, or (for step response) a thin scipy.signal wrapper over the same A/B/ C/D linearize_network() already produces – no new model type, no new derivation.

class g2elin_core.modal.toolbox.SensitivityEntry(row_state: 'str', col_state: 'str', value: 'float')[source]

Bases: object

Parameters:
row_state: str
col_state: str
value: float
class g2elin_core.modal.toolbox.SensitivityResult(mode: 'int', matrix: 'np.ndarray', top: 'list[SensitivityEntry]')[source]

Bases: object

Parameters:
mode: int
matrix: ndarray
top: list[SensitivityEntry]
g2elin_core.modal.toolbox.eigenvalue_sensitivity(modal, A, mode, n_top=8)[source]

sensMatrix(k,j,i) = leftV(i,k)*rightV(j,i) / (leftV(i,:)*rightV(:,i)) for the chosen mode i (modal_analysis.m Part 3) – the standard eigenvalue-to-matrix-element sensitivity (Kundur eq. 12.75-ish): d(lambda_i)/d(A[k,j]) = w_i[k] * v_i[j], w_i/v_i the left/ right eigenvectors of mode i. Masked to where A is actually nonzero (modal_analysis.m’s Anum_ones) – a sensitivity value at a structurally-zero A entry isn’t a “sensitivity to a real parameter,” it’s noise from an entry that was never a parameter to begin with.

Parameters:
Return type:

SensitivityResult

class g2elin_core.modal.toolbox.ModeShapeResult(mode: 'int', states: 'list[str]', angles_deg: 'list[float]')[source]

Bases: object

Parameters:
mode: int
states: list[str]
angles_deg: list[float]
g2elin_core.modal.toolbox.mode_shape(modal, mode, n_top=5)[source]

Phase angle of the top-n_top participating states’ right eigenvector components, for polar plotting (modal_analysis.m Part: “Plotting the mode shape”). Magnitude is fixed at 1 in the original (ModeMatrix(k,1:2,i) = [1 angle(rightV(k,i))]) – it’s a shape plot (relative phase between states’ oscillation), not a magnitude one.

Parameters:
Return type:

ModeShapeResult

g2elin_core.modal.toolbox.free_response(modal, perturb_index, offset, t)[source]

Closed-form modal-expansion response to an initial-condition perturbation, no ODE integration needed (modal_analysis.m’s “Free Motion” section): x(t) = V @ (c * exp(eig*t)), c = W @ x0 the modal coordinates of the perturbation x0 (a unit vector at perturb_index scaled by offset). Returns x(t) for every state (shape (n_states, len(t))) – cheap (this is an O(n^2) matrix product per timestep for these state counts, not a solve), so there’s no reason to compute only one state’s row and force a second call for another.

Parameters:
Return type:

ndarray

g2elin_core.modal.toolbox.step_response(system, input_name, output_name, amplitude, t)[source]

MIMO step response between one chosen input/output pair (modal_analysis.m’s “step_resp” section), via a SISO reduction of the already-linearized (A, B, C, D) – pick the one column of B and row of C/D the chosen input/output correspond to, scale the unit-step response by amplitude afterward (linear system, so this is exact, not an approximation).

Parameters:
Return type:

ndarray