Skip to content

Layer 4 V1 — Trapezoidal companion (Capacitors + Inductors)

Layers 0-5 V0 could simulate piecewise-linear static circuits (R + V + Switch). Layer 4 V1 + Layer 5 V1 extend the kernel with Capacitor + Inductor support via the textbook trapezoidal companion model. The result: any linear PE circuit can now run end-to-end through run_transient.

This document covers the companion-model math, how the dt-aware cache + history-state plumbing fit together, and the V0 limitations to be aware of (notably: all-zero initial conditions + trap-rule boundary artifact).

The companion-model math

The trapezoidal rule turns a dynamic ODE step into a static equation that the existing PWL cache can solve. For a 2-terminal device with constitutive relation f(v, i, dv/dt, di/dt) = 0, the trap rule approximates the integral ∫f dt ≈ (dt/2)·(f_n + f_{n+1}).

Capacitor

i_C = C · dv_C/dt → trap →

i_{n+1} = G_eq · v_{n+1} − I_hist
G_eq   = 2C / dt
I_hist = G_eq · v_n + i_n

So at step n+1, the cap behaves as a Norton equivalent: - A linear conductance G_eq (positive into v_{n+1}) - A history current source I_hist (parallel)

G_eq goes into the MNA matrix at assembly time (identical to a resistor with G = G_eq). I_hist depends on the previous- step state — Layer 5's HistoryState tracks it and contributes to b_extra at solve time.

Inductor

v_L = L · di_L/dt → trap →

i_{n+1} = G_L,eq · v_{n+1} + I_hist,L
G_L,eq   = dt / (2L)
I_hist,L = i_n + G_L,eq · v_n

