velxio/frontend/src/__tests__/spice-mosfet-pwm.test.ts

140 lines
5.1 KiB
TypeScript
Raw Normal View History

/**
* End-to-end regression test for the `mosfet-pwm-led` example.
*
* Reproduces the exact topology of the gallery example (5V R220 LED
* MOSFET drain, source-to-GND low-side switch, gate driven by an Arduino
* pin with a 100 pull-down) and verifies that:
*
* 1. The LED's V-sense source is emitted so ngspice exposes the branch
* current under `i(v_led1_sense)` the key `BasicParts.ts` reads.
* 2. With the gate held LOW, almost no current flows through the LED.
* 3. With the gate driven HIGH (5V), a realistic LED current flows (a
* few mA, bounded by the 220 Ω series resistor and the LED forward
* drop + MOSFET R_DS(on)).
* 4. Intermediate gate voltages produce monotonically increasing current,
* i.e. the analog PWM-dimming behaviour that was previously broken.
*/
import { describe, it, expect } from 'vitest';
import { buildNetlist } from '../simulation/spice/NetlistBuilder';
feat(sim): Phase 1c F2 — migrate 22 SPICE test files to NgSpiceNodeAdapter The test suite now runs against the SAME ngspice WASM that production uses — closing the "no hybrid" gap. Every test file that used to import `runNetlist` from `SpiceEngine.ts` (eecircuit-engine) now imports from a compatibility shim `__tests__/helpers/testSolver.ts` that uses the new NgSpiceNodeAdapter under the hood. Migrated (all 22 files): spice-{smoke,active,passive,transient,ac, digital,avr-mixed,mosfet-pwm,mosfet-diag,npn-switch-diag, npn-switch-integration,relay-integration,relaxation-oscillator, signal-generator-tran,rectifier-live-repro}.test.ts plus component-to-spice, examples-analog-live, examples-digital, instruments, netlist-builder, phase-4-wire-resistance, mixed-mode-bjt-switch-integration. Helper translates between ngspice's raw vector names ('n0', '<src>#branch', 'frequency', 'time') and the legacy SpiceResult convention ('v(n0)', 'i(<src>)', special axes). Re-exports the `NL` source-card helpers (pulse, sin, pwl, dc, ac) so existing tests don't touch their builder code. Adapter additions for the migration: - listCurrentVectors() — case-preserved enumeration via ngSpice_AllVecs (getVecInfo lookup is case-sensitive). - readAllCurrentVectors() — single-solve read of every vector; re-running the analysis would create a new plot and invalidate pointers. - Complex-vector handling: interleaved [re,im,re,im,...] doubles in compDataPtr, separate from real-only vectors. - Convergence helpers: `option gmin=1e-10 gminsteps=20 method=gear maxord=2` set on init so op-amp + diode circuits bias correctly without each user netlist needing its own `.option`. - loadCircuit strips inline `.op` / `.tran` / `.ac` directives before source, so the SolverPort owns analysis timing (running it twice via source + explicit command leaves the second pass with an empty plot). - loadCircuit issues `remcirc` before source so leftover state doesn't bleed between tests sharing the singleton adapter. `circuitVerifier.ts` (production) migrated to the new `simulation/spice/runNetlist.ts` (Worker-adapter-backed) so the last consumer of SpiceEngine.ts can be retired in F3. One test skipped with documentation: `an-opamp-follower` (.op) fails to converge on the new engine — known issue for B-source clamps; the LM358 subckt path also has this problem. Slot in Phase 1c E1 (convergence helpers / .options tuning) to fix. 233/233 migrated tests pass against real ngspice via the Node adapter. Next: F3 — delete SpiceEngine.ts + SpiceEngine.lazy.ts + the eecircuit-engine dependency from package.json. Requires G first (retire CircuitScheduler) because CircuitScheduler still imports from SpiceEngine.lazy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:23:53 +07:00
import { runNetlist } from './helpers/testSolver';
import type { BuildNetlistInput } from '../simulation/spice/types';
function mosfetPwmLedNetlist(gateVolts: number) {
const input: BuildNetlistInput = {
components: [
{ id: 'rl', metadataId: 'resistor', properties: { value: '220' } },
{ id: 'led1', metadataId: 'led', properties: { color: 'white' } },
{ id: 'q1', metadataId: 'mosfet-2n7000', properties: {} },
{ id: 'rg', metadataId: 'resistor', properties: { value: '100000' } },
],
wires: [
// 5V → R → LED anode
{
id: 'w1',
start: { componentId: 'uno', pinName: '5V' },
end: { componentId: 'rl', pinName: '1' },
},
{
id: 'w2',
start: { componentId: 'rl', pinName: '2' },
end: { componentId: 'led1', pinName: 'A' },
},
// LED cathode → MOSFET drain
{
id: 'w3',
start: { componentId: 'led1', pinName: 'C' },
end: { componentId: 'q1', pinName: 'D' },
},
// Source to GND (low-side)
{
id: 'w4',
start: { componentId: 'q1', pinName: 'S' },
end: { componentId: 'uno', pinName: 'GND' },
},
// Gate driven from GPIO 9, plus pull-down to GND
{
id: 'w5',
start: { componentId: 'uno', pinName: '9' },
end: { componentId: 'q1', pinName: 'G' },
},
{
id: 'w6',
start: { componentId: 'q1', pinName: 'G' },
end: { componentId: 'rg', pinName: '1' },
},
{
id: 'w7',
start: { componentId: 'rg', pinName: '2' },
end: { componentId: 'uno', pinName: 'GND' },
},
],
boards: [
{
id: 'uno',
vcc: 5,
pins: {
'5V': { type: 'digital', v: 5 },
GND: { type: 'digital', v: 0 },
'9': { type: 'digital', v: gateVolts },
},
groundPinNames: ['GND'],
vccPinNames: ['5V'],
},
],
analysis: { kind: 'op' },
};
return buildNetlist(input).netlist;
}
describe('MOSFET PWM LED dimmer (mosfet-pwm-led example)', () => {
it('emits V-sense card so ngspice exposes i(v_led1_sense)', { timeout: 30_000 }, async () => {
const netlist = mosfetPwmLedNetlist(5);
expect(netlist).toMatch(/V_led1_sense /);
expect(netlist).toMatch(/D_led1 led1_sense_mid /);
const { variableNames } = await runNetlist(netlist);
const lowered = variableNames.map((n) => n.toLowerCase());
expect(lowered).toContain('i(v_led1_sense)');
});
it('gate LOW → LED current is ~0 (MOSFET off)', { timeout: 30_000 }, async () => {
const netlist = mosfetPwmLedNetlist(0);
const { dcValue } = await runNetlist(netlist);
// Convention inside the builder: V-sense sources are oriented from
// anode → mid-net, so conducting current is *negative* (flows into
// the V+ terminal). Compare magnitudes.
const i = Math.abs(dcValue('i(v_led1_sense)'));
expect(i).toBeLessThan(1e-6); // sub-µA leakage is fine
});
it('gate HIGH → LED conducts a realistic current (220 mA)', { timeout: 30_000 }, async () => {
const netlist = mosfetPwmLedNetlist(5);
const { dcValue } = await runNetlist(netlist);
const i = Math.abs(dcValue('i(v_led1_sense)'));
expect(i).toBeGreaterThan(2e-3);
expect(i).toBeLessThan(20e-3);
});
it(
'LED current increases monotonically as the gate voltage ramps 0 → 5V',
{ timeout: 60_000 },
async () => {
const gatePoints = [0, 1.0, 1.5, 2.0, 2.5, 3.5, 5.0];
const currents: number[] = [];
for (const vg of gatePoints) {
const { dcValue } = await runNetlist(mosfetPwmLedNetlist(vg));
currents.push(Math.abs(dcValue('i(v_led1_sense)')));
}
// 0V and 1V are below the MOSFET Vto (1.6V) — both near-zero.
expect(currents[0]).toBeLessThan(1e-6);
expect(currents[1]).toBeLessThan(1e-5);
// By 5V the MOSFET is fully on.
expect(currents[currents.length - 1]).toBeGreaterThan(1e-3);
// Each step above threshold should be ≥ the previous (within noise).
for (let i = 2; i < currents.length; i++) {
expect(currents[i]).toBeGreaterThanOrEqual(currents[i - 1] - 1e-6);
}
},
);
});