timedomain — EMT (nonlinear) time-domain simulation
Feature 2.2 from the migration plan. Reuses exactly the nonlinear
f/g/h callables built in components and the same
interconnection topology used for linear modal analysis
(interconnect) — the wiring between components
(KVL/KCL in the dq frame) is exact regardless of whether the components
themselves are linearized, so it doesn’t need to be re-derived. Only the
per-component math changes, from “substitute numbers into a Jacobian” to
“call a nonlinear function and Newton-solve the coupled algebraic system.”
On the name. This integrates the network’s actual nonlinear differential-algebraic equations forward in time — not a linearized small-signal approximation — which is the property that makes this “EMT simulation” for this project’s purposes. It’s a dq-frame, averaged- converter, single-rotating-reference-frame formulation, so it doesn’t capture switching-level converter physics, abc-frame/unbalanced faults, or distributed/traveling-wave line effects.
Why a single global Newton solve, not one per component
Each component’s own algebraic variables z_i are governed by its own
g_i(x_i, z_i, u_i) = 0. But a component’s inputs u_i are wired to
other components’ outputs y_j = h_j(x_j, z_j, u_j) — and since h_j
can itself depend on u_j, the full input vector u and every
component’s z have to be solved together:
0 = g(x, z, u) — every component's own algebraic eqs
u = F @ u_exo + G @ h(x, z, u) — the interconnection, exact and linear
at every state x the ODE integrator asks for. There’s no way to solve
one component’s z_i in isolation and plug it into the next.
flowchart TD
XU["x (current ODE state), u_exo"] --> RES["_residual(z, u)<br/>= [ g(x,z,u) ; u - (F@u_exo + G@h(x,z,u)) ]"]
RES --> NEWTON["scipy.optimize.root(method='hybr')<br/>with analytic jac=_residual_jacobian"]
JAC["_residual_jacobian(z, u)<br/>assembled per-block from Gz/Gu/Hz/Hu<br/>+ interconnection's G matrix"] --> NEWTON
NEWTON --> SOL["(z, u) satisfying the coupled system"]
SOL --> RHS["rhs(): xdot = concat(f_i(x_i, z_i, u_i))"]
RHS --> IVP["scipy.integrate.solve_ivp<br/>(Radau — stiff, L-stable)"]
IVP -->|next step, warm-started z/u guess| XU
IVP --> RESULT["EmtSimulationResult<br/>t, x, state_names"]
The analytic Jacobian is the load-bearing optimization here. An
earlier version used SciPy’s numerically-estimated Jacobian:
method="hybr" converged fine exactly at the operating point but reliably
failed (“not making good progress”) for the nearby points an ODE
integrator’s own adaptive stepping evaluates; method="lm" was robust but
slow (~0.05s/solve, re-estimating its own Jacobian every iteration). The
analytic _residual_jacobian — assembled the same way the linear A/B/C/D
elimination combines per-component Jacobians with the interconnection’s
G matrix, just kept as an explicit matrix instead of solved in closed
form — let "hybr" come back both faster (~0.006s/solve, ~9x) and at
least as robust.
NonlinearBlockComp and NonlinearNetworkModel
classDiagram
class NonlinearBlockComp {
n_states n_z n_us n_ug n_out_s n_out_g : int
f(x,z,u) g(x,z,u) h(x,z,u)
Gz Gx Gu Hz Hx Hu : Callable
x0 z0 u0 : ndarray
}
class NonlinearNetworkModel {
blocks : list~Block~
topology : Topology
initial_state() ndarray
solve_algebraic(x, u_exo) tuple
rhs(x, u_exo) tuple
state_names : list~str~
}
NonlinearNetworkModel "1" *-- "many" NonlinearBlockComp : via Block.comp
NonlinearBlockComp satisfies the same PortSpec protocol
LinearComponent does (see interconnect), so it
slots into the exact same Block/Wiring/compute_topology() machinery —
NonlinearNetworkModel is the nonlinear sibling of AssembledSystem,
built by build_nonlinear_network() the same way pipeline.linearize_network()
builds the linear one, down to sharing the same “unsupported DER unit type”
and slack-must-be-SM-or-IB restrictions.
simulate() integrates the DAE with scipy.integrate.solve_ivp, defaulting
to Radau (implicit, L-stable) because the system’s stiffest modes reach
~1e6–1e7 rad/s. Each RHS evaluation warm-starts its Newton solve from the
previous step’s (z, u) solution (cache in simulate()), which is both
a speed optimization and — since the initial per-component operating-point
guess and the true coupled-network equilibrium differ by a small but real
amount (~0.08 for WSCC-9/CIGRE, from the node-susceptance MATLAB quirk) —
closer to the actual solution than the naive per-component guess would be.
solve_algebraic now retries with "lm" on an "hybr" failure,
found necessary (not preemptive) when wiring the SMIB presets in: SMIB’s
tighter 2-DER coupling produces an initial-guess offset of ~0.23 pu in the
reactive-current direction — large enough that "hybr" (even with the
analytic Jacobian) can’t reach the true equilibrium from that starting
point, though "lm" finds it to machine precision from the identical
guess, confirming a real equilibrium exists nearby and this is a basin-of-
convergence issue, not a modeling bug.
u_exo_fn is now actually used. The module docstring for simulate()
always described it — “lets a caller drive a disturbance (e.g. a P_ref
step)” — but nothing called it with a non-default function until the EMT
tab gained the ability to perturb an input (a permanent step in one
exogenous reference, held from t=0) as an alternative to perturbing a
state (an initial-condition offset). g2elin_api.main.run_emt builds a
closure returning the stepped default_u_exo() vector for every t, and
threads the same closure into recover_inputs_and_outputs() too — using
the unperturbed default there instead would silently contradict the
trajectory that was actually integrated.
Recovering inputs and outputs after the fact
solve_ivp only returns state trajectories — the algebraic variables
z/full inputs u it solves for internally live at the integrator’s own
adaptive step points, not necessarily the (possibly interpolated) sample
times it returns. NonlinearNetworkModel.recover_inputs_and_outputs(t, x)
re-solves the coupled algebraic system once per already-integrated sample
(warm-started from the previous one, same as during integration) to
recover a (z, u) consistent with that exact (t[i], x[:, i]), then
evaluates each block’s own named outputs h(x, z, u)[:n_out_s] there —
the same output_names AssembledSystem exposes for the linear model,
since both come from the same ComponentDAE.output_names. Costs about as
much again as the integration itself, so the EMT API endpoint only calls
it when the caller actually asked for input/output trajectories (see
g2elin_api and the web UI).
Physics: the coupled nonlinear DAE
Every component contributes its own nonlinear differential-algebraic
system — literally the same diffeqVec/algeqVec/outputeqVec
components documents, evaluated as functions instead
of linearized:
Stacked over every block and coupled through the exact same interconnection selector matrices interconnect builds (\(u = F\,u_{exo} + G\,y\), \(y=h(x,z,u)\) now nonlinear instead of \(Cx+Du\)), the full network’s algebraic state at a fixed \(x\) solves the residual
via Newton’s method, using the analytic residual Jacobian (not a numerically-estimated one — see below for why that distinction matters)
assembled per-block from each component’s own \(G_z, G_u, H_z, H_u\)
Jacobians (evaluated at the current Newton iterate, not once at a fixed
operating point — the crucial difference from components’s linearization, which only ever needs one such evaluation).
Once \((z,u)\) solves \(R=0\) at the current \(x\), the state derivative
\(\dot x = f(x,z,u)\) is the right-hand side scipy.integrate.solve_ivp
(implicit Radau) integrates forward — one full Newton solve per RHS
evaluation the ODE integrator requests, warm-started from the previous
step’s \((z,u)\).
Choosing a solver
The integration settings live on the request (EmtRequest), not the
network: they are about how a trajectory is computed, not about what is
being modelled. GET /api/solvers serves the catalogue the web UI’s picker
is built from.
The Jacobian, first
Radau, BDF and LSODA are implicit, so each needs d(xdot)/dx. Given none,
SciPy finite-differences it — one full coupled Newton solve per state,
and SciPy does not count those in sol.nfev, so the cost is invisible in
the diagnostics. Measured on WSCC-9 at full order: 257 real RHS evaluations
against a reported nfev of 81, so 68% of the work was building a matrix.
ode_jacobian()
assembles it instead. Differentiating the constraints along the trajectory
gives a linear system whose matrix is the same Newton Jacobian the
algebraic solve already factorises, so it costs one sparse factorisation
plus n_x back-substitutions. It is passed automatically to every implicit
method; there is no setting for it.
What that changed, measured:
Case |
Before |
After |
|---|---|---|
WSCC-9, full order, Radau |
650 s (320,714 RHS calls) |
0.84 s (141 calls) |
IEEE-39, full order |
456 s |
267 s |
IEEE-118, full order |
did not finish in 30 min |
189 s |
The first row is the pathology in miniature: without a usable Jacobian, Radau’s inner Newton fails, it rejects the step, shrinks it, rebuilds the Jacobian, and spirals.
Method
Method |
When |
|---|---|
|
the default. L-stable, copes with the full model’s 1e7 rad/s modes. |
|
usually the fastest. Measured 1.4–5× quicker than Radau on the same run. Less robust across a sharp event. |
|
detects stiffness and switches. A reasonable “don’t know” choice. |
|
explicit, no Jacobian at all — but they must resolve every fast mode. |
Warning
The explicit methods are offered for completeness and were slower in every case measured. On the full EMT model they diverge outright; even on a quasi-stationary model whose fastest mode is ~6 krad/s, RK45 took 952 steps and 61 s where BDF took 39 steps and 1.5 s. Model-order reduction removes several orders of stiffness but not enough to make an explicit method competitive.
Tolerance
rtol is the largest single lever after the method. Loosening it from 1e-4
to 1e-3 roughly halved one run’s time — and moved the peak of the signal by
7%, which is the trade in one sentence. 1e-3 for screening, 1e-6 for a
number that goes in a paper.
max_step is best left unset. It bounds the solver’s step, which only
forces steps it did not need; the output spacing is a separate setting.
Fixed stepping
simulate_fixed_step() integrates on a step
you choose, with the trapezoidal rule — how an EMT program works. It gives
up error control and gets back a cost you can predict before starting, output
exactly on the grid you asked for with no interpolation, and a result that
does not move when a tolerance is nudged.
It is not usually faster: on a smooth trajectory a variable-step solver takes big steps where nothing happens and wins easily (measured: 5.2 s at a 2 ms fixed step against 2.8 s for BDF over the same run). Reach for it when you want reproducibility, a direct comparison against PSCAD or EMTP, or a run whose duration you can promise.
Trapezoidal is A-stable, so too large a step cannot make it blow up — but it can make it ring, the numerical oscillation EMT programs damp deliberately. A trace oscillating at exactly two samples per cycle is that, and the fix is a smaller step, not a smaller tolerance.
Reference
EMT (nonlinear) time-domain simulation — feature 2.2/2.3 from the migration plan.
This is a dq-frame, averaged-converter, single-rotating-reference-frame nonlinear time-domain model — not a switching-level, three-phase/abc, distributed-network model. Whether that counts as “EMT” is a matter of definition: it integrates the actual nonlinear differential-algebraic equations (not a linearized small-signal approximation), which is the property that matters for this project’s purposes, so it’s named and scoped as the EMT simulation feature. It doesn’t capture switching-level converter physics, abc-frame/unbalanced faults, or distributed/ traveling-wave line effects.
Reuses exactly the nonlinear f/g/h callables built for
cross-validation (g2elin_core.components.base) and the same
interconnection topology used for linear modal analysis
(g2elin_core.interconnect) — the wiring between components (KVL/KCL
in the dq frame) is exact regardless of whether the components themselves
are linearized, so it doesn’t need to be re-derived; only the per-component
math changes from “substitute numbers into a Jacobian” to “call a nonlinear
function and Newton-solve the coupled algebraic system”.
What’s coupled and why a single global Newton solve is needed. Each
component’s own algebraic variables z_i are governed by its own
g_i(x_i, z_i, u_i) = 0. But a component’s inputs u_i are wired to
other components’ outputs y_j = h_j(x_j, z_j, u_j) (the same linear
F/G selector matrices interconnect.compute_topology builds) —
and since h_j can itself depend on u_j, the full input vector u
and every component’s z have to be solved together:
0 = g(x, z, u) (every component’s own algebraic eqs) u = F @ u_exo + G @ h(x, z, u) (the interconnection, exact and linear)
at every state x the ODE integrator asks for — there’s no way to solve
one component’s z_i in isolation first and plug it into the next.
- class g2elin_core.timedomain.emt.NonlinearBlockComp(n_states, n_z, n_us, n_ug, n_out_s, n_out_g, f, g, h, Gz, Gx, Gu, Hz, Hx, Hu, Fx, Fz, Fu, state_names, input_names, output_names, x0, z0, u0)[source]
Bases:
objectA component instance’s nonlinear model, bound to its own fixed parameters — satisfies
PortSpecso it slots into the sameBlock/Wiring/topology machineryg2elin_core.interconnectuses for linear assembly.
- g2elin_core.timedomain.emt.nonlinear_sm_block(op, modes=None)[source]
- Parameters:
op (SmOperatingPoint)
- Return type:
- g2elin_core.timedomain.emt.nonlinear_gfm_block(op, modes=None)[source]
- Parameters:
op (GfmOperatingPoint)
- Return type:
- g2elin_core.timedomain.emt.nonlinear_gfl_block(op, modes=None)[source]
- Parameters:
op (GflOperatingPoint)
- Return type:
- class g2elin_core.timedomain.emt.NonlinearNetworkModel(blocks, topology, z_offsets, n_z, network=None, theta_g0=0.0, op=None, zu_guess=None, x_init=None, _sparsity_plan=None)[source]
Bases:
objectThe nonlinear counterpart of
AssembledSystem: sameBlock``s and ``Topology(built by the samebuild_blocks_and_wiring()+compute_topology()), but each block’scompis aNonlinearBlockCompinstead of aLinearComponent— so solving requires the coupled Newton step this class’ssolve_algebraic()implements, described in the module docstring.- Parameters:
- property input_names: list[str]
Each block’s own named (“us”) inputs – e.g. an SM’s own P_ref, not the anonymous interconnection (“ug”) port signals wired between blocks (see
interconnect.assemble’s_UG_PORTS, which have no names of their own outside that internal wiring).
- property output_names: list[str]
Each block’s own named (“s”) outputs – e.g. an SM’s
p_e/q_e/w_r/theta/dw_r_dot/V_t– the same setAssembledSystem’soutput_namesexposes for the linear model, since both come from the sameComponentDAE.output_names.
- output_rates(x_all, z_all, u_all)[source]
dy/dtfor every block output, at an already-solved(x, z, u).Differentiating the same two relations
solve_algebraic()solves, along the trajectory, gives a linear system in(zdot, udot)with the Newton Jacobian itself as its matrix:Gz zdot + Gu udot = -Gx xdot udot - G (Hz zdot + Hu udot) = G Hx xdot
so one extra sparse solve with a single right-hand side yields exact output derivatives, rather than differencing the trajectory.
This is what a quasi-stationary bus’s frequency is computed from: once a node stops integrating
C dv/dt, its voltage has no derivative of its own, but it still has an exact one through the states that drive it (seetimedomain/measurements.py).Exogenous inputs are taken as momentarily constant (
u_exo_dot = 0), which is true except exactly at a step, where a derivative isn’t defined anyway.
- NEWTON_TOL = 1e-10
- NEWTON_ACCEPT_TOL = 1e-08
- ode_jacobian(x_all, u_exo, z_guess=None, u_guess=None)[source]
d(xdot)/dx— the Jacobian an implicit ODE solver needs.Why this exists. Radau and BDF are implicit, so they need this matrix; given no
jac, SciPy finite-differences it, which costs one full coupled Newton solve per state. That is invisible insol.nfev(SciPy doesn’t count it) and dominated everything else: measured on WSCC-9, 68% of all RHS evaluations in a run were spent differencing this matrix, and on a 118-bus model it is over 800 Newton solves for every single Jacobian.Assembled instead, exactly. Differentiating the constraints
g(x, z, u) = 0andu = F u_exo + G h(x, z, u)with respect toxgives a linear system in(dz/dx, du/dx)whose matrix is the same Newton Jacobian the algebraic solve already factorises:J @ [dz/dx; du/dx] = [-Gx; G Hx]
so this is one sparse factorisation plus
n_xback-substitutions – microseconds each – and thendxdot/dx = Fx + Fz (dz/dx) + Fu (du/dx).
- solve_algebraic(x_all, u_exo, z_guess=None, u_guess=None)[source]
Newton-solves the coupled
g(x, z, u) = 0/ interconnection system for(z, u)at a fixed statexand exogenous input, using the analytic Jacobian from_residual_jacobian.Uses
method="hybr"(Powell hybrid, MINPACK’shybrjwhen a Jacobian is supplied) first — fast, and converges from the naive per-component guess for every network validated so far (WSCC-9, CIGRE). An earlier version of this method used SciPy’s numerically-estimated Jacobian, and with it"hybr"converged fine exactly at the operating point but reliably failed (“not making good progress”) for the nearby points an ODE integrator’s own step-size/collocation probing evaluates; with the analytic Jacobian that mostly went away.“Mostly” is doing real work in that sentence: SMIB’s initial guess (found when wiring the infinite bus in) sits ~0.23 pu away from the true coupled equilibrium in the reactive-current direction — WSCC’s equivalent offset is ~0.08 pu and
hybrhandles it; SMIB’s is outsidehybr’s basin of convergence from that starting point, even though a true equilibrium demonstrably exists nearby (lmfinds it to machine precision from the identical guess). Rather than tune per-network tolerances, this retries with Levenberg-Marquardt ("lm", also given the analytic Jacobian) on anhybrfailure — slower (estimates its own step directions instead of taking Newton steps directly) but has not yet failed wherehybralso failed, on any network tried.
- rhs(x_all, u_exo, z_guess=None, u_guess=None)[source]
One nonlinear DAE evaluation: solves the algebraic system, then returns
(xdot, z, u)— the latter two so callers can warm-start the next call’s Newton solve from this one’s solution.
- recover_signals(t, x, u_exo_fn=None, measure=None)[source]
Like
recover_inputs_and_outputs(), plus, whenmeasureis given (measure(x, z, u) -> {name: value}, seeg2elin_core.timedomain.measurements), those measurements at each sample – from the same per-sample algebraic solve, so they cost nothing extra when inputs/outputs are recovered anyway.
- recover_inputs_and_outputs(t, x, u_exo_fn=None)[source]
Named input/output trajectories at each already-integrated sample
(t[i], x[:, i])– e.g. fromsimulate()’ssol.t/sol.x.solve_ivponly returns state trajectories; the algebraic variablesz/full inputsuit solves for internally are evaluated at the integrator’s own adaptive step points, not necessarily at the returned (possibly interpolated) sample times. So this re-solves the coupled algebraic system once per returned sample (warm-started from the previous one, same as during integration) to recover a(z, u)consistent with that exact(t[i], x[:, i]), then evaluates each block’s own named outputsh(x, z, u)[:n_out_s]there. Costs about as much as the integration itself (one more Newton solve per sample) – bounded by the samet_final/timestep limits assimulate(), not unbounded.
- g2elin_core.timedomain.emt.build_nonlinear_network(network, result)[source]
The nonlinear counterpart of
pipeline.linearize_network— same operating point, same topology, nonlinear component models instead of linearized ones. Same DER-type/slack-type restrictions apply (seeinterconnect/network_assembly.py).- Parameters:
network (Network)
result (PowerFlowResult)
- Return type:
- class g2elin_core.timedomain.emt.EmtSimulationResult(t: 'Vec', x: 'Vec', state_names: 'list[str]', scipy_result: 'object')[source]
Bases:
object
- g2elin_core.timedomain.emt.find_state_index(model, name_contains)[source]
Locates a state by a (unique) substring of its name, e.g.
"dw_r_{SM_2}"or just"SM_2}"if that’s unambiguous. Raises if zero or more than one state matches, rather than silently guessing.- Parameters:
model (NonlinearNetworkModel)
name_contains (str)
- Return type:
- g2elin_core.timedomain.emt.simulate(model, t_span, *, x0=None, u_exo_fn=None, t_eval=None, method='Radau', rtol=1e-06, atol=1e-08, first_step=None, max_step=inf)[source]
Integrates the nonlinear DAE from
x0(defaults to the model’s own operating point — pass an explicitx0to start from a perturbed state).u_exo_fn(t)lets a caller drive a disturbance (e.g. a P_ref step) by returning a modified exogenous-input vector at each t; defaults to holding it fixed at the operating point.At full order the stiffest modes reach ~1e6-1e7 rad/s (see the “SM operating point” README note on the Rg penalty parameter), so this defaults to
Radau(implicit, L-stable). Once model-order reduction has removed those modes (g2elin_core.reduction) an explicit method can be far quicker – seeSOLVERS.An implicit method is given the analytic
ode_jacobian(). Without it SciPy finite-differences the Jacobian at one full coupled Newton solve per state, which measured as 68% of all the work in a run.
- g2elin_core.timedomain.emt.simulate_fixed_step(model, t_span, step, *, x0=None, u_exo_fn=None, newton_tol=1e-09, max_newton=20)[source]
Integrate on a fixed time step with the trapezoidal rule.
This is how an EMT program integrates: a step you choose, held for the whole run, rather than one the solver picks. What you give up is error control – nothing adapts if the trajectory turns sharply. What you get back is a run whose cost you know before starting it (steps x the cost of one step, no rejected steps, no surprises), output exactly at the points you asked for, and a result that doesn’t move when a tolerance is nudged.
Trapezoidal is A-stable, so a step far larger than the fastest time constant cannot make it blow up – but it can make it ring: the classic numerical oscillation that EMT programs damp deliberately. If a trace oscillates at exactly two samples per cycle, that is this, and the answer is a smaller step, not a smaller tolerance.
Each step solves
x_{n+1} = x_n + h/2 (f_n + f_{n+1})by Newton, using the same analyticode_jacobian()the variable-step solvers get.
- class g2elin_core.timedomain.emt.EmtStep(t, x, inputs, outputs, z=None, u=None)[source]
Bases:
objectOne accepted solver step from
simulate_steps()– the state at that step, plus this step’s own named inputs/outputs read off the same cached algebraic solution the step itself already computed (not a freshNonlinearNetworkModel.recover_inputs_and_outputs()re-solve – see that function’s docstring for why that one needs to re-solve and this one doesn’t).- Parameters:
- g2elin_core.timedomain.emt.simulate_steps(model, t_span, *, x0=None, u_exo_fn=None, method='Radau', rtol=1e-06, atol=1e-08, first_step=None, max_step=inf)[source]
The step-by-step sibling of
simulate(): yields oneEmtStepafter every individual solver step, instead of returning the complete trajectory at once – for a caller that wants to observe the integration as it happens (a live-tracing UI, a progress indicator) rather than wait for the whole thing. Same warm-started coupled-algebraic-solve patternsimulate()uses internally, driven through scipy’s low-level OOP stepper class (the same onemethod’s string namessolve_ivpaccepts, viagetattr(scipy.integrate, method)) instead of the one-shotsolve_ivp()convenience wrapper, which has no way to yield control back between steps – it runs straight through to completion.Getting inputs/outputs “for free” at each step (no extra Newton solve, unlike
NonlinearNetworkModel.recover_inputs_and_outputs()) relies on evaluating them fromcache["z"]/cache["u"]right after eachstepper.step()call – valid because everyOdeSolversubclass (Radau included) evaluates the RHS at its own just-accepted solution point as part of normal stepping (needed for the next step’s error estimate), so the cache is already consistent withstepper.t/stepper.yto within the samertolthe whole integration uses, not a separate approximation.max_stepcaps the solver’s own adaptive step size but does not force a literal fixed step – there is no such mode for an implicit adaptive method. It also does not reliably control total runtime for a stiff system: capping it from unbounded down to 0.03s changed a representative stiff trajectory’s total step count by under 1% (2036 vs 2025 steps) while nearly doubling wall-clock time (more, smaller steps). Treat it as a redraw-granularity knob for a caller watching the steps arrive, not a speed control.