Co-simulation and mixed-signal
What co-simulation does
A co-simulation model is an external circuit or process that steps next to your firmware. LabWired drives the model at a fixed interval. Each step, LabWired sends the model its inputs and reads back its outputs. The manifest routes those inputs and outputs to board pins, so firmware can drive a circuit and read its response.
Use co-simulation to test firmware against a plant it controls. An RC filter, a motor, a
battery pack, or a sensor array are all valid models. The model does not need to run in
LabWired’s process; the external_process adapter runs any program that reads and writes
JSON lines. The analog adapter runs a small circuit solver inside LabWired itself,
including in the browser.
The result is a signal path: firmware writes a pin, the model reacts, and a probe reads the result back into firmware or into the oscilloscope.
The manifest block
Add a cosim_models list to system.yaml. Each entry describes one model.
cosim_models:
- id: rc
adapter: analog
step_ns: 100000
inputs:
gpio: board.gpio.pa5
outputs:
v_out: board.analog.pa0_volts
config:
netlist: ./rc.cir
vdd: 3.3
probes: { v_out: "v(out)" }
sources: { gpio: Vgpio }
| Field | Type | Required | Meaning |
|---|---|---|---|
id | string | yes | Unique name for this model in the manifest. |
adapter | string | yes | mock, external_process, or analog. |
model | string | for external_process | Path to the model program, relative to the manifest. The analog adapter takes its netlist from config.netlist. |
step_ns | integer | yes | Step interval in nanoseconds. Must be greater than zero. |
inputs | map | yes | LabWired input name to signal path, for example gpio: board.gpio.pa5. |
outputs | map | yes | LabWired output name to signal path, for example v_out: board.analog.pa0_volts. |
config | map | no | Adapter-specific settings. See each adapter below. |
Adapters
LabWired supports three adapters. Pick one per model.
| Adapter | Runs where | Use for |
|---|---|---|
mock | native, browser | Tests and dry runs. Static outputs, no real model. |
external_process | native only | Any program that speaks the JSONL contract. Includes the ngspice wrapper. |
analog | native, browser | A linear circuit solved in-core, without ngspice. |
mock
The mock adapter returns fixed outputs. Declare them under config.outputs.
cosim_models:
- id: plant_model
adapter: mock
step_ns: 10000
inputs:
controller_enable: control.enable
outputs:
plant_ready: observables.plant_ready
config:
outputs:
plant_ready: true
Use mock to test manifest wiring before the real model exists.
external_process
The external_process adapter starts the program named by model and speaks a JSONL
contract over stdin and stdout. LabWired sends one line per step:
{"time_ns": 1000000, "dt_ns": 1000000, "inputs": {"gpio": true}}
The model replies with one line:
{"outputs": {"v_out": 2.08}}
time_ns is the end of the step, not the start. The model returns the value at that time.
LabWired ships a wrapper for ngspice: tools/cosim/labwired_ngspice.py. It loads
libngspice in-process and steps a SPICE netlist to each step boundary. Install
libngspice0 before you run it:
sudo apt install libngspice0
Any ngspice-compatible model works, including vendor or open-source models pulled in with
.include or .lib. One ngspice process holds one circuit. Declare a second
cosim_models entry for a second circuit.
cosim_models:
- id: rc
adapter: external_process
model: ./models/rc_lowpass.py
step_ns: 100000
inputs:
gpio: board.gpio.pa5
outputs:
v_out: board.analog.pa0_volts
The model program owns the netlist, the source map, and the probe map. See
examples/cosim-spice-rc/models/rc_lowpass.py for a three-line stub that calls the wrapper.
analog
The analog adapter solves a linear circuit inside LabWired’s own code. It needs no
external process and runs in the browser playground. It supports a fixed set of elements.
| Element | Line | Notes |
|---|---|---|
| Resistor | R<name> n1 n2 <value> | |
| Capacitor | C<name> n1 n2 <value> [ic=<v>] | ic sets the initial voltage. |
| Inductor | L<name> n1 n2 <value> [ic=<i>] | ic sets the initial current. |
| Voltage source | V<name> n+ n- dc <value> | Routing overrides the value at run time. |
| Current source | I<name> n+ n- dc <value> | Routing overrides the value at run time. |
| Switch | S<name> n1 n2 <ctrl> ron=<r> roff=<r> | <ctrl> is a routed boolean input. |
Node 0 and node gnd are both ground. Values accept SPICE suffixes: k, meg, u,
n, p, m.
cosim_models:
- id: rc
adapter: analog
step_ns: 100000
inputs:
gpio: board.gpio.pa5
outputs:
v_out: board.analog.pa0_volts
config:
netlist: ./rc.cir
vdd: 3.3
substeps: 10
integration: be
probes: { v_out: "v(out)" }
sources: { gpio: Vgpio }
| Config key | Meaning |
|---|---|
netlist | Path to a netlist file, relative to the manifest. |
netlist_text | Inline netlist text, as an alternative to netlist. |
vdd | Voltage a boolean input drives when true. |
substeps | Internal solver steps per step_ns. Default 10. |
integration | be (backward Euler, default) or trap (trapezoidal). |
probes | Output name to node or current, for example v_out: "v(out)". |
sources | Input name to element name, for example gpio: Vgpio. |
trace_samples | Ring buffer size for the oscilloscope trace. Default 20000. |
The analog adapter parses a fixed element set. A diode, a transistor, or an .include
line is outside that set. Manifest validation then fails and names the line. Switch that
model to adapter: external_process with the ngspice wrapper.
Signal paths
LabWired routes co-simulation inputs and outputs through three signal path forms.
| Path | Direction | Example |
|---|---|---|
board.gpio.<pin> | Firmware pin state into the model | board.gpio.pa5 carries the PA5 output level into inputs.gpio. |
board.analog.<pin>_volts | Model voltage into a board ADC pin | board.analog.pa0_volts sets the voltage the ADC on PA0 reads. |
board.gpio_in.<pin> | Model output into a firmware input pin | board.gpio_in.pb0 drives PB0 high or low from a model output. |
Oscilloscope
The oscilloscope instrument shows analog channels and digital pins on one time axis.
Open it from the Tools menu in the playground, or load a lab with ?panel=scope in the
URL.
| Feature | Description |
|---|---|
| Channels | Any analog channel from outputs, or a digital pin with pin:<name>. |
| Trigger | none, rising, falling, or level, on a chosen channel. |
| Cursors | Two movable cursors, A and B, with a Δt and ΔV readout. |
| Export | Save the visible trace as CSV. |
On the command line, add --analog-trace to run or test to write the same trace to a
file:
labwired run examples/rc-oscilloscope-lab/system.yaml --analog-trace out.csv
Pass a .vcd path instead of .csv to open the trace in GTKWave or PulseView.
Worked example: the RC lab
The rc-oscilloscope-lab example toggles a GPIO into an RC low-pass filter and reads the
result back on an ADC pin.
Netlist (rc.cir), a 10 kΩ / 100 nF low-pass, τ = 1 ms:
Vgpio in 0 dc 0
R1 in out 10k
C1 out 0 100n
.end
Manifest entry:
cosim_models:
- id: rc
adapter: analog
step_ns: 100000
inputs:
gpio: board.gpio.pa5
outputs:
v_out: board.analog.pa0_volts
config:
netlist: ./rc.cir
vdd: 3.3
probes: { v_out: "v(out)" }
sources: { gpio: Vgpio }
Firmware toggles PA5 every 5 ms and reads ADC1 channel 0 every 500 µs. The charge curve crosses 1.5 V to 3 V as the capacitor charges toward 3.3 V.
Run it natively:
labwired run examples/rc-oscilloscope-lab/system.yaml --analog-trace out.csv
Open it in the playground:
https://app.labwired.com/?board=rc-oscilloscope-lab&panel=scope
The oscilloscope shows the PA5 edge on one row and the v_out charge curve on another, on
the same time axis.
Limits
The analog adapter solves linear circuits only. It does not model diodes, transistors, or
any nonlinear device. It does not run an AC or DC sweep. It has no .include or .lib
support.
For a nonlinear device, a vendor SPICE model, or a sweep, use adapter: external_process
with the ngspice wrapper. The ngspice wrapper runs on native builds only; the browser
playground supports the analog adapter alone.