feat(sim): Phase 1b continued, step 4 — bridge legacy solver into MixedModeScheduler

Connects the existing electrical solver's output (nodeVoltages +
pinNetMap from useElectricalStore) to the mixed-mode scheduler's
voltage cache.  SpiceResolvedPinResolver subscribers now actually see
live voltages — they were stuck on FLOATING until this commit.

Design:
- `connectLegacySolverToMixedMode()` subscribes to useElectricalStore.
  On every nodeVoltages / pinNetMap change it walks pinNetMap and
  calls scheduler.publishVoltage(componentId, pinName, v) for each
  pin.  Ground pins (canonical net '0') resolve to 0 V directly.
  NaN / Infinity voltages are skipped.
- `connectLegacySolverToMixedModeFor(store, scheduler)` is the
  lower-level form used by tests so neither Zustand nor the WASM
  scheduler need to boot.
- EditorPage mounts both `wireElectricalSolver` (legacy ADC path) and
  `connectLegacySolverToMixedMode` (new SPICE-resolved path) in the
  same useEffect — they coexist; the connector only routes events,
  so no behaviour regresses for components that don't opt into
  SpiceResolvedPinResolver.

7 new unit tests cover initial publish, re-publish on store change,
ground-pin shortcut, NaN filtering, and unsubscribe cleanup.

This is the wiring that completes Phase 1b's end-to-end pipe.  The
WASM-driven onMcuPinChange path (loadCircuit + alter + tran in the
scheduler itself) stays available for future migration off the legacy
solver entirely — see Phase 1b doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 17:15:24 +02:00
parent 61b04e46f8
commit da07345bc0
3 changed files with 263 additions and 1 deletions

View File

@ -0,0 +1,167 @@
/**
* Phase 1b continued, step 4 tests for connectLegacySolverToMixedMode.
*
* Verifies that voltages produced by the legacy CircuitScheduler reach
* the MixedModeScheduler's voltage cache, so SpiceResolvedPinResolver
* subscribers actually see live voltages.
*
* Uses fake store + fake scheduler no Zustand, no WASM. Real
* EditorPage wiring is verified by manual smoke testing.
*/
import { describe, it, expect, vi } from 'vitest';
import {
connectLegacySolverToMixedModeFor,
type ElectricalStoreLike,
} from '../simulation/spice/connectLegacySolverToMixedMode';
function makeStore(initial: {
nodeVoltages: Record<string, number>;
pinNetMap: Map<string, string>;
}): {
store: ElectricalStoreLike;
set(
next: Partial<{ nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> }>,
): void;
} {
let state = { ...initial };
const listeners: Array<
(
state: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
prev: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
) => void
> = [];
return {
store: {
getState() {
return state;
},
subscribe(listener) {
listeners.push(listener);
return () => {
const i = listeners.indexOf(listener);
if (i >= 0) listeners.splice(i, 1);
};
},
},
set(next) {
const prev = state;
state = { ...state, ...next };
for (const l of listeners) l(state, prev);
},
};
}
function makeScheduler(): {
publishVoltage: (id: string, pin: string, v: number) => void;
calls: Array<{ id: string; pin: string; v: number }>;
} {
const calls: Array<{ id: string; pin: string; v: number }> = [];
return {
calls,
publishVoltage(id, pin, v) {
calls.push({ id, pin, v });
},
};
}
describe('connectLegacySolverToMixedMode', () => {
it('publishes the initial voltages immediately on subscribe', () => {
const { store } = makeStore({
nodeVoltages: { net_drain: 4.2, net_gate: 0.1 },
pinNetMap: new Map([
['q1:D', 'net_drain'],
['q1:G', 'net_gate'],
]),
});
const sched = makeScheduler();
const cancel = connectLegacySolverToMixedModeFor(store, sched);
expect(sched.calls).toEqual(
expect.arrayContaining([
{ id: 'q1', pin: 'D', v: 4.2 },
{ id: 'q1', pin: 'G', v: 0.1 },
]),
);
cancel();
});
it('skips nets that have no voltage in the solver result', () => {
const { store } = makeStore({
nodeVoltages: { net_drain: 3.3 },
pinNetMap: new Map([
['q1:D', 'net_drain'],
['q1:G', 'net_missing'],
]),
});
const sched = makeScheduler();
connectLegacySolverToMixedModeFor(store, sched);
expect(sched.calls).toEqual([{ id: 'q1', pin: 'D', v: 3.3 }]);
});
it('publishes 0 V for canonical ground pins regardless of nodeVoltages map', () => {
const { store } = makeStore({
nodeVoltages: {}, // ground is implicit — never appears in nodeVoltages
pinNetMap: new Map([
['q1:S', '0'],
['q1:D', 'net_drain'],
]),
});
const sched = makeScheduler();
connectLegacySolverToMixedModeFor(store, sched);
expect(sched.calls).toEqual([{ id: 'q1', pin: 'S', v: 0 }]);
});
it('re-publishes when nodeVoltages changes (subsequent solves)', () => {
const initial = makeStore({
nodeVoltages: { net: 1.0 },
pinNetMap: new Map([['c:p', 'net']]),
});
const sched = makeScheduler();
connectLegacySolverToMixedModeFor(initial.store, sched);
expect(sched.calls).toEqual([{ id: 'c', pin: 'p', v: 1.0 }]);
initial.set({ nodeVoltages: { net: 2.5 } });
expect(sched.calls).toEqual([
{ id: 'c', pin: 'p', v: 1.0 },
{ id: 'c', pin: 'p', v: 2.5 },
]);
});
it('re-publishes when pinNetMap changes (circuit rebuild)', () => {
const initial = makeStore({
nodeVoltages: { net_a: 1.5, net_b: 3.0 },
pinNetMap: new Map([['c:p', 'net_a']]),
});
const sched = makeScheduler();
connectLegacySolverToMixedModeFor(initial.store, sched);
initial.set({ pinNetMap: new Map([['c:p', 'net_b']]) });
expect(sched.calls.at(-1)).toEqual({ id: 'c', pin: 'p', v: 3.0 });
});
it('drops NaN / Infinity voltages silently — never publishes them', () => {
const { store } = makeStore({
nodeVoltages: { net_nan: Number.NaN, net_inf: Number.POSITIVE_INFINITY, net_ok: 1.2 },
pinNetMap: new Map([
['c:a', 'net_nan'],
['c:b', 'net_inf'],
['c:c', 'net_ok'],
]),
});
const sched = makeScheduler();
connectLegacySolverToMixedModeFor(store, sched);
expect(sched.calls).toEqual([{ id: 'c', pin: 'c', v: 1.2 }]);
});
it('unsubscribe stops future updates', () => {
const { store, set } = makeStore({
nodeVoltages: { n: 0.5 },
pinNetMap: new Map([['c:p', 'n']]),
});
const sched = makeScheduler();
const cancel = connectLegacySolverToMixedModeFor(store, sched);
cancel();
set({ nodeVoltages: { n: 1.5 } });
// Only the initial publish — no update after unsubscribe.
expect(sched.calls).toHaveLength(1);
});
});

View File

@ -5,6 +5,7 @@
import React, { useRef, useState, useCallback, useEffect, lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { wireElectricalSolver } from '../simulation/spice/subscribeToStore';
import { connectLegacySolverToMixedMode } from '../simulation/spice/connectLegacySolverToMixedMode';
import { useSEO } from '../utils/useSEO';
import { CodeEditor } from '../components/editor/CodeEditor';
import { EditorToolbar } from '../components/editor/EditorToolbar';
@ -91,7 +92,11 @@ export const EditorPage: React.FC = () => {
// ── Electrical simulation subscriber (one-time, idempotent) ───────────────
useEffect(() => {
const unsub = wireElectricalSolver();
return unsub;
const unsubMixedMode = connectLegacySolverToMixedMode();
return () => {
unsub();
unsubMixedMode();
};
}, []);
// ── GitHub star prompt (show once: 2nd visit OR after 3 min) ──────────────

View File

@ -0,0 +1,90 @@
/**
* Bridges the legacy electrical solver's output into the MixedModeScheduler's
* voltage cache. Phase 1b continued, step 4.
*
* Why:
* The Phase 1b skeleton introduced `SpiceResolvedPinResolver`, which lets a
* component pin downstream of a BJT/MOSFET/op-amp consume voltages from a
* SpiceVoltageSource (the scheduler). Until step 4, nothing wrote to that
* cache, so SPICE-resolved components saw FLOATING forever.
*
* The cleanest first wiring is to reuse the existing solver: every time
* the legacy `CircuitScheduler` produces fresh `nodeVoltages`, walk the
* pinNetMap and republish each (component, pin) voltage into the mixed-mode
* scheduler. The legacy ADC injection path is unchanged.
*
* What this does NOT do:
* - It does not call `scheduler.loadCircuit` or `scheduler.onMcuPinChange`
* (the WASM-driven path). Those wait until we're ready to replace the
* legacy CircuitScheduler entirely.
* - It does not start the WASM engine. `getMixedModeScheduler()` is used
* purely as a fan-out for voltage events.
*
* Lifecycle:
* Call from `EditorPage` alongside `wireElectricalSolver()`. Returns an
* unsubscribe function for cleanup on unmount.
*/
import { useElectricalStore } from '../../store/useElectricalStore';
import { getMixedModeScheduler } from './MixedModeScheduler';
/** Stripped-down store shape so this module can be unit-tested with a fake. */
export interface ElectricalStoreLike {
getState(): {
nodeVoltages: Record<string, number>;
pinNetMap: Map<string, string>;
};
subscribe(
listener: (
state: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
prev: { nodeVoltages: Record<string, number>; pinNetMap: Map<string, string> },
) => void,
): () => void;
}
interface SchedulerLike {
publishVoltage(componentId: string, pinName: string, voltage: number): void;
}
function publishOnce(store: ElectricalStoreLike, scheduler: SchedulerLike): void {
const { nodeVoltages, pinNetMap } = store.getState();
for (const [key, net] of pinNetMap) {
const idx = key.indexOf(':');
if (idx < 0) continue;
const componentId = key.slice(0, idx);
const pinName = key.slice(idx + 1);
if (net === '0') {
scheduler.publishVoltage(componentId, pinName, 0);
continue;
}
const v = nodeVoltages[net];
if (typeof v === 'number' && Number.isFinite(v)) {
scheduler.publishVoltage(componentId, pinName, v);
}
}
}
/**
* Default entry point subscribes against the live useElectricalStore and
* the singleton MixedModeScheduler. Returns an unsubscribe handle.
*/
export function connectLegacySolverToMixedMode(): () => void {
return connectLegacySolverToMixedModeFor(
useElectricalStore as unknown as ElectricalStoreLike,
getMixedModeScheduler(),
);
}
/**
* Lower-level form for tests accepts the store and scheduler explicitly.
*/
export function connectLegacySolverToMixedModeFor(
store: ElectricalStoreLike,
scheduler: SchedulerLike,
): () => void {
publishOnce(store, scheduler);
return store.subscribe((state, prev) => {
if (state.nodeVoltages !== prev.nodeVoltages || state.pinNetMap !== prev.pinNetMap) {
publishOnce(store, scheduler);
}
});
}