V2 uses the branch-current formulation: i_L is an explicit MNA unknown (like a voltage source's branch current), with the constraint row (scaled by 2L/dt to keep the +1 / -1 voltage- column pattern):

v_{n+1, from} − v_{n+1, to} − (2L/dt) · i_{n+1} + (2L/dt) · I_hist,L = 0

-(2L/dt) stamps on the branch_var_id diagonal; +(2L/dt) · I_hist,L goes into b_extra.

Why trapezoidal, not backwards Euler

  • Second-order accurate vs BE's first-order. PWM circuits with µs dt benefit.
  • Energy-preserving for ideal LC tanks — oscillations don't decay due to numerical dissipation. BE has 1st-order damping.
  • SPICE / PLECS default — proven on millions of circuits.

Gear-2 / BDF can be added later if a workload demands more damping. Out of scope for this OpenSpec.

Cache dt-dependency

V0's cache was dt-independent. V1's MNA matrix contains G_eq = 2C/dt and −(2L/dt) terms, so the factor is dt- specific:

cache.build(dt);   // factor for this dt only
cache.solve(mask, b_extra, x);
// ... change dt? ...
cache.build(other_dt);   // rebuilds all segments

Calling build() (no argument) keeps the V0 behaviour: dt = 0 sentinel, dynamic devices SKIPPED. Useful for static-only circuits and as the backwards-compat path.

For a 1 µF capacitor at dt = 1 µs, G_eq = 2. At dt = 100 ns, G_eq = 20. At dt = 10 ns, G_eq = 200. The matrix gets stiffer at small dt — sparse LU handles it, but watch for near-singularity if dt is wildly mismatched to the time constants.

History-state plumbing

pulsim::pwl::HistoryState owns one entry per dynamic device:

struct HistoryEntry {
    Index branch_id;
    Index from, to;
    DevicePool::StoredKind kind;
    Index inductor_branch_var_id;   // valid only for inductors
    Real  v_prev = 0;
    Real  i_prev = 0;
    Real  C_or_L;                   // farads or henries
};

Layer 5 V1's run_transient calls it twice per step:

b_extra = history.compute_b_extra(dt);   // before solve
cache.solve(mask, b_extra, x);
history.update_from_state(x, dt);        // after solve

compute_b_extra builds a state-size vector with the trap companion's contributions: - Cap (between from and to): b_extra(from) -= I_hist, b_extra(to) += I_hist - Inductor (at branch_var_id): b_extra(branch_var_id) += (2L/dt) · I_hist,L

update_from_state reads (v_new, i_new) for each entry: - Cap: v_new = v_from - v_to (from x); i_new = G_eq · (v_new - v_prev) - i_prev (derived from companion). - Inductor: v_new = v_from - v_to; i_new = x[branch_var_id] (direct unknown).

The run_transient V1 loop

SimulationResult run_transient(
    const pwl::PwlStateSpaceCache& cache,
    const topology::Graph& graph,
    const pwl::DevicePool& pool,
    const SimulationOptions& opts,
    const SwitchScheduleFn& switch_fn,
    const BExtraFn& b_extra_fn = {});

The signature changes from V0 (which took state_size directly) to V1 (graph + pool, state_size derived). The V0 signature is preserved for backwards compatibility — it skips HistoryState entirely.

Loop body (dynamic path):

// Record IC as sample 0
result.push((t_start, x = 0))

for k = 1 .. N - 1:
    t = t_start + k · dt
    b_extra = history.compute_b_extra(dt)
    if (b_extra_fn) b_extra += b_extra_fn(t)
    cache.solve(switch_fn(t), b_extra, x)
    history.update_from_state(x, dt)
    result.push((t, x))

cache.dt() != opts.dt throws std::invalid_argument — a mismatched cache would silently drift, so we catch it up-front.

For static-only circuits (cache.dt() == 0), the loop takes a simpler path identical to V0 — each sample is an independent solve, no history bookkeeping.

Worked example: RC charging

V_dc(5V) ──[Source]── n0 ──[R=1Ω]── n1 ──[C=1µF]── GND
                                     ^
                                     v_C(t) here

Analytical: V_C(t) = V_dc · (1 − e^{−t/τ}), τ = RC = 1 µs.

Graph g;
auto n0 = g.add_node("n0");
auto n1 = g.add_node("n1");
g.add_branch(n0, g.ground(), BranchKind::Source);
g.add_branch(n0, n1,         BranchKind::PassiveLinear);
g.add_branch(n1, g.ground(), BranchKind::PassiveLinear);

DevicePool pool;
pool.add_voltage_source(0, {.V = 5.0});
pool.add_resistor(1, {.G = 1.0});       // G = 1/R
pool.add_capacitor(2, {.C = 1e-6});

PwlStateSpaceCache cache(g, pool);
cache.build(/*dt=*/1e-8);                // 10 ns step

SimulationOptions opts{
    .t_start = 0,
    .t_end   = 5e-6,                      // 5τ
    .dt      = 1e-8,
};

auto result = run_transient(
    cache, g, pool, opts,
    [](Real){ return SwitchStateMask(0); });

// result.states[k][n1] tracks 1 − e^{−t_k/τ} to within < 1 %
// for k > N/4 (after the IC artifact decays).

V0 limitations

Inconsistent initial conditions

V0 starts with v_C(0) = 0 and i_L(0) = 0 for every dynamic device. That's only one piece of the puzzle — the trap rule ALSO needs i_C(0) (which is determined by the rest of the circuit at t = 0+) and v_L(0) (= V_source − v_C(0) − ...).

For the RC charge from rest: - v_C(0) = 0 ✓ (matches V0 convention) - i_C(0+) = V_dc/R = 5 A ≠ 0 ← V0 forces this to 0

This trap-rule boundary artifact decays as λ_trap^n ≈ (1 - dt/(2RC))^n per step. For dt = τ/100, the artifact is < 1 % of the analytical value after roughly N/4 simulation steps. Our integration tests skip the first N/4 samples to bypass this transient.

The proper fix is DC operating-point pre-charge — a follow-up OpenSpec (pulsim-v2-dc-operating-point).

No event detection

Switching is still externally scheduled via switch_fn(t). Auto zero-crossing detection (for diode commutation, etc.) is a Layer 5 V1.5 follow-up.

No adaptive dt

The cache is dt-specific. Changing dt rebuilds every segment — expensive. Adaptive dt with LTE estimation needs lazy per-segment rebuild or rank-1 updates. Future Layer 5 V2.

No nonlinear devices

BranchKind::Nonlinear branches are still SKIPPED in assembly. Newton iteration on top of the cached factor lands in pulsim-v2-nonlinear-segment-newton.

Validation

pulsim_v2_layer4_v1_tests: 76 assertions / 32 cases. - Capacitor / Inductor math (16 cases) - DevicePool with caps + L (9 cases) - stamp_companion helpers (5 cases) - dt-aware cache (5 cases) - V0 regression coverage (1 case)

pulsim_v2_layer5_v1_tests: 51 assertions / 15 cases. - HistoryState basic API (5 cases) - run_transient V1 wiring (3 cases) - RC charging (2 cases) - RL ramping (2 cases) - RLC ringdown (3 cases)

Total v2 surface after Layer 4/5 V1: 2614 assertions / 199 test cases.

What V1+ delivers

This OpenSpec unlocks dynamic circuits at the kernel level. A buck converter, a boost converter, an LLC resonant tank — all now runnable in v2 with PLECS-style per-step performance (one hash probe + one triangular solve, no Newton, no factorise).

Next on the roadmap: - DC operating-point pre-charge — fixes the IC artifact outright (pulsim-v2-dc-operating-point). - Event detection — auto zero-crossing on watch signals (pulsim-v2-event-detection). - Newton for nonlinear devices — per-segment iteration on top of the cached factor (pulsim-v2-nonlinear-segment-newton). - Adaptive dt + LTE estimation — needs cache invalidation on dt changes (pulsim-v2-adaptive-dt).