Gotchas¶
Most pulsim simulations Just Work. The handful that don't almost always fail for one of the reasons below. This page is the field-debugging guide.
Newton convergence¶
Symptom: "Newton iteration did not converge"¶
The transient throws std::runtime_error partway through. Common causes:
(a) Instantaneous gate edges on an inductive load¶
A PulseVoltageSource with rise_time = fall_time = 0 driving a MOSFET/IGBT gate, with an inductor in the drain/collector path, will diverge within a few PWM cycles. The trapezoidal companion model for L can't follow a discontinuous current command.
Fix: add realistic ramps:
b.add_pulse_voltage_source(
"Vg", "g", "gnd",
v_pulsed=15.0,
rise_time=100e-9, fall_time=100e-9, # ← critical
pulse_width=..., period=...,
)
100 ns is realistic for a real gate driver and gives Newton ~5 iterations to follow each edge smoothly at dt = 20 ns.
(b) MOSFET without a body diode¶
When the high-side MOSFET opens, the inductor needs a freewheel path. Without one, the MNA matrix briefly becomes structurally singular.
Fix: use add_mosfet_level1(..., with_body_diode=True) (proposal #3.1) or add an explicit anti-parallel add_diode("Body", source, drain, ...).
(c) Cold start at x = 0¶
Some op-amp / feedback circuits have a non-trivial DC operating point that x = 0 is far from. Newton can wander before converging.
Fix: seed from the DC OP:
res = p.simulate(b, ..., start_from_dc_op=True)
This calls compute_dc_op(graph, pool, mask, opts.t_start) first and feeds the result into HistoryState.
(d) Sharp sigmoid (kappa too high)¶
MOSFETs / IGBTs use sigmoid blending between regions; kappa controls how sharp. A kappa = 50 is more accurate at the boundary but makes the Jacobian condition number worse.
Fix: dial kappa down to 10–15.
Symptom: residual oscillates but doesn't shrink¶
You hit a Newton "limit cycle" — the iterate ping-pongs between two faces of a non-smooth feature.
Fix: the trust-region Newton (V5) and pseudo-transient continuation (V10) are designed for this. Set opts.max_newton_iterations = 50 and let the solver escalate strategies internally.
Cache size + build time¶
Symptom: cache.build(dt) takes forever¶
The cache enumerates all 2^N reachable switch combinations. With N = 8 switches that's 256 entries; with N = 12 it's 4096. Each entry stamps the MNA matrix + KLU-factorizes it.
Fix: lazy-build (Layer 4 V6) is the default — only states reached by switch_fn are factored. If yours isn't lazy, check that you haven't pinned all bits via a custom switch driver. For pathological topologies with very many switches, consider partitioning into sub-circuits.
Symptom: KLU singular-matrix error¶
Cache build fails with compute_dc_op: DC matrix structurally singular for mask ....
This usually means:
- A node is dangling (no connection to ground).
- All sources are current sources and the topology has no DC path to ground.
- A nonlinear branch (BranchKind::Nonlinear) has no diagonal contribution. Pulsim already adds a 1e-12 G_min for SaturableInductor; for other custom devices you may need to add your own.
Fix: check the topology with a small dump (pool.state_size(graph) should equal num_active_nodes + num_sources + num_inductors). Add a 1 µΩ resistor between any floating node and ground.
Time-step (dt) choices¶
Symptom: ringing or instability with reasonable circuits¶
If dt is too large compared to the system's smallest time constant, the trapezoidal companion can ring (overshoot a true exponential by 10-20 %).
Heuristic: use dt ≤ min(τ) / 10 where τ are the RC and L/R time constants. For switching circuits, also enforce dt ≤ T_PWM / 200 (catch the ON/OFF edge within 1 sample even when the PWM frequency drifts).
Symptom: wall-clock time too long¶
run_transient is linear in N_steps. If a 100 µs simulation at dt = 1 ns takes too long, you have 100,000 steps — even at 10 µs per step, that's a second.
Fix: profile to confirm where time is spent. The cache lookup is O(1); the trap solve is O(state_size) for a triangular back-sub. Newton refresh dominates for nonlinear circuits.
Builder / API pitfalls¶
Symptom: "branch_id N is not a Resistor"¶
You called the wrong typed accessor on DevicePool. The kind_of(b_id) casts the variant index directly to StoredKind.
Fix: always go through the typed accessor matching pool.kind_of(b_id), or use the variant directly.
Symptom: forgotten ground¶
Every isolated subnet must have a path to ground. Even galvanically-isolated transformer secondaries need a 1 µΩ resistor to primary ground (or use a BranchKind::Source like a 0 V source to tie the secondary somewhere).
Fix: add b.add_resistor("R_iso", "sec_gnd", "gnd", 1e-6). The current flowing through R_iso will be in the picoamps and doesn't perturb the result.
Symptom: switch_fn never fires the bit I expect¶
SwitchStateMask indexes by switch order in the graph, not by branch_id. The mapping is established by add_switch / add_diode insertion order.
Fix: inspect b.graph.num_switches and check the insertion sequence. The first switch added gets bit 0, the next gets bit 1, etc.
Python-specific pitfalls¶
Symptom: AttributeError: module 'pulsim' has no attribute 'simulate'¶
The installed pulsim wheel is stale relative to the source tree.
Fix: if you're developing locally, set PYTHONPATH=build/python:$PYTHONPATH or do pip install -e .. Confirm via print(pulsim.__file__) that you're using the development tree.
Symptom: NumPy-array vs. list confusion¶
SimulationResult.states[k] is a NumPy 1-D array; SimulationResult.times is a Python list of floats.
Fix: pass times through np.asarray(...) if you need to do vectorized arithmetic. For state, just index directly: res.states[k][node_id].
When to ask for help¶
If you hit something not listed here, the best signals are:
- Minimum repro YAML or 10-line Python snippet.
opts.max_newton_iterations = 50; print(res.commutations)— the event log shows which switch flipped when, often pointing at the root cause.cache.dt()value — confirms you didn't accidentally passdt = 0(static-only cache).
Open an issue with those three pieces of evidence and we can usually pinpoint the problem in one round-trip.