Architecture
spice-rs is a faithful port of ngspice's core simulation engine in Rust. Every algorithm -- Newton-Raphson convergence, device model evaluation, timestep control, sparse factorization -- is translated directly from the ngspice C source code, preserving the same logic, the same control flow, and in many cases the same variable names.
The architecture mirrors ngspice's structure:
- Parser reads SPICE netlists into a circuit topology.
- Circuit builder allocates nodes, branches, and device instances.
- MNA system assembles the Modified Nodal Analysis matrix and RHS vectors.
- Device models stamp conductances and currents into the MNA matrix each Newton-Raphson iteration.
- NR solver (
ni_iter) drives the load-factor-solve loop until convergence. - Analysis engines orchestrate DC operating point, transient, AC, DC sweep, sensitivity, transfer function, and pole-zero analyses.
- Sparse solver (in the separate
sparse-rscrate) handles LU factorization and back-substitution.
The solver layer is decoupled: sparse-rs provides two independent backends (KLU and Markowitz), both ported from their respective C reference implementations. spice-rs uses the Markowitz backend by default, matching ngspice's default solver.
Key design decisions
One load() method per device. ngspice uses a single DEVload function pointer per device type that handles all modes (DC, transient, AC initialization). spice-rs follows this pattern exactly. Devices check mode flags internally to determine behavior.
Persistent MNA matrix. The matrix is created once, elements are allocated during setup, and values are cleared/restamped each NR iteration. The matrix is never moved, copied, or rebuilt. This matches ngspice's architecture and is critical for Markowitz solver correctness.
State vectors with history. Device state (charges, fluxes, junction voltages) is stored in flat arrays with 8 history levels, matching ngspice's CKTstates[0..7]. The arrays are rotated between timesteps using O(1) pointer swaps.
Crate Structure
The simulation system is split across five crates in the sim/ directory.
spice-rs (sim/spice-rs/)
The core SPICE engine. Contains:
parser-- SPICE netlist parser. Handles R, C, L, V, I, D, M, Q, J, E, G, F, H, T device lines plus.MODEL,.OP,.TRAN,.DC,.AC,.TF,.SENS,.PZ,.OPTIONS,.IC,.NODESET, and.END.circuit-- Circuit topology: nodes, branches, device instances, node-name-to-equation-number mapping.mna-- Modified Nodal Analysis matrix system. Wraps the Markowitz sparse matrix with TRANSLATE (external-to-internal node remapping), element caching, and RHS vectors.solver--SimState(mutable simulation state) andni_iter()(Newton-Raphson iteration loop, port of ngspiceNIiter).analysis/-- Analysis engines:dc,transient,ac,tf,sens,pz.device/-- Device models:resistor,capacitor,inductor,vsource,isource,diode,mosfet1,mosfet2,mosfet3,bsim3,bsim4,bjt,jfet,vcvs,vccs,ccvs,cccs,tline,mutual_inductor.integration-- Numerical integration: trapezoidal rule companion model computation.state--StateVectors: arena-allocated per-device state with 8 history levels.config--SimConfig: simulation options (tolerances, temperature, iteration limits).runner-- High-level entry point:run_netlist(text) -> HashMap<String, f64>.
sparse-rs (sim/sparse-rs/)
Pure Rust sparse direct solver. Two independent backends:
klu/-- Gilbert-Peierls LU factorization with BTF decomposition and AMD column ordering. Port of SuiteSparse KLU.markowitz/-- Markowitz pivoting with diagonal preference. Port of Sparse 1.3 (Kundert, 1988). Arena-based linked-list matrix with u32 indices.
ngspice-ffi (sim/ngspice-ffi/)
Safe Rust wrapper around libngspice (shared library). Used exclusively by the eval harness -- never by the simulation engine itself.
spice-eval (sim/spice-eval/)
Validation harness that runs test circuits through both spice-rs and ngspice (via ngspice-ffi), then compares results.
spice-rs-wasm (sim/spice-rs-wasm/)
wasm-bindgen bindings exposing spice-rs to the browser.
Dependency graph
spice-eval
|-- spice-rs
| +-- sparse-rs
+-- ngspice-ffi
+-- libngspice (C, linked at build time)
spice-rs-wasm
|-- spice-rs
| +-- sparse-rs
|-- ferrite-schematic-render
+-- ferrite-data-model
Data Flow
The simulation pipeline from netlist text to results follows ngspice's structure: parse, setup, temperature, analyze, extract.
+-------------------+
netlist text ---> | parse_netlist() | ---> ParseResult
+-------------------+
|
+-------------------+
| circuit.setup() | allocate state vectors
+-------------------+
|
+--------------------------------+
| resolve_coupled_inductors() | link K <-> L
+--------------------------------+
|
+------------------------+
| circuit.temperature() | temp-dependent params
+------------------------+
|
+------------------------------+
| analysis engine |
| (dc_operating_point / |
| transient / ac_analysis / |
| dc_sweep / ...) |
+------------------------------+
|
+------------------------------+
| extract_node_values() | solution -> HashMap
+------------------------------+
Step-by-step
1. Parse: The parser reads each line and builds the circuit topology. Device lines create device instances and allocate nodes. .MODEL lines populate model parameter structs. Analysis directives set the Analysis enum.
2. Setup: Calls device.setup(&mut states) on every device. Each device allocates contiguous state vector slots. After all devices have allocated, states.finalize() resizes all 8 history arrays.
3. Coupled inductors: Links K elements to their referenced L elements by name lookup.
4. Temperature: Calls device.temperature(temp, tnom) on every device. Devices compute temperature-dependent parameters.
5. Analysis engine: Creates a SimState, calls device.setup_matrix(&mut mna) to pre-allocate matrix elements, then runs the appropriate analysis.
6. Extract: Maps equation numbers back to node names.
The NR iteration loop
ni_iter() is the innermost loop and the performance-critical path. Each iteration:
mna.clear()-- zero all matrix elements and RHS.device.pre_load()-- inductor flux computation (first pass).device.load()-- stamp conductances and currents.- Add
diag_gminto matrix diagonal (if nonzero). mna.solve()-- Markowitz LU factorization + forward/backward substitution.- Convergence check.
- If converged and
NEWCONV: rundevice.conv_test()for per-device checks. - Swap
rhsandrhs_oldfor the next iteration.
Device Trait
All circuit components implement the Device trait, matching ngspice's SPICEdev function pointer table.
pub trait Device: std::fmt::Debug + Any {
fn name(&self) -> &str;
// --- Setup phase ---
fn setup(&mut self, states: &mut StateVectors) -> usize { 0 }
fn setup_matrix(&mut self, mna: &mut MnaSystem) {}
fn setic(&mut self, rhs: &[f64]) {}
fn temperature(&mut self, temp: f64, tnom: f64) {}
// --- NR iteration ---
fn pre_load(&mut self, mna: &mut MnaSystem,
states: &mut StateVectors, mode: Mode) {}
fn load(&mut self, mna: &mut MnaSystem,
states: &mut StateVectors, mode: Mode,
src_fact: f64, gmin: f64,
noncon: &mut bool) -> Result<(), SimError>;
fn conv_test(&self, mna: &MnaSystem,
states: &StateVectors,
reltol: f64, abstol: f64) -> bool { true }
// --- Transient ---
fn truncate(&self, states: &StateVectors) -> f64 { f64::INFINITY }
fn accept(&mut self, states: &StateVectors) {}
// --- AC ---
fn ac_load(&mut self, mna: &mut MnaSystem,
states: &StateVectors,
omega: f64) -> Result<(), SimError> { Ok(()) }
// --- Pole-Zero ---
fn pz_load(&mut self, mna: &mut MnaSystem,
s_re: f64, s_im: f64) -> Result<(), SimError> { Ok(()) }
}
Device inventory
| Prefix | Device | Source | States |
|---|---|---|---|
| R | Resistor | resistor.rs |
0 |
| C | Capacitor | capacitor.rs |
2 |
| L | Inductor | inductor.rs |
2 |
| K | Mutual Inductor | mutual_inductor.rs |
0 |
| V | Voltage Source | vsource.rs |
0 |
| I | Current Source | isource.rs |
0 |
| D | Diode | diode.rs |
5 |
| M (Level 1) | MOSFET Level 1 | mosfet1.rs |
17 |
| M (Level 2) | MOSFET Level 2 | mosfet2.rs |
17 |
| M (Level 3) | MOSFET Level 3 | mosfet3.rs |
17 |
| M (BSIM3) | BSIM3v3 | bsim3.rs |
17 |
| M (BSIM4) | BSIM4 | bsim4.rs |
17 |
| Q | BJT | bjt.rs |
13 |
| J | JFET | jfet.rs |
7 |
| E | VCVS | vcvs.rs |
0 |
| G | VCCS | vccs.rs |
0 |
| F | CCCS | cccs.rs |
0 |
| H | CCVS | ccvs.rs |
0 |
| T | Transmission Line | tline.rs |
6 |
State Management
Device state -- capacitor charges, inductor fluxes, junction voltages, terminal currents -- is stored in a flat arena with multiple history levels. This matches ngspice's CKTstates[0..7] arrays.
StateVectors
pub struct StateVectors {
states: [Vec<f64>; 8],
num_states: usize,
}
Eight parallel arrays, each of length num_states. Indexed by (level, offset):
states[0]-- current values (being computed this NR iteration)states[1]-- previous accepted timepointstates[2..7]-- older history (for higher-order integration methods)
Allocation
During circuit.setup(), each device calls states.allocate(count) to claim a contiguous block of slots. The allocator is a simple bump allocator.
History rotation
Between accepted transient timesteps, the state vectors are rotated:
states.rotate(max_order);
This is O(1) -- it swaps Vec ownership without copying data.
Numerical integration
The integration system converts stored charges to currents using the companion model. ni_integrate() (port of ngspice NIintegrate) takes the ag coefficients, a charge state offset, and the capacitance value, and returns the companion model
= equivalent conductance to stamp on the matrix diagonal = equivalent current source to stamp in the RHS
For trapezoidal:
Charges and currents are stored at adjacent offsets: qcap = charge, qcap + 1 = current (derivative of charge).
Truncation error
ckt_terr() (port of ngspice CKTterr) estimates the local truncation error for a charge state and returns the maximum safe timestep. The transient engine takes the minimum across all devices.