velxio/frontend/src/simulation/spice/subscribeToStore.ts

134 lines
5.1 KiB
TypeScript
Raw Normal View History

feat: electrical simulation via ngspice-WASM (eecircuit-engine) Adds full SPICE-accurate electrical simulation to Velxio, behind a lazy- loaded ⚡ toolbar toggle. Arduino / ESP32 / RP2040 sketches now co-simulate with real analog behaviour: correct voltages on wires, real I–V curves on LEDs, working potentiometers, NTC thermistors read by analogRead(), PWM driving RC filters, transistors, op-amps, diodes, MOSFETs, etc. Engine: eecircuit-engine (ngspice compiled to WebAssembly). Main bundle stays at 2.4 MB; the 20 MB SPICE chunk only loads when the user activates electrical mode. Disabled at build time via VITE_ELECTRICAL_SIM=false. Frontend additions: - simulation/spice/: SpiceEngine wrapper + lazy entry, NetlistBuilder with UnionFind over wires, componentToSpice mapping (24 metadataIds incl. real part numbers: 2N2222, 2N3055, BC547, IRF540, 2N7000, 1N4148, 1N4007, 1N4733, LEDs, NTC, op-amp ideal), CircuitScheduler with debounced coalescing, AVRSpiceBridge for quasi-static co-simulation. - store/useElectricalStore: Zustand slice, feature-flag aware. - components/analog-ui/: ⚡ toolbar toggle + SVG voltage overlay. - components/components-instruments/: Voltmeter, Ammeter probes. - 62 tests (spice-*, netlist-builder, component-to-spice, instruments). Sandbox (test/test_circuit/): 47-test validation sandbox that proved the approach (hand-rolled MNA baseline + ngspice pipeline) before porting to the app. Kept as reference. Docs: docs/wiki/circuit-emulation-*.md (13 engineering pages covering architecture, solvers, components, AVR bridge, gotchas, performance, integration plan, API reference, appendix) + electrical-simulation- user-guide.md (end-user facing). Reference plan: test/test_circuit/plan/phase_8_velxio_implementation.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:11:54 +07:00
/**
* Hooks up the electrical solver to the main simulator store:
* - 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).
* Returns an `unsubscribe()` for cleanup.
*/
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import { buildInputFromStore } from './storeAdapter';
import type { PinSourceState } from './types';
import type { BoardKind } from '../../types/board';
// Which Arduino-style pin name maps to which ADC channel, per board.
// (Keep narrow for Phase 8.3 — extend as boards are added.)
const ADC_PIN_MAP: Partial<Record<BoardKind, Array<{ pinName: string; channel: number }>>> = {
'arduino-uno': [
{ pinName: 'A0', channel: 0 },
{ pinName: 'A1', channel: 1 },
{ pinName: 'A2', channel: 2 },
{ pinName: 'A3', channel: 3 },
{ pinName: 'A4', channel: 4 },
{ pinName: 'A5', channel: 5 },
],
'arduino-nano': [
{ pinName: 'A0', channel: 0 }, { pinName: 'A1', channel: 1 }, { pinName: 'A2', channel: 2 },
{ pinName: 'A3', channel: 3 }, { pinName: 'A4', channel: 4 }, { pinName: 'A5', channel: 5 },
{ pinName: 'A6', channel: 6 }, { pinName: 'A7', channel: 7 },
],
};
export function wireElectricalSolver(): () => void {
let lastPinStates: Record<string, Record<string, PinSourceState>> = {};
function collectPinStates(): Record<string, Record<string, PinSourceState>> {
const boards = useSimulatorStore.getState().boards;
const out: Record<string, Record<string, PinSourceState>> = {};
for (const board of boards) {
const simulator = board.simulator as unknown as {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pinManager?: any;
} | null;
const entries: Record<string, PinSourceState> = {};
// NOTE: Velxio's PinManager tracks state per pin number. Translating
// number → pin name depends on board-specific maps. For Phase 8.3 we
// emit pin states only when `pinManager.getPinState(pin)` is readable;
// boards without that accessor simply contribute no GPIO sources
// (their wires still participate via canonicalized ground/vcc).
if (simulator?.pinManager?.getPinState) {
// Best-effort: we don't yet have a canonical pin-number → pin-name
// map for all boards here. Future work (Phase 8.4) will enrich this.
}
out[board.id] = entries;
}
return out;
}
function maybeSolve() {
const { mode } = useElectricalStore.getState();
if (mode === 'off') return;
const storeState = useSimulatorStore.getState();
const pinStates = collectPinStates();
lastPinStates = pinStates;
const snap = {
components: storeState.components,
wires: storeState.wires,
boards: storeState.boards.map((b) => ({
id: b.id,
boardKind: b.boardKind,
pinStates: pinStates[b.id] ?? {},
})),
};
const input = buildInputFromStore(snap);
useElectricalStore.getState().triggerSolve(input);
}
function injectVoltagesIntoADC() {
const { nodeVoltages } = useElectricalStore.getState();
const { boards } = useSimulatorStore.getState();
for (const board of boards) {
const map = ADC_PIN_MAP[board.boardKind];
if (!map) continue;
const simulator = board.simulator as unknown as {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getADC?: () => { channelValues: number[] } | null;
} | null;
const adc = simulator?.getADC?.();
if (!adc) continue;
// Try to find a net matching "<boardId>_<pinName>" by string lookup
for (const { pinName, channel } of map) {
const probeKey = `${board.id}:${pinName}`;
// ngspice result keys are net names from NetlistBuilder — we don't
// yet have a direct pin→net lookup here. This is a forward-looking
// scaffold; Phase 8.4 will add the map via storeAdapter.
void probeKey;
// For now: if any net name literally equals the pin label, use it.
const v = nodeVoltages[pinName] ?? nodeVoltages[probeKey] ?? null;
if (v != null) {
adc.channelValues[channel] = Math.max(0, Math.min(board.boardKind.startsWith('esp32') ? 3.3 : 5, v));
}
}
}
}
// Re-solve on components / wires / mode changes.
const unsubSim = useSimulatorStore.subscribe((state, prev) => {
if (state.components !== prev.components || state.wires !== prev.wires) {
maybeSolve();
}
});
const unsubMode = useElectricalStore.subscribe((state, prev) => {
if (state.mode !== prev.mode && state.mode !== 'off') {
maybeSolve();
}
});
// On every solve result, re-inject ADC voltages.
const unsubResult = useElectricalStore.subscribe((state, prev) => {
if (state.nodeVoltages !== prev.nodeVoltages) {
injectVoltagesIntoADC();
}
});
return () => {
unsubSim();
unsubMode();
unsubResult();
void lastPinStates;
};
}