diff --git a/frontend/src/__tests__/examples-analog-live.test.ts b/frontend/src/__tests__/examples-analog-live.test.ts index 8c5354be..9a9d9796 100644 --- a/frontend/src/__tests__/examples-analog-live.test.ts +++ b/frontend/src/__tests__/examples-analog-live.test.ts @@ -21,11 +21,7 @@ const ARCHETYPES = [ 'an-voltage-divider', 'an-half-wave-rectifier', 'an-bjt-switch', - // 'an-opamp-follower' — skipped after the F2 migration to NgSpiceNodeAdapter: - // the LM358 behavioural B-source clamp fails `.op` convergence on the new - // engine (length=0 vectors despite a valid plot). The follower IS solved - // correctly under `.tran` (see phase-2-lm358 plan); convergence fix for - // .op is a separate ticket — Phase 1c E1 (convergence helpers). + 'an-opamp-follower', ]; describe('analogExamples — representative ngspice solves', () => { diff --git a/frontend/src/__tests__/examples-analog.test.ts b/frontend/src/__tests__/examples-analog.test.ts index 753cfa4f..0206fd97 100644 --- a/frontend/src/__tests__/examples-analog.test.ts +++ b/frontend/src/__tests__/examples-analog.test.ts @@ -132,10 +132,11 @@ describe('analogExamples — netlist generation', () => { analysis: { kind: 'op' }, }); for (const c of ex.components) { - // Cards begin with an element prefix (R/C/L/D/Q/M/E/S/V/B) followed by + // Cards begin with an element prefix (R/C/L/D/Q/M/E/S/V/B/X) followed by // "_" somewhere. The voltmeter e.g. emits "R_vm_vmR ...", so the - // id may be followed by any non-word-boundary character. - const re = new RegExp(`^[RCLDQMESVB]_${c.id}(?:_|\\b)`, 'm'); + // id may be followed by any non-word-boundary character. X is the + // SPICE subcircuit instance prefix (Phase 1d #9 LM358 macro-model). + const re = new RegExp(`^[RCLDQMESVBX]_${c.id}(?:_|\\b)`, 'm'); expect(netlist, `${ex.id} missing card for ${c.id} (${c.type})`).toMatch(re); } } diff --git a/frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts b/frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts index 0f2405d8..8a625f17 100644 --- a/frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts +++ b/frontend/src/simulation/spice/adapters/NgSpiceNodeAdapter.ts @@ -190,7 +190,7 @@ export class NgSpiceNodeAdapter implements SolverPort { // user netlist. Method=gear maxord=2 stabilises stiff transient // solves involving B-source clamps and reactive networks. this.api.command('set noaskquit'); - this.api.command('option gmin=1e-10 gminsteps=20 method=gear maxord=2'); + this.api.command('option gmin=1e-10 gminsteps=20 sourcesteps=10 method=gear maxord=2'); } async loadCircuit(netlist: string): Promise { diff --git a/frontend/src/simulation/spice/adapters/NgSpiceWorkerAdapter.ts b/frontend/src/simulation/spice/adapters/NgSpiceWorkerAdapter.ts index 7ee07d90..cf6d2edd 100644 --- a/frontend/src/simulation/spice/adapters/NgSpiceWorkerAdapter.ts +++ b/frontend/src/simulation/spice/adapters/NgSpiceWorkerAdapter.ts @@ -54,7 +54,23 @@ export class NgSpiceWorkerAdapter implements SolverPort { async init(): Promise { if (this.initialised) return; if (!this.initPromise) { - this.initPromise = this.client.init().then(() => { + this.initPromise = this.client.init().then(async () => { + // Convergence helpers — relaxed gmin lets op-amp + diode + // circuits bias correctly without each user netlist needing + // its own `.option`. Method=gear maxord=2 stabilises stiff + // transient solves involving B-source clamps and reactive + // networks. Mirrors NgSpiceNodeAdapter.initialiseNgspice so + // production and tests run with identical solver tolerances. + try { + await this.client.command('set noaskquit'); + await this.client.command( + 'option gmin=1e-10 gminsteps=20 sourcesteps=10 method=gear maxord=2', + ); + } catch { + // Ignore: the build always supports these options. If the + // command path is dead, the actual solve will fail loudly + // later anyway. + } this.initialised = true; }); } @@ -63,6 +79,13 @@ export class NgSpiceWorkerAdapter implements SolverPort { async loadCircuit(netlist: string): Promise { await this.init(); + // Drop the previous circuit deck so leftover state doesn't leak + // into the new solve. Mirrors the Node adapter's loadCircuit. + try { + await this.client.command('remcirc'); + } catch { + // No previous circuit — ignore. + } await this.client.loadNetlist(netlist); } diff --git a/frontend/src/simulation/spice/componentToSpice.ts b/frontend/src/simulation/spice/componentToSpice.ts index 72637215..95149309 100644 --- a/frontend/src/simulation/spice/componentToSpice.ts +++ b/frontend/src/simulation/spice/componentToSpice.ts @@ -370,32 +370,21 @@ const MAPPERS: Record = { // // Input impedance is 1 MΩ differential + a 10 MΩ common-mode load so the // netlist never has floating inputs during DC. - 'opamp-lm358': (comp, netLookup, ctx) => { + 'opamp-lm358': (comp, netLookup) => { const inp = netLookup('IN+'); const inn = netLookup('IN-'); const out = netLookup('OUT'); if (!inp || !inn || !out) return null; - // Phase 2.2 lesson: the full LM358 macro-model subckt is vendored at - // ./models/lm358Subckt.ts but doesn't converge on `.op` analysis - // because of its internal capacitors / inductors / poly sources. - // The follower test (Vin → IN+, OUT → IN-) hangs >60 s. The - // behavioural B-source clamp below is kept until either: - // (a) the default analysis switches to `.tran` (Phase 1c WASM - // loop will probably do this anyway), or - // (b) we add `.options gmin=1e-10` to the netlist and confirm - // convergence across all canvases. - // The subckt module remains exported so future work can opt in. - const A = 1e5; - const vLo = 0.05; - const vHi = ctx.vcc - 1.5; + // Phase 1d #9: real LM358 macro-model subckt enabled now that + // Phase 1d #2 added `.options gmin=1e-10 gminsteps=20 sourcesteps=10 + // method=gear maxord=2` to both adapters — the subckt converges + // where the prior `.op` skipped. Power rails wire implicitly to + // vcc_rail / 0 (the canvas doesn't draw op-amp power pins). + // Real slew rate (~0.5 V/µs), GBW (~1 MHz), and rail headroom + // come for free vs the prior behavioural B-source clamp. return { - cards: [ - `R_${comp.id}_inp ${inp} 0 10Meg`, - `R_${comp.id}_inn ${inn} 0 10Meg`, - `B_${comp.id} ${out} 0 V = max(${vLo}, min(${vHi}, ${A}*(V(${inp})-V(${inn}))))`, - `R_${comp.id}_out ${out} 0 1Meg`, - ], - modelsUsed: new Set(), + cards: [`X_${comp.id} ${inp} ${inn} vcc_rail 0 ${out} LM358`], + modelsUsed: new Set([LM358_SUBCKT]), }; }, 'opamp-lm741': (comp, netLookup, ctx) => { diff --git a/frontend/src/utils/exampleToBuildNetlistInput.ts b/frontend/src/utils/exampleToBuildNetlistInput.ts index b687a3fb..88d1d243 100644 --- a/frontend/src/utils/exampleToBuildNetlistInput.ts +++ b/frontend/src/utils/exampleToBuildNetlistInput.ts @@ -14,6 +14,8 @@ * the new behaviour automatically. */ import type { BuildNetlistInput, AnalysisMode } from '../simulation/spice/types'; +import { buildInputFromStore } from '../simulation/spice/storeAdapter'; +import type { BoardKind } from '../types/board'; import type { ExampleProject } from '../data/examples'; /** @@ -48,19 +50,28 @@ export function isBoardComponentType(componentType: string): boolean { /** * Convert an `ExampleProject` into a `BuildNetlistInput` ready for - * `NetlistBuilder.buildNetlist`. Boards are filtered out of the - * component list (they don't get SPICE cards — only V-sources via - * pin states), and component types lose their brand prefix. + * `NetlistBuilder.buildNetlist`. * - * Boards[] is empty by default — for smoke tests we don't need to - * stamp MCU pin voltages. Callers that DO need them (e.g. live - * tests of multi-board setups) pass `opts.boards` explicitly. + * Delegates to the production `buildInputFromStore` helper so the + * analysis-picking logic (`.op` vs `.tran` based on signal-generator / + * MCU-driven reactive networks) is shared with the real load path. + * That means a smoke test sees the SAME analysis kind a user would + * trigger by opening the example in the editor. + * + * Boards default to empty — for analog-only examples that's fine. + * Caller can override (e.g. testing a multi-board mixed example). */ export function exampleToBuildNetlistInput( example: ExampleProject, opts: { + /** Override the analysis-picking result (e.g. force `.op` for a smoke check). */ analysis?: AnalysisMode; - boards?: BuildNetlistInput['boards']; + /** Inject boards with MCU pin states (otherwise empty — see `pinStates: {}`). */ + boards?: Array<{ + id: string; + boardKind: BoardKind; + pinStates: Record; + }>; } = {}, ): BuildNetlistInput { const components = example.components @@ -75,12 +86,16 @@ export function exampleToBuildNetlistInput( id: w.id, start: { componentId: w.start.componentId, pinName: w.start.pinName }, end: { componentId: w.end.componentId, pinName: w.end.pinName }, + color: '#666', + waypoints: [], })); - return { + const input = buildInputFromStore({ components, wires, boards: opts.boards ?? [], - analysis: opts.analysis ?? { kind: 'op' }, - }; + }); + // Caller may override the auto-picked analysis (e.g. force `.op`). + if (opts.analysis) input.analysis = opts.analysis; + return input; }