Skip to content

UX helpers — discover, plot, monitor

These are the helpers that turn a 20-line matplotlib + state-vector indexing dance into a one-line call. They live in python/pulsim/v2_discovery.py and python/pulsim/v2_plot.py and are re-exported from pulsim, so the import surface stays flat:

import pulsim as p

1. Discovery — never leave the Python REPL

p.catalog([category])

List every device, helper, and entry point grouped by category (passives, sources, switches, nonlinear actives, magnetics, controlled sources, PWM helpers, control blocks, AC analysis, top-level entry points). Pass a substring to filter:

>>> p.catalog()                  # everything
>>> p.catalog("sources")         # only the source category
>>> p.catalog("control blocks")  # only PIController etc.

Each entry has:

  • One-line description — what it is in a sentence
  • "when:" — when to reach for this vs. an alternative

p.example(name)

Print a runnable code snippet:

>>> p.example("buck")           # open-loop buck topology
>>> p.example("PIController")   # closed-loop PI pattern
>>> p.example("bode")           # AC sweep recipe
>>> p.example("tune")           # auto-tune from Bode
>>> p.example("rc")             # simplest 3-line RC charge
>>> p.example("yaml")           # load a YAML netlist
>>> p.example("boost")          # open-loop boost

Calling with an unknown name prints the list of available snippets.

p.tour()

One-page walking tour of pulsim — six steps from "build a circuit" to "auto-tune from a measured Bode". Read top to bottom for the end-to-end story.

p.list_topologies()

Catalog of every runnable script in examples/scripts/ (open loop / closed loop / AC analysis / diagnostic). Use to find the example that matches your topology.

2. Plot helpers — no matplotlib boilerplate

p.scope(builder, result, signals=[...])

The one you'll reach for most. Signals are node names; each is plotted on its own y-axis (stacked subplots), time axis auto-scaled (ns / µs / ms / s), optional save to PNG.

res = p.simulate(b, t_end=5e-3, dt=1e-7, switch_fn=pwm)
p.scope(b, res, signals=["sw", "vout"], save="output/buck.png")

Options: - tight_lw=True for dense PWM traces - stack=False to put all signals on one axes - title=..., show=True, etc.

p.scope_fft(builder, result, signal=..., f_fundamental=...)

FFT magnitude spectrum with optional THD annotation. Defaults to skipping the first 30 % of samples (initial settling) and applies a Hann window with amplitude correction.

p.scope_fft(b, res, signal="vout",
              f_max=600.0, f_fundamental=60.0,
              save="output/rectifier_fft.png")
# Title gets " THD = X.XX %" appended automatically.

Returns (fig, freqs, mag) if you want to do further analysis.

p.scope_grid(builder, result, panels=[...])

Multi-panel waveform with per-panel customisation:

p.scope_grid(b, res, panels=[
    dict(signals=["vout"], ylabel="V_out [V]"),
    dict(signals=["sw"],   ylabel="V_sw [V]", lw=0.4),
])

p.plot_currents(builder, result, branch_ids=[1, 5])

Plot inductor or source branch currents. Resolves the state-vector index via branch_var_id_for_inductor (falls back to branch_var_id_for_source).

p.plot_bode(sweep_result, compare_analytical=...)

Magnitude + phase Bode for an AcSweepResult from p.run_ac_sweep(...). Pass compare_analytical=lambda f: H(jω) to overlay an analytical reference.

p.compare(builder, {label: result}, signal="vout")

Overlay one signal from multiple simulation runs on the same axes — the parameter-sweep helper:

runs = {}
for kp in [0.01, 0.05, 0.1]:
    runs[f"Kp={kp}"] = run_my_sim(kp)
p.compare(b, runs, signal="vout", title="Buck — Kp sensitivity")

3. Progress monitor — know your simulation is alive

The simulate(..., progress=...) kwarg controls feedback during long runs:

Value Behaviour
False (default) silent
True print "progress: X%" every 10 %
<int> print every N % (e.g. progress=20)
"bar" animated ASCII bar (one line, updates in place)
res = p.simulate(b, t_end=10e-3, dt=1e-7, switch_fn=pwm,
                 progress="bar")
#   [████████████████░░░░░░░░░░░░░░░░░░░░░░] 41 %   t=4.10 ms (0.8 s)

Implementation: reuses the step_observer callback. If you also pass your own step_observer=... (e.g. for a closed-loop PI), the progress observer wraps it transparently.

For programmatic monitoring (UI integration), pass step_observer yourself and emit your own progress events — the progress= kwarg is just sugar for the common case.

4. End-to-end — what it looks like now

Before (manual matplotlib):

res = p.run_transient(cache, b.graph, b.pool, opts, switch_fn=pwm)
times = np.asarray(res.times) * 1e3
vout_idx = b.node_id_of("vout")
v_out = np.array([s[vout_idx] for s in res.states])
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(times, v_out, lw=0.8)
ax.set_xlabel("time [ms]"); ax.set_ylabel("V_out [V]")
ax.grid(alpha=0.3); ax.set_title(...)
plt.tight_layout()
plt.savefig("output/buck.png", dpi=120)

After (helpers):

res = p.simulate(b, t_end=5e-3, dt=1e-7, switch_fn=pwm,
                 progress="bar")
p.scope(b, res, signals=["sw", "vout"],
          title="Buck converter", save="output/buck.png")

Same plot, one quarter the code, time units auto-picked, no manual node_id_of calls.


5. v1.5 — named lookups + closed-loop + ergonomics

The v1.5 surface lands three additive helpers — every existing script keeps working unchanged.

5.1 Named result accessors (add-python-named-lookups)

result.v(name) / result.i(name) / result.power(name) resolve node / branch / device names without raw column arithmetic:

res = p.simulate(b, t_end=20e-3, dt=2e-6, switch_fn=pwm)

# Instead of:
states = np.asarray(res.states)
v_out  = states[:, b.node_id_of("vout")]
i_L1   = states[:, b.graph.num_nodes + b.branch_index_of("L1")]

# Just:
v_out = res.v("vout")          # full series
v_end = res.v("vout", t=-1)    # scalar
i_L1  = res.i("L1")            # branch current (inductor / source only)
p_R   = res.power("R_L")       # avg dissipation

Builder introspection:

b.switch_index_of("Q1")   # bit position in SwitchStateMask
b.branch_index_of("L1")   # branch_id offset (alias of branch_id_of)
b.devices()               # [DeviceInfo(name, kind, terminals), …]

Unknown names raise pulsim.NameNotFoundError(KeyError) with suggestions from difflib.get_close_matches.

5.2 PI + PWM closed-loop helper (add-python-closed-loop-helper)

Replaces the 30-line closure-with-mutable-list pattern with one call:

pi = p.PIController(Kp=0.08, Ki=40.0, output_min=0.05, output_max=0.95)
loop = p.bind_pi_to_switch(
    b, pi=pi,
    measured=lambda x: x[b.node_id_of("vout")],
    setpoint=5.0,
    switch="Q1",        # name or int index
    freq=10e3,
)
res = p.simulate(b, t_end=20e-3, dt=2e-6, closed_loops=[loop])

# Inspect the loop's history post-run:
ts, ds = np.asarray(loop.duty_history).T  # (N,) (N,)

For circuits with multiple independent loops, pass the list: p.simulate(..., closed_loops=[loop_a, loop_b]). The helper composes their switch_fns via make_combined_switch_fn and fans out the step_observers automatically.

For one PI driving multiple switches (e.g. half-bridge complementary pair), use p.bind_pi_to_duty_callable(...) instead: it returns (duty_get, step_observer, history) so the caller composes the switch_fn manually with both bits.

A complete buck closed-loop in ~25 lines lives at examples/scripts/run_buck_with_helper.py.

5.3 Builder ergonomics (add-python-builder-ergonomics)

Initial conditions as add_* kwargs:

b.add_capacitor("Cout", "vout", "gnd", 220e-6, c0=12.0)  # IC=12 V
b.add_inductor("L1", "sw", "vout", 220e-6, i0=0.5)       # IC=0.5 A
# Or post-hoc:
b.set_initial("Cout", 12.0)
# Synthesised automatically when simulate(initial_state=None):
res = p.simulate(b, t_end=20e-3, dt=2e-6)  # Cout starts at 12 V.

GUI-friendly aliases for human-readable names:

b.set_alias("vin", node="net_42")
b.aliases()                # {"vin": ("node", "net_42")}
res.v("vin") == res.v("net_42")   # alias resolves transparently

Cancellation via should_continue= on every long-running entry point:

cancel_event = threading.Event()

# GUI Run button reuses one builder for many runs; Cancel button:
res = p.simulate(b, t_end=20e-3, dt=2e-6,
                 should_continue=lambda: not cancel_event.is_set())

T_j = p.compute_temperature(t, p_loss, stages,
                              should_continue=lambda: not cancel_event.is_set())

x_dc = p.compute_dc_op(b,
                         should_continue=lambda: not cancel_event.is_set())

All raise pulsim.Cancelled(RuntimeError) with a typed .where and .point_index attribute pinpointing where the cancellation fired.


6. v1.5 migration status — examples + notebooks

Scripts (examples/scripts/) — migrated

The 18 scripts most exercised by users picked up the v1.5 idioms in the same PR that introduced the helpers:

Family Scripts Idiom migrated
Closed-loop (PI + PWM) run_{buck,boost,flyback,boost_saturable,pfc_boost}_closed_loop.py current_duty = [...] closure → bind_pi_to_switch
Open-loop / single-shot run_{buck,boost_realistic_mosfet_v14,ldo_with_opamp,common_source_amplifier,flyback,phase_shift_full_bridge,pwm_chopper_realistic_mosfet,three_phase_inverter,3phase_rectifier_grid,thermal_buck,rlc_step_response,half_wave_rectifier,dc_motor_speed}.py states[:, node_id_of(name)]res.v(name); pool.branch_var_id_for_inductor(...)res.i(name)
Reference run_buck_with_helper.py New — 30-line minimal showcase

All 18 scripts produce numerically-equivalent KPIs to their pre-migration baselines (verified within ±1% for closed-loop scripts where the throttle-cadence detail can cause sub-percent drift; bit- equal for open-loop scripts where the migration is just a different spelling of the same column read).

Notebooks (examples/notebooks/, projects/*/*/*.ipynb) — pending

The 29 pulsim-using notebooks pre-date the v1.3 namespace flatten and still target the v0 ps.Circuit() API: add_pwm_voltage_source + add_virtual_component("pi_controller", ...) + period-by-period run_transient_compat loops. Migrating them to v1.5 requires:

  1. Rewrite ps.Circuit()ps.CircuitBuilder() (different add_* signatures, no virtual components).
  2. Replace the virtual pi_controller with a Python-side bind_pi_to_switch(...) call.
  3. Collapse the per-period for period in range(n_periods) loop into a single pulsim.simulate(..., closed_loops=[loop]).
  4. Re-execute every notebook so the rendered plots reflect the new solver path (the period-by-period vs simulate(...) paths have slightly different switching-edge resolution and would produce visibly different waveforms — the existing PNGs in the notebook outputs would mislead readers post-rewrite).

That's a separate effort tracked under a future "legacy notebook modernisation" change.