diff --git a/frontend/src/__tests__/pin-resolver-phase1b.test.ts b/frontend/src/__tests__/pin-resolver-phase1b.test.ts new file mode 100644 index 00000000..f9cdc284 --- /dev/null +++ b/frontend/src/__tests__/pin-resolver-phase1b.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index b96e6ffb..a2226cee 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -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 = ({ // 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 = ({ 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 = ({ 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, diff --git a/frontend/src/simulation/PinResolver.ts b/frontend/src/simulation/PinResolver.ts index cbfd6ca3..bc833cf7 100644 --- a/frontend/src/simulation/PinResolver.ts +++ b/frontend/src/simulation/PinResolver.ts @@ -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); + } + }); + }, + }; +} diff --git a/frontend/src/simulation/spice/MixedModeScheduler.ts b/frontend/src/simulation/spice/MixedModeScheduler.ts new file mode 100644 index 00000000..d58c04a8 --- /dev/null +++ b/frontend/src/simulation/spice/MixedModeScheduler.ts @@ -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(); + private running = false; + private initPromise: Promise | 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 { + 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__ dc ` + 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;