velxio/frontend/src/__tests__/mixed-mode-bjt-switch-integ...

164 lines
6.0 KiB
TypeScript
Raw Normal View History

/**
* Phase 1b end-to-end integration — real ngspice driving real
* SpiceResolvedPinResolver subscribers.
*
* Scenario: Arduino pin 9 → 1k → BJT base; collector via 220Ω to 5V;
* emitter to GND. When pin 9 is HIGH (5V), the BJT saturates and the
* collector pulls LOW; when pin 9 is LOW, the collector floats HIGH.
* A SpiceResolvedPinResolver subscribed to "led:A" (whose net equals
* the BJT collector net) must emit HIGH / LOW transitions tracking the
* real SPICE solve.
*
* Coverage:
* - NetlistBuilder produces a usable netlist + pinNetMap
* - runNetlist (eecircuit-engine, real ngspice) solves it
* - connectLegacySolverToMixedModeFor publishes the voltages
* - SpiceResolvedPinResolver threshold-converts via AVR_HC family
*
* Skips the mock — this is the highest-confidence proof we can run in
* a node test that the pipe doesn't have hidden conversion bugs.
*/
import { describe, it, expect, vi, afterEach } 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 {
getMixedModeScheduler,
__resetMixedModeScheduler,
} from '../simulation/spice/MixedModeScheduler';
import {
createSpiceResolvedPinResolver,
configFromLogicFamily,
} from '../simulation/PinResolver';
import { FAMILIES } from '../simulation/LogicFamilies';
import type { BuildNetlistInput } from '../simulation/spice/types';
afterEach(() => {
__resetMixedModeScheduler();
});
function bjtSwitchNetlist(pin9V: number): BuildNetlistInput {
return {
components: [
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
{ id: 'rc', metadataId: 'resistor', properties: { value: '220' } },
{ id: 'q1', metadataId: 'bjt-2n2222', properties: {} },
// 'led' isn't here — we model just the BJT and read its collector net.
// Adding a real LED + V-sense would prove the same idea but makes the
// assertion noisier.
],
wires: [
// Arduino 9 → Rb → BJT base
{ id: 'w1', start: { componentId: 'uno', pinName: '9' }, end: { componentId: 'rb', pinName: '1' } },
{ id: 'w2', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'q1', pinName: 'B' } },
// 5V → Rc → BJT collector
{ id: 'w3', start: { componentId: 'uno', pinName: '5V' }, end: { componentId: 'rc', pinName: '1' } },
{ id: 'w4', start: { componentId: 'rc', pinName: '2' }, end: { componentId: 'q1', pinName: 'C' } },
// BJT emitter → GND
{ id: 'w5', start: { componentId: 'q1', pinName: 'E' }, 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: pin9V },
},
groundPinNames: ['GND'],
vccPinNames: ['5V'],
},
],
analysis: { kind: 'op' },
};
}
/** Solve, then return the (nodeVoltages, pinNetMap) snapshot in the shape
* connectLegacySolverToMixedModeFor expects. */
async function solveAndSnapshot(input: BuildNetlistInput): Promise<{
nodeVoltages: Record<string, number>;
pinNetMap: Map<string, string>;
}> {
const { netlist, pinNetMap } = buildNetlist(input);
const result = await runNetlist(netlist);
const nodeVoltages: Record<string, number> = {};
for (const name of result.variableNames) {
const m = name.toLowerCase().match(/^v\((.+)\)$/);
if (!m) continue;
nodeVoltages[m[1]] = result.dcValue(name);
}
return { nodeVoltages, pinNetMap };
}
describe('Mixed-mode end-to-end — BJT switch with real ngspice', () => {
it(
'SpiceResolvedPinResolver emits LOW when BJT is saturated (pin 9 HIGH)',
{ timeout: 30_000 },
async () => {
const scheduler = getMixedModeScheduler();
const snapshot = await solveAndSnapshot(bjtSwitchNetlist(5));
feat(sim): Phase 1c G+F3 — retire legacy CircuitScheduler / eecircuit-engine The mixed-mode migration's endgame. After this commit there is ONE SPICE solver path in the codebase — the vendored ngspice WASM via SolverPort, behind both NgSpiceWorkerAdapter (production browser) and NgSpiceNodeAdapter (Vitest Node). Zero hybrids; zero legacy left to maintain. Deleted production files: • simulation/spice/CircuitScheduler.ts (200ms-poll legacy) • simulation/spice/SpiceEngine.ts (eecircuit-engine wrap) • simulation/spice/SpiceEngine.lazy.ts (lazy code-split) • simulation/spice/subscribeToStore.ts (legacy solve loop) • simulation/spice/connectLegacySolverToMixedMode.ts (bridge) • simulation/spice/connectMixedModeSchedulerToStore.ts (feature flag) Deleted tests (no longer cover any live code): • connect-legacy-solver-to-mixed-mode.test.ts • connect-mixed-mode-scheduler-to-store.test.ts • spice-rectifier-live-bootstrap.test.ts Migrated 6 tests off the deleted `circuitScheduler.solveNow` API to the new `__tests__/helpers/solveInput.ts` (same shape, backed by NgSpiceNodeAdapter). `useElectricalStore` rewritten as a pure state container: • setSolveResult(snapshot) — atomic publish from the service • paused / setPaused — UI control unchanged • reset — project unload • REMOVED: triggerSolve, solveNow, setDebounceMs, scheduler hook • REMOVED: dependency on SpiceEngine.lazy preload EditorPage now mounts a single `startSimulation()` from `simulation/spice/start.ts`, which constructs CircuitSimulationService + ADC bridge + MCU edge bridge. Four useEffect calls collapsed to one. `circuitVerifier.ts` (production) and `runNetlist.ts` use an environment-aware factory: Web Worker in browser, in-proc WASM in Node tests. `/* @vite-ignore */` keeps the Node adapter chain (node:fs, node:url) out of the browser bundle while still letting Node resolve it dynamically. Removed `eecircuit-engine` from package.json dependencies. `collectPinStates` extracted to its own module so the service doesn't depend on the (now deleted) subscribeToStore.ts. Verification: • 1392/1392 tests pass across 103 files (28 pre-existing skips). • `tsc --noEmit` clean. • `vite build` succeeds (27 s, only the existing chunk-size warning that pre-dates this work). Phase 1c — COMPLETE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:46:34 +07:00
// Publish the snapshot voltages into the scheduler cache so
// SpiceResolvedPinResolver consumers see them.
for (const [key, net] of snapshot.pinNetMap) {
const idx = key.indexOf(':');
if (idx < 0) continue;
const componentId = key.slice(0, idx);
const pinName = key.slice(idx + 1);
if (net === '0') {
scheduler.publishVoltage(componentId, pinName, 0);
continue;
}
const v = snapshot.nodeVoltages[net];
if (typeof v === 'number') scheduler.publishVoltage(componentId, pinName, v);
}
// Resolver for "q1:C" — the BJT collector pin.
const resolver = createSpiceResolvedPinResolver(
'q1',
'C',
scheduler,
configFromLogicFamily(FAMILIES.AVR_HC),
);
const cb = vi.fn();
resolver.onChange(cb);
// BJT saturated → Vc ≈ Vce(sat) ≈ 0.1–0.3 V, well below AVR_HC vil=1.0V → LOW.
expect(resolver.getCurrentState()).toBe('LOW');
expect(resolver.getCurrentVoltage()).toBeLessThan(1.0);
},
);
it(
'SpiceResolvedPinResolver emits HIGH when BJT is cut off (pin 9 LOW)',
{ timeout: 30_000 },
async () => {
const scheduler = getMixedModeScheduler();
const snapshot = await solveAndSnapshot(bjtSwitchNetlist(0));
feat(sim): Phase 1c G+F3 — retire legacy CircuitScheduler / eecircuit-engine The mixed-mode migration's endgame. After this commit there is ONE SPICE solver path in the codebase — the vendored ngspice WASM via SolverPort, behind both NgSpiceWorkerAdapter (production browser) and NgSpiceNodeAdapter (Vitest Node). Zero hybrids; zero legacy left to maintain. Deleted production files: • simulation/spice/CircuitScheduler.ts (200ms-poll legacy) • simulation/spice/SpiceEngine.ts (eecircuit-engine wrap) • simulation/spice/SpiceEngine.lazy.ts (lazy code-split) • simulation/spice/subscribeToStore.ts (legacy solve loop) • simulation/spice/connectLegacySolverToMixedMode.ts (bridge) • simulation/spice/connectMixedModeSchedulerToStore.ts (feature flag) Deleted tests (no longer cover any live code): • connect-legacy-solver-to-mixed-mode.test.ts • connect-mixed-mode-scheduler-to-store.test.ts • spice-rectifier-live-bootstrap.test.ts Migrated 6 tests off the deleted `circuitScheduler.solveNow` API to the new `__tests__/helpers/solveInput.ts` (same shape, backed by NgSpiceNodeAdapter). `useElectricalStore` rewritten as a pure state container: • setSolveResult(snapshot) — atomic publish from the service • paused / setPaused — UI control unchanged • reset — project unload • REMOVED: triggerSolve, solveNow, setDebounceMs, scheduler hook • REMOVED: dependency on SpiceEngine.lazy preload EditorPage now mounts a single `startSimulation()` from `simulation/spice/start.ts`, which constructs CircuitSimulationService + ADC bridge + MCU edge bridge. Four useEffect calls collapsed to one. `circuitVerifier.ts` (production) and `runNetlist.ts` use an environment-aware factory: Web Worker in browser, in-proc WASM in Node tests. `/* @vite-ignore */` keeps the Node adapter chain (node:fs, node:url) out of the browser bundle while still letting Node resolve it dynamically. Removed `eecircuit-engine` from package.json dependencies. `collectPinStates` extracted to its own module so the service doesn't depend on the (now deleted) subscribeToStore.ts. Verification: • 1392/1392 tests pass across 103 files (28 pre-existing skips). • `tsc --noEmit` clean. • `vite build` succeeds (27 s, only the existing chunk-size warning that pre-dates this work). Phase 1c — COMPLETE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:46:34 +07:00
for (const [key, net] of snapshot.pinNetMap) {
const idx = key.indexOf(':');
if (idx < 0) continue;
const componentId = key.slice(0, idx);
const pinName = key.slice(idx + 1);
if (net === '0') {
scheduler.publishVoltage(componentId, pinName, 0);
continue;
}
const v = snapshot.nodeVoltages[net];
if (typeof v === 'number') scheduler.publishVoltage(componentId, pinName, v);
}
const resolver = createSpiceResolvedPinResolver(
'q1',
'C',
scheduler,
configFromLogicFamily(FAMILIES.AVR_HC),
);
// BJT cut off → Vc ≈ 5V (no current through Rc) → above AVR_HC vih=3.0V → HIGH.
expect(resolver.getCurrentVoltage()).toBeGreaterThan(3.0);
expect(resolver.getCurrentState()).toBe('HIGH');
},
);
});