Co-simulation and mixed-signal
What co-simulation does
A co-simulation model is an external circuit or process. The model steps at the same time as your firmware. LabWired drives the model at a fixed interval. At each step, LabWired sends the inputs to the model. LabWired then reads the outputs from the model. The manifest routes those inputs and outputs to board pins. Firmware can then drive a circuit. Firmware can read the circuit response.
Use co-simulation to test firmware against a plant that the firmware controls. An RC filter, a motor, a battery pack, or a sensor array is a valid model. The model does not need to run in the LabWired process. The external_process adapter runs a program. The program reads JSON lines. The program writes JSON lines. The analog adapter runs a small circuit solver inside LabWired. The solver also runs in the browser.
The result is a signal path. Firmware writes a pin. The model reacts. A probe reads the result. The result goes 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 adapter for each 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 uses 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 the manifest wiring before the real model exists.
external_process
The external_process adapter starts the program that model names. The adapter uses a JSONL contract on stdin and stdout. LabWired sends one line for each 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. time_ns is not the start of the step. The model returns the value at that time.
LabWired provides a wrapper for ngspice: tools/cosim/labwired_ngspice.py. The wrapper loads libngspice in-process. The wrapper steps a SPICE netlist to each step boundary. Install libngspice0 before you run the wrapper:
sudo apt install libngspice0
Any ngspice-compatible model operates. The model can be a vendor model or an open-source model. The netlist loads that model with .include or .lib. One ngspice process contains 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 defines 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 in the LabWired code. It needs no external process. It 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 is ground. Node gnd is ground. A value can use a SPICE suffix. The suffixes are k, meg, u, n, p, and 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 set of elements. A diode, a transistor, or an .include line is outside that set. Manifest validation then fails. Validation names the line. For that model, use adapter: external_process and 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 the oscilloscope 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 to test. The option writes the same trace to a file:
labwired run examples/rc-oscilloscope-lab/system.yaml --analog-trace out.csv
Pass a .vcd path instead of a .csv path. You can then 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. The example reads the result on an ADC pin.
The netlist file is rc.cir. The circuit is 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. Firmware reads ADC1 channel 0 every 500 µs. The capacitor charges toward 3.3 V. The charge curve crosses 1.5 V to 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. The oscilloscope shows the v_out charge curve on another row. The two rows use the same time axis.
Limits
The analog adapter solves linear circuits only. It does not model a diode, a transistor, or any nonlinear device. It does not run an AC sweep. It does not run a DC sweep. It has no .include support. It has no .lib support.
Use adapter: external_process with the ngspice wrapper for a nonlinear device. Use that adapter for a vendor SPICE model. Use that adapter for a sweep. The ngspice wrapper runs on native builds only. The browser playground supports the analog adapter alone.