What Mokxi simulates: the simulator contract
Mokxi simulates a circuit with three engines, and this page is the contract each one keeps.
- The digital kernel. An event-driven kernel runs every microcontroller, logic chip and digital net. A net's voltage comes from the drivers on it, each a voltage behind a source impedance, and its logic level from the strongest one (section 6). This is what lets a few hundred parts and a CPU core run at real time in a browser tab.
- Analog islands. A capacitor, an inductor, an op-amp, a signal generator, a diode or a transistor pulls the nets it touches into an island, and the island is solved as one matrix by modified nodal analysis, with device models behind the semiconductors: Shockley diodes, Ebers-Moll transistors and level 1 MOSFETs, each with junction capacitance, solved by Newton-Raphson (sections 6a and 6b). On the canvas you can read the DC operating point, sweep a value and plot a frequency response (section 7).
- The SPICE deck runner. Run a SPICE deck, in the editor menu, runs a SPICE netlist on its own engine:
.op,.tran,.ac,.dcwith nested sweeps,.tf,.noiseand.meas, with subcircuits, parameters,.include, Gummel-Poon BJTs, MOSFET levels 1 to 3, JFETs, behavioral sources and.temp. It is checked against ngspice on 42 benchmark circuits, each within a stated tolerance (section 7e).
What each one does not do, plainly:
- The live canvas has no noise, temperature or distortion analysis, and no transfer function; every junction on it is at 27 C. Its op-amp is a single-pole macromodel with a gain-bandwidth product, a slew rate, headroom, an offset and a current limit, but no second pole.
- The SPICE deck runner does not run BSIM MOSFET models, lossy transmission lines, XSPICE devices, or the
.four,.sens,.pzand.distoanalyses, and it says so, by line, when a deck uses one. There is no Monte Carlo analysis and no library of manufacturers' models. - Nothing has a power rating. A part never burns out, a supply never sags and a pin has no current limit, so a circuit that works here can still fail on the bench (how parts are modeled).
The contract
The rest of this page is the boundary between the Rust simulator (crates: sim-kernel, parts, analog, spice, sim-wasm) and the TypeScript UI (web/). Both sides build against this file. Change it only by editing this file first.
1. Netlist JSON (UI -> simulator)
The UI owns the diagram (positions, wires, breadboards). Before starting a simulation it flattens the diagram into a netlist: breadboard strips and wires are merged with union-find into nets. Breadboards never reach the simulator.
{
"version": 1,
"parts": [
{ "id": "led1", "type": "led", "props": { "color": "red" } },
{ "id": "r1", "type": "resistor", "props": { "value": 220 } },
{ "id": "vcc1", "type": "vcc", "props": { "voltage": 5 } },
{ "id": "gnd1", "type": "gnd" }
],
"nets": [
["vcc1:VCC", "r1:1"],
["r1:2", "led1:A"],
["led1:C", "gnd1:GND"]
]
}
parts[i]order definespartIndexused byprobes()andpoke().nets[j]order definesnetIndexused bynetLevels()andnetVoltages().- A pin reference is
"<partId>:<pinName>". Pin names come from the catalog. Unknown part type, unknown pin, or a pin in two nets is an error atcreate. - Props not given take the catalog default. Unknown props are an error.
- A net may contain a single pin. The UI emits one when a pin sits alone on a breadboard strip (a real node, needed for switches); the simulator accepts it.
2. WASM surface (crates/sim-wasm, wasm-bindgen, --target web)
Rust uses snake_case; export with js_name so TypeScript sees exactly this:
export class Sim {
static create(netlistJson: string): Sim; // throws Error(message) on a bad netlist
runForNs(ns: number): number; // advance simulated time by ns (a duration, not an absolute target); 0 = ok, 1 = oscillation (see lastError)
lastError(): string;
nowNs(): number;
probes(): Float32Array; // one f32 per part, in parts[] order; meaning is per part type (catalog "probe")
netLevels(): Uint8Array; // one u8 per net: 0 Low, 1 High, 2 Unknown, 3 Floating
netVoltages(): Float32Array; // one f32 per net, NaN when floating
netEdges(): Uint32Array; // one u32 per net: changes between low and high since the last call, which resets them
poke(partIndex: number, code: number): number; // external stimulus; code meaning is per part type; 0 = ok, 1 = oscillation
stats(): string; // JSON: { events, dispatches, parts, nets, memoryBytes }
loadProgram(partIndex: number, data: Uint8Array): string; // program image for a part (catalog "program"); "" = ok, else the reason
writeHost(partIndex: number, data: Uint8Array): number; // host -> part bytes (catalog "host"); 0 = ok, 1 = oscillation
readHost(partIndex: number): Uint8Array; // part -> host bytes since the last call; empty when the part has no channel
operatingPoint(): string; // JSON, see section 7a; runs no time
sweep(specJson: string): string; // JSON in, JSON out, see section 7b; runs no time on the circuit it is called on
setMaxStepNs(ns: number, fixed: boolean): void; // section 7c; 0 or less clears it
acSweep(specJson: string): string; // section 7d; the small-signal frequency response
stepLimitNs(): Float64Array; // [dtMinNs, dtMaxNs], empty when the circuit has no analog island
free(): void;
}
export function catalog(): string; // JSON, see section 3
export function version(): string;
Build: wasm-pack build --target web --release --out-dir ../../web/src/sim/wasm --out-name sim crates/sim-wasm (options before the crate path). Output is committed to git so the UI builds without Rust.
3. Catalog JSON (simulator -> UI)
[
{
"type": "led",
"pins": [ { "name": "A", "dir": "inout" }, { "name": "C", "dir": "inout" } ],
"props": [ { "name": "color", "kind": "string", "default": "red", "options": ["red", "green", "blue", "yellow", "white"] } ],
"probe": "brightness 0..1",
"poke": null,
"program": null,
"host": null
}
]
program says what loadProgram takes for this part ("elf32 riscv32imc" for a board), or null. host says what the host byte channel carries ("utf-8 bytes on UART0"), or null. A part with a host channel is what the serial monitor talks to.
dir is in, out, or inout. kind is number, string, or bool. options is optional; when present the UI shows a select. A UI part visual (web/src/parts/<type>.ts) must declare a pin position for every catalog pin and nothing else; a test in web/ checks this against web/src/sim/wasm/catalog.json, which the wasm build step also writes.
4. Part types frozen for the Phase 1 UI shell
The kernel side ships these first so the UI has something to drive. More parts arrive from part agents and only add entries.
| type | pins | props | probe | poke |
|---|---|---|---|---|
led |
A, C |
color string (red) |
brightness 0..1 | none |
resistor |
1, 2 |
value ohms (1000) |
current in amps | none |
pushbutton |
1a, 1b, 2a, 2b (1a-1b shorted, 2a-2b shorted, pressing joins the pairs) |
bounce string ("typical", one of none/typical/worst), see 4a |
1 pressed, 0 released | 1 = press, 0 = release |
vcc |
VCC |
voltage (5) |
voltage | none |
gnd |
GND |
none | 0 | none |
clock |
OUT |
hz (1) |
1 when high, 0 when low | none |
4a. Phase 1 part set (added after the shell, only ever appended to)
Units: resistor.value ohms, capacitor.value microfarads. Chips list their pins in package pin-number order. A probe that carries more than one bit packs a bitmask into the f32.
| type | pins | props | probe | poke |
|---|---|---|---|---|
capacitor |
1, 2 |
value number (10) |
voltage across it, V(1) - V(2) | none |
gate |
A, B, Y |
function string ("and", one of and/or/nand/nor/xor/xnor) |
1 when Y is high, 0 when low, 0.5 when unknown | none |
not |
A, Y |
none | 1 when Y is high, 0 when low, 0.5 when unknown | none |
bitin |
Q |
label string ("A"); value (0, 0 or 1) |
1 when Q is high, 0 when low | 0 drives Q low, 1 high; drives through a pull, so any output on the net wins |
bitout |
A |
label string ("Y"); color string ("green", one of green/red/amber/blue) |
1 high, 0 low, 0.5 unknown or not driven | none |
bytein |
D0..D7 |
label string ("A"); value (0, 0 to 255); width (8, 1 to 8) |
the value on D0..D(width-1); pins above the width are high-Z | 0..255 sets the value (masked to the width); 2^32 counts up by one |
byteout |
D0..D7 |
label string ("S"); width (8, 1 to 8) |
the value, undriven bits as 0; -1 when a driven bit is unknown | none |
bitconst |
Q |
value (0, 0 or 1) |
the value it drives | none; drives push-pull, like a gate output |
stepclock |
CLK |
hz (2); mode string ("step", one of step/run) |
the level (0 or 1), plus 2 while running free | 0 stops it (step mode), 1 runs it free, 2 gives one tick (a rise, then a fall half a period later) while stopped |
progrom |
A0..A7 in; C0..C7, K0..K7 out |
program string (Kestrel assembly, crates/parts/src/kestrel.rs) |
the address being read; -1 while it is unknown | none; the word at the address drives C (high byte) and K (low byte) after 10 ns |
terminal |
D0..D7, WE, CLK |
mode string ("text", one of text/numbers) |
bytes written since power-up | none; host channel: each byte written on a rising CLK edge with WE high |
sevenseg |
E, D, COM1, C, DP, G, F, COM2, A, B |
common string ("cathode", one of cathode/anode); color string ("red", one of red/green/blue/yellow/white) |
lit segments as a bitmask 0..255: bit 0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G, 7 DP | none |
slideswitch |
1, 2, 3 |
none | 0 = common on pin 1, 1 = common on pin 3 | 0 = connect 2 to 1, 1 = connect 2 to 3 |
buzzer |
1, 2 |
none | 1 when sounding, 0 when silent | none |
74hc00 |
1A, 1B, 1Y, 2A, 2B, 2Y, GND, 3Y, 3A, 3B, 4Y, 4A, 4B, VCC |
none | outputs as a bitmask, bit 0 = 1Y .. bit 3 = 4Y; 0 unpowered | none |
74hc08 |
same pinout as 74hc00 |
none | same | none |
74hc32 |
same pinout as 74hc00 |
none | same | none |
74hc86 |
same pinout as 74hc00 |
none | same | none |
74hc04 |
1A, 1Y, 2A, 2Y, 3A, 3Y, GND, 4Y, 4A, 5Y, 5A, 6Y, 6A, VCC |
none | outputs as a bitmask, bit 0 = 1Y .. bit 5 = 6Y; 0 unpowered | none |
74hc74 |
1RD, 1D, 1CP, 1SD, 1Q, 1QN, GND, 2QN, 2Q, 2SD, 2CP, 2D, 2RD, VCC |
none | Q outputs as a bitmask, bit 0 = 1Q, bit 1 = 2Q; 0 unpowered or X | none |
74hc595 |
Q1, Q2, Q3, Q4, Q5, Q6, Q7, GND, Q7S, MR, SHCP, STCP, OE, DS, Q0, VCC |
none | byte on Q0..Q7, bit 0 = Q0; 0 when OE is high or unpowered | none |
ne555 |
GND, TRIG, OUT, RESET, CTRL, THR, DIS, VCC |
none | 1 when OUT is high, 0 when low or unpowered | none |
inductor |
1, 2 |
value millihenries (100) |
current in amps, positive from pin 1 to pin 2 | none |
opamp |
IN+, IN-, OUT, V+, GND |
gain (100000) |
output voltage above the GND pin | none |
generator |
OUT, GND |
wave string ("sine", one of sine/square/triangle); frequency Hz (1000); amplitude volts peak (2.5); offset volts (2.5); duty percent (50, square only) |
output voltage above the GND pin | none |
funcgen |
OUT, GND |
wave string ("sine", one of sine/square/triangle/sawtooth); frequency Hz (1000); amplitude volts peak (2.5); offset volts (2.5); duty percent (50, square only) |
output voltage above the GND pin, at the terminals | none |
scope |
CH1, CH2, GND |
window seconds (0.02, 0.0001 to 10); volts full scale (5) |
channel 1 volts above the ground clip | (1 << 32) | window in microseconds, or (2 << 32) | full scale in millivolts |
multimeter |
V, A, COM |
mode string ("dcv", one of dcv/acv/dca/aca/ohms/continuity) |
the reading: volts, amps or ohms by mode, NaN when over range or open | none |
lm35 |
VS, VOUT, GND |
temperature number (22.5) |
temperature in degrees Celsius | tenths of a degree plus 1000 |
aht20 |
VCC, GND, SCL, SDA (I2C at 0x38) |
temperature number (22); humidity number (45) |
temperature in degrees Celsius | (1 << 32) | (tenths + 1000) temperature, (2 << 32) | tenths humidity |
bh1750 |
VCC, GND, SCL, SDA, ADDR (I2C at 0x23, 0x5C with ADDR high) |
none | the light in lux | the light in lux, 0 to 100000 |
a3144 |
VCC, GND, OUT (open collector) |
operate number (210 gauss); hysteresis number (55) |
magnet code, plus 100000 while OUT is on | tenths of a mm, 0 to 400, plus 10000 for the north pole |
ss49e |
VCC, GND, OUT |
none | the field at the sensor in gauss, south positive | tenths of a mm, 0 to 400 (400 is no magnet), plus 10000 for the north pole |
photointerrupter |
SIG, VCC, GND |
slots number (20) |
wheel code (RPM, plus 10000 with the card in), plus 100000 while SIG is high | RPM 0 to 600, plus 10000 with the card in |
linearray |
GND, VCC, S1 to S5 |
threshold number (0.5) |
the line code, plus 1000 x the channels over black as bits (bit 0 is S1) | 100 + mm from the middle sensor (40 to 160); 1 a black bar across; 0 no line |
fc51 |
OUT, GND, VCC |
threshold number (0.5) |
obstacle code (cm, 80 is none, plus 10000 for black), plus 100000 while the lamp is lit | cm 1 to 80, plus 10000 for a black object |
sw420 |
VCC, GND, DO |
threshold number (0.5) |
0 still, 1 shaking, 2 knocking, plus 100000 while the lamp is lit (DO low) | 0 still, 1 shaking until the next poke, 2 one knock |
ttp223 |
SIG, VCC, GND |
toggle bool (false); activelow bool (false) |
bit 0 finger on the pad, bit 1 SIG high | 1 finger on, 0 off |
battery |
V+, V- |
kind string ("9v", one of 9v/aa2/aa3/aa4/18650/18650x2); charge number (100) |
volts across the terminals | none |
solar |
+, - |
none | volts across the panel | watts per square meter, 0 to 1200 (1000 is full sun) |
tp4056 |
IN+, IN-, BAT+, BAT- |
none | charge current in mA, plus 10000 while the standby lamp is lit | none |
regulator |
IN, GND, OUT |
model string ("7805", one of 7805/AMS1117-3.3/MCP1700-3.3) |
OUT above GND, in volts | none |
tb6612 |
PWMA, AIN2, AIN1, STBY, BIN1, BIN2, PWMB, GND1, VM, VCC, GND2, AO1, AO2, BO2, BO1, GND3 |
none | channel modes, A in bits 0-1 and B in 2-3: 0 off, 1 CW, 2 CCW, 3 brake | none |
l9110 |
BIA, BIB, GND, VCC, AIA, AIB, OA1, OA2, OB1, OB2 |
none | motor drive, A in bits 0-1 and B in 2-3: 0 off, 1 forward, 2 reverse, 3 brake | none |
encmotor |
M1, M2, VCC, GND, A, B |
the plain motor's voltage (6), rpm (200), resistance (6), inertia (120), back_emf (0.85), plus ppr number (11) and ratio number (30) |
wheel speed in rpm, signed | none |
Chips are powered when the voltage between their VCC and GND pins is at least 2 V (4.5 V for the NE555); unpowered, every output is high-Z.
Units: resistor.value ohms, capacitor.value microfarads, inductor.value millihenries.
The scope's sample channel
The scope carries its samples on the same road a display's pixels take: readHost, collected once a frame, with a catalog host of null because that field means "a text channel the serial monitor talks to" and these are samples.
readHost(scopeIndex) returns everything sampled since the last call, as one little-endian message, or nothing when no sample has been taken:
byte 0 magic, 0x53
byte 1 channels, always 2
u16 2..4 number of samples that follow
f32 4..8 seconds per sample (the window divided by 256)
f32 8..12 seconds at the first sample
u16 12..14 flags: bit 0 = the time base moved, so throw away every earlier
sample; bit 1 = samples were dropped before this message
u16 14..16 reserved, zero
f32 16..20 full-scale volts the part is set to
then, per sample: f32 channel 1 volts, f32 channel 2 volts
A probe clipped to nothing reads NaN. The part holds 1024 samples for a host that has stopped collecting and then drops the oldest, setting the dropped flag.
The time base and the full scale are properties, and they are also pokeable, because they are the two things a person changes while watching a trace and rebuilding the netlist to change one would restart the simulation. A window poke takes effect at once: the part clears what it has not sent, sets the restart flag on its next message so the UI drops the screen it was drawing, and carries on sampling at the new rate without the circuit noticing.
The bench instruments
funcgen, scope and multimeter are the three instruments you drop on the canvas and wire in, and they are ordinary parts: no privileged path into the engine, nothing the catalog does not describe.
funcgenisgeneratorwith a sawtooth and a real output impedance: 50 ohms, stamped as the Norton pair (a current source ofv(t)/50with 50 ohms across it) because a part has no internal nodes to hang a series resistor from. A load divides against it, which is the first thing a bench generator teaches.generatorstays ideal and is still the right part for driving a node and forgetting about it.scopeis two 1 Mohm probes and a ground clip; it joins an island, never seeds one.multimeteris 10 Mohm betweenVandCOMon the volts ranges, a 1 ohm shunt betweenAandCOMon the current ranges (both of which pass a Thevenin through on the fast digital path and are stamped when an island reaches them), and, on ohms and continuity, a 30 uA test current with 100 kohm across it, which does seed an island. It integrates over a 250 ms aperture at 4 kHz and publishes one reading four times a second: the mean on a DC range, the true RMS of the AC part on an AC range, and, on ohms, the resistance that test current implies,NaNabove 1 Mohm. Above about a kilohertz an AC reading is under-sampled and reads low.
Contact bounce on the pushbutton
A real tactile switch closes, rebounds and settles, so pushbutton chatters on every press and every release. Its bounce property picks the profile:
bounce |
transitions per press or release | settles within |
|---|---|---|
none |
1 | at once |
typical |
3 to 8 | 1 to 5 ms |
worst |
up to 20 | 10 to 20 ms |
The default is typical: pick none when the button is not the point of the circuit, and worst to show why a sketch has to debounce. Everything wired to the button sees the chatter as ordinary edges (a 74HC input, an NE555 trigger, a board's GPIO), because the button drives its nets the same way it always did; nothing else in the contract changes.
Each burst starts with an immediate edge (the contact does make when pressed) and plays the rest out as wakeups on the timing wheel, the last of them landing exactly on the settle time. The count is odd, so a burst always ends on the state that was asked for. A poke arriving mid-burst (released while still chattering) cancels the rest and starts a fresh burst. The gap lengths come from a per-part random stream seeded from the part's index in the netlist, so two buttons in one circuit chatter differently, successive presses differ, and the same netlist poked the same way produces the same edges to the picosecond: a run is reproducible.
A part visual may declare interaction: 'hold' (pointer down pokes 1, up pokes 0) or 'toggle' (a click pokes 0 when the probe is at least 0.5, else 1). The canvas never names a part type.
Everything the UI needs to draw a part is in web/. Everything a part needs to behave is in crates/parts. They meet only on type, pin names, and props.
5. Run loop (UI side)
Sim.runForNs(ns) advances simulated time by ns and returns, with nowNs() reporting the new total. The UI calls it from requestAnimationFrame, targeting simulated time = wall time, with a per-frame wall budget (say 12 ms); if the simulator falls behind, the UI shows the achieved speed ratio rather than freezing. The simulator runs in a Web Worker (web/src/sim/client.ts), so the page stays responsive while it does.
probes(), netLevels() and netEdges() are read once per frame, together, into the snapshot the UI paints from. netEdges() is there because a level read once a frame is only where each net ended up: a bouncing contact makes twenty edges in a few milliseconds, well inside one 16 ms frame, and a lesson that asks for them has to be able to count them. An edge is a change of clean level (low to unknown to high is one; low to floating to low is none), and a flip that is taken back inside the same instant is not counted at all. Reading the counts resets them, so the frame loop is the only caller. netVoltages() is read on the same pass but only when something on the page is showing volts: the Show levels readout (web/src/ui/levels.ts), which is off by default. A snapshot that did not ask for them carries an empty array, so a net voltage per net per frame costs nothing until it is being read.
6. Electrical model
Every driver on a net is a voltage with a source impedance (Drive { voltage, impedance, level }). A net's voltage is the impedance-weighted mean of its drivers (Millman). A strong digital output has a few tens of ohms, a pull-up has its resistor value, high-Z is infinite. The digital level of a net comes from the lowest-impedance driver; if equal-impedance drivers disagree it is Unknown; with no drivers it is Floating. Two-terminal parts read the Thevenin equivalent seen at one pin, excluding their own contribution, and drive the other pin accordingly.
That is the fast path, and it is what every net with nothing analog on it still does.
6a. Analog islands
A part whose behavior is a differential equation over several nodes at once (a capacitor, an inductor, an op-amp, a signal generator) declares itself analog through the Component::analog capability, and the kernel gathers every net it touches into an island solved as one matrix by crates/analog: modified nodal analysis, an LU with partial pivoting, a DC operating point at power-on with every capacitor discharged and every inductor at zero current, and a transient with trapezoidal companion models and a step chosen by local truncation error.
The two halves meet in three places and nowhere else.
- Going in. Every driver on an island net that does not belong to the island is collapsed by Millman into one voltage behind one resistance and stamped as a Thevenin source on that node. A digital pin is a Thevenin source; that is the whole of the interface.
- Coming out. The island owns one driver slot on each of its nets, like any other pin, and writes the island's own Thevenin equivalent at that node (the island with that net's boundary driver removed), so that resolving the two by the ordinary Millman rule reproduces the solved node voltage exactly.
- Time. The island books its own next step on the timing wheel. A settled island with no moving source books nothing at all, so an idle analog circuit costs what an idle digital one costs.
A part with an analog input threshold (a comparator, the two trip points of a 555) calls Ctx::watch(pin, volts, hysteresis); the island then refines its step onto the crossing instead of stepping over it, so the trip time is right to a microsecond rather than to the length of an integration step. Off an island the call costs nothing and changes nothing. A microcontroller's digital input does the same through Ctx::read_input(pin, threshold), which watches the pin at the chip's own switching level and then reads it: every board reads its input pads this way, so a slow analog edge reaches a 3.3 V part and a 5 V part at the voltage each would really switch at, not at the kernel's one LOGIC_THRESHOLD. An input pin presents no load to the island beyond the pull-up or pull-down its firmware enables; the sub-microamp leakage of a real CMOS input is not modeled.
Islands step at most every 2 ms and at least every 100 ns, and hold the step to a sixty-fourth of whatever time constant is moving fastest, so a trace sampled between steps is never more than that behind. A resistor joins an island that reaches it but never creates one; a scope probe is 1 Mohm to its ground clip and does the same.
6b. Nonlinear islands
A part with device physics in it (a diode, an LED, a bipolar transistor, a MOSFET) declares AnalogPart::devices alongside its elements and names them from an Element::Device { nodes: [Node; 4], dev: u32 }, whose dev indexes that part's own device list. Element stays Copy; the devices are Box<dyn Device> on the island. The kernel offsets each part's device indices as it merges the parts into an island and knows nothing else about them: no device name reaches sim-kernel, and the physics lives entirely in crates/parts behind a trait defined in crates/analog.
A Device does three things. It loads a companion model (conductances and equivalent current sources) from the terminal voltages at the current iterate. It limits the voltages the last solve produced before the next load, which for a pn junction is SPICE's pnjlim and is the difference between a diode that converges and one that overflows exp. And it reports its parameters as a plain vector of numbers, so Ctx::restamp_device(which, params) can move a beta without rebuilding the island, exactly as Ctx::restamp moves a resistance.
The solve is Newton-Raphson: load every device, factor, solve, test every unknown against reltol |x| + abstol (volts on a node row, amps on a branch row), limit, repeat, to a cap of Limits::max_iter. An island with no devices takes exactly one pass and produces the matrix it always did, which the textbook tests assert to the digit. An operating point Newton cannot reach from a cold start falls back to gmin stepping (a milliohm-scale leak on every node, walked back down to GMIN), and then to source stepping, and then reports failure rather than pretending. A transient step that will not converge halves and retries on the same budget the error controller uses; at the floor the iterate stands and the failure is counted in Solver::stats(), which also carries iterations, limited iterations and rejected steps so tests can assert on them rather than on wall time.
The devices in phase 1: a diode (Shockley with Is, N and a bulk Rs), an LED (the same junction with the color's drop at 20 mA behind it), a bipolar transistor (Ebers-Moll in its transport form with Bf, Br, Is and an Early voltage, NPN and PNP) and a MOSFET (level 1 Shichman-Hodges with Vto, beta and lambda, N and P channel). A part uses the same device off an island as on one: a two-terminal part solves the one loop through itself against the same equation, and a three-terminal part builds a private three-node island with its own device on it and asks the same solver. There is one model per device and never two.
Those were the devices of phase 1. Since then the analyses have landed (the operating point, DC and parameter sweeps and the AC frequency response, section 7), every device card carries its junction capacitance, and the op-amp is a single-pole macromodel. On the canvas, temperature is still fixed at 27 C; a SPICE deck (section 7e) has .temp.
What the implementation does that the three rules above do not say
- Islands are found once, at
Kernel::init. The kernel freezes its topology there, so a wire change is a new netlist and a new kernel, which is what the UI already does on every edit. There is no incremental rediscovery and there does not need to be. - A part is told whether it joined.
Component::analog_joined(bool)is called once after discovery. A part that was stamped must not also drive its pins, and this is where a capacitor stops running its own integrator, a resistor stops passing a Thevenin along and a pot stops driving its wiper. - A part can ask for the solved values.
Component::analog_solved(currents, volts)is called after every solve, for the elements that part declared, when itsAnalogPartasked for it withreporting(). An inductor's current is a state of the matrix rather than a function of the node voltages, so it is the one thing a part cannot read back off its own nets. - Values can change without a rebuild.
Ctx::restamp(&[Element])hands the kernel the part's stamps as they are now; the kernel takes the values out of them, leaves the nodes alone, and re-solves at that instant holding every capacitor voltage and every inductor current. A potentiometer's knob is what this is for. The shape of an island never changes; only the numbers in it do. - A pin in the air is a node of its own. An analog pin wired to nothing gets a fresh node with nothing but gmin on it, so a capacitor with one leg hanging holds its charge and drives nothing.
- An island parks on a one-second horizon. It stops booking steps when nothing it holds would move more than a tolerance over the next second of simulated time. Asking whether anything will happen in the next step is the wrong question: the tail of an exponential moves by less than a tolerance per step long before it has arrived.
- The kernel can say how much it is solving.
Kernel::island_count,island_of_netandisland_nodesexist for the tests that hold the fast path to its promise. A digital netlist builds no island, andcrates/sim-kernel/tests/kernel.rsasserts it on the 497-part proof circuit along with the dispatch count that circuit has always cost.
Both potentiometers join an island the same way a resistor does: a track is two resistors, R x f from the low end to the wiper and R x (1 - f) from the wiper to the high end, and off an island those two are folded into a Thevenin source per pin instead.
7. Analyses
Calls that do not advance simulated time. Everything else on the surface goes through runForNs; these run an analysis and hand back an answer, which is a different shape for the contract and is the reason this section exists.
7a. operatingPoint(): string
{
"tNs": 0,
"levels": [1, 0, 0],
"volts": [5.0, 2.5, 0.0],
"analogNets": [false, true, false],
"probes": [5.0, 0.00025, 0.00025, 0.0],
"parts": [
{ "index": 1, "id": "r1", "type": "resistor",
"elements": [ { "kind": "resistor", "amps": 0.00025, "volts": 2.5 } ] }
],
"error": ""
}
levels, volts and analogNets are one entry per net in nets[] order; probes is one per part in parts[] order. A voltage that is not a number (a floating net) comes back as null, never as a number.
It reports the solution the engine holds now and never re-solves. At tNs of zero, before anything has run, that is the power-on operating point: every capacitor discharged, every inductor at zero current. That is a circuit that has just been switched on and it is not SPICE's .op, where a capacitor is an open circuit. For the settled DC answer, run the circuit until it has settled and take the operating point then. tNs is in the result so a readout can say which of the two it is showing.
analogNets[i] is true when net i was solved by the matrix and false when it was resolved by the per-driver model. They are different kinds of answer and a readout has to be able to say so.
parts holds only the parts stamped into an analog island, with one entry per element they stamped, in stamp order. amps is positive flowing from the element's first terminal to its second and volts is the voltage across those two in the same order. A part with no entry has no branch current in the matrix; that is not the same as having no current, and probes is usually the answer there: a resistor's probe is its current whether or not it ended up in a matrix (catalog probe says what each part's probe means).
7b. sweep(specJson: string): string
{ "part": "v1", "prop": "voltage", "from": 0, "to": 5, "step": 0.25 }
{ "part": "p1", "poke": true, "from": 0, "to": 1023, "step": 64 }
settleNs may be added to either; it runs each point that far past power-on before reading it, and anything above zero means the point is no longer a DC answer.
{
"source": { "part": "v1", "prop": "voltage", "discrete": false, "continued": false },
"settleNs": 0,
"parts": [ { "index": 1, "id": "r1", "type": "resistor", "kind": "resistor", "elements": 1 } ],
"points": [
{ "value": 0, "levels": [0,0,0], "volts": [0,0,0], "currents": [0], "probes": [0,0,0,0], "error": "" }
],
"error": ""
}
currents is parallel to parts and carries each analog part's first element. An analysis that could not run comes back with error set and points empty; it never throws.
Two paths, because there are two kinds of settable thing in this engine.
proprebuilds the circuit at each point from the netlist with that one number changed and powers it on, so every point is a cold-start DC operating point andcontinuedis false. A property that is not a number is refused in words, and so is a property the part does not have.poke: truesteps the part's poke code on the live circuit (a potentiometer's shaft), which restamps and re-solves in place, socontinuedis true anddiscreteis true, because a shaft is a whole count and not a continuum. The part is put back where the sweep found it.
The most points one sweep takes is 1001. Past that it is refused with the count in the message rather than run.
7c. setMaxStepNs(ns, fixed) and stepLimitNs()
Limits.dt_max as a setting. ns is the longest step any analog island may take; fixed pins the floor to it as well, so the error controller has exactly one step to choose from. Zero or less clears it and puts every island back on the kernel's own 2 ms ceiling.
Two things still shorten a forced step and both are deliberate: a source discontinuity is landed on rather than integrated through, and a watched logic threshold inside a step is refined onto the crossing. Nothing ever lengthens one, so a forced step is a promise of "at least this fine".
A part can ask for the same thing on its own island with Ctx::limit_step(dt, fixed), and the finest request on an island wins. The scope part's step property is that call: zero, the default, leaves the controller alone.
7d. acSweep(specJson: string): string
{ "source": "gen1", "probe": 4, "fStart": 10, "fStop": 100000, "pointsPerDecade": 20 }
input may be added: the net index the gain is taken against. Left out, it is the net the source's own element drives, which is what a person means by "the input" when they picked a generator.
{
"source": { "part": "gen1", "type": "generator", "net": 0, "drive": "vsource" },
"input": 0,
"probe": 1,
"tNs": 0,
"freq": [10, 11.22, 12.59],
"magDb": [-0.0004, -0.0005, -0.0006],
"phaseDeg": [-0.576, -0.646, -0.725],
"inputMag": [1, 1, 1],
"outputMag": [0.99995, 0.99994, 0.99992],
"error": ""
}
The five arrays are parallel and one entry long per frequency. magDb and phaseDeg are the ratio of the output phasor to the input phasor (20 log10 |Vout / Vin| and arg(Vout / Vin) in degrees), which is the only thing here that is a transfer function. inputMag and outputMag are the raw phasor magnitudes in whatever unit the stimulus is, and source.drive says which: vsource is one volt across the source, isource one amp into it, thevenin one volt open circuit behind its resistance. A function generator is a Norton pair, so its drive is isource and its inputMag is an impedance in ohms rather than a volt; the ratio is unaffected, which is the point of reporting the ratio.
freq is fStart * 10^(k / pointsPerDecade) up to and including fStop, which is the same grid .ac dec walks, so a sweep here and a sweep in a reference simulator with the same three numbers compare point by point. fStart must be above zero: zero hertz is DC, which is the operating point and not a point of a log sweep. The most points one sweep takes is 1001, the same cap sweep has.
It is taken about the operating point the engine is holding now, exactly as operatingPoint reports it, and re-solves nothing. At tNs of zero that is the power-on bias; after time has run it is the state at that instant, and tNs says which. Every nonlinear device is linearized at the iterate Newton left it at, and every op-amp in whichever mode the operating point put it in: an op-amp that saturated is stamped saturated, so its small-signal gain is zero.
Since Analog 3 phase 1 every device card carries its junction capacitance, and the AC stamp is j w C at the same dQ/dv the transient integrates, so a transistor has a high-frequency corner of its own and a common-emitter stage shows the Miller effect. What the model does not have is in crates/analog/src/ac.rs and repeated in docs/help/editor/analysis.md: a BJT has no reverse transit time TR, a MOSFET's gate-drain capacitance is a constant and so has no Miller plateau, the TIP120 Darlington has no capacitance at all; there is no noise analysis and no distortion analysis, because a linearized circuit has neither; and the op-amp is still the rail-limited linear one with flat gain to infinite frequency.
An analysis that could not run comes back with error set and the arrays empty; it never throws. The three things that go wrong say which they are: the named part has no source in it an AC analysis can drive, a net is not on the same analog island as the source, or the range is not a range.
7e. spiceRun(deck: string, filesJson: string): string
A free function of the module, not a method of Sim: it runs a SPICE deck
on crates/spice and has nothing to do with any loaded circuit. filesJson
is [{ "name": "models.lib", "text": "..." }], the files the deck may
.include or .lib (matched by exact name, then by file name alone), or an
empty string. The answer:
{ "ok": true, "title": "RC step",
"diagnostics": [{ "severity": "warning", "line": 4, "col": 1, "message": "..." }],
"results": [{ "kind": "tran", "notes": ["412 accepted steps"], "error": null,
"vectors": [{ "name": "time", "unit": "s", "data": [0, 1e-5] }] }],
"deck": "the flat deck, written back out" }
ok false means the deck did not read; diagnostics then lists every
error with its line and column and results is empty. kind is op,
tran, ac, dc, tf, noise or meas, one result per analysis card
per .step point per .temp, in that nesting, then the .meas values. AC
vectors come as pairs, re(v(out)) and im(v(out)), beside frequency; a
DC sweep has sweep (and sweep2 for the outer of a nested one). A number
that is not finite is null. The editor calls it through the simulation
worker (SimClient.spiceRun) and draws it with src/ui/spice-panel.ts.