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, .dc with nested sweeps, .tf, .noise and .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, .pz and .disto analyses, 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 defines partIndex used by probes() and poke().
  • nets[j] order defines netIndex used by netLevels() and netVoltages().
  • 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 at create.
  • 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.

  • funcgen is generator with a sawtooth and a real output impedance: 50 ohms, stamped as the Norton pair (a current source of v(t)/50 with 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. generator stays ideal and is still the right part for driving a node and forgetting about it.
  • scope is two 1 Mohm probes and a ground clip; it joins an island, never seeds one.
  • multimeter is 10 Mohm between V and COM on the volts ranges, a 1 ohm shunt between A and COM on 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, NaN above 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 its AnalogPart asked for it with reporting(). 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_net and island_nodes exist for the tests that hold the fast path to its promise. A digital netlist builds no island, and crates/sim-kernel/tests/kernel.rs asserts 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.

  • prop rebuilds 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 and continued is false. A property that is not a number is refused in words, and so is a property the part does not have.
  • poke: true steps the part's poke code on the live circuit (a potentiometer's shaft), which restamps and re-solves in place, so continued is true and discrete is 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.

Questions

Is this SPICE?

For netlists, yes: Run a SPICE deck, in the editor menu, runs .op, .tran, .ac, .dc, .tf, .noise and .meas on a SPICE engine checked against ngspice on 42 benchmark circuits, without BSIM MOSFET models, XSPICE or distortion analysis. On the canvas, the analog corners of a circuit are solved as a matrix by nodal analysis, the same way SPICE does it, and everything else runs on the event-driven digital path, which is what lets a few hundred parts run at real time on a laptop.

What will it not tell me?

On the live canvas: noise, temperature, distortion or a transfer function, and no part has a power rating, so nothing burns out. The canvas does give the DC operating point, sweeps and a frequency response, and a SPICE deck adds noise, .tf and .temp. A deck does not run BSIM MOSFET models, XSPICE devices, Monte Carlo or distortion analysis.

How fast is it really?

The kernel holds a few hundred parts at real time, and the CPU cores run at roughly the speed of the real chips on an ordinary laptop.