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.
modal.analyze()
Ported from the numeric core of Functions/modal_analysis.m — eigenvalues,
the damping/frequency table, and participation factors.
flowchart LR
A["AssembledSystem.A"] --> EIG["scipy.linalg.eig(A, left=True, right=True)<br/>(single call — right & left eigenvectors<br/>come back consistently paired)"]
EIG --> SORT["sort by ascending real part"]
SORT --> NORM["normalize: W @ V = I"]
NORM --> PART["participation[i,j] =<br/>|V[i,j]|*|W[j,i]| / sum_k(|V[k,j]|*|W[j,k]|)"]
PART --> RES["ModalAnalysisResult<br/>eigenvalues, eigenvectors, participation"]
RES --> TAB["summary_table()<br/>freq/damping + top-3 participating states per mode"]
Bug fixed here, not incidental: an earlier version computed right
eigenvectors from eig(A) and left eigenvectors from a separate
eig(A.conj().T) call, assuming the two came back in matching order —
nothing guarantees that, and mismatched pairing silently produced
near-zero or NaN participation factors on cases with closely-spaced
eigenvalues (first caught on the CIGRE preset, which has two
near-identical GFM units). The current single-call scipy.linalg.eig(A, left=True, right=True) sidesteps the whole class of bug.
modal.toolbox — the rest of modal_analysis.m
modal/analysis.py’s own docstring used to flag its plotting and
sensitivity-tensor features as “not ported yet.” Read in full, that MATLAB
file’s toolbox turns out to be four more pieces of pure linear algebra on
the same eigendecomposition analyze() already computes (plus one thin
scipy.signal wrapper) — nothing here is a new model or a new derivation:
flowchart TD
MODAL["ModalAnalysisResult<br/>(eigenvalues, left/right eigenvectors, participation)"] --> SENS["eigenvalue_sensitivity(mode)<br/>d(lambda_i)/d(A[k,j]) = w_i[k]*v_i[j]<br/>masked to A's structurally-nonzero entries"]
MODAL --> SHAPE["mode_shape(mode)<br/>top-5 states' right-eigenvector phase angle<br/>(magnitude fixed at 1 -- a shape plot, not a magnitude one)"]
MODAL --> FREE["free_response(state, offset, t)<br/>x(t) = V @ (c * exp(eig*t)), c = W @ x0<br/>closed-form -- no ODE solve"]
SYS["AssembledSystem<br/>(A, B, C, D)"] --> STEP["step_response(input, output, amplitude, t)<br/>SISO reduction + scipy.signal.step"]
Each is cross-checked against an independent reference in
tests/test_modal_toolbox.py, not just “runs and is finite”: free_response
against scipy.linalg.expm(A*t) @ x0 directly, step_response against
scipy.signal.step on the same SISO-reduced system built by hand.
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:
— 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
modal.toolbox — sensitivity, mode shape, free/step response
Eigenvalue sensitivity to a structural \(A_{tot}\) entry (Kundur eq. 12.75-family result): perturbing element \(A_{kj}\) shifts eigenvalue \(\lambda_i\) by
masked to only the entries where \(A_{tot}\) is structurally nonzero (a sensitivity value at a zero entry isn’t a sensitivity to any real physical parameter).
Mode shape: the relative phase of the top-participating states’ own right-eigenvector components for mode \(i\) — magnitude fixed at 1 by construction, since this is a shape (relative timing/direction of oscillation between states) not a magnitude comparison:
Free (initial-condition) response — a closed-form modal expansion, no ODE integration needed, valid because the system is linear:
(\(x_0\) a unit perturbation at one chosen state, \(\odot\) elementwise product against each mode’s own \(e^{\lambda_i t}\) — this is literally the general solution of a linear ODE in modal coordinates).
Step response between one chosen exogenous input and output is a
SISO reduction of the full MIMO \((A,B,C,D)\) — pick input \(i\)’s column of
\(B\) and output \(j\)’s row of \(C\)/\(D\) — then scipy.signal.step on that
reduced system, exact (not approximate) since the system is linear and
the reduction is just picking out one input/output pair:
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:
network (Network)
result (PowerFlowResult)
- Return type:
- 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 (seeg2elin_core.modal.parameters).opis 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:
network (Network)
result (PowerFlowResult)
op (NetworkOperatingPoint | None)
- Return type:
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:
- 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) < 0test 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:
- 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, sodtheta/dt = wanddw/dt = 0along 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:
result (ModalAnalysisResult)
- Return type:
int | None
- g2elin_core.modal.analysis.analyze(A, state_names)[source]
Eigen-decompose
Aand compute the participation-factor matrix.Right eigenvectors
Vand left eigenvectorsWare computed by a singlescipy.linalg.eig(A, left=True, right=True)call so they come back consistently paired and ordered — computing them from two separateeig()calls (once onA, once onA.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 @ Vnormalization coefficients. They’re normalized soW @ V = I(modal_analysis.m’scoef = diag(W'*V)normalization), then the participation factor of stateiin modejis|V[i,j]| * |W[j,i]| / sum_k(|V[k,j]|*|W[j,k]|).- Parameters:
- Return type:
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
- 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])
- 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 modei(modal_analysis.mPart 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_ithe left/ right eigenvectors of modei. Masked to whereAis actually nonzero (modal_analysis.m’sAnum_ones) – a sensitivity value at a structurally-zeroAentry isn’t a “sensitivity to a real parameter,” it’s noise from an entry that was never a parameter to begin with.- Parameters:
modal (ModalAnalysisResult)
A (ndarray)
mode (int)
n_top (int)
- Return type:
- class g2elin_core.modal.toolbox.ModeShapeResult(mode: 'int', states: 'list[str]', angles_deg: 'list[float]')[source]
Bases:
object
- g2elin_core.modal.toolbox.mode_shape(modal, mode, n_top=5)[source]
Phase angle of the top-
n_topparticipating states’ right eigenvector components, for polar plotting (modal_analysis.mPart: “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:
modal (ModalAnalysisResult)
mode (int)
n_top (int)
- Return type:
- 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 @ x0the modal coordinates of the perturbationx0(a unit vector atperturb_indexscaled byoffset). Returnsx(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:
modal (ModalAnalysisResult)
perturb_index (int)
offset (float)
t (ndarray)
- Return type:
- 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 ofBand row ofC/Dthe chosen input/output correspond to, scale the unit-step response byamplitudeafterward (linear system, so this is exact, not an approximation).