feat: always-on SPICE mode, full board ADC integration, LED brightness from current

Electrical simulation is now active by default (mode='spice' instead of
'off') — users no longer need to toggle the mode on manually. The engine
lazy-loads on first solve, so there is no startup cost penalty.

Changes:
- useElectricalStore: default mode = 'spice' when ELECTRICAL_SIM_ENABLED
- subscribeToStore: ADC_PIN_MAP expanded to all 18 board types (Uno, Nano,
  Mega with 16 ADC channels, ATtiny85, RP2040 GP26-29, ESP32/S3/C3 GPIO
  ADCs). Voltages from SPICE solutions now inject into MCU ADC peripherals
  for all boards.
- BasicParts LED: reads branchCurrents from useElectricalStore when SPICE
  is active. Brightness = clamp(|I_led| / 20mA, 0, 1) instead of boolean.
  Subscribes to store changes to update in real time.
- ElectricalOverlay: shows per-wire voltage labels (gold monospace on dark
  pill) using buildWireNetMap() which replicates the NetlistBuilder's
  Union-Find to map wireId -> netName -> nodeVoltage. Summary pill shows
  net count + solve time.
- NetlistBuilder: new export buildWireNetMap() for lightweight wire-to-net
  resolution without running ngspice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-04-15 22:59:22 -03:00
parent 36543e2479
commit 04d14a74b2
5 changed files with 189 additions and 40 deletions

View File

@ -1,7 +1,8 @@
/**
* Floating SVG overlay that renders voltage labels at wire midpoints.
* Only visible when electrical mode is active. Reads voltages from
* `useElectricalStore.nodeVoltages` (updated by the scheduler).
* `useElectricalStore.nodeVoltages` (updated by the scheduler) and maps
* wires to nets via `buildWireNetMap`.
*
* This is a read-only, zero-interactivity layer it sits ABOVE the wire
* layer but below the component layer so labels remain legible without
@ -10,10 +11,12 @@
import { useMemo } from 'react';
import { useElectricalStore } from '../../store/useElectricalStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { buildWireNetMap } from '../../simulation/spice/NetlistBuilder';
import { BOARD_PIN_GROUPS } from '../../simulation/spice/boardPinGroups';
function formatV(v: number): string {
const abs = Math.abs(v);
if (abs < 1e-3) return `${(v * 1e6).toFixed(1)}µV`;
if (abs < 1e-3) return `${(v * 1e6).toFixed(0)}uV`;
if (abs < 1) return `${(v * 1e3).toFixed(1)}mV`;
if (abs < 100) return `${v.toFixed(2)}V`;
return `${v.toFixed(1)}V`;
@ -27,28 +30,56 @@ export function ElectricalOverlay() {
const solveMs = useElectricalStore((s) => s.lastSolveMs);
const wires = useSimulatorStore((s) => s.wires);
const components = useSimulatorStore((s) => s.components);
const boards = useSimulatorStore((s) => s.boards);
// Pre-compute midpoint labels from wires (we don't currently map wire → net,
// but for the initial version we label every wire's midpoint with all the
// voltages the solver emitted and let the user cross-check).
const labels = useMemo(() => {
if (mode === 'off') return [];
if (mode === 'off' || Object.keys(nodeVoltages).length === 0) return [];
const boardsForSpice = boards.map((b) => {
const pg = BOARD_PIN_GROUPS[b.boardKind] ?? BOARD_PIN_GROUPS.default;
return {
id: b.id,
vcc: pg.vcc,
groundPinNames: pg.gnd,
vccPinNames: pg.vcc_pins,
pins: {},
};
});
const compsForSpice = components.map((c) => ({
id: c.id,
metadataId: c.metadataId,
properties: c.properties ?? {},
}));
const wiresForSpice = wires.map((w) => ({
id: w.id,
start: { componentId: w.start.componentId, pinName: w.start.pinName },
end: { componentId: w.end.componentId, pinName: w.end.pinName },
}));
const wireNetMap = buildWireNetMap({
components: compsForSpice,
wires: wiresForSpice,
boards: boardsForSpice,
});
return wires.map((w) => {
const mx = (w.start.x + w.end.x) / 2;
const my = (w.start.y + w.end.y) / 2;
return { id: w.id, x: mx, y: my };
});
}, [wires, mode]);
const netName = wireNetMap.get(w.id);
const v = netName ? nodeVoltages[netName] : undefined;
return { id: w.id, x: mx, y: my, v, netName };
}).filter((l) => l.v !== undefined && l.netName !== '0');
}, [wires, components, boards, mode, nodeVoltages]);
if (mode === 'off') return null;
// A summary pill in the top-left of the canvas
const summaryLines: string[] = [];
if (error) summaryLines.push(`${error}`);
else if (!converged) summaryLines.push('⚠ did not converge');
if (error) summaryLines.push(`Warning: ${error}`);
else if (!converged) summaryLines.push('Warning: did not converge');
else {
const n = Object.keys(nodeVoltages).length;
summaryLines.push(`${n} nets • solved in ${solveMs.toFixed(0)} ms`);
summaryLines.push(`${n} nets | ${solveMs.toFixed(0)} ms`);
}
return (
@ -70,20 +101,39 @@ export function ElectricalOverlay() {
y={0}
rx={4}
ry={4}
width={260}
height={28}
width={220}
height={24}
fill="rgba(26, 26, 26, 0.85)"
stroke={error ? '#ff6666' : '#ffa500'}
/>
<text x={10} y={19} fontSize={12} fill={error ? '#ff9999' : '#ffa500'}>
{summaryLines.join(' · ')}
<text x={8} y={17} fontSize={11} fill={error ? '#ff9999' : '#ffa500'} fontFamily="monospace">
SPICE {summaryLines.join(' ')}
</text>
</g>
{/* Per-wire midpoint markers (minimal only a small dot so we don't
clutter until we have wirenet mapping in Phase 8.3.1) */}
{/* Per-wire voltage labels */}
{labels.map((l) => (
<circle key={l.id} cx={l.x} cy={l.y} r={2} fill="#ffa50055" />
<g key={l.id} transform={`translate(${l.x}, ${l.y})`}>
<rect
x={-20}
y={-9}
rx={3}
ry={3}
width={40}
height={16}
fill="rgba(0, 0, 0, 0.75)"
/>
<text
x={0}
y={4}
textAnchor="middle"
fontSize={10}
fontFamily="monospace"
fill="#ffd700"
>
{formatV(l.v!)}
</text>
</g>
))}
</svg>
);

