#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:
parent
33570d7690
commit
54936ef660
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue