velxio/test/test_circuit/test/spice_rc_lowpass_bug.test.js

147 lines
6.0 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import { runNetlist } from '../src/spice/SpiceEngine.js';
/**
* BUG REPRO — "RC Low-Pass Filter" example ships with a singular matrix.
*
* User-visible symptom (frontend console, right after loading the example):
* Warning: singular matrix: check node n1
* Warning: Dynamic gmin stepping failed
* Note: Starting true gmin stepping
* Warning: True gmin stepping failed
* Note: Starting source stepping
* Warning: source stepping failed
* Note: Transient op started
* Note: Transient op finished successfully
*
* Example circuit (frontend/src/data/examples-circuits.ts — 'rc-low-pass-filter'):
* arduino-uno:9 ── r1.1
* r1.2 ── arduino-uno:A0
* r1.2 ── c1.1
* c1.2 ── arduino-uno:GND
*
* What NetlistBuilder emits before the MCU starts running (pin 9 has not yet
* been driven by analogWrite, pm.getPinState(9) returns false → NO V source
* is stamped on pin 9):
*
* * Velxio circuit @ ...
* R_r1 n0 n1 10000
* C_c1 n1 0 10u IC=0
* .op
* .end
*
* Why the matrix is singular:
* - n0 (Arduino pin-9 net) is ONLY connected via R_r1 to n1.
* - n1 is connected via R_r1 to n0, and via C_c1 to ground.
* - In DC operating-point analysis a capacitor is an OPEN circuit.
* → n0 and n1 have NO DC path to ground → MNA matrix is singular.
* - NetlistBuilder.detectFloatingNets() says both nets are "DC-safe"
* because each is touched by at least one R — but "touched by R" does
* NOT imply "has a DC path to 0". The heuristic is wrong for
* capacitor-ended chains like this one.
*
* Note on timing: bare `.op` on a fully-floating circuit makes ngspice burn
* a long time in source stepping / gmin stepping before returning. Tests
* below use `.tran 1m 10m` with the same topology so ngspice reaches the
* same "transient op failed → fall back to tran" path the frontend sees,
* but completes fast enough not to wedge the singleton engine.
*/
describe('BUG repro — RC low-pass filter, pin 9 not driven', () => {
it('buggy topology: warnings appear and V(n1) stays stuck at 0', { timeout: 60_000 }, async () => {
// Exact topology that NetlistBuilder emits for the example on load.
// No voltage source anywhere → n0 and n1 have no DC reference.
const netlist = `RC low-pass (buggy: floating input)
R_r1 n0 n1 10000
C_c1 n1 0 10u IC=0
.tran 1m 10m
.end`;
const { vec } = await runNetlist(netlist);
const v = vec('v(n1)');
// Entire circuit collapses to 0 V — capacitor initial condition dominates,
// pin 9 input is floating, so the user sees analogRead ≈ 0 forever.
for (const s of v) expect(Math.abs(s)).toBeLessThan(0.01);
});
it('fix via auto pull-down on the floating input node: V(n1) = 0V but matrix is solvable', { timeout: 60_000 }, async () => {
// Proper behaviour: when n0 has no DC path to 0, NetlistBuilder should
// emit a 100 MΩ pull-down on it. Same end-state voltage (pin 9 LOW = 0V)
// but no singular-matrix warnings and no failed source-stepping passes.
const netlist = `RC low-pass (fixed: pull-down on floating input)
R_r1 n0 n1 10000
C_c1 n1 0 10u IC=0
R_autopull_n0 n0 0 100Meg
.op
.end`;
const { dcValue } = await runNetlist(netlist);
expect(dcValue('v(n0)')).toBeCloseTo(0, 3);
expect(dcValue('v(n1)')).toBeCloseTo(0, 3);
});
it('when the MCU IS running (pin 9 PWM duty 50%): V(n1) charges to 2.5V with τ=100ms', { timeout: 60_000 }, async () => {
// Step stimulus representing analogWrite(9, 128) applied at t=0.
// PULSE-sourced .tran avoids the singular-matrix hang that pure DC+.op
// triggers in the eecircuit-engine WASM running in Node.
// τ = R*C = 10_000 * 10e-6 = 100 ms.
const netlist = `RC low-pass transient (step to 2.5V)
V_arduino_uno_9 n0 0 PULSE(0 2.5 0 1n 1n 10 20)
R_r1 n0 n1 10000
C_c1 n1 0 10u IC=0
.tran 1m 400m
.ic v(n1)=0
.end`;
const { vec } = await runNetlist(netlist);
const t = vec('time');
const v = vec('v(n1)');
const tau = 10_000 * 10e-6; // 100 ms
let bestI = 0, bestDt = Infinity;
for (let i = 0; i < t.length; i++) {
const d = Math.abs(t[i] - tau);
if (d < bestDt) { bestDt = d; bestI = i; }
}
const expected = 2.5 * (1 - 1 / Math.E);
expect(v[bestI]).toBeGreaterThan(expected * 0.90);
expect(v[bestI]).toBeLessThan(expected * 1.10);
// Steady state at t = 4τ is > 95% of final value.
expect(v[v.length - 1]).toBeCloseTo(2.5, 1);
});
});
describe('Root cause — NetlistBuilder.detectFloatingNets heuristic', () => {
it('minimal repro: R ending at a C-only node is NOT a DC-safe path', { timeout: 60_000 }, async () => {
// Same topology as the example, stripped to the smallest failing circuit.
// - n0 has only R_r1.
// - n1 has R_r1 + C_c1 (C is open in DC → does not count).
// Current heuristic: "any net with an R terminal is safe" → both marked
// safe → no pull-down emitted → singular matrix.
const netlist = `Smallest repro of the heuristic bug
R_r1 n0 n1 1k
C_c1 n1 0 1u IC=0
.tran 1m 10m
.end`;
const { vec } = await runNetlist(netlist);
const v = vec('v(n1)');
// Without a source, the solution is all zeros — .op was singular, .tran
// accepts the broken op-point and never moves.
for (const s of v) expect(Math.abs(s)).toBeLessThan(0.01);
});
it('proposed fix: DC reachability from 0 through R/L/V/I/S/B/E/X elements', { timeout: 60_000 }, async () => {
// Replace the current detectFloatingNets() with a graph walk from node "0"
// over DC-conducting cards. Any net NOT reached gets R_autopull_<n> 0 100Meg.
// Applying that rule to the minimal repro produces:
const netlist = `Heuristic fixed via proper DC reachability
R_r1 n0 n1 1k
C_c1 n1 0 1u IC=0
R_autopull_n0 n0 0 100Meg
.op
.end`;
const { dcValue } = await runNetlist(netlist);
expect(dcValue('v(n0)')).toBeCloseTo(0, 3);
expect(dcValue('v(n1)')).toBeCloseTo(0, 3);
});
});