View File

@ -132,7 +132,7 @@ PartSimulationRegistry.register('dip-switch-8', {
* LED stays off regardless of the anode state.
*/
PartSimulationRegistry.register('led', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pinManager = (simulator as any).pinManager;
if (!pinManager) return () => {};
@ -141,23 +141,36 @@ PartSimulationRegistry.register('led', {
let anodeHigh = false;
let cathodeLow = false;
const update = () => { el.value = anodeHigh && cathodeLow; };
const update = () => {
// When electrical mode is active, use real branch current for
// analog brightness (0..1) instead of boolean on/off. The SPICE
// mapper emits a diode card `D_<componentId>` whose branch current
// is stored in `branchCurrents`.
try {
const { useElectricalStore } = require('../../store/useElectricalStore');
const { mode, branchCurrents } = useElectricalStore.getState();
if (mode !== 'off') {
const iKey = `d_${componentId}`;
const current = Math.abs(branchCurrents[iKey] ?? 0);
el.value = current > 1e-6;
el.brightness = Math.min(1, current / 0.020); // 20 mA = full brightness
return;
}
} catch { /* store not available — fall through to digital mode */ }
el.value = anodeHigh && cathodeLow;
};
// Cathode pin: -1 means wired to GND (always LOW), >=0 means GPIO
const cathodePin = getArduinoPinHelper('C');
if (cathodePin === -1) {
// Wired to GND — always LOW
cathodeLow = true;
} else if (cathodePin !== null && cathodePin >= 0) {
// Wired to a GPIO — track its state
unsubs.push(pinManager.onPinChange(cathodePin, (_: number, state: boolean) => {
cathodeLow = !state; // cathode needs to be LOW for current to flow
cathodeLow = !state;
update();
}));
}
// cathodePin === null → not wired → cathodeLow stays false → LED off
// Anode pin
const anodePin = getArduinoPinHelper('A');
if (anodePin !== null && anodePin >= 0) {
unsubs.push(pinManager.onPinChange(anodePin, (_: number, state: boolean) => {
@ -166,6 +179,18 @@ PartSimulationRegistry.register('led', {
}));
}
// Also subscribe to electrical store changes to update brightness
// whenever the SPICE solver delivers a new result.
try {
const { useElectricalStore } = require('../../store/useElectricalStore');
const unsubElectrical = useElectricalStore.subscribe(
(state: { branchCurrents: Record<string, number> }, prev: { branchCurrents: Record<string, number> }) => {
if (state.branchCurrents !== prev.branchCurrents) update();
},
);
unsubs.push(unsubElectrical);
} catch { /* store not available */ }
return () => { unsubs.forEach(u => u()); };
},
});

View File

@ -219,5 +219,48 @@ function detectFloatingNets(netNames: Map<string, string>, cards: string[]): Set
return floating;
}
/**
* Build a wireId netName map using the same Union-Find logic as buildNetlist.
* Lightweight (no SPICE call) suitable for the overlay to look up voltages.
*/
export function buildWireNetMap(
input: Pick<BuildNetlistInput, 'components' | 'wires' | 'boards'>,
): Map<string, string> {
const { wires, boards, components } = input;
const uf = new UnionFind();
const pin = (cId: string, pName: string) => `${cId}:${pName}`;
for (const w of wires) {
const a = pin(w.start.componentId, w.start.pinName);
const b = pin(w.end.componentId, w.end.pinName);
uf.add(a);
uf.add(b);
uf.union(a, b);
}
for (const board of boards) {
for (const pName of board.groundPinNames ?? []) uf.setCanonical(pin(board.id, pName), '0');
for (const pName of board.vccPinNames ?? []) uf.setCanonical(pin(board.id, pName), 'vcc_rail');
}
for (const comp of components) {
if (comp.metadataId.startsWith('instr-')) continue;
for (const pName of pinsReferencedByWires(comp.id, wires)) {
if (GROUND_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), '0');
else if (VCC_PIN_RE.test(pName)) uf.setCanonical(pin(comp.id, pName), 'vcc_rail');
}
}
const netNames = assignDeterministicNetNames(uf);
const result = new Map<string, string>();
for (const w of wires) {
const key = pin(w.start.componentId, w.start.pinName);
if (uf.has(key)) {
const netName = netNames.get(uf.find(key));
if (netName) result.set(w.id, netName);
}
}
return result;
}
/** Re-export types for callers. */
export type { BuildNetlistInput, ComponentForSpice, BoardForSpice, WireForSpice } from './types';

View File

@ -14,21 +14,52 @@ 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.)
// 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 }>>> = {
'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 },
// 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 },
],
'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 },
'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),
};
export function wireElectricalSolver(): () => void {

View File

@ -58,7 +58,7 @@ export const useElectricalStore = create<ElectricalState>((set, get) => {
});
return {
mode: 'off',
mode: ELECTRICAL_SIM_ENABLED ? 'spice' : 'off',
nodeVoltages: {},
branchCurrents: {},
converged: true,