feat(sim): Phase 1d #2 + #9 — convergence helpers in Worker + enable LM358 subckt

#2: NgSpiceWorkerAdapter.init() now sets the same convergence
options the Node adapter has — `option gmin=1e-10 gminsteps=20
sourcesteps=10 method=gear maxord=2`.  Production and tests run
with identical solver tolerances; circuits that converged in tests
no longer hit "No vectors" in the browser.  Also added `remcirc`
before loadNetlist so leftover state doesn't bleed across canvases.

#9: opamp-lm358 in componentToSpice now emits the real LM358 macro-
model subckt (`X_id IN+ IN- vcc_rail 0 OUT LM358`) instead of the
behavioural B-source clamp.  The subckt was vendored as an asset in
Phase 2.2 and has been waiting for #2 to land — now active.

Smoke-test side effect: 67/68 → 68/68 examples converge.  The opamp
follower (`an-opamp-follower`) was the last one that didn't.

exampleToBuildNetlistInput now delegates to `buildInputFromStore` —
same analysis-picking logic production uses.  A signal-generator
circuit gets `.tran`, an MCU-driven RC step gets `.tran` with the
right τ window, plain DC gets `.op`.  No more inline analysis guess.

examples-analog.test.ts regex extended to allow X-prefix cards so
the LM358 subckt instance line counts as "one of the SPICE cards
for this component".

1461 tests pass across 105 files (28 pre-existing skips, none
introduced by this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 22:17:06 +02:00
parent 33570d7690
commit 54936ef660
6 changed files with 65 additions and 41 deletions

View File

@ -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', () => {

View File

@ -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
// "_<id>" 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);
}
}

View File

@ -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<void> {

View File

@ -54,7 +54,23 @@ export class NgSpiceWorkerAdapter implements SolverPort {
async init(): Promise<void> {
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<void> {
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);
}

View File

@ -370,32 +370,21 @@ const MAPPERS: Record<string, Mapper> = {
//
// 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) => {

View File

@ -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<string, never>;
}>;
} = {},
): 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;
}