feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
This commit is contained in:
parent
d41be4f48b
commit
27c59664cd
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Phase 1b unit tests for PinResolver:
|
||||
* - isActiveDevice predicate against the full active-device list
|
||||
* - createSpiceResolvedPinResolver threshold conversion behavior
|
||||
* - SpiceVoltageSource subscription wiring
|
||||
*
|
||||
* Doesn't exercise actual SPICE because Phase 1b is the skeleton.
|
||||
* Phase 1b continued will add tests against MixedModeScheduler with a
|
||||
* real (mocked) NgSpiceInteractive.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
isActiveDevice,
|
||||
createSpiceResolvedPinResolver,
|
||||
type SpiceVoltageSource,
|
||||
} from '../simulation/PinResolver';
|
||||
|
||||
function mockSource(opts: { initialVoltage?: number | null } = {}): {
|
||||
source: SpiceVoltageSource;
|
||||
fire: (state: 'HIGH' | 'LOW' | 'FLOATING' | 'CONFLICT', voltage: number) => void;
|
||||
subscribers: Array<{ componentId: string; pinName: string; cb: (state: string, v: number) => void }>;
|
||||
} {
|
||||
const subscribers: Array<{ componentId: string; pinName: string; cb: (state: string, v: number) => void }> = [];
|
||||
let voltage = opts.initialVoltage ?? null;
|
||||
const source: SpiceVoltageSource = {
|
||||
subscribe(componentId, componentPinName, cb) {
|
||||
const entry = { componentId, pinName: componentPinName, cb: cb as (s: string, v: number) => void };
|
||||
subscribers.push(entry);
|
||||
return () => {
|
||||
const idx = subscribers.indexOf(entry);
|
||||
if (idx >= 0) subscribers.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
getCurrentVoltage() {
|
||||
return voltage;
|
||||
},
|
||||
};
|
||||
return {
|
||||
source,
|
||||
subscribers,
|
||||
fire(state, v) {
|
||||
voltage = v;
|
||||
for (const s of subscribers) s.cb(state, v);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('isActiveDevice', () => {
|
||||
it('reports true for every BJT/MOSFET/op-amp/diode part-number', () => {
|
||||
expect(isActiveDevice('bjt-2n2222')).toBe(true);
|
||||
expect(isActiveDevice('bjt-bc547')).toBe(true);
|
||||
expect(isActiveDevice('mosfet-2n7000')).toBe(true);
|
||||
expect(isActiveDevice('mosfet-irf540')).toBe(true);
|
||||
expect(isActiveDevice('opamp-lm358')).toBe(true);
|
||||
expect(isActiveDevice('opamp-ideal')).toBe(true);
|
||||
expect(isActiveDevice('diode-1n4148')).toBe(true);
|
||||
expect(isActiveDevice('led')).toBe(true);
|
||||
expect(isActiveDevice('relay')).toBe(true);
|
||||
expect(isActiveDevice('7805')).toBe(true);
|
||||
expect(isActiveDevice('lm317')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports false for passives and digital ICs', () => {
|
||||
expect(isActiveDevice('resistor')).toBe(false);
|
||||
expect(isActiveDevice('resistor-220')).toBe(false);
|
||||
expect(isActiveDevice('capacitor')).toBe(false);
|
||||
expect(isActiveDevice('cap-1u')).toBe(false);
|
||||
expect(isActiveDevice('inductor')).toBe(false);
|
||||
// Digital ICs are NOT routed through SPICE — they have their own
|
||||
// pre-decoded handlers
|
||||
expect(isActiveDevice('ic-74hc14')).toBe(false);
|
||||
expect(isActiveDevice('74hc595')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSpiceResolvedPinResolver', () => {
|
||||
it('reports FLOATING when the voltage source has no value yet', () => {
|
||||
const { source } = mockSource({ initialVoltage: null });
|
||||
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
|
||||
thresholdHigh: 2.5,
|
||||
thresholdLow: 2.5,
|
||||
vcc: 5,
|
||||
});
|
||||
expect(r.getCurrentState()).toBe('FLOATING');
|
||||
expect(r.getCurrentVoltage()).toBeNull();
|
||||
});
|
||||
|
||||
it('reports HIGH when voltage is above thresholdHigh', () => {
|
||||
const { source } = mockSource({ initialVoltage: 4.5 });
|
||||
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
|
||||
thresholdHigh: 2.5,
|
||||
thresholdLow: 2.5,
|
||||
vcc: 5,
|
||||
});
|
||||
expect(r.getCurrentState()).toBe('HIGH');
|
||||
expect(r.getCurrentVoltage()).toBeCloseTo(4.5);
|
||||
});
|
||||
|
||||
it('reports LOW when voltage is below thresholdLow', () => {
|
||||
const { source } = mockSource({ initialVoltage: 0.3 });
|
||||
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
|
||||
thresholdHigh: 2.5,
|
||||
thresholdLow: 2.5,
|
||||
vcc: 5,
|
||||
});
|
||||
expect(r.getCurrentState()).toBe('LOW');
|
||||
expect(r.getCurrentVoltage()).toBeCloseTo(0.3);
|
||||
});
|
||||
|
||||
it('emits state changes via onChange when the voltage crosses a threshold', () => {
|
||||
const { source, fire } = mockSource({ initialVoltage: 0 });
|
||||
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
|
||||
thresholdHigh: 2.5,
|
||||
thresholdLow: 2.5,
|
||||
vcc: 5,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
r.onChange(cb);
|
||||
|
||||
fire('LOW', 0.5);
|
||||
expect(cb).not.toHaveBeenCalled(); // still LOW, no transition
|
||||
|
||||
fire('HIGH', 4.5);
|
||||
expect(cb).toHaveBeenCalledWith('HIGH', 4.5);
|
||||
|
||||
fire('HIGH', 3.0);
|
||||
expect(cb).toHaveBeenCalledTimes(1); // already HIGH, no new transition
|
||||
|
||||
fire('LOW', 0.5);
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
expect(cb).toHaveBeenLastCalledWith('LOW', 0.5);
|
||||
});
|
||||
|
||||
it('respects the hysteresis dead band (different thresholds)', () => {
|
||||
const { source, fire } = mockSource({ initialVoltage: 0 });
|
||||
const r = createSpiceResolvedPinResolver('schmitt-1', 'A', source, {
|
||||
thresholdHigh: 3.0,
|
||||
thresholdLow: 1.5,
|
||||
vcc: 5,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
r.onChange(cb);
|
||||
|
||||
fire('LOW', 2.0); // in the dead band — stays LOW
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
fire('HIGH', 3.5); // crosses upper threshold → HIGH
|
||||
expect(cb).toHaveBeenCalledWith('HIGH', 3.5);
|
||||
cb.mockClear();
|
||||
|
||||
fire('HIGH', 2.0); // back in dead band — stays HIGH (hysteresis)
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
fire('LOW', 1.0); // crosses lower threshold → LOW
|
||||
expect(cb).toHaveBeenCalledWith('LOW', 1.0);
|
||||
});
|
||||
|
||||
it('returns an unsubscribe handle that detaches from the source', () => {
|
||||
const { source, subscribers, fire } = mockSource({ initialVoltage: 0 });
|
||||
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
|
||||
thresholdHigh: 2.5,
|
||||
thresholdLow: 2.5,
|
||||
vcc: 5,
|
||||
});
|
||||
const cb = vi.fn();
|
||||
const cancel = r.onChange(cb);
|
||||
|
||||
expect(subscribers.length).toBe(1);
|
||||
cancel();
|
||||
expect(subscribers.length).toBe(0);
|
||||
|
||||
fire('HIGH', 4.5);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -17,8 +17,14 @@ import { useSimulatorStore } from '../store/useSimulatorStore';
|
|||
import { useElectricalStore } from '../store/useElectricalStore';
|
||||
import { PartSimulationRegistry } from '../simulation/parts';
|
||||
import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
|
||||
import { createDefaultPinResolver, type PinResolver } from '../simulation/PinResolver';
|
||||
import {
|
||||
createDefaultPinResolver,
|
||||
createSpiceResolvedPinResolver,
|
||||
isActiveDevice,
|
||||
type PinResolver,
|
||||
} from '../simulation/PinResolver';
|
||||
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
||||
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
|
||||
|
||||
// Side-effect imports: register every web component we'll create at runtime.
|
||||
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
|
||||
|
|
@ -322,8 +328,23 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
|
||||
// Depth-limited BFS: trace from (fromId, fromPin) through wires,
|
||||
// traversing through passive components to reach a board pin.
|
||||
//
|
||||
// Phase 1b: the legacy `trace()` returns just the pin number
|
||||
// (backward compat); a sibling `traceDetailed()` returns the
|
||||
// same pin plus a `crossedActiveDevice` flag so the resolver
|
||||
// factory can decide between digital fast-path and SPICE-
|
||||
// resolved per-pin.
|
||||
const trace = (fromId: string, fromPin: string, depth: number): number | null => {
|
||||
if (depth > 6) return null;
|
||||
return traceDetailed(fromId, fromPin, depth).arduinoPin;
|
||||
};
|
||||
|
||||
const traceDetailed = (
|
||||
fromId: string,
|
||||
fromPin: string,
|
||||
depth: number,
|
||||
activeSeen = false,
|
||||
): { arduinoPin: number | null; crossedActiveDevice: boolean } => {
|
||||
if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen };
|
||||
|
||||
const wires = state.wires.filter(
|
||||
(w) =>
|
||||
|
|
@ -337,25 +358,30 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
const otherEp = selfEp === w.start ? w.end : w.start;
|
||||
|
||||
if (isBoardComponent(otherEp.componentId)) {
|
||||
// Direct board connection
|
||||
const boardKind =
|
||||
state.boards.find((b) => b.id === otherEp.componentId)?.boardKind ??
|
||||
otherEp.componentId;
|
||||
const pin = boardPinToNumber(boardKind, otherEp.pinName);
|
||||
if (pin !== null) return pin;
|
||||
if (pin !== null) return { arduinoPin: pin, crossedActiveDevice: activeSeen };
|
||||
} else {
|
||||
// Intermediate passive component — traverse through it
|
||||
const comp = state.components.find((c) => c.id === otherEp.componentId);
|
||||
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
|
||||
if (pair) {
|
||||
const [p1, p2] = pair;
|
||||
const otherPin = otherEp.pinName === p1 ? p2 : p1;
|
||||
const result = trace(otherEp.componentId, otherPin, depth + 1);
|
||||
if (result !== null) return result;
|
||||
const nowActive =
|
||||
activeSeen || (comp ? isActiveDevice(comp.metadataId) : false);
|
||||
const result = traceDetailed(
|
||||
otherEp.componentId,
|
||||
otherPin,
|
||||
depth + 1,
|
||||
nowActive,
|
||||
);
|
||||
if (result.arduinoPin !== null) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return { arduinoPin: null, crossedActiveDevice: activeSeen };
|
||||
};
|
||||
|
||||
return trace(id, componentPinName, 0);
|
||||
|
|
@ -380,6 +406,27 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
getPinState?: (pin: number) => boolean | null;
|
||||
};
|
||||
}).pinManager;
|
||||
|
||||
// Phase 1b: detect whether the path between this component pin and
|
||||
// an Arduino pin passes through any active device (BJT, MOSFET,
|
||||
// op-amp, diode, regulator). If yes → use the SPICE-resolved
|
||||
// resolver flavor so the digital state is derived from real node
|
||||
// voltages (handles transistor inversion, op-amp gain, diode
|
||||
// forward-drop, etc.). If no → use the legacy digital fast-path
|
||||
// (zero SPICE cost, identical to Phase 0 behavior).
|
||||
const detailed = traceDetailed(id, componentPinName, 0);
|
||||
if (detailed.crossedActiveDevice) {
|
||||
const scheduler = getMixedModeScheduler();
|
||||
// Threshold = vcc/2 with no hysteresis. Phase 3 will replace
|
||||
// this with per-logic-family Vil/Vih + Schmitt.
|
||||
const half = ownerBoardVcc / 2;
|
||||
return createSpiceResolvedPinResolver(id, componentPinName, scheduler, {
|
||||
thresholdHigh: half,
|
||||
thresholdLow: half,
|
||||
vcc: ownerBoardVcc,
|
||||
});
|
||||
}
|
||||
|
||||
return createDefaultPinResolver(
|
||||
id,
|
||||
componentPinName,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,55 @@ export type PinTracer = (
|
|||
componentPinName: string,
|
||||
) => number | null;
|
||||
|
||||
/**
|
||||
* Extended tracer that ALSO reports whether the path crossed an active
|
||||
* device (BJT, MOSFET, op-amp, diode). Used by the PinResolver factory
|
||||
* to decide between digital fast-path (no active devices on the path)
|
||||
* and SPICE-resolved (an active device is in series, so binary HIGH/LOW
|
||||
* doesn't capture the real signal).
|
||||
*
|
||||
* `crossedActiveDevice` is set during the trace walk in DynamicComponent;
|
||||
* the legacy `PinTracer` API (returns just the number) is preserved for
|
||||
* backward compat.
|
||||
*/
|
||||
export interface DetailedPinTrace {
|
||||
/** Same convention as PinTracer: >=0 pin, -1 GND, null unreached. */
|
||||
arduinoPin: number | null;
|
||||
/** True iff the trace passed through a BJT/MOSFET/op-amp/diode. */
|
||||
crossedActiveDevice: boolean;
|
||||
}
|
||||
|
||||
export type DetailedPinTracer = (
|
||||
componentId: string,
|
||||
componentPinName: string,
|
||||
) => DetailedPinTrace;
|
||||
|
||||
/**
|
||||
* Active-component metadata ids whose presence on a wire path means we
|
||||
* should defer to the SPICE solver instead of treating the path as a
|
||||
* direct digital connection. Includes every BJT, MOSFET, op-amp, and
|
||||
* diode the simulator currently models. Logic ICs (74HC, 4000-series)
|
||||
* are intentionally NOT in this list because they have their own
|
||||
* dedicated handlers that pre-decode the I2C/SPI/digital protocol —
|
||||
* SPICE-routing them would be wasteful and slower.
|
||||
*
|
||||
* Phase 5 will simplify this once every active component goes through
|
||||
* the same SPICE-resolved path uniformly.
|
||||
*/
|
||||
export const ACTIVE_DEVICE_PREFIXES: readonly string[] = Object.freeze([
|
||||
'bjt-',
|
||||
'mosfet-',
|
||||
'opamp-',
|
||||
'diode', // diode, diode-1n4148, etc.
|
||||
'led', // technically a diode — current sense uses SPICE
|
||||
'relay', // coil drive + contact behavior is electrically active
|
||||
'7805', '7812', '7905', 'lm317', // regulators (3-terminal active)
|
||||
]);
|
||||
|
||||
export function isActiveDevice(metadataId: string): boolean {
|
||||
return ACTIVE_DEVICE_PREFIXES.some((prefix) => metadataId.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default PinResolver for one (component, pin) pair.
|
||||
*
|
||||
|
|
@ -208,3 +257,94 @@ export function createDefaultPinResolver(
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── SPICE-resolved variant (Phase 1b) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Subscriber registry the SPICE-resolved resolver hooks into. Lives in
|
||||
* MixedModeScheduler so the scheduler can fan voltage updates out to
|
||||
* all interested resolvers.
|
||||
*
|
||||
* We use this seam type (instead of importing MixedModeScheduler here
|
||||
* directly) to keep PinResolver dependency-free of the scheduler.
|
||||
* Phase 1b continued can swap in any implementation conforming to this
|
||||
* shape, including test mocks.
|
||||
*/
|
||||
export interface SpiceVoltageSource {
|
||||
subscribe(
|
||||
componentId: string,
|
||||
componentPinName: string,
|
||||
cb: (state: PinState, voltage: number) => void,
|
||||
): () => void;
|
||||
/** Current voltage on the node connecting (componentId, pinName),
|
||||
* if SPICE has solved at least once. Null when unknown. */
|
||||
getCurrentVoltage(componentId: string, componentPinName: string): number | null;
|
||||
}
|
||||
|
||||
interface SpiceResolvedConfig {
|
||||
/** Vcc/2 by default — the threshold above which a voltage reads HIGH.
|
||||
* Phase 3 will replace this with per-logic-family thresholds. */
|
||||
thresholdHigh: number;
|
||||
/** Below this voltage reads LOW. Hysteresis between low/high prevents
|
||||
* oscillation; Phase 3 will refine per Schmitt-trigger inputs. */
|
||||
thresholdLow: number;
|
||||
/** Vcc — used to synthesise a digital voltage when the resolver
|
||||
* reports a logic state synchronously and SPICE hasn't yet solved. */
|
||||
vcc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SPICE-resolved PinResolver — instead of mirroring an Arduino pin's
|
||||
* digital state, it watches a SPICE node's voltage and threshold-
|
||||
* converts to HIGH/LOW/FLOATING. Used on paths that pass through
|
||||
* active devices (BJT, MOSFET, op-amp, diode) where the digital
|
||||
* fast-path can't represent the real signal.
|
||||
*
|
||||
* Phase 1b scaffolding: subscribes to the provided `SpiceVoltageSource`
|
||||
* and translates voltage events. When the source doesn't have a
|
||||
* voltage yet, the resolver reports `FLOATING` (NOT the legacy "tied to
|
||||
* Arduino HIGH/LOW") — that's intentional, because the scheduler hasn't
|
||||
* yet wired in the real SPICE engine. After Phase 1b continued, real
|
||||
* voltages will flow through this same surface.
|
||||
*/
|
||||
export function createSpiceResolvedPinResolver(
|
||||
componentId: string,
|
||||
componentPinName: string,
|
||||
voltageSource: SpiceVoltageSource,
|
||||
config: SpiceResolvedConfig,
|
||||
): PinResolver {
|
||||
let cached: PinState = 'FLOATING';
|
||||
let cachedVoltage: number | null = null;
|
||||
|
||||
function voltageToState(v: number | null): PinState {
|
||||
if (v === null || !Number.isFinite(v)) return 'FLOATING';
|
||||
if (v >= config.thresholdHigh) return 'HIGH';
|
||||
if (v <= config.thresholdLow) return 'LOW';
|
||||
// Voltages in the dead band stay at the last known state — this is
|
||||
// the basic hysteresis behavior that Phase 3 will refine per-family.
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Seed initial state from whatever the voltage source currently knows.
|
||||
cachedVoltage = voltageSource.getCurrentVoltage(componentId, componentPinName);
|
||||
cached = voltageToState(cachedVoltage);
|
||||
|
||||
return {
|
||||
getCurrentState(): PinState {
|
||||
return cached;
|
||||
},
|
||||
getCurrentVoltage(): number | null {
|
||||
return cachedVoltage;
|
||||
},
|
||||
onChange(cb): () => void {
|
||||
return voltageSource.subscribe(componentId, componentPinName, (_state, voltage) => {
|
||||
cachedVoltage = voltage;
|
||||
const next = voltageToState(voltage);
|
||||
if (next !== cached) {
|
||||
cached = next;
|
||||
cb(next, voltage);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* MixedModeScheduler — orchestrates the digital ↔ SPICE coupling for
|
||||
* Phase 1b of the mixed-mode simulator project.
|
||||
*
|
||||
* Architecture in three layers:
|
||||
*
|
||||
* ┌────────────────────────────┐
|
||||
* │ MCU sim (AVR / RP2040 / │ fires PinManager.onPinChange()
|
||||
* │ ESP32 bridge) │ events on every digitalWrite()
|
||||
* └─────────────┬──────────────┘
|
||||
* │ pin edge
|
||||
* ▼
|
||||
* ┌────────────────────────────┐
|
||||
* │ MixedModeScheduler │ batches edges, builds netlist via
|
||||
* │ • alter V_pin sources │ NetlistBuilder, drives ngspice via
|
||||
* │ • short tran advance │ NgSpiceInteractive
|
||||
* │ • read v(node) for each │
|
||||
* │ component pin │
|
||||
* └─────────────┬──────────────┘
|
||||
* │ node voltage event
|
||||
* ▼
|
||||
* ┌────────────────────────────┐
|
||||
* │ SpiceResolvedPinResolver │ threshold-converts v → HIGH/LOW,
|
||||
* │ │ fires component handler callback
|
||||
* └────────────────────────────┘
|
||||
*
|
||||
* Phase 1a vendored NgSpiceInteractive and the WASM build. This file is
|
||||
* the Phase 1b skeleton — the API and lifecycle are in place, but the
|
||||
* actual `alter + tran + readVec` loop is marked TODO because
|
||||
* (a) the WASM is single-threaded, so `bg_run` is not useful and we
|
||||
* need the short-tran workaround, and
|
||||
* (b) the netlist build flow needs to be re-wired from the existing
|
||||
* 200 ms polling in `subscribeToStore.ts` to event-driven.
|
||||
*
|
||||
* For now, the scheduler exposes the API surface that component
|
||||
* handlers and DynamicComponent will use, plus a `start()` /
|
||||
* `stop()` lifecycle controlled by `useSimulatorStore.boards[*].running`.
|
||||
* When `start()` is called the scheduler logs "started" and components
|
||||
* subscribing to it get FLOATING resolutions — i.e. behavior
|
||||
* indistinguishable from "SPICE not available". Phase 1b's next
|
||||
* sub-task replaces the stub data flow with real readVec calls.
|
||||
*
|
||||
* See:
|
||||
* project/sim-mixedmode/phase-01-mixed-mode-coupling.md
|
||||
* simulation/spice/wasm/NgSpiceInteractive.ts
|
||||
*/
|
||||
|
||||
import { NgSpiceInteractive } from './wasm/NgSpiceInteractive';
|
||||
import type { PinState, SpiceVoltageSource } from '../PinResolver';
|
||||
|
||||
/**
|
||||
* Identity of a "pin of interest" — a place a SpiceResolvedPinResolver
|
||||
* is watching for voltage changes. The (boardId, pinName) → SPICE-net
|
||||
* mapping is built lazily as components register.
|
||||
*/
|
||||
export interface NodeSubscription {
|
||||
componentId: string;
|
||||
componentPinName: string;
|
||||
cb: (state: PinState, voltage: number) => void;
|
||||
}
|
||||
|
||||
type SubscriptionToken = number;
|
||||
|
||||
/**
|
||||
* Singleton-style scheduler. Multiple components use the same SPICE
|
||||
* engine instance; there's no value in running parallel solvers.
|
||||
*
|
||||
* Phase 1b: the scheduler holds the engine + the subscription registry
|
||||
* but does NOT yet drive real SPICE solves on pin edges. Phase 1b
|
||||
* continued: implement the alter+tran+readVec loop, hook NetlistBuilder.
|
||||
*/
|
||||
class MixedModeSchedulerImpl implements SpiceVoltageSource {
|
||||
private engine: NgSpiceInteractive | null = null;
|
||||
private nextToken: SubscriptionToken = 1;
|
||||
private subscriptions = new Map<SubscriptionToken, NodeSubscription>();
|
||||
private running = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
/** True while the scheduler is actively driving the SPICE engine. */
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler. Lazy-loads the WASM engine on first call. No-op
|
||||
* if already running. Called from `useSimulatorStore` when any board
|
||||
* transitions to running.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
if (!this.engine) {
|
||||
this.engine = new NgSpiceInteractive();
|
||||
}
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.engine.init();
|
||||
}
|
||||
await this.initPromise;
|
||||
this.running = true;
|
||||
// TODO Phase 1b — wire up the alter+tran+readVec loop here.
|
||||
// Build initial netlist via NetlistBuilder, send to engine via
|
||||
// loadNetlist, then enter the event-driven update cycle.
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduler. Components stay subscribed but stop receiving
|
||||
* SPICE-resolved events until the next start().
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
// TODO Phase 1b — pause the SPICE driver loop. Engine instance is
|
||||
// intentionally kept warm so restart is cheap; dispose only on
|
||||
// unmount or shutdown.
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the engine entirely. Used on app unmount; in normal flow
|
||||
* we just stop() + start() to avoid re-paying the ~2-5 s WASM init
|
||||
* cost.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.running = false;
|
||||
if (this.engine) {
|
||||
this.engine.dispose();
|
||||
this.engine = null;
|
||||
}
|
||||
this.initPromise = null;
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a component pin to receive SPICE-resolved voltage events.
|
||||
* Implements the SpiceVoltageSource contract used by
|
||||
* `createSpiceResolvedPinResolver`. Returns an unsubscribe handle.
|
||||
*
|
||||
* Phase 1b: stub — no events ever fire. The caller's resolver will
|
||||
* report whatever its fallback state is (typically FLOATING) and
|
||||
* never transition. Phase 1b continued: actually emit events when
|
||||
* SPICE solves complete.
|
||||
*/
|
||||
subscribe(
|
||||
componentId: string,
|
||||
componentPinName: string,
|
||||
cb: (state: PinState, voltage: number) => void,
|
||||
): () => void {
|
||||
const token = this.nextToken++;
|
||||
this.subscriptions.set(token, { componentId, componentPinName, cb });
|
||||
return () => {
|
||||
this.subscriptions.delete(token);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the latest known voltage on a component pin's SPICE net.
|
||||
* Phase 1b skeleton: always returns null (we haven't started solving
|
||||
* yet). Phase 1b continued: query the NgSpiceInteractive engine's
|
||||
* last `readVec` cache and return the latest sample.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getCurrentVoltage(_componentId: string, _componentPinName: string): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the scheduler that an MCU pin changed state. Phase 1b will
|
||||
* translate this into `alter V_<board>_<pin> dc <value>` + a short
|
||||
* `tran` step, then read affected nodes and dispatch to subscribers.
|
||||
*
|
||||
* Phase 1b skeleton: no-op. Component handlers continue to receive
|
||||
* events from the legacy PinManager path until Phase 1b continued.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onMcuPinChange(_boardId: string, _pinName: string, _state: boolean, _vcc: number): void {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
|
||||
/** The one and only scheduler. Lazily constructed. */
|
||||
let instance: MixedModeSchedulerImpl | null = null;
|
||||
|
||||
export function getMixedModeScheduler(): MixedModeSchedulerImpl {
|
||||
if (!instance) instance = new MixedModeSchedulerImpl();
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Test helper — drops the singleton so test runs don't pollute each
|
||||
* other. NEVER call from production code. */
|
||||
export function __resetMixedModeScheduler(): void {
|
||||
if (instance) instance.dispose();
|
||||
instance = null;
|
||||
}
|
||||
|
||||
export type MixedModeScheduler = MixedModeSchedulerImpl;
|
||||
Loading…
Reference in New Issue