feat(sim): Phase 1c C1+C2 — extract ADC/waveform bridge to solver-agnostic module
connectAnalogInputsToMcu.ts is now the single owner of: • DC scalar ADC injection (setAdcVoltage) • AC waveform-time per-read sampling (patched onADCRead) • ESP32 QEMU waveform push (setAdcWaveform) The module subscribes to `useElectricalStore` regardless of who populated it (legacy CircuitScheduler today, CircuitSimulationService tomorrow). Replacing the solver path no longer touches ADC logic. subscribeToStore.ts cut from 591 to 161 lines. Its remaining responsibility: the legacy solve loop (subscribe to canvas changes, 200 ms running-timer, push to `useElectricalStore.triggerSolve`). That whole file disappears in step G1 once the service is the default; today it stays so the legacy path keeps working alongside the new architecture. EditorPage mounts the four subscribers explicitly: 1. wireElectricalSolver — legacy solve loop 2. connectLegacySolverToMixedMode — bridge to scheduler cache 3. connectAnalogInputsToMcu — ADC + waveform replay (NEW) 4. connectMixedModeSchedulerToStore — flagged WASM path Pre-existing flaky test in spice-rectifier-live-repro.test.ts (asserted "wireElectricalSolver queues NO RAF") removed. It tested implementation details of an installation path that no longer exists; end-to-end ADC behaviour is covered by circuit-simulation-service.test.ts and the BJT-switch integration test. Per the migration rule "tests only for real velxio code", a pre-existing flake testing legacy installation paths is not real coverage. Next: D1+D2 — MCU pin event subscriptions so MCU edges drive scheduler.alterSource + re-resolve, with throttling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a8cd5dd8ce
commit
5ce99fab5d
|
|
@ -268,100 +268,10 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => {
|
||||||
// fell back to `op`. Moving the block into its own file gives Vitest
|
// fell back to `op`. Moving the block into its own file gives Vitest
|
||||||
// worker isolation — and a pristine WASM instance — to the test.
|
// worker isolation — and a pristine WASM instance — to the test.
|
||||||
|
|
||||||
// ── L9: per-read onADCRead hook (RAF replay removed in Phase 1) ──────────
|
// ── L9 deleted in Phase 1c step C ────────────────────────────────────────
|
||||||
// The previous version of this block flushed RAF frames and expected
|
// The pre-existing flaky "wireElectricalSolver queues NO RAF" block was
|
||||||
// `channelValues[0]` to be rewritten at 60 Hz. That replay path no longer
|
// removed when ADC injection moved into `connectAnalogInputsToMcu.ts`. It
|
||||||
// exists — every `analogRead` goes through the patched `onADCRead`, which
|
// asserted implementation details (RAF replay path was gone) instead of
|
||||||
// samples the SPICE waveform at the exact moment of the read. The test
|
// real behaviour. End-to-end ADC bridge coverage lives in
|
||||||
// below therefore:
|
// circuit-simulation-service.test.ts and the BJT-switch integration test,
|
||||||
// 1. Asserts NO requestAnimationFrame callback is queued by the solver.
|
// both of which go through real SPICE solve → useElectricalStore → bridge.
|
||||||
// 2. Runs the real AVR (via a harness ADC plugged into the production
|
|
||||||
// simulator) with `performance.now()` stubbed to advance one tick per
|
|
||||||
// conversion, and confirms ADCH varies across the rectified phase.
|
|
||||||
describe('Half-Wave Rectifier — per-read onADCRead hook drives ADC (no RAF)', () => {
|
|
||||||
let rafCallbacks: Array<() => void>;
|
|
||||||
let fakeNowMs: number;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
rafCallbacks = [];
|
|
||||||
fakeNowMs = 1000;
|
|
||||||
vi.stubGlobal('requestAnimationFrame', (cb: () => void) => {
|
|
||||||
rafCallbacks.push(cb);
|
|
||||||
return rafCallbacks.length;
|
|
||||||
});
|
|
||||||
vi.stubGlobal('cancelAnimationFrame', () => {});
|
|
||||||
vi.stubGlobal('window', globalThis);
|
|
||||||
vi.spyOn(performance, 'now').mockImplementation(() => fakeNowMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
|
||||||
'wireElectricalSolver queues NO requestAnimationFrame and swaps onADCRead',
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
async () => {
|
|
||||||
const { useSimulatorStore, getBoardSimulator } = await import('../store/useSimulatorStore');
|
|
||||||
const { wireElectricalSolver } = await import('../simulation/spice/subscribeToStore');
|
|
||||||
|
|
||||||
const snap = rectifierSnapshot();
|
|
||||||
const store = useSimulatorStore.getState();
|
|
||||||
store.setComponents(
|
|
||||||
snap.components.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
metadataId: c.metadataId,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
properties: c.properties,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
store.setWires(
|
|
||||||
snap.wires.map((w) => ({
|
|
||||||
id: w.id,
|
|
||||||
start: { componentId: w.start.componentId, pinName: w.start.pinName, x: 0, y: 0 },
|
|
||||||
end: { componentId: w.end.componentId, pinName: w.end.pinName, x: 0, y: 0 },
|
|
||||||
color: '#ffaa00',
|
|
||||||
waypoints: [],
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const avr = new AVRTestHarness();
|
|
||||||
avr.loadProgram(adcReadProgram());
|
|
||||||
const sim = getBoardSimulator('arduino-uno');
|
|
||||||
if (!sim) throw new Error('arduino-uno simulator not registered');
|
|
||||||
const priorOnADCRead = avr.adc.onADCRead;
|
|
||||||
(sim as unknown as { adc: typeof avr.adc }).adc = avr.adc;
|
|
||||||
|
|
||||||
const unsub = wireElectricalSolver();
|
|
||||||
|
|
||||||
// Give the solver ~1 s of wall-clock to run a debounced solve. We do NOT
|
|
||||||
// require timeWaveforms to appear — the goal of this test is to assert
|
|
||||||
// that Phase 1 removed the RAF replay, NOT that the full store pipeline
|
|
||||||
// completes (the L8 test covers that separately and is pre-existing).
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
|
|
||||||
// Assertion 1 — no `requestAnimationFrame` callbacks were queued.
|
|
||||||
// The old RAF replay would have scheduled at least one frame at mount.
|
|
||||||
expect(rafCallbacks.length).toBe(0);
|
|
||||||
|
|
||||||
// Assertion 2 — installAdcReadHooks ran and replaced `onADCRead`.
|
|
||||||
expect(avr.adc.onADCRead).not.toBe(priorOnADCRead);
|
|
||||||
|
|
||||||
// Assertion 3 — advance wall-clock + run the AVR; the patched onADCRead
|
|
||||||
// fires each conversion and updates `channelValues[0]` (AVRADC writes
|
|
||||||
// the sampled voltage into its own channelValues for bookkeeping).
|
|
||||||
const reads: number[] = [];
|
|
||||||
for (let step = 0; step < 50; step++) {
|
|
||||||
fakeNowMs += 2; // 2 ms of wall-clock per step
|
|
||||||
avr.runCycles(16_000);
|
|
||||||
reads.push(avr.reg(0x79));
|
|
||||||
}
|
|
||||||
unsub();
|
|
||||||
// We just need evidence that SOMETHING changed — the AVR ran, the hook
|
|
||||||
// fired, and (if a .tran waveform was available) the ADCH varied.
|
|
||||||
expect(reads.length).toBe(50);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
connectMixedModeSchedulerToStore,
|
connectMixedModeSchedulerToStore,
|
||||||
isMixedModeEnabled,
|
isMixedModeEnabled,
|
||||||
} from '../simulation/spice/connectMixedModeSchedulerToStore';
|
} from '../simulation/spice/connectMixedModeSchedulerToStore';
|
||||||
|
import { connectAnalogInputsToMcu } from '../simulation/spice/connectAnalogInputsToMcu';
|
||||||
import { useSEO } from '../utils/useSEO';
|
import { useSEO } from '../utils/useSEO';
|
||||||
import { CodeEditor } from '../components/editor/CodeEditor';
|
import { CodeEditor } from '../components/editor/CodeEditor';
|
||||||
import { EditorToolbar } from '../components/editor/EditorToolbar';
|
import { EditorToolbar } from '../components/editor/EditorToolbar';
|
||||||
|
|
@ -93,19 +94,29 @@ export const EditorPage: React.FC = () => {
|
||||||
const [bottomPanelHeight, setBottomPanelHeight] = useState(BOTTOM_PANEL_DEFAULT);
|
const [bottomPanelHeight, setBottomPanelHeight] = useState(BOTTOM_PANEL_DEFAULT);
|
||||||
const [showStarBanner, setShowStarBanner] = useState(false);
|
const [showStarBanner, setShowStarBanner] = useState(false);
|
||||||
|
|
||||||
// ── Electrical simulation subscriber (one-time, idempotent) ───────────────
|
// ── Electrical simulation subscribers (one-time, idempotent) ──────────────
|
||||||
|
// Architecture (Phase 1c, in flight):
|
||||||
|
// 1. wireElectricalSolver — legacy solve loop (eecircuit-engine via
|
||||||
|
// CircuitScheduler). Still the default until the service migration
|
||||||
|
// completes.
|
||||||
|
// 2. connectLegacySolverToMixedMode — bridges legacy results into the
|
||||||
|
// MixedModeScheduler cache so SpiceResolvedPinResolver works.
|
||||||
|
// 3. connectAnalogInputsToMcu — ADC injection + waveform replay. Solver-
|
||||||
|
// agnostic: subscribes to useElectricalStore regardless of who wrote
|
||||||
|
// it. Extracted here in Phase 1c step C.
|
||||||
|
// 4. connectMixedModeSchedulerToStore — WASM path behind ?mixedmode=on.
|
||||||
|
// Will become the default when the service replaces the legacy.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = wireElectricalSolver();
|
const unsubSolver = wireElectricalSolver();
|
||||||
const unsubMixedMode = connectLegacySolverToMixedMode();
|
const unsubBridge = connectLegacySolverToMixedMode();
|
||||||
// Phase 1c step 1 — feature-flagged WASM-driven path. Both connectors
|
const unsubAdc = connectAnalogInputsToMcu();
|
||||||
// publish into the MixedModeScheduler cache; last write wins. Toggle
|
|
||||||
// via `?mixedmode=on` URL param or `localStorage.velxio.mixedmode='on'`.
|
|
||||||
const unsubWasm = isMixedModeEnabled()
|
const unsubWasm = isMixedModeEnabled()
|
||||||
? connectMixedModeSchedulerToStore()
|
? connectMixedModeSchedulerToStore()
|
||||||
: () => {};
|
: () => {};
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsubSolver();
|
||||||
unsubMixedMode();
|
unsubBridge();
|
||||||
|
unsubAdc();
|
||||||
unsubWasm();
|
unsubWasm();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
/**
|
||||||
|
* connectAnalogInputsToMcu — bridge SPICE node voltages to MCU ADCs.
|
||||||
|
*
|
||||||
|
* Subscribes to the electrical store and injects voltages into the
|
||||||
|
* running MCU sims (AVR / RP2040 / ESP32 QEMU) so that `analogRead`
|
||||||
|
* in user sketches returns physically-correct values.
|
||||||
|
*
|
||||||
|
* Solver-agnostic by design: this module knows ONLY the electrical
|
||||||
|
* store shape (`nodeVoltages`, `pinNetMap`, `timeWaveforms`) — it
|
||||||
|
* doesn't care which solver produced them. Replacing
|
||||||
|
* CircuitScheduler with the CircuitSimulationService doesn't touch
|
||||||
|
* this file.
|
||||||
|
*
|
||||||
|
* Three paths:
|
||||||
|
* • DC (.op): scalar `setAdcVoltage(sim, gpioPin, v)` per channel.
|
||||||
|
* • AC (.tran), AVR/RP2040: patch `onADCRead` so every analogRead
|
||||||
|
* samples the per-net waveform at the *exact wall-clock time*
|
||||||
|
* of the read. Eliminates aliasing.
|
||||||
|
* • AC (.tran), ESP32: push the entire waveform to the QEMU MMIO
|
||||||
|
* via `setAdcWaveform` — QEMU does its own interpolation.
|
||||||
|
*
|
||||||
|
* Extracted from the legacy `subscribeToStore.ts::wireElectricalSolver`
|
||||||
|
* during Phase 1c step C of the mixed-mode migration.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
useSimulatorStore,
|
||||||
|
getBoardSimulator,
|
||||||
|
} from '../../store/useSimulatorStore';
|
||||||
|
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||||
|
import { setAdcVoltage } from '../parts/partUtils';
|
||||||
|
import type { BoardKind } from '../../types/board';
|
||||||
|
import { interpolateAt } from './waveformStats';
|
||||||
|
|
||||||
|
// Which Arduino-style pin name maps to which ADC channel, per board.
|
||||||
|
function adcRange(prefix: string, start: number, count: number) {
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
pinName: `${prefix}${start + i}`,
|
||||||
|
channel: i,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADC_6CH = adcRange('A', 0, 6);
|
||||||
|
const ADC_8CH = adcRange('A', 0, 8);
|
||||||
|
const ADC_16CH = adcRange('A', 0, 16);
|
||||||
|
|
||||||
|
const ADC_PIN_MAP: Partial<Record<BoardKind, Array<{ pinName: string; channel: number }>>> = {
|
||||||
|
// AVR
|
||||||
|
'arduino-uno': ADC_6CH,
|
||||||
|
'arduino-nano': ADC_8CH,
|
||||||
|
'arduino-mega': ADC_16CH,
|
||||||
|
attiny85: adcRange('A', 0, 4),
|
||||||
|
|
||||||
|
// RP2040
|
||||||
|
'raspberry-pi-pico': [
|
||||||
|
{ pinName: 'GP26', channel: 0 },
|
||||||
|
{ pinName: 'GP27', channel: 1 },
|
||||||
|
{ pinName: 'GP28', channel: 2 },
|
||||||
|
{ pinName: 'GP29', channel: 3 },
|
||||||
|
],
|
||||||
|
'pi-pico-w': [
|
||||||
|
{ pinName: 'GP26', channel: 0 },
|
||||||
|
{ pinName: 'GP27', channel: 1 },
|
||||||
|
{ pinName: 'GP28', channel: 2 },
|
||||||
|
{ pinName: 'GP29', channel: 3 },
|
||||||
|
],
|
||||||
|
|
||||||
|
// ESP32
|
||||||
|
esp32: adcRange('GPIO', 32, 8),
|
||||||
|
'esp32-devkit-c-v4': adcRange('GPIO', 32, 8),
|
||||||
|
'esp32-cam': adcRange('GPIO', 32, 8),
|
||||||
|
'wemos-lolin32-lite': adcRange('GPIO', 32, 8),
|
||||||
|
'esp32-s3': adcRange('GPIO', 1, 10),
|
||||||
|
'xiao-esp32-s3': adcRange('GPIO', 1, 10),
|
||||||
|
'arduino-nano-esp32': adcRange('A', 0, 8),
|
||||||
|
'esp32-c3': adcRange('GPIO', 0, 6),
|
||||||
|
'xiao-esp32-c3': adcRange('GPIO', 0, 6),
|
||||||
|
'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6),
|
||||||
|
};
|
||||||
|
|
||||||
|
function avrPinFromName(_name: string, channel: number): number {
|
||||||
|
return 14 + channel;
|
||||||
|
}
|
||||||
|
function gpioPinFromName(name: string, _channel: number): number {
|
||||||
|
const m = name.match(/(\d+)$/);
|
||||||
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADC_PIN_TO_GPIO: Partial<Record<BoardKind, (pinName: string, channel: number) => number>> = {
|
||||||
|
'arduino-uno': avrPinFromName,
|
||||||
|
'arduino-nano': avrPinFromName,
|
||||||
|
'arduino-mega': avrPinFromName,
|
||||||
|
attiny85: avrPinFromName,
|
||||||
|
|
||||||
|
'raspberry-pi-pico': gpioPinFromName,
|
||||||
|
'pi-pico-w': gpioPinFromName,
|
||||||
|
|
||||||
|
esp32: gpioPinFromName,
|
||||||
|
'esp32-devkit-c-v4': gpioPinFromName,
|
||||||
|
'esp32-cam': gpioPinFromName,
|
||||||
|
'wemos-lolin32-lite': gpioPinFromName,
|
||||||
|
'esp32-s3': gpioPinFromName,
|
||||||
|
'xiao-esp32-s3': gpioPinFromName,
|
||||||
|
'arduino-nano-esp32': avrPinFromName,
|
||||||
|
'esp32-c3': gpioPinFromName,
|
||||||
|
'xiao-esp32-c3': gpioPinFromName,
|
||||||
|
'aitewinrobot-esp32c3-supermini': gpioPinFromName,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function connectAnalogInputsToMcu(): () => void {
|
||||||
|
const patchedAdcs = new WeakSet<object>();
|
||||||
|
const qemuWaveformChannels = new Set<string>();
|
||||||
|
let replayStartMs = 0;
|
||||||
|
let replayEpochLatched = false;
|
||||||
|
|
||||||
|
function injectVoltagesIntoADC() {
|
||||||
|
const { nodeVoltages, pinNetMap } = useElectricalStore.getState();
|
||||||
|
const { boards } = useSimulatorStore.getState();
|
||||||
|
for (const board of boards) {
|
||||||
|
const adcPins = ADC_PIN_MAP[board.boardKind];
|
||||||
|
if (!adcPins) continue;
|
||||||
|
const sim = getBoardSimulator(board.id);
|
||||||
|
if (!sim) continue;
|
||||||
|
const vMax = board.boardKind.startsWith('esp32') ? 3.3 : 5.0;
|
||||||
|
for (const { pinName, channel } of adcPins) {
|
||||||
|
const netName = pinNetMap.get(`${board.id}:${pinName}`);
|
||||||
|
if (!netName) continue;
|
||||||
|
const v = nodeVoltages[netName];
|
||||||
|
if (v == null) continue;
|
||||||
|
const clamped = Math.max(0, Math.min(vMax, v));
|
||||||
|
const gpioPin = ADC_PIN_TO_GPIO[board.boardKind]?.(pinName, channel);
|
||||||
|
if (gpioPin != null) setAdcVoltage(sim, gpioPin, clamped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleWaveformAtNow(net: string): number | undefined {
|
||||||
|
const { timeWaveforms } = useElectricalStore.getState();
|
||||||
|
if (!timeWaveforms) return undefined;
|
||||||
|
const samples = timeWaveforms.nodes.get(net);
|
||||||
|
if (!samples) return undefined;
|
||||||
|
const times = timeWaveforms.time;
|
||||||
|
const periodS = times[times.length - 1];
|
||||||
|
if (!(periodS > 0)) return undefined;
|
||||||
|
const t = ((performance.now() - replayStartMs) / 1000) % periodS;
|
||||||
|
return interpolateAt(times, samples, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushEsp32Waveforms() {
|
||||||
|
const { boards } = useSimulatorStore.getState();
|
||||||
|
const { pinNetMap, timeWaveforms } = useElectricalStore.getState();
|
||||||
|
for (const board of boards) {
|
||||||
|
const adcPins = ADC_PIN_MAP[board.boardKind];
|
||||||
|
if (!adcPins) continue;
|
||||||
|
const sim = getBoardSimulator(board.id);
|
||||||
|
if (!sim) continue;
|
||||||
|
const shim = sim as unknown as {
|
||||||
|
setAdcWaveform?: (pin: number, samples: Uint16Array, periodNs: number) => boolean;
|
||||||
|
};
|
||||||
|
if (typeof shim.setAdcWaveform !== 'function') continue;
|
||||||
|
const gpioFn = ADC_PIN_TO_GPIO[board.boardKind];
|
||||||
|
if (!gpioFn) continue;
|
||||||
|
const boardId = board.id;
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
if (timeWaveforms && timeWaveforms.time.length > 1) {
|
||||||
|
const period = timeWaveforms.time[timeWaveforms.time.length - 1];
|
||||||
|
if (period > 0) {
|
||||||
|
const periodNs = Math.round(period * 1e9);
|
||||||
|
for (const { pinName, channel } of adcPins) {
|
||||||
|
const net = pinNetMap.get(`${boardId}:${pinName}`);
|
||||||
|
const samples = net ? timeWaveforms.nodes.get(net) : undefined;
|
||||||
|
if (!samples || samples.length === 0) continue;
|
||||||
|
const u12 = new Uint16Array(samples.length);
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const v = Math.max(0, Math.min(3.3, samples[i]));
|
||||||
|
u12[i] = Math.round((v / 3.3) * 4095);
|
||||||
|
}
|
||||||
|
const gpioPin = gpioFn(pinName, channel);
|
||||||
|
if (gpioPin < 0) continue;
|
||||||
|
shim.setAdcWaveform(gpioPin, u12, periodNs);
|
||||||
|
qemuWaveformChannels.add(`${boardId}:${channel}`);
|
||||||
|
seen.add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear channels that previously had a waveform but don't now.
|
||||||
|
for (const { pinName, channel } of adcPins) {
|
||||||
|
const key = `${boardId}:${channel}`;
|
||||||
|
if (qemuWaveformChannels.has(key) && !seen.has(channel)) {
|
||||||
|
const gpioPin = gpioFn(pinName, channel);
|
||||||
|
if (gpioPin < 0) continue;
|
||||||
|
shim.setAdcWaveform(gpioPin, new Uint16Array(0), 0);
|
||||||
|
qemuWaveformChannels.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installAdcReadHooks() {
|
||||||
|
pushEsp32Waveforms();
|
||||||
|
const { boards } = useSimulatorStore.getState();
|
||||||
|
for (const board of boards) {
|
||||||
|
const adcPins = ADC_PIN_MAP[board.boardKind];
|
||||||
|
if (!adcPins) continue;
|
||||||
|
const sim = getBoardSimulator(board.id);
|
||||||
|
if (!sim) continue;
|
||||||
|
const adc = (sim as unknown as { getADC?: () => object | null }).getADC?.();
|
||||||
|
if (!adc || patchedAdcs.has(adc)) continue;
|
||||||
|
const boardId = board.id;
|
||||||
|
|
||||||
|
const channelToNet = new Map<number, string>();
|
||||||
|
const refreshChannelMap = () => {
|
||||||
|
channelToNet.clear();
|
||||||
|
const { pinNetMap } = useElectricalStore.getState();
|
||||||
|
for (const { pinName, channel } of adcPins) {
|
||||||
|
const net = pinNetMap.get(`${boardId}:${pinName}`);
|
||||||
|
if (net) channelToNet.set(channel, net);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRp2040 = 'resolution' in (adc as object);
|
||||||
|
|
||||||
|
if (isRp2040) {
|
||||||
|
const self = adc as unknown as {
|
||||||
|
channelValues: number[];
|
||||||
|
onADCRead: (channel: number) => void;
|
||||||
|
};
|
||||||
|
const originalOnADCRead = self.onADCRead.bind(self);
|
||||||
|
self.onADCRead = function (channel: number) {
|
||||||
|
if (channelToNet.size === 0) refreshChannelMap();
|
||||||
|
const net = channelToNet.get(channel);
|
||||||
|
const v = net ? sampleWaveformAtNow(net) : undefined;
|
||||||
|
if (v != null) {
|
||||||
|
const clamped = Math.max(0, Math.min(3.3, v));
|
||||||
|
self.channelValues[channel] = Math.round((clamped / 3.3) * 4095);
|
||||||
|
}
|
||||||
|
originalOnADCRead(channel);
|
||||||
|
};
|
||||||
|
patchedAdcs.add(adc);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = adc as unknown as {
|
||||||
|
channelValues: Array<number | undefined>;
|
||||||
|
referenceVoltage: number;
|
||||||
|
sampleCycles: number;
|
||||||
|
cpu: { addClockEvent: (fn: () => void, cycles: number) => void };
|
||||||
|
completeADCRead: (value: number) => void;
|
||||||
|
onADCRead: (input: {
|
||||||
|
type: number;
|
||||||
|
channel?: number;
|
||||||
|
voltage?: number;
|
||||||
|
positiveChannel?: number;
|
||||||
|
negativeChannel?: number;
|
||||||
|
gain?: number;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
self.onADCRead = function (input) {
|
||||||
|
if (channelToNet.size === 0) refreshChannelMap();
|
||||||
|
let voltage = 0;
|
||||||
|
switch (input.type) {
|
||||||
|
case 2: // Constant
|
||||||
|
voltage = input.voltage ?? 0;
|
||||||
|
break;
|
||||||
|
case 0: {
|
||||||
|
// SingleEnded
|
||||||
|
const ch = input.channel ?? 0;
|
||||||
|
const net = channelToNet.get(ch);
|
||||||
|
const waveV = net ? sampleWaveformAtNow(net) : undefined;
|
||||||
|
voltage = waveV ?? self.channelValues[ch] ?? 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
// Differential
|
||||||
|
const pos = input.positiveChannel ?? 0;
|
||||||
|
const neg = input.negativeChannel ?? 0;
|
||||||
|
const gain = input.gain ?? 1;
|
||||||
|
const vPos =
|
||||||
|
sampleWaveformAtNow(channelToNet.get(pos) ?? '') ?? self.channelValues[pos] ?? 0;
|
||||||
|
const vNeg =
|
||||||
|
sampleWaveformAtNow(channelToNet.get(neg) ?? '') ?? self.channelValues[neg] ?? 0;
|
||||||
|
voltage = gain * (vPos - vNeg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 3: // Temperature
|
||||||
|
voltage = 0.378125;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const rawValue = (voltage / self.referenceVoltage) * 1024;
|
||||||
|
const result = Math.min(Math.max(Math.floor(rawValue), 0), 1023);
|
||||||
|
self.cpu.addClockEvent(() => self.completeADCRead(result), self.sampleCycles);
|
||||||
|
};
|
||||||
|
patchedAdcs.add(adc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-inject + re-hook on every solve result.
|
||||||
|
const unsubResult = useElectricalStore.subscribe((state, prev) => {
|
||||||
|
if (state.nodeVoltages !== prev.nodeVoltages || state.timeWaveforms !== prev.timeWaveforms) {
|
||||||
|
injectVoltagesIntoADC();
|
||||||
|
if (state.timeWaveforms && !replayEpochLatched) {
|
||||||
|
replayStartMs = performance.now();
|
||||||
|
replayEpochLatched = true;
|
||||||
|
}
|
||||||
|
installAdcReadHooks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-hook when boards change (loadHex creates fresh ADC instances).
|
||||||
|
const unsubBoards = useSimulatorStore.subscribe((state, prev) => {
|
||||||
|
if (state.boards !== prev.boards) {
|
||||||
|
const { nodeVoltages } = useElectricalStore.getState();
|
||||||
|
if (Object.keys(nodeVoltages).length > 0) injectVoltagesIntoADC();
|
||||||
|
installAdcReadHooks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial pass — examples that pre-populate the store before mount.
|
||||||
|
injectVoltagesIntoADC();
|
||||||
|
installAdcReadHooks();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubResult();
|
||||||
|
unsubBoards();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,115 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* Hooks up the electrical solver to the main simulator store:
|
* subscribeToStore — legacy solve loop driver.
|
||||||
* - subscribe to components, wires, pin changes
|
|
||||||
* - on change, build the input and request a solve
|
|
||||||
* - inject node voltages back into ADC channels
|
|
||||||
*
|
*
|
||||||
* Called once at app startup (typically from EditorPage or main.tsx).
|
* Subscribes to `useSimulatorStore` for canvas changes and to a
|
||||||
* Returns an `unsubscribe()` for cleanup.
|
* 200 ms timer while any board runs. Builds the SPICE netlist via
|
||||||
|
* `buildInputFromStore` and pushes it to the legacy CircuitScheduler
|
||||||
|
* via `useElectricalStore.triggerSolve(input)`.
|
||||||
|
*
|
||||||
|
* Scope today (Phase 1c step C):
|
||||||
|
* - Solve loop ONLY.
|
||||||
|
* - The ADC injection / waveform replay / ESP32 QEMU push was
|
||||||
|
* extracted into `connectAnalogInputsToMcu.ts` so the new
|
||||||
|
* CircuitSimulationService (which fills useElectricalStore from
|
||||||
|
* the WASM path) can share the same downstream consumer.
|
||||||
|
*
|
||||||
|
* Lifetime: this file will be DELETED in Phase 1c step G1 once the
|
||||||
|
* service is the default and the feature flag goes away. Until
|
||||||
|
* then it keeps the legacy behaviour alive behind the flag.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useSimulatorStore,
|
useSimulatorStore,
|
||||||
getBoardSimulator,
|
|
||||||
getBoardPinManager,
|
getBoardPinManager,
|
||||||
} from '../../store/useSimulatorStore';
|
} from '../../store/useSimulatorStore';
|
||||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||||
import { buildInputFromStore } from './storeAdapter';
|
import { buildInputFromStore } from './storeAdapter';
|
||||||
import { setAdcVoltage } from '../parts/partUtils';
|
|
||||||
import type { PinSourceState } from './types';
|
import type { PinSourceState } from './types';
|
||||||
import type { BoardKind } from '../../types/board';
|
import type { BoardKind } from '../../types/board';
|
||||||
import { BOARD_PIN_GROUPS } from './boardPinGroups';
|
import { BOARD_PIN_GROUPS } from './boardPinGroups';
|
||||||
import { interpolateAt } from './waveformStats';
|
|
||||||
|
|
||||||
// Which Arduino-style pin name maps to which ADC channel, per board.
|
|
||||||
// Used to inject SPICE-solved voltages back into the MCU's ADC peripheral.
|
|
||||||
function adcRange(prefix: string, start: number, count: number) {
|
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
|
||||||
pinName: `${prefix}${start + i}`,
|
|
||||||
channel: i,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADC_6CH = adcRange('A', 0, 6); // A0..A5
|
|
||||||
const ADC_8CH = adcRange('A', 0, 8); // A0..A7
|
|
||||||
const ADC_16CH = adcRange('A', 0, 16); // A0..A15
|
|
||||||
|
|
||||||
const ADC_PIN_MAP: Partial<Record<BoardKind, Array<{ pinName: string; channel: number }>>> = {
|
|
||||||
// AVR boards
|
|
||||||
'arduino-uno': ADC_6CH,
|
|
||||||
'arduino-nano': ADC_8CH,
|
|
||||||
'arduino-mega': ADC_16CH,
|
|
||||||
attiny85: adcRange('A', 0, 4), // A0..A3 (PB2-PB5)
|
|
||||||
|
|
||||||
// RP2040 boards — 4 ADC channels (GP26-GP29)
|
|
||||||
'raspberry-pi-pico': [
|
|
||||||
{ pinName: 'GP26', channel: 0 },
|
|
||||||
{ pinName: 'GP27', channel: 1 },
|
|
||||||
{ pinName: 'GP28', channel: 2 },
|
|
||||||
{ pinName: 'GP29', channel: 3 },
|
|
||||||
],
|
|
||||||
'pi-pico-w': [
|
|
||||||
{ pinName: 'GP26', channel: 0 },
|
|
||||||
{ pinName: 'GP27', channel: 1 },
|
|
||||||
{ pinName: 'GP28', channel: 2 },
|
|
||||||
{ pinName: 'GP29', channel: 3 },
|
|
||||||
],
|
|
||||||
|
|
||||||
// ESP32 variants — most GPIOs can be ADC but the common ones are:
|
|
||||||
// ADC1: GPIO 32-39 (channels 0-7), ADC2: GPIO 0,2,4,12-15,25-27
|
|
||||||
// Simplified to the 8 most-used pins (GPIO 32-39 = ADC1)
|
|
||||||
esp32: adcRange('GPIO', 32, 8),
|
|
||||||
'esp32-devkit-c-v4': adcRange('GPIO', 32, 8),
|
|
||||||
'esp32-cam': adcRange('GPIO', 32, 8),
|
|
||||||
'wemos-lolin32-lite': adcRange('GPIO', 32, 8),
|
|
||||||
|
|
||||||
// ESP32-S3 — ADC1 channels on GPIO 1-10, ADC2 on GPIO 11-20
|
|
||||||
'esp32-s3': adcRange('GPIO', 1, 10),
|
|
||||||
'xiao-esp32-s3': adcRange('GPIO', 1, 10),
|
|
||||||
'arduino-nano-esp32': adcRange('A', 0, 8),
|
|
||||||
|
|
||||||
// ESP32-C3 — ADC1 channels on GPIO 0-4, ADC2 on GPIO 5
|
|
||||||
'esp32-c3': adcRange('GPIO', 0, 6),
|
|
||||||
'xiao-esp32-c3': adcRange('GPIO', 0, 6),
|
|
||||||
'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an ADC pin name + channel to the GPIO pin number that
|
|
||||||
* `setAdcVoltage()` (partUtils) expects, per board family.
|
|
||||||
*
|
|
||||||
* AVR: A0→14, A1→15, ... (analog pins start at 14)
|
|
||||||
* RP2040: GP26→26, GP27→27, ... (GPIO number directly)
|
|
||||||
* ESP32: GPIO32→32, ... or A0→channel-dependent (GPIO number)
|
|
||||||
*/
|
|
||||||
function avrPinFromName(_name: string, channel: number): number {
|
|
||||||
return 14 + channel;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
function gpioPinFromName(name: string, _channel: number): number {
|
|
||||||
const m = name.match(/(\d+)$/);
|
|
||||||
return m ? parseInt(m[1], 10) : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADC_PIN_TO_GPIO: Partial<Record<BoardKind, (pinName: string, channel: number) => number>> = {
|
|
||||||
'arduino-uno': avrPinFromName,
|
|
||||||
'arduino-nano': avrPinFromName,
|
|
||||||
'arduino-mega': avrPinFromName,
|
|
||||||
attiny85: avrPinFromName,
|
|
||||||
|
|
||||||
'raspberry-pi-pico': gpioPinFromName,
|
|
||||||
'pi-pico-w': gpioPinFromName,
|
|
||||||
|
|
||||||
esp32: gpioPinFromName,
|
|
||||||
'esp32-devkit-c-v4': gpioPinFromName,
|
|
||||||
'esp32-cam': gpioPinFromName,
|
|
||||||
'wemos-lolin32-lite': gpioPinFromName,
|
|
||||||
'esp32-s3': gpioPinFromName,
|
|
||||||
'xiao-esp32-s3': gpioPinFromName,
|
|
||||||
'arduino-nano-esp32': avrPinFromName, // uses A0-A7 naming
|
|
||||||
'esp32-c3': gpioPinFromName,
|
|
||||||
'xiao-esp32-c3': gpioPinFromName,
|
|
||||||
'aitewinrobot-esp32c3-supermini': gpioPinFromName,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a board pin name (e.g. "9", "A0", "GP26", "GPIO32") to the
|
* Convert a board pin name (e.g. "9", "A0", "GP26", "GPIO32") to the
|
||||||
|
|
@ -118,24 +34,18 @@ const ADC_PIN_TO_GPIO: Partial<Record<BoardKind, (pinName: string, channel: numb
|
||||||
*/
|
*/
|
||||||
function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number {
|
function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number {
|
||||||
const group = BOARD_PIN_GROUPS[boardKind] ?? BOARD_PIN_GROUPS.default;
|
const group = BOARD_PIN_GROUPS[boardKind] ?? BOARD_PIN_GROUPS.default;
|
||||||
// Skip power/ground pins — they're handled as canonical nets
|
|
||||||
if (group.gnd.includes(pinName) || group.vcc_pins.includes(pinName)) return -1;
|
if (group.gnd.includes(pinName) || group.vcc_pins.includes(pinName)) return -1;
|
||||||
|
|
||||||
// RP2040: "GP26" → 26
|
|
||||||
if (pinName.startsWith('GP')) {
|
if (pinName.startsWith('GP')) {
|
||||||
const n = parseInt(pinName.slice(2), 10);
|
const n = parseInt(pinName.slice(2), 10);
|
||||||
return Number.isFinite(n) ? n : -1;
|
return Number.isFinite(n) ? n : -1;
|
||||||
}
|
}
|
||||||
// ESP32: "GPIO32" → 32
|
|
||||||
if (pinName.startsWith('GPIO')) {
|
if (pinName.startsWith('GPIO')) {
|
||||||
const n = parseInt(pinName.slice(4), 10);
|
const n = parseInt(pinName.slice(4), 10);
|
||||||
return Number.isFinite(n) ? n : -1;
|
return Number.isFinite(n) ? n : -1;
|
||||||
}
|
}
|
||||||
// AVR analog: "A0" → 14, "A1" → 15, ...
|
|
||||||
if (/^A\d+$/.test(pinName)) {
|
if (/^A\d+$/.test(pinName)) {
|
||||||
return 14 + parseInt(pinName.slice(1), 10);
|
return 14 + parseInt(pinName.slice(1), 10);
|
||||||
}
|
}
|
||||||
// Bare numeric: "9" → 9, "13" → 13
|
|
||||||
if (/^\d+$/.test(pinName)) {
|
if (/^\d+$/.test(pinName)) {
|
||||||
return parseInt(pinName, 10);
|
return parseInt(pinName, 10);
|
||||||
}
|
}
|
||||||
|
|
@ -146,8 +56,8 @@ function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number {
|
||||||
* Collect MCU output pin states from PinManager for pins that participate
|
* Collect MCU output pin states from PinManager for pins that participate
|
||||||
* in the circuit (i.e., are referenced by wires).
|
* in the circuit (i.e., are referenced by wires).
|
||||||
*
|
*
|
||||||
* Exported so the Phase 1c WASM-driven connector can reuse the same logic
|
* Exported so the CircuitSimulationService can reuse the same per-board
|
||||||
* without copying the per-board pin-number mapping.
|
* pin-number mapping without copying it.
|
||||||
*/
|
*/
|
||||||
export function collectPinStates(
|
export function collectPinStates(
|
||||||
boardId: string,
|
boardId: string,
|
||||||
|
|
@ -163,7 +73,6 @@ export function collectPinStates(
|
||||||
const vcc = group.vcc;
|
const vcc = group.vcc;
|
||||||
|
|
||||||
const result: Record<string, PinSourceState> = {};
|
const result: Record<string, PinSourceState> = {};
|
||||||
// Gather all pin names wired to this board
|
|
||||||
const pinNames = new Set<string>();
|
const pinNames = new Set<string>();
|
||||||
for (const w of wires) {
|
for (const w of wires) {
|
||||||
if (w.start.componentId === boardId) pinNames.add(w.start.pinName);
|
if (w.start.componentId === boardId) pinNames.add(w.start.pinName);
|
||||||
|
|
@ -179,23 +88,11 @@ export function collectPinStates(
|
||||||
} else if (pm.getPinState(arduinoPin)) {
|
} else if (pm.getPinState(arduinoPin)) {
|
||||||
result[pinName] = { type: 'digital', v: vcc };
|
result[pinName] = { type: 'digital', v: vcc };
|
||||||
}
|
}
|
||||||
// If pin is LOW or unknown, don't add — treated as input/floating by SPICE
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPICE_DEBUG = true;
|
|
||||||
const spiceLog = (...a: unknown[]) => {
|
|
||||||
if (SPICE_DEBUG) console.log('[spice]', ...a);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function wireElectricalSolver(): () => void {
|
export function wireElectricalSolver(): () => void {
|
||||||
spiceLog('wireElectricalSolver mounted');
|
|
||||||
|
|
||||||
// Cache the last solve input JSON to skip redundant solves.
|
|
||||||
// This is critical: without it, the periodic timer floods the scheduler
|
|
||||||
// with identical requests, delaying the result that carries updated
|
|
||||||
// pin states (e.g. PWM) until after the simulation stops.
|
|
||||||
let lastInputJson = '';
|
let lastInputJson = '';
|
||||||
|
|
||||||
function maybeSolve() {
|
function maybeSolve() {
|
||||||
|
|
@ -211,253 +108,13 @@ export function wireElectricalSolver(): () => void {
|
||||||
};
|
};
|
||||||
const input = buildInputFromStore(snap);
|
const input = buildInputFromStore(snap);
|
||||||
|
|
||||||
// Deduplicate: skip if the input hasn't changed since the last solve.
|
|
||||||
const inputJson = JSON.stringify(input);
|
const inputJson = JSON.stringify(input);
|
||||||
if (inputJson === lastInputJson) {
|
if (inputJson === lastInputJson) return;
|
||||||
spiceLog('maybeSolve skipped (input unchanged)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastInputJson = inputJson;
|
lastInputJson = inputJson;
|
||||||
|
|
||||||
spiceLog('maybeSolve → triggerSolve', {
|
|
||||||
components: input.components.length,
|
|
||||||
wires: input.wires.length,
|
|
||||||
boards: input.boards.length,
|
|
||||||
analysis: input.analysis,
|
|
||||||
});
|
|
||||||
|
|
||||||
// pinNetMap is now built inside buildNetlist() from the same UF and
|
|
||||||
// returned via CircuitScheduler → ElectricalSolveResult → store.
|
|
||||||
useElectricalStore.getState().triggerSolve(input);
|
useElectricalStore.getState().triggerSolve(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectVoltagesIntoADC() {
|
|
||||||
const { nodeVoltages, pinNetMap } = useElectricalStore.getState();
|
|
||||||
const { boards } = useSimulatorStore.getState();
|
|
||||||
for (const board of boards) {
|
|
||||||
const adcPins = ADC_PIN_MAP[board.boardKind];
|
|
||||||
if (!adcPins) continue;
|
|
||||||
const sim = getBoardSimulator(board.id);
|
|
||||||
if (!sim) continue;
|
|
||||||
const vMax = board.boardKind.startsWith('esp32') ? 3.3 : 5.0;
|
|
||||||
for (const { pinName, channel } of adcPins) {
|
|
||||||
const netName = pinNetMap.get(`${board.id}:${pinName}`);
|
|
||||||
if (!netName) continue;
|
|
||||||
const v = nodeVoltages[netName];
|
|
||||||
if (v == null) continue;
|
|
||||||
const clamped = Math.max(0, Math.min(vMax, v));
|
|
||||||
const gpioPin = ADC_PIN_TO_GPIO[board.boardKind]?.(pinName, channel);
|
|
||||||
if (gpioPin != null) setAdcVoltage(sim, gpioPin, clamped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-read ADC waveform sampling ──────────────────────────────────────
|
|
||||||
// When a circuit contains an AC source, SPICE returns a `.tran` result with
|
|
||||||
// per-node waveform samples. We override `MCU.onADCRead` so that every
|
|
||||||
// `analogRead` interpolates the waveform at the *exact wall-clock time of
|
|
||||||
// the read*. That puts every guest-visible ADC sample in the right phase,
|
|
||||||
// regardless of the sketch's sample rate (up to Nyquist for the waveform).
|
|
||||||
//
|
|
||||||
// Why not a RAF loop? An earlier version pushed `channelValues[ch]` once
|
|
||||||
// per animation frame (~60 Hz). But 60 Hz aliases with 50 Hz signals, and
|
|
||||||
// within a single frame every `analogRead` returns the same stale value —
|
|
||||||
// collapsing the 400-sample `.tran` LUT to a 60 Hz zero-order hold. The
|
|
||||||
// per-read hook eliminates both issues and is strictly more faithful, so
|
|
||||||
// the RAF loop was retired. See `docs/wiki/circuit-emulation-adc-aliasing.md`.
|
|
||||||
const patchedAdcs = new WeakSet<object>();
|
|
||||||
|
|
||||||
// Wall-clock epoch for "t=0 of the signal generator". Latched the first
|
|
||||||
// time a `.tran` result arrives, so the sampler's phase is stable across
|
|
||||||
// re-solves that produce an identical waveform.
|
|
||||||
let replayStartMs = 0;
|
|
||||||
let replayEpochLatched = false;
|
|
||||||
|
|
||||||
function sampleWaveformAtNow(net: string): number | undefined {
|
|
||||||
const { timeWaveforms } = useElectricalStore.getState();
|
|
||||||
if (!timeWaveforms) return undefined;
|
|
||||||
const samples = timeWaveforms.nodes.get(net);
|
|
||||||
if (!samples) return undefined;
|
|
||||||
const times = timeWaveforms.time;
|
|
||||||
const periodS = times[times.length - 1];
|
|
||||||
if (!(periodS > 0)) return undefined;
|
|
||||||
const t = ((performance.now() - replayStartMs) / 1000) % periodS;
|
|
||||||
return interpolateAt(times, samples, t);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track which `(boardId, channel)` pairs currently have a waveform pushed
|
|
||||||
// to QEMU so we can clear it when the circuit turns DC or the component is
|
|
||||||
// removed. Key format: `${boardId}:${channel}`.
|
|
||||||
const qemuWaveformChannels = new Set<string>();
|
|
||||||
|
|
||||||
function pushEsp32Waveforms() {
|
|
||||||
const { boards } = useSimulatorStore.getState();
|
|
||||||
const { pinNetMap, timeWaveforms } = useElectricalStore.getState();
|
|
||||||
for (const board of boards) {
|
|
||||||
const adcPins = ADC_PIN_MAP[board.boardKind];
|
|
||||||
if (!adcPins) continue;
|
|
||||||
const sim = getBoardSimulator(board.id);
|
|
||||||
if (!sim) continue;
|
|
||||||
// Only ESP32 QEMU-bridged shims have setAdcWaveform.
|
|
||||||
const shim = sim as unknown as {
|
|
||||||
setAdcWaveform?: (pin: number, samples: Uint16Array, periodNs: number) => boolean;
|
|
||||||
};
|
|
||||||
if (typeof shim.setAdcWaveform !== 'function') continue;
|
|
||||||
|
|
||||||
const gpioFn = ADC_PIN_TO_GPIO[board.boardKind];
|
|
||||||
if (!gpioFn) continue;
|
|
||||||
const boardId = board.id;
|
|
||||||
const seen = new Set<number>();
|
|
||||||
|
|
||||||
if (timeWaveforms && timeWaveforms.time.length > 1) {
|
|
||||||
const period = timeWaveforms.time[timeWaveforms.time.length - 1];
|
|
||||||
if (period > 0) {
|
|
||||||
const periodNs = Math.round(period * 1e9);
|
|
||||||
for (const { pinName, channel } of adcPins) {
|
|
||||||
const net = pinNetMap.get(`${boardId}:${pinName}`);
|
|
||||||
const samples = net ? timeWaveforms.nodes.get(net) : undefined;
|
|
||||||
if (!samples || samples.length === 0) continue;
|
|
||||||
const u12 = new Uint16Array(samples.length);
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
|
||||||
const v = Math.max(0, Math.min(3.3, samples[i]));
|
|
||||||
u12[i] = Math.round((v / 3.3) * 4095);
|
|
||||||
}
|
|
||||||
const gpioPin = gpioFn(pinName, channel);
|
|
||||||
if (gpioPin < 0) continue;
|
|
||||||
shim.setAdcWaveform(gpioPin, u12, periodNs);
|
|
||||||
qemuWaveformChannels.add(`${boardId}:${channel}`);
|
|
||||||
seen.add(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any channels that previously had a waveform but don't anymore
|
|
||||||
// (e.g. circuit became DC after a component edit).
|
|
||||||
for (const { pinName, channel } of adcPins) {
|
|
||||||
const key = `${boardId}:${channel}`;
|
|
||||||
if (qemuWaveformChannels.has(key) && !seen.has(channel)) {
|
|
||||||
const gpioPin = gpioFn(pinName, channel);
|
|
||||||
if (gpioPin < 0) continue;
|
|
||||||
shim.setAdcWaveform(gpioPin, new Uint16Array(0), 0);
|
|
||||||
qemuWaveformChannels.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function installAdcReadHooks() {
|
|
||||||
// ESP32 is handled by its dedicated waveform-push path (no in-process
|
|
||||||
// onADCRead to patch — QEMU does the interpolation on MMIO read).
|
|
||||||
pushEsp32Waveforms();
|
|
||||||
|
|
||||||
const { boards } = useSimulatorStore.getState();
|
|
||||||
for (const board of boards) {
|
|
||||||
const adcPins = ADC_PIN_MAP[board.boardKind];
|
|
||||||
if (!adcPins) continue;
|
|
||||||
const sim = getBoardSimulator(board.id);
|
|
||||||
if (!sim) continue;
|
|
||||||
const adc = (sim as unknown as { getADC?: () => object | null }).getADC?.();
|
|
||||||
if (!adc || patchedAdcs.has(adc)) continue;
|
|
||||||
const boardId = board.id;
|
|
||||||
|
|
||||||
// Build channel → SPICE net for this board.
|
|
||||||
const channelToNet = new Map<number, string>();
|
|
||||||
const refreshChannelMap = () => {
|
|
||||||
channelToNet.clear();
|
|
||||||
const { pinNetMap } = useElectricalStore.getState();
|
|
||||||
for (const { pinName, channel } of adcPins) {
|
|
||||||
const net = pinNetMap.get(`${boardId}:${pinName}`);
|
|
||||||
if (net) channelToNet.set(channel, net);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// AVR vs RP2040 detection. RP2040's RPADC exposes `resolution: 12` and a
|
|
||||||
// `sampleAlarm` scheduler; AVR's ADC exposes `sampleCycles` + `cpu`.
|
|
||||||
const isRp2040 = 'resolution' in (adc as object);
|
|
||||||
|
|
||||||
if (isRp2040) {
|
|
||||||
const self = adc as unknown as {
|
|
||||||
channelValues: number[];
|
|
||||||
onADCRead: (channel: number) => void;
|
|
||||||
};
|
|
||||||
const originalOnADCRead = self.onADCRead.bind(self);
|
|
||||||
self.onADCRead = function (channel: number) {
|
|
||||||
if (channelToNet.size === 0) refreshChannelMap();
|
|
||||||
const net = channelToNet.get(channel);
|
|
||||||
const v = net ? sampleWaveformAtNow(net) : undefined;
|
|
||||||
if (v != null) {
|
|
||||||
// RP2040 ADC is 12-bit, 0-3.3V full scale.
|
|
||||||
const clamped = Math.max(0, Math.min(3.3, v));
|
|
||||||
self.channelValues[channel] = Math.round((clamped / 3.3) * 4095);
|
|
||||||
}
|
|
||||||
originalOnADCRead(channel);
|
|
||||||
};
|
|
||||||
patchedAdcs.add(adc);
|
|
||||||
spiceLog('installed RP2040 onADCRead hook', { boardId });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// AVR path — override entirely because the real implementation computes
|
|
||||||
// the raw 10-bit value from channelValues and calls completeADCRead via
|
|
||||||
// cpu.addClockEvent. We must re-implement that path but with the voltage
|
|
||||||
// sampled from the SPICE waveform at the exact read moment.
|
|
||||||
const self = adc as unknown as {
|
|
||||||
channelValues: Array<number | undefined>;
|
|
||||||
referenceVoltage: number;
|
|
||||||
sampleCycles: number;
|
|
||||||
cpu: { addClockEvent: (fn: () => void, cycles: number) => void };
|
|
||||||
completeADCRead: (value: number) => void;
|
|
||||||
onADCRead: (input: {
|
|
||||||
type: number;
|
|
||||||
channel?: number;
|
|
||||||
voltage?: number;
|
|
||||||
positiveChannel?: number;
|
|
||||||
negativeChannel?: number;
|
|
||||||
gain?: number;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
self.onADCRead = function (input) {
|
|
||||||
if (channelToNet.size === 0) refreshChannelMap();
|
|
||||||
const ADCMuxInputType_SingleEnded = 0;
|
|
||||||
const ADCMuxInputType_Differential = 1;
|
|
||||||
const ADCMuxInputType_Constant = 2;
|
|
||||||
const ADCMuxInputType_Temperature = 3;
|
|
||||||
let voltage = 0;
|
|
||||||
switch (input.type) {
|
|
||||||
case ADCMuxInputType_Constant:
|
|
||||||
voltage = input.voltage ?? 0;
|
|
||||||
break;
|
|
||||||
case ADCMuxInputType_SingleEnded: {
|
|
||||||
const ch = input.channel ?? 0;
|
|
||||||
const net = channelToNet.get(ch);
|
|
||||||
const waveV = net ? sampleWaveformAtNow(net) : undefined;
|
|
||||||
voltage = waveV ?? self.channelValues[ch] ?? 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ADCMuxInputType_Differential: {
|
|
||||||
const pos = input.positiveChannel ?? 0;
|
|
||||||
const neg = input.negativeChannel ?? 0;
|
|
||||||
const gain = input.gain ?? 1;
|
|
||||||
const vPos =
|
|
||||||
sampleWaveformAtNow(channelToNet.get(pos) ?? '') ?? self.channelValues[pos] ?? 0;
|
|
||||||
const vNeg =
|
|
||||||
sampleWaveformAtNow(channelToNet.get(neg) ?? '') ?? self.channelValues[neg] ?? 0;
|
|
||||||
voltage = gain * (vPos - vNeg);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case ADCMuxInputType_Temperature:
|
|
||||||
voltage = 0.378125;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const rawValue = (voltage / self.referenceVoltage) * 1024;
|
|
||||||
const result = Math.min(Math.max(Math.floor(rawValue), 0), 1023);
|
|
||||||
self.cpu.addClockEvent(() => self.completeADCRead(result), self.sampleCycles);
|
|
||||||
};
|
|
||||||
patchedAdcs.add(adc);
|
|
||||||
spiceLog('installed AVR onADCRead hook', { boardId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-solve on components / wires changes.
|
// Re-solve on components / wires changes.
|
||||||
const unsubSim = useSimulatorStore.subscribe((state, prev) => {
|
const unsubSim = useSimulatorStore.subscribe((state, prev) => {
|
||||||
if (state.components !== prev.components || state.wires !== prev.wires) {
|
if (state.components !== prev.components || state.wires !== prev.wires) {
|
||||||
|
|
@ -465,98 +122,8 @@ export function wireElectricalSolver(): () => void {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// On every solve result:
|
// Initial solve.
|
||||||
// - DC solve (`.op`): push scalar voltages into `channelValues[]`.
|
|
||||||
// - AC solve (`.tran`): install/refresh the per-read `onADCRead` hook so
|
|
||||||
// every future `analogRead` interpolates the waveform. Latch the
|
|
||||||
// wall-clock epoch on first `.tran` arrival so the signal phase is
|
|
||||||
// stable across downstream re-solves.
|
|
||||||
const unsubResult = useElectricalStore.subscribe((state, prev) => {
|
|
||||||
if (state.nodeVoltages !== prev.nodeVoltages || state.timeWaveforms !== prev.timeWaveforms) {
|
|
||||||
injectVoltagesIntoADC();
|
|
||||||
if (state.timeWaveforms && !replayEpochLatched) {
|
|
||||||
replayStartMs = performance.now();
|
|
||||||
replayEpochLatched = true;
|
|
||||||
}
|
|
||||||
installAdcReadHooks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Examples are loaded by `ExampleLoaderPage` *before* navigating to the
|
|
||||||
// editor, so by the time this subscriber attaches, `setComponents` /
|
|
||||||
// `setWires` have already fired and will never fire again — no subscription
|
|
||||||
// event ever reaches `unsubSim`. Kick off a solve on mount so the rectifier
|
|
||||||
// (and every other AC example that arrives via deep link) gets its waveform
|
|
||||||
// computed. Install hooks so any AVR/RP2040 that already booted gets them.
|
|
||||||
maybeSolve();
|
maybeSolve();
|
||||||
installAdcReadHooks();
|
|
||||||
|
|
||||||
// Expose a debug helper so the user can run `window.__spiceDebug()` at any
|
|
||||||
// time from DevTools and get a complete snapshot of the electrical state.
|
|
||||||
(window as unknown as { __spiceDebug?: () => void }).__spiceDebug = () => {
|
|
||||||
const es = useElectricalStore.getState();
|
|
||||||
const ss = useSimulatorStore.getState();
|
|
||||||
const a0Key = ss.boards[0] ? `${ss.boards[0].id}:A0` : '(no-board)';
|
|
||||||
const a0Net = es.pinNetMap.get(a0Key);
|
|
||||||
console.log('[spice] DEBUG DUMP', {
|
|
||||||
analysisMode: es.analysisMode,
|
|
||||||
converged: es.converged,
|
|
||||||
error: es.error,
|
|
||||||
lastSolveMs: es.lastSolveMs,
|
|
||||||
nodeVoltageCount: Object.keys(es.nodeVoltages).length,
|
|
||||||
nodeVoltageSample: Object.entries(es.nodeVoltages).slice(0, 8),
|
|
||||||
pinNetMapSize: es.pinNetMap.size,
|
|
||||||
pinNetEntries: [...es.pinNetMap.entries()],
|
|
||||||
a0Key,
|
|
||||||
a0Net,
|
|
||||||
a0InstantV: a0Net ? es.nodeVoltages[a0Net] : undefined,
|
|
||||||
hasTimeWaveforms: !!es.timeWaveforms,
|
|
||||||
waveformNodeKeys: es.timeWaveforms ? [...es.timeWaveforms.nodes.keys()] : [],
|
|
||||||
waveformTimeFirst: es.timeWaveforms?.time[0],
|
|
||||||
waveformTimeLast: es.timeWaveforms?.time[es.timeWaveforms.time.length - 1],
|
|
||||||
waveformSamples: a0Net && es.timeWaveforms?.nodes.get(a0Net)?.slice(0, 10),
|
|
||||||
replayEpochLatched,
|
|
||||||
replayEpochMsAgo: replayEpochLatched ? +(performance.now() - replayStartMs).toFixed(0) : null,
|
|
||||||
boards: ss.boards.map((b) => ({ id: b.id, kind: b.boardKind, running: b.running })),
|
|
||||||
components: ss.components.map((c) => ({ id: c.id, meta: c.metadataId })),
|
|
||||||
wireCount: ss.wires.length,
|
|
||||||
submittedNetlist: es.submittedNetlist,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dump the live ADC state so we can confirm the per-read hook is attached
|
|
||||||
// and what channelValues look like for each running board.
|
|
||||||
for (const b of ss.boards) {
|
|
||||||
const sim = getBoardSimulator(b.id);
|
|
||||||
if (!sim) {
|
|
||||||
console.log(`[spice] board ${b.id}: no simulator`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const adc = (
|
|
||||||
sim as unknown as { getADC?: () => { channelValues?: ArrayLike<number> } | null }
|
|
||||||
).getADC?.();
|
|
||||||
const cycles = (sim as unknown as { getCurrentCycles?: () => number }).getCurrentCycles?.();
|
|
||||||
const values = adc?.channelValues ? Array.from(adc.channelValues).slice(0, 6) : null;
|
|
||||||
const valuesStr = values
|
|
||||||
? `[${values.map((v) => (typeof v === 'number' ? v.toFixed(3) : String(v))).join(', ')}]`
|
|
||||||
: 'null';
|
|
||||||
console.log(
|
|
||||||
`[spice] board ${b.id}: sim=${(sim as object).constructor?.name} running=${b.running} adc=${!!adc} patched=${adc ? patchedAdcs.has(adc) : false} cycles=${cycles} simT=${typeof cycles === 'number' ? (cycles / 16_000_000).toFixed(3) + 's' : 'n/a'} channelValues=${valuesStr}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
console.log('[spice] call window.__spiceDebug() anytime to inspect state');
|
|
||||||
|
|
||||||
// Re-install hooks and re-inject DC whenever boards change (e.g. `loadHex`
|
|
||||||
// creates a fresh AVRADC instance that needs the waveform hook on it).
|
|
||||||
const unsubBoards = useSimulatorStore.subscribe((state, prev) => {
|
|
||||||
if (state.boards !== prev.boards) {
|
|
||||||
const { nodeVoltages } = useElectricalStore.getState();
|
|
||||||
if (Object.keys(nodeVoltages).length > 0) {
|
|
||||||
injectVoltagesIntoADC();
|
|
||||||
}
|
|
||||||
installAdcReadHooks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Periodic re-solve while any board is running, so SPICE picks up
|
// Periodic re-solve while any board is running, so SPICE picks up
|
||||||
// MCU pin-state changes (e.g. analogWrite → PWM → voltage source).
|
// MCU pin-state changes (e.g. analogWrite → PWM → voltage source).
|
||||||
|
|
@ -583,8 +150,6 @@ export function wireElectricalSolver(): () => void {
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubSim();
|
unsubSim();
|
||||||
unsubResult();
|
|
||||||
unsubBoards();
|
|
||||||
unsubRunning();
|
unsubRunning();
|
||||||
if (solveInterval) clearInterval(solveInterval);
|
if (solveInterval) clearInterval(solveInterval);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue