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:
parent
36543e2479
commit
04d14a74b2
|
|
@ -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 wire→net 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()); };
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export const useElectricalStore = create<ElectricalState>((set, get) => {
|
|||
});
|
||||
|
||||
return {
|
||||
mode: 'off',
|
||||
mode: ELECTRICAL_SIM_ENABLED ? 'spice' : 'off',
|
||||
nodeVoltages: {},
|
||||
branchCurrents: {},
|
||||
converged: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue