Custom Code Blocks (C / C++ / Python)¶
A C block is a PSIM-/Simulink-style sampled subsystem you wire into the circuit. It does three things, every block step:
- reads a set of circuit signals (its inputs),
- runs your code at a sample rate you choose,
- drives signals back into the circuit (its outputs).
Reach for it when your logic doesn't map to a fixed component — digital control laws, lookup tables, protection/interlocks, state machines, soft-start sequencers, or a custom behavioural model. The step code can be Python, C, or C++ behind one interface.
Everything lives in pulsim.c_block and is re-exported on the top-level
package (pulsim.add_c_block, pulsim.wire_c_blocks_from_yaml,
pulsim.CBlockHandle, pulsim.CBLOCK_ABI).
No kernel rebuild
A C block is pure-Python orchestration on top of Pulsim's existing
per-step hooks (a throttled step_observer + b_extra residual
injection). add_c_block registers the block on the builder and
simulate() picks it up automatically — you just call simulate.
Quick start¶
A proportional gain that reads V(vout) and drives a control node:
import pulsim as p
b = p.CircuitBuilder()
b.add_voltage_source("Vin", "vout", "gnd", 2.0) # stand-in source
b.add_resistor("Rin", "vout", "gnd", 1e3)
b.add_resistor("Rout", "ctrl", "gnd", 1e3)
def step(t, dt, inp, out, state):
out[0] = 3.0 * inp[0] # control law
p.add_c_block(
b,
inputs =[("v", "vout")], # read the vout node voltage
outputs=[("v", "ctrl", "gnd")], # drive a controlled voltage source
dt=1e-4, # block sample time (100 µs)
fn=step,
)
res = p.simulate(b, t_end=2e-3, dt=2e-6)
print(res.v("ctrl")[-1]) # ≈ 6.0 (= 3 · 2.0)
At a glance¶
| Topic | API |
|---|---|
| Add a block | add_c_block(builder, inputs, outputs, *, dt, fn= \| lib= \| code=) |
| Input wires (read) | ("v", node) node voltage · ("i", branch) branch current |
| Output wires (drive) | ("v", n+, n-) controlled V source · ("i", n+, n-) controlled I source |
| Sample time | dt — fires at t=0, then every dt; zero-order hold between |
| Python | fn=callable |
| Compiled C/C++ | lib="block.so" (+ symbol=) |
| Inline C/C++ | code="…", lang="c"\|"cpp" |
| From YAML | wire_c_blocks_from_yaml(loaded, spec) |
| Inspect at runtime | the returned CBlockHandle (.outputs, .state, .n_fires) |
The step interface¶
Whatever the language, the block implements the same logical step,
invoked once per dt:
step(t, dt, in[N], out[M], state)
| Argument | Meaning |
|---|---|
t |
current time (s) |
dt |
the block sample time (s) — not the simulation dt |
in |
the N input wires, sampled at this step (read-only) |
out |
write the M outputs here; held until the next block step |
state |
persists across steps — a dict (Python) or an opaque pointer (C/C++) |
N and M are simply len(inputs) and len(outputs) — you choose them.
Writing the block¶
Python¶
The step is a plain callable. Write out in place, or return a
length-M sequence.
def step(t, dt, inp, out, state):
# stateful example: a leaky integrator + gain
state["z"] = 0.99 * state.get("z", 0.0) + inp[0] * dt
out[0] = 2.0 * inp[0] + state["z"]
p.add_c_block(b, inputs=[("v", "vout")], outputs=[("v", "ctrl", "gnd")],
dt=1e-4, fn=step, state={"z": 0.0})
For hot loops, a Numba-jitted function works too (see
pulsim.fast_block).
Inline C / C++¶
code is the body of the step function. In scope: in, out,
n_in, n_out, t, dt, state. Pulsim wraps it in the ABI,
compiles it once with the system compiler, and caches the result (keyed
by source + flags), so repeat runs don't recompile.
# C
p.add_c_block(b, inputs=[("v", "vout")], outputs=[("v", "ctrl", "gnd")],
dt=1e-4, lang="c", code="out[0] = 0.5 * in[0];")
# C++ (math from <cmath> available)
p.add_c_block(b, inputs=[("v", "vout")], outputs=[("v", "ctrl", "gnd")],
dt=1e-4, lang="cpp", code="out[0] = std::tanh(in[0]);")
<math.h>/<cmath>, <string.h>/<cstring>, <stdlib.h>/<cstdlib>
are pre-included. Extra headers/libraries:
p.add_c_block(b, ..., lang="c", code="...",
include_dirs=["/opt/mylib/include"],
extra_compile_args=["-ffast-math"],
extra_link_args=["-lm"])
Precompiled C / C++ shared library¶
Ship a .so/.dll/.dylib that exports the ABI. Useful
for larger blocks, shared IP, or code you don't want recompiled.
/* my_block.c */
#include "pulsim_cblock.h" /* or copy the prototypes from p.CBLOCK_ABI */
#include <stdlib.h>
void *pulsim_cblock_init(int n_in, int n_out) {
return calloc(1, sizeof(double)); /* one state word */
}
void pulsim_cblock_term(void *s) { free(s); }
void pulsim_cblock_step(const double *in, int n_in,
double *out, int n_out,
double t, double dt, void **state) {
double *acc = (double *)(*state);
*acc += in[0] * dt; /* integrate */
out[0] = 0.5 * in[0] + *acc;
}
Compile (note -shared -fPIC):
cc -shared -fPIC -O2 my_block.c -o my_block.so # Linux/macOS (C)
c++ -shared -fPIC -O2 my_block.cpp -o my_block.so # C++ (keep extern "C")
cl /LD my_block.c # Windows (MSVC)
p.add_c_block(b, inputs=[("v", "vout")], outputs=[("v", "ctrl", "gnd")],
dt=1e-4, lib="my_block.so")
# multiple blocks in one library → pass symbol="my_other_step"
Same code, both compilers
The ABI is extern "C", so the same .c file compiles as C or
C++. In C++ keep the extern "C" linkage (the shipped header and the
inline lang="cpp" template do this for you) so the symbols aren't
name-mangled.
The C ABI¶
The contract a compiled block must satisfy — available as the string
pulsim.CBLOCK_ABI and as the shipped header pulsim/cblock_abi.h:
/* required */
void pulsim_cblock_step(const double *in, int n_in,
double *out, int n_out,
double t, double dt, void **state);
/* optional */
void *pulsim_cblock_init(int n_in, int n_out); /* allocate state */
void pulsim_cblock_term(void *state); /* free state */
in/out— contiguousdoublebuffers of lengthn_in/n_out. Readin, writeout.state— an opaque pointer you own. Either allocate it inpulsim_cblock_init(its return value is passed back as*stateto everystep, and toterm), or lazily insidestepviaif (!*state) { *state = malloc(...); }.pulsim_cblock_termruns when the block handle is garbage-collected.- Symbol names are configurable via
symbol=(defaultpulsim_cblock_step;init/termuse the…_init/…_termsuffixes).
Wires — reading and driving the circuit¶
Inputs (read)¶
| Wire | Reads |
|---|---|
("v", "node") |
the node voltage V(node) |
("i", "branch") |
the current through an inductor or source branch |
Input wires are resolved to state-vector locations when you call
add_c_block, so add the block after the circuit topology is built.
Outputs (drive)¶
Each output wire creates one controlled source:
| Wire | Imposes |
|---|---|
("v", "n+", "n-") |
a voltage V(n+) − V(n−) = out[k] |
("i", "n+", "n-") |
a current out[k] flowing from n+ to n− |
Outputs are injected through Pulsim's b_extra path, which is part of
the fixed-step (PWL) engine — see Engines.
Sample time, ZOH, and the one-sample delay¶
dt is the block's own rate, independent of the simulation step.
The step fires at t = 0 and at each subsequent multiple of dt;
between firings the outputs are held constant (zero-order hold) —
exactly how a discrete controller sees a continuous plant.
# Block runs at 20 kHz while the sim integrates at 500 kHz:
p.add_c_block(b, ..., dt=50e-6, fn=step) # sim with dt=2e-6
dtmust be ≥ the simulationdt; if smaller it is clamped to the sim step with a warning. Passsim_dt=toadd_c_blockif you want that check to run at build time rather than atsimulate.- There is a one-sample delay in the feedback path: the output applied during a step was computed from the previous block step's inputs. This is inherent to any sampled block (same as PSIM/PLECS discrete C blocks). Algebraic (zero-delay feedthrough) blocks are not supported.
State management¶
| Language | State |
|---|---|
| Python | the state dict argument; persists across steps. Seed it with state={...} on add_c_block. |
| Compiled C/C++ | the opaque *state pointer — allocate in init (or lazily), free in term. One instance per add_c_block call. |
| Inline C/C++ | works the same via *state. Caveat: a static variable in inline code is shared by every block compiled from identical source (they share one cached library) — use *state for per-instance state. |
From YAML¶
Declare blocks declaratively and wire them after loading the circuit:
loaded = p.load_yaml_file("drive.yaml")
p.wire_c_blocks_from_yaml(loaded, [
{
"name": "duty_ctrl",
"inputs": [["v", "vout"]],
"outputs": [["v", "ctrl", "gnd"]],
"dt": 1e-4,
"lang": "c",
"code": "out[0] = 0.5 * in[0];",
},
])
res = p.simulate(loaded.builder, t_end=..., dt=2e-6)
spec may be a Python list of dicts (shown) or a YAML string. Per-block
fields:
| Field | Required | Notes |
|---|---|---|
inputs |
yes | list of [kind, name] wires |
outputs |
yes | list of [kind, n+, n-] wires |
dt |
yes | block sample time (s) |
code + lang |
one of | inline source |
file + lang |
one of | path to a source file (read as code) |
lib |
one of | path to a precompiled library |
symbol |
no | step symbol name |
name |
no | block / output-source name |
include_dirs, extra_compile_args, extra_link_args |
no | compiler passthrough |
(Python fn callables can't be serialised, so YAML blocks use
code / file / lib.)
Worked example: a discrete PI controlling a converter¶
A PI compensator living entirely in a C block, regulating an LC output to a voltage setpoint:
b = p.CircuitBuilder()
b.add_inductor("L", "sw", "out", 10e-3)
b.add_capacitor("C", "out", "gnd", 10e-6)
b.add_resistor("R", "out", "gnd", 10.0)
setpoint, Kp, Ki, umin, umax = 5.0, 0.3, 200.0, 0.0, 20.0
def pi(t, dt, inp, out, st):
e = setpoint - inp[0] # read V(out)
integ = st.get("i", 0.0) + e * dt
u = Kp * e + Ki * integ
if u > umax: u, integ = umax, (umax - Kp * e) / Ki # anti-windup
elif u < umin: u, integ = umin, (umin - Kp * e) / Ki
st["i"] = integ
out[0] = u # drive the control voltage
p.add_c_block(b, inputs=[("v", "out")], outputs=[("v", "sw", "gnd")],
dt=50e-6, fn=pi, name="PI")
res = p.simulate(b, t_end=40e-3, dt=2e-6) # V(out) → 5 V
The block reads V(out), runs the discrete PI at 20 kHz, and drives the
control voltage back; Pulsim solves the plant and the controller
together.
Engines and limits¶
- Outputs require the PWL engine (
engine="pwl", the default) — they are injected viab_extra, which the variable-step DSED engine does not consume. A block with inputs only (logging, an observer, a software trip) also runs underengine="dsed". - One-sample (ZOH) feedback delay; no algebraic feedthrough.
- Inline compilation needs a system C/C++ compiler; without one, use a
precompiled
lib=.
Performance¶
- Python blocks pay one Python call per block step (not per sim
step) — coarse
dtkeeps that cheap; ZOH skips the call between firings. - Compiled
lib=/code=blocks are one C call per block step; numpy buffers are passed by pointer (no copy). - The inline cache means a given source compiles once per machine.
Troubleshooting¶
| Symptom | Cause / fix |
|---|---|
provide exactly one of fn= / lib= / code= |
give a single step source |
no C/C++ compiler found |
install a compiler, or ship a precompiled lib= |
shared library … does not export 'pulsim_cblock_step' |
wrong symbol=, or missing extern "C" in C++ |
| output has the wrong sign | check the output wire orientation ("v", n+, n-) |
| block never updates | dt larger than t_end; lower dt |
dt … < simulation dt … clamping warning |
block dt below the sim step — raise it |
outputs ignored under engine="dsed" |
outputs are PWL-only; use engine="pwl" |
Reference¶
pulsim.add_c_block(builder, inputs, outputs, *, dt, fn=None, lib=None, code=None, lang="c", symbol="pulsim_cblock_step", include_dirs=None, extra_compile_args=None, extra_link_args=None, name="CBLK", state=None, sim_dt=None) -> CBlockHandlepulsim.wire_c_blocks_from_yaml(loaded, spec, *, sim_dt=None) -> list[CBlockHandle]pulsim.CBlockHandle—.outputs,.state,.n_fires,.dt_blockpulsim.CBLOCK_ABI— the C ABI as a string; header:pulsim/cblock_abi.h