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:

\[ \dot x_i = f_i(x_i, z_i, u_i) \qquad 0 = g_i(x_i, z_i, u_i) \qquad y_i = h_i(x_i, z_i, u_i) \]

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

\[\begin{split} R(z,u) = \begin{bmatrix} g(x,z,u) \\[2pt] u - \big(F\,u_{exo} + G\,h(x,z,u)\big) \end{bmatrix} = 0 \end{split}\]

via Newton’s method, using the analytic residual Jacobian (not a numerically-estimated one — see below for why that distinction matters)

\[\begin{split} \frac{\partial R}{\partial(z,u)} = \begin{bmatrix} G_z & G_u \\[2pt] -G\,H_z & I - G\,H_u \end{bmatrix} \end{split}\]

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

Radau

the default. L-stable, copes with the full model’s 1e7 rad/s modes.

BDF

usually the fastest. Measured 1.4–5× quicker than Radau on the same run. Less robust across a sharp event.

LSODA

detects stiffness and switches. A reasonable “don’t know” choice.

RK45, DOP853

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: object

A component instance’s nonlinear model, bound to its own fixed parameters — satisfies PortSpec so it slots into the same Block/Wiring/topology machinery g2elin_core.interconnect uses for linear assembly.

Parameters:
n_states: int
n_z: int
n_us: int
n_ug: int
n_out_s: int
n_out_g: int
f: Callable[[ndarray, ndarray, ndarray], ndarray]
g: Callable[[ndarray, ndarray, ndarray], ndarray]
h: Callable[[ndarray, ndarray, ndarray], ndarray]
Gz: Callable[[ndarray, ndarray, ndarray], ndarray]
Gx: Callable[[ndarray, ndarray, ndarray], ndarray]
Gu: Callable[[ndarray, ndarray, ndarray], ndarray]
Hz: Callable[[ndarray, ndarray, ndarray], ndarray]
Hx: Callable[[ndarray, ndarray, ndarray], ndarray]
Hu: Callable[[ndarray, ndarray, ndarray], ndarray]
Fx: Callable[[ndarray, ndarray, ndarray], ndarray]
Fz: Callable[[ndarray, ndarray, ndarray], ndarray]
Fu: Callable[[ndarray, ndarray, ndarray], ndarray]
state_names: list[str]
input_names: list[str]
output_names: list[str]
x0: ndarray
z0: ndarray
u0: ndarray
g2elin_core.timedomain.emt.nonlinear_sm_block(op, modes=None)[source]
Parameters:

op (SmOperatingPoint)

Return type:

NonlinearBlockComp

g2elin_core.timedomain.emt.nonlinear_gfm_block(op, modes=None)[source]
Parameters:

op (GfmOperatingPoint)

Return type:

NonlinearBlockComp

g2elin_core.timedomain.emt.nonlinear_gfl_block(op, modes=None)[source]
Parameters:

op (GflOperatingPoint)

Return type:

NonlinearBlockComp

g2elin_core.timedomain.emt.nonlinear_ib_block(**kwargs)[source]
Return type:

NonlinearBlockComp

g2elin_core.timedomain.emt.nonlinear_frame_block(**kwargs)[source]
Return type:

NonlinearBlockComp

g2elin_core.timedomain.emt.nonlinear_line_block(**kwargs)[source]
Return type:

NonlinearBlockComp

g2elin_core.timedomain.emt.nonlinear_node_block(**kwargs)[source]
Return type:

NonlinearBlockComp

g2elin_core.timedomain.emt.nonlinear_load_block(**kwargs)[source]
Return type:

NonlinearBlockComp

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: object

The nonlinear counterpart of AssembledSystem: same Block``s and ``Topology (built by the same build_blocks_and_wiring() + compute_topology()), but each block’s comp is a NonlinearBlockComp instead of a LinearComponent — so solving requires the coupled Newton step this class’s solve_algebraic() implements, described in the module docstring.

Parameters:
blocks: list[Block]
topology: object
z_offsets: list[int]
n_z: int
network: object = None
theta_g0: float = 0.0
op: object = None
zu_guess: tuple | None = None
x_init: ndarray | None = None
property state_names: list[str]
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 set AssembledSystem’s output_names exposes for the linear model, since both come from the same ComponentDAE.output_names.

initial_state()[source]
Return type:

ndarray

initial_algebraic_guess()[source]
Return type:

tuple[ndarray, ndarray]

default_u_exo()[source]
Return type:

ndarray

output_rates(x_all, z_all, u_all)[source]

dy/dt for 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 (see timedomain/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.

Parameters:
Return type:

ndarray

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 in sol.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) = 0 and u = F u_exo + G h(x, z, u) with respect to x gives 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_x back-substitutions – microseconds each – and then

dxdot/dx = Fx + Fz (dz/dx) + Fu (du/dx).

Parameters:
Return type:

ndarray

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 state x and exogenous input, using the analytic Jacobian from _residual_jacobian.

Uses method="hybr" (Powell hybrid, MINPACK’s hybrj when 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 hybr handles it; SMIB’s is outside hybr’s basin of convergence from that starting point, even though a true equilibrium demonstrably exists nearby (lm finds 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 an hybr failure — slower (estimates its own step directions instead of taking Newton steps directly) but has not yet failed where hybr also failed, on any network tried.

Parameters:
Return type:

tuple[ndarray, ndarray]

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.

Parameters:
Return type:

tuple[ndarray, ndarray, ndarray]

recover_signals(t, x, u_exo_fn=None, measure=None)[source]

Like recover_inputs_and_outputs(), plus, when measure is given (measure(x, z, u) -> {name: value}, see g2elin_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.

Parameters:
Return type:

tuple[dict[str, ndarray], dict[str, ndarray], dict[str, ndarray]]

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. from simulate()’s sol.t/sol.x.

solve_ivp only returns state trajectories; the algebraic variables z/full inputs u it 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 outputs h(x, z, u)[:n_out_s] there. Costs about as much as the integration itself (one more Newton solve per sample) – bounded by the same t_final/timestep limits as simulate(), not unbounded.

Parameters:
Return type:

tuple[dict[str, ndarray], dict[str, ndarray]]

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 (see interconnect/network_assembly.py).

Parameters:
Return type:

NonlinearNetworkModel

class g2elin_core.timedomain.emt.EmtSimulationResult(t: 'Vec', x: 'Vec', state_names: 'list[str]', scipy_result: 'object')[source]

Bases: object

Parameters:
t: ndarray
x: ndarray
state_names: list[str]
scipy_result: 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:
Return type:

int

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 explicit x0 to 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 – see SOLVERS.

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.

Parameters:
Return type:

EmtSimulationResult

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 analytic ode_jacobian() the variable-step solvers get.

Parameters:
Return type:

EmtSimulationResult

class g2elin_core.timedomain.emt.EmtStep(t, x, inputs, outputs, z=None, u=None)[source]

Bases: object

One 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 fresh NonlinearNetworkModel.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:
t: float
x: ndarray
inputs: dict[str, float]
outputs: dict[str, float]
z: ndarray | None = None
u: ndarray | None = None
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 one EmtStep after 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 pattern simulate() uses internally, driven through scipy’s low-level OOP stepper class (the same one method’s string names solve_ivp accepts, via getattr(scipy.integrate, method)) instead of the one-shot solve_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 from cache["z"]/cache["u"] right after each stepper.step() call – valid because every OdeSolver subclass (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 with stepper.t/ stepper.y to within the same rtol the whole integration uses, not a separate approximation.

max_step caps 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.

Parameters:
Return type:

Iterator[EmtStep]