feat: add capacitor and inductor components, update netlist builder to return pinNetMap
This commit is contained in:
parent
64f6f76160
commit
61c1ddfc22
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"generatedAt": "2026-04-16T04:07:07.823Z",
|
"generatedAt": "2026-04-17T22:10:37.555Z",
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/>\n <text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"10\" fill=\"#666\">\n DIODE-1N4007\n </text>\n </svg>",
|
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/>\n <text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"10\" fill=\"#666\">\n DIODE-1N4007\n </text>\n </svg>",
|
||||||
|
|
@ -2853,6 +2853,29 @@
|
||||||
"led"
|
"led"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "capacitor",
|
||||||
|
"tagName": "wokwi-capacitor",
|
||||||
|
"name": "Capacitor",
|
||||||
|
"category": "passive",
|
||||||
|
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/>\n <text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"10\" fill=\"#666\">\n CAPACITOR\n </text>\n </svg>",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"type": "string",
|
||||||
|
"defaultValue": "1u",
|
||||||
|
"control": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValues": {
|
||||||
|
"value": "1u"
|
||||||
|
},
|
||||||
|
"pinCount": 0,
|
||||||
|
"tags": [
|
||||||
|
"capacitor",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "franzininho",
|
"id": "franzininho",
|
||||||
"tagName": "wokwi-franzininho",
|
"tagName": "wokwi-franzininho",
|
||||||
|
|
@ -2889,6 +2912,29 @@
|
||||||
"franzininho"
|
"franzininho"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "inductor",
|
||||||
|
"tagName": "wokwi-inductor",
|
||||||
|
"name": "Inductor",
|
||||||
|
"category": "passive",
|
||||||
|
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/>\n <text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"10\" fill=\"#666\">\n INDUCTOR\n </text>\n </svg>",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"type": "string",
|
||||||
|
"defaultValue": "1m",
|
||||||
|
"control": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultValues": {
|
||||||
|
"value": "1m"
|
||||||
|
},
|
||||||
|
"pinCount": 0,
|
||||||
|
"tags": [
|
||||||
|
"inductor",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ir-receiver",
|
"id": "ir-receiver",
|
||||||
"tagName": "wokwi-ir-receiver",
|
"tagName": "wokwi-ir-receiver",
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ describe('componentToSpice — ngspice accepts every card', () => {
|
||||||
});
|
});
|
||||||
extraLoadCards.push(`R_load_${i} n_probe_${i} 0 1Meg`);
|
extraLoadCards.push(`R_load_${i} n_probe_${i} 0 1Meg`);
|
||||||
}
|
}
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [
|
components: [
|
||||||
{ id: 'dut', metadataId: id, properties: fx.properties ?? {} },
|
{ id: 'dut', metadataId: id, properties: fx.properties ?? {} },
|
||||||
...Array.from({ length: pins.length - 2 }, (_, i) => ({
|
...Array.from({ length: pins.length - 2 }, (_, i) => ({
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { runNetlist } from '../simulation/spice/SpiceEngine';
|
||||||
|
|
||||||
describe('Voltmeter (instr-voltmeter)', () => {
|
describe('Voltmeter (instr-voltmeter)', () => {
|
||||||
it('reads ~3.33V across the midpoint of a 1k+2k divider', { timeout: 30_000 }, async () => {
|
it('reads ~3.33V across the midpoint of a 1k+2k divider', { timeout: 30_000 }, async () => {
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [
|
components: [
|
||||||
{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } },
|
{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } },
|
||||||
{ id: 'r2', metadataId: 'resistor', properties: { value: '2k' } },
|
{ id: 'r2', metadataId: 'resistor', properties: { value: '2k' } },
|
||||||
|
|
@ -48,7 +48,7 @@ describe('Ammeter (instr-ammeter)', () => {
|
||||||
it('reads ~22.7 mA through a 220 Ω load on 5V', { timeout: 30_000 }, async () => {
|
it('reads ~22.7 mA through a 220 Ω load on 5V', { timeout: 30_000 }, async () => {
|
||||||
// Circuit:
|
// Circuit:
|
||||||
// +5V ── R (220Ω) ── [A+ ammeter A-] ── GND
|
// +5V ── R (220Ω) ── [A+ ammeter A-] ── GND
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [
|
components: [
|
||||||
{ id: 'r1', metadataId: 'resistor', properties: { value: '220' } },
|
{ id: 'r1', metadataId: 'resistor', properties: { value: '220' } },
|
||||||
{ id: 'am', metadataId: 'instr-ammeter', properties: {} },
|
{ id: 'am', metadataId: 'instr-ammeter', properties: {} },
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ describe('parseValueWithUnits', () => {
|
||||||
|
|
||||||
describe('NetlistBuilder — simple cases', () => {
|
describe('NetlistBuilder — simple cases', () => {
|
||||||
it('emits a voltage divider and ngspice gets 6V', { timeout: 30_000 }, async () => {
|
it('emits a voltage divider and ngspice gets 6V', { timeout: 30_000 }, async () => {
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [
|
components: [
|
||||||
{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } },
|
{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } },
|
||||||
{ id: 'r2', metadataId: 'resistor', properties: { value: '2k' } },
|
{ id: 'r2', metadataId: 'resistor', properties: { value: '2k' } },
|
||||||
|
|
@ -86,7 +86,7 @@ describe('NetlistBuilder — simple cases', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits an LED + resistor from 5V rail and ngspice solves non-linear', { timeout: 30_000 }, async () => {
|
it('emits an LED + resistor from 5V rail and ngspice solves non-linear', { timeout: 30_000 }, async () => {
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [
|
components: [
|
||||||
{ id: 'r1', metadataId: 'resistor', properties: { value: '220' } },
|
{ id: 'r1', metadataId: 'resistor', properties: { value: '220' } },
|
||||||
{ id: 'led1', metadataId: 'led', properties: { color: 'red' } },
|
{ id: 'led1', metadataId: 'led', properties: { color: 'red' } },
|
||||||
|
|
@ -120,7 +120,7 @@ describe('NetlistBuilder — simple cases', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('NTC divider: T=25°C produces V(a0) ≈ 2.5V', { timeout: 30_000 }, async () => {
|
it('NTC divider: T=25°C produces V(a0) ≈ 2.5V', { timeout: 30_000 }, async () => {
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [
|
components: [
|
||||||
{ id: 'r1', metadataId: 'resistor', properties: { value: '10k' } },
|
{ id: 'r1', metadataId: 'resistor', properties: { value: '10k' } },
|
||||||
{ id: 'ntc1', metadataId: 'ntc-temperature-sensor', properties: { temperature: 25 } },
|
{ id: 'ntc1', metadataId: 'ntc-temperature-sensor', properties: { temperature: 25 } },
|
||||||
|
|
@ -149,7 +149,7 @@ describe('NetlistBuilder — simple cases', () => {
|
||||||
|
|
||||||
it('adds auto pull-down on floating cap-only node', () => {
|
it('adds auto pull-down on floating cap-only node', () => {
|
||||||
// Two caps share a node (net x) that only touches capacitors → DC-floating.
|
// Two caps share a node (net x) that only touches capacitors → DC-floating.
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [
|
components: [
|
||||||
{ id: 'c1', metadataId: 'capacitor', properties: { value: '1u' } },
|
{ id: 'c1', metadataId: 'capacitor', properties: { value: '1u' } },
|
||||||
{ id: 'c2', metadataId: 'capacitor', properties: { value: '1u' } },
|
{ id: 'c2', metadataId: 'capacitor', properties: { value: '1u' } },
|
||||||
|
|
@ -166,7 +166,7 @@ describe('NetlistBuilder — simple cases', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PWM pin emits DC-equivalent voltage source', () => {
|
it('PWM pin emits DC-equivalent voltage source', () => {
|
||||||
const netlist = buildNetlist({
|
const { netlist } = buildNetlist({
|
||||||
components: [],
|
components: [],
|
||||||
wires: [
|
wires: [
|
||||||
{ id: 'w1', start: { componentId: 'uno', pinName: 'D9' }, end: { componentId: 'uno', pinName: 'GND' } },
|
{ id: 'w1', start: { componentId: 'uno', pinName: 'D9' }, end: { componentId: 'uno', pinName: 'GND' } },
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ void loop() {
|
||||||
category: 'circuits', difficulty: 'beginner',
|
category: 'circuits', difficulty: 'beginner',
|
||||||
code: `// RC Low-Pass Filter
|
code: `// RC Low-Pass Filter
|
||||||
// PWM on pin 9 → R=10k → C=10uF → smooth DC on A0
|
// PWM on pin 9 → R=10k → C=10uF → smooth DC on A0
|
||||||
|
// DC steady-state: V_out ≈ duty × Vcc = 0.5 × 5 = 2.5 V
|
||||||
void setup() { Serial.begin(9600); analogWrite(9, 128); } // 50% duty
|
void setup() { Serial.begin(9600); analogWrite(9, 128); } // 50% duty
|
||||||
void loop() {
|
void loop() {
|
||||||
float v = analogRead(A0) * 5.0 / 1023.0;
|
float v = analogRead(A0) * 5.0 / 1023.0;
|
||||||
|
|
@ -64,11 +65,13 @@ void loop() {
|
||||||
components: [
|
components: [
|
||||||
UNO,
|
UNO,
|
||||||
{ type: 'wokwi-resistor', id: 'r1', x: 350, y: 100, properties: { value: '10000' } },
|
{ type: 'wokwi-resistor', id: 'r1', x: 350, y: 100, properties: { value: '10000' } },
|
||||||
|
{ type: 'wokwi-capacitor', id: 'c1', x: 420, y: 200, properties: { value: '10u' } },
|
||||||
],
|
],
|
||||||
wires: [
|
wires: [
|
||||||
w('w1', ['arduino-uno','9'], ['r1','1'], '#00aaff'),
|
w('w1', ['arduino-uno','9'], ['r1','1'], '#00aaff'),
|
||||||
w('w2', ['r1','2'], ['arduino-uno','A0'], '#ffaa00'),
|
w('w2', ['r1','2'], ['arduino-uno','A0'], '#ffaa00'),
|
||||||
w('w3', ['r1','2'], ['arduino-uno','GND'], '#000000'),
|
w('w3', ['r1','2'], ['c1','1'], '#ffaa00'),
|
||||||
|
w('w4', ['c1','2'], ['arduino-uno','GND'], '#000000'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -290,11 +293,13 @@ void loop() {
|
||||||
components: [
|
components: [
|
||||||
UNO,
|
UNO,
|
||||||
{ type: 'wokwi-resistor', id: 'r1', x: 380, y: 100, properties: { value: '10000' } },
|
{ type: 'wokwi-resistor', id: 'r1', x: 380, y: 100, properties: { value: '10000' } },
|
||||||
|
{ type: 'wokwi-capacitor', id: 'c1', x: 450, y: 200, properties: { value: '100u' } },
|
||||||
],
|
],
|
||||||
wires: [
|
wires: [
|
||||||
w('w1', ['arduino-uno','8'], ['r1','1'], '#00aaff'),
|
w('w1', ['arduino-uno','8'], ['r1','1'], '#00aaff'),
|
||||||
w('w2', ['r1','2'], ['arduino-uno','A0'], '#ffaa00'),
|
w('w2', ['r1','2'], ['c1','1'], '#ffaa00'),
|
||||||
w('w3', ['r1','2'], ['arduino-uno','GND'], '#000000'),
|
w('w3', ['r1','2'], ['arduino-uno','A0'], '#ffaa00'),
|
||||||
|
w('w4', ['c1','2'], ['arduino-uno','GND'], '#000000'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ class CircuitScheduler {
|
||||||
this.pending = null;
|
this.pending = null;
|
||||||
this.inFlight = true;
|
this.inFlight = true;
|
||||||
|
|
||||||
const netlist = buildNetlist(req.input);
|
const { netlist, pinNetMap } = buildNetlist(req.input);
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
let result: ElectricalSolveResult;
|
let result: ElectricalSolveResult;
|
||||||
try {
|
try {
|
||||||
|
|
@ -99,6 +99,7 @@ class CircuitScheduler {
|
||||||
error: null,
|
error: null,
|
||||||
solveMs: performance.now() - t0,
|
solveMs: performance.now() - t0,
|
||||||
submittedNetlist: netlist,
|
submittedNetlist: netlist,
|
||||||
|
pinNetMap,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
result = {
|
result = {
|
||||||
|
|
@ -108,6 +109,7 @@ class CircuitScheduler {
|
||||||
error: String(err instanceof Error ? err.message : err),
|
error: String(err instanceof Error ? err.message : err),
|
||||||
solveMs: performance.now() - t0,
|
solveMs: performance.now() - t0,
|
||||||
submittedNetlist: netlist,
|
submittedNetlist: netlist,
|
||||||
|
pinNetMap,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
this.inFlight = false;
|
this.inFlight = false;
|
||||||
|
|
@ -141,6 +143,7 @@ function noopResult(reason: string): ElectricalSolveResult {
|
||||||
error: reason,
|
error: reason,
|
||||||
solveMs: 0,
|
solveMs: 0,
|
||||||
submittedNetlist: '',
|
submittedNetlist: '',
|
||||||
|
pinNetMap: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,13 @@ function skipCanonicalization(metadataId: string): boolean {
|
||||||
return metadataId.startsWith('instr-');
|
return metadataId.startsWith('instr-');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNetlist(input: BuildNetlistInput): string {
|
export interface BuildNetlistResult {
|
||||||
|
netlist: string;
|
||||||
|
/** "boardId:pinName" → SPICE net name, from the same UF used to build the netlist. */
|
||||||
|
pinNetMap: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
||||||
const { components, wires, boards, analysis, extraCards = [] } = input;
|
const { components, wires, boards, analysis, extraCards = [] } = input;
|
||||||
|
|
||||||
// ── 1. Union-Find over wires ─────────────────────────────────────────────
|
// ── 1. Union-Find over wires ─────────────────────────────────────────────
|
||||||
|
|
@ -141,7 +147,23 @@ export function buildNetlist(input: BuildNetlistInput): string {
|
||||||
}
|
}
|
||||||
lines.push('.end');
|
lines.push('.end');
|
||||||
|
|
||||||
return lines.join('\n');
|
// ── 9. Build board pin → net map from the same UF ─────────────────────────
|
||||||
|
// Must use the same `uf` and `netNames` so net names match what ngspice sees.
|
||||||
|
const pinNetMap = new Map<string, string>();
|
||||||
|
for (const board of boards) {
|
||||||
|
// All wire endpoints that belong to this board are already in the UF.
|
||||||
|
for (const w of wires) {
|
||||||
|
for (const endpoint of [w.start, w.end]) {
|
||||||
|
if (endpoint.componentId !== board.id) continue;
|
||||||
|
const key = pinKey(board.id, endpoint.pinName);
|
||||||
|
if (!uf.has(key)) continue;
|
||||||
|
const netName = netNames.get(uf.find(key));
|
||||||
|
if (netName) pinNetMap.set(key, netName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { netlist: lines.join('\n'), pinNetMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -262,5 +284,75 @@ export function buildWireNetMap(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map from `"${boardId}:${pinName}"` → SPICE net name for every
|
||||||
|
* board pin that participates in the circuit. Used by the ADC injection
|
||||||
|
* step in subscribeToStore so it can look up voltages by pin name.
|
||||||
|
*/
|
||||||
|
export function buildBoardPinNetMap(
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
// Collect board IDs for fast lookup
|
||||||
|
const boardIds = new Set(boards.map((b) => b.id));
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize board ground/vcc pins (from boardPinGroups metadata)
|
||||||
|
for (const board of boards) {
|
||||||
|
for (const pName of board.groundPinNames ?? []) {
|
||||||
|
const k = pin(board.id, pName);
|
||||||
|
uf.add(k);
|
||||||
|
uf.setCanonical(k, '0');
|
||||||
|
}
|
||||||
|
for (const pName of board.vccPinNames ?? []) {
|
||||||
|
const k = pin(board.id, pName);
|
||||||
|
uf.add(k);
|
||||||
|
uf.setCanonical(k, 'vcc_rail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Canonicalize non-board component GND/VCC pins referenced by wires
|
||||||
|
for (const comp of components) {
|
||||||
|
if (comp.metadataId.startsWith('instr-')) continue;
|
||||||
|
if (boardIds.has(comp.id)) continue; // board handled above
|
||||||
|
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 each board, collect ALL pins that appear in wires (via wire endpoints)
|
||||||
|
// plus the explicit groundPinNames/vccPinNames/pins lists.
|
||||||
|
for (const board of boards) {
|
||||||
|
const wireReferencedPins = pinsReferencedByWires(board.id, wires);
|
||||||
|
const allPins = new Set([
|
||||||
|
...(board.groundPinNames ?? []),
|
||||||
|
...(board.vccPinNames ?? []),
|
||||||
|
...Object.keys(board.pins ?? {}),
|
||||||
|
...wireReferencedPins, // ← the pins that actually exist in the UF
|
||||||
|
]);
|
||||||
|
for (const pName of allPins) {
|
||||||
|
const k = pin(board.id, pName);
|
||||||
|
if (uf.has(k)) {
|
||||||
|
const netName = netNames.get(uf.find(k));
|
||||||
|
if (netName) result.set(k, netName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-export types for callers. */
|
/** Re-export types for callers. */
|
||||||
export type { BuildNetlistInput, ComponentForSpice, BoardForSpice, WireForSpice } from './types';
|
export type { BuildNetlistInput, ComponentForSpice, BoardForSpice, WireForSpice } from './types';
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@
|
||||||
* Called once at app startup (typically from EditorPage or main.tsx).
|
* Called once at app startup (typically from EditorPage or main.tsx).
|
||||||
* Returns an `unsubscribe()` for cleanup.
|
* Returns an `unsubscribe()` for cleanup.
|
||||||
*/
|
*/
|
||||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
import { useSimulatorStore, getBoardSimulator, getBoardPinManager } 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';
|
||||||
|
|
||||||
// Which Arduino-style pin name maps to which ADC channel, per board.
|
// 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.
|
// Used to inject SPICE-solved voltages back into the MCU's ADC peripheral.
|
||||||
|
|
@ -62,75 +64,149 @@ const ADC_PIN_MAP: Partial<Record<BoardKind, Array<{ pinName: string; channel: n
|
||||||
'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6),
|
'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function wireElectricalSolver(): () => void {
|
/**
|
||||||
let lastPinStates: Record<string, Record<string, PinSourceState>> = {};
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
function collectPinStates(): Record<string, Record<string, PinSourceState>> {
|
const ADC_PIN_TO_GPIO: Partial<Record<BoardKind, (pinName: string, channel: number) => number>> = {
|
||||||
const boards = useSimulatorStore.getState().boards;
|
'arduino-uno': avrPinFromName,
|
||||||
const out: Record<string, Record<string, PinSourceState>> = {};
|
'arduino-nano': avrPinFromName,
|
||||||
for (const board of boards) {
|
'arduino-mega': avrPinFromName,
|
||||||
const simulator = board.simulator as unknown as {
|
'attiny85': avrPinFromName,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
pinManager?: any;
|
'raspberry-pi-pico': gpioPinFromName,
|
||||||
} | null;
|
'pi-pico-w': gpioPinFromName,
|
||||||
const entries: Record<string, PinSourceState> = {};
|
|
||||||
// NOTE: Velxio's PinManager tracks state per pin number. Translating
|
'esp32': gpioPinFromName,
|
||||||
// number → pin name depends on board-specific maps. For Phase 8.3 we
|
'esp32-devkit-c-v4': gpioPinFromName,
|
||||||
// emit pin states only when `pinManager.getPinState(pin)` is readable;
|
'esp32-cam': gpioPinFromName,
|
||||||
// boards without that accessor simply contribute no GPIO sources
|
'wemos-lolin32-lite': gpioPinFromName,
|
||||||
// (their wires still participate via canonicalized ground/vcc).
|
'esp32-s3': gpioPinFromName,
|
||||||
if (simulator?.pinManager?.getPinState) {
|
'xiao-esp32-s3': gpioPinFromName,
|
||||||
// Best-effort: we don't yet have a canonical pin-number → pin-name
|
'arduino-nano-esp32': avrPinFromName, // uses A0-A7 naming
|
||||||
// map for all boards here. Future work (Phase 8.4) will enrich this.
|
'esp32-c3': gpioPinFromName,
|
||||||
}
|
'xiao-esp32-c3': gpioPinFromName,
|
||||||
out[board.id] = entries;
|
'aitewinrobot-esp32c3-supermini': gpioPinFromName,
|
||||||
}
|
};
|
||||||
return out;
|
|
||||||
|
/**
|
||||||
|
* Convert a board pin name (e.g. "9", "A0", "GP26", "GPIO32") to the
|
||||||
|
* Arduino-style pin number that PinManager uses internally.
|
||||||
|
* Returns -1 if the name doesn't map to a GPIO pin.
|
||||||
|
*/
|
||||||
|
function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// RP2040: "GP26" → 26
|
||||||
|
if (pinName.startsWith('GP')) {
|
||||||
|
const n = parseInt(pinName.slice(2), 10);
|
||||||
|
return Number.isFinite(n) ? n : -1;
|
||||||
}
|
}
|
||||||
|
// ESP32: "GPIO32" → 32
|
||||||
|
if (pinName.startsWith('GPIO')) {
|
||||||
|
const n = parseInt(pinName.slice(4), 10);
|
||||||
|
return Number.isFinite(n) ? n : -1;
|
||||||
|
}
|
||||||
|
// AVR analog: "A0" → 14, "A1" → 15, ...
|
||||||
|
if (/^A\d+$/.test(pinName)) {
|
||||||
|
return 14 + parseInt(pinName.slice(1), 10);
|
||||||
|
}
|
||||||
|
// Bare numeric: "9" → 9, "13" → 13
|
||||||
|
if (/^\d+$/.test(pinName)) {
|
||||||
|
return parseInt(pinName, 10);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect MCU output pin states from PinManager for pins that participate
|
||||||
|
* in the circuit (i.e., are referenced by wires).
|
||||||
|
*/
|
||||||
|
function collectPinStates(
|
||||||
|
boardId: string,
|
||||||
|
boardKind: BoardKind,
|
||||||
|
wires: Array<{ start: { componentId: string; pinName: string }; end: { componentId: string; pinName: string } }>,
|
||||||
|
): Record<string, PinSourceState> {
|
||||||
|
const pm = getBoardPinManager(boardId);
|
||||||
|
if (!pm) return {};
|
||||||
|
const group = BOARD_PIN_GROUPS[boardKind] ?? BOARD_PIN_GROUPS.default;
|
||||||
|
const vcc = group.vcc;
|
||||||
|
|
||||||
|
const result: Record<string, PinSourceState> = {};
|
||||||
|
// Gather all pin names wired to this board
|
||||||
|
const pinNames = new Set<string>();
|
||||||
|
for (const w of wires) {
|
||||||
|
if (w.start.componentId === boardId) pinNames.add(w.start.pinName);
|
||||||
|
if (w.end.componentId === boardId) pinNames.add(w.end.pinName);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pinName of pinNames) {
|
||||||
|
const arduinoPin = pinNameToArduinoPin(pinName, boardKind);
|
||||||
|
if (arduinoPin < 0) continue;
|
||||||
|
const pwmDuty = pm.getPwmValue(arduinoPin);
|
||||||
|
if (pwmDuty > 0) {
|
||||||
|
result[pinName] = { type: 'pwm', duty: pwmDuty };
|
||||||
|
} else if (pm.getPinState(arduinoPin)) {
|
||||||
|
result[pinName] = { type: 'digital', v: vcc };
|
||||||
|
}
|
||||||
|
// If pin is LOW or unknown, don't add — treated as input/floating by SPICE
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wireElectricalSolver(): () => void {
|
||||||
|
|
||||||
function maybeSolve() {
|
function maybeSolve() {
|
||||||
const { mode } = useElectricalStore.getState();
|
const { mode } = useElectricalStore.getState();
|
||||||
if (mode === 'off') return;
|
if (mode === 'off') return;
|
||||||
const storeState = useSimulatorStore.getState();
|
const storeState = useSimulatorStore.getState();
|
||||||
const pinStates = collectPinStates();
|
|
||||||
lastPinStates = pinStates;
|
|
||||||
const snap = {
|
const snap = {
|
||||||
components: storeState.components,
|
components: storeState.components,
|
||||||
wires: storeState.wires,
|
wires: storeState.wires,
|
||||||
boards: storeState.boards.map((b) => ({
|
boards: storeState.boards.map((b) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
boardKind: b.boardKind,
|
boardKind: b.boardKind,
|
||||||
pinStates: pinStates[b.id] ?? {},
|
pinStates: collectPinStates(b.id, b.boardKind, storeState.wires),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const input = buildInputFromStore(snap);
|
const input = buildInputFromStore(snap);
|
||||||
|
// 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() {
|
function injectVoltagesIntoADC() {
|
||||||
const { nodeVoltages } = useElectricalStore.getState();
|
const { nodeVoltages, pinNetMap } = useElectricalStore.getState();
|
||||||
const { boards } = useSimulatorStore.getState();
|
const { boards } = useSimulatorStore.getState();
|
||||||
for (const board of boards) {
|
for (const board of boards) {
|
||||||
const map = ADC_PIN_MAP[board.boardKind];
|
const adcPins = ADC_PIN_MAP[board.boardKind];
|
||||||
if (!map) continue;
|
if (!adcPins) continue;
|
||||||
const simulator = board.simulator as unknown as {
|
const sim = getBoardSimulator(board.id);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!sim) continue;
|
||||||
getADC?: () => { channelValues: number[] } | null;
|
const vMax = board.boardKind.startsWith('esp32') ? 3.3 : 5.0;
|
||||||
} | null;
|
for (const { pinName, channel } of adcPins) {
|
||||||
const adc = simulator?.getADC?.();
|
const netName = pinNetMap.get(`${board.id}:${pinName}`);
|
||||||
if (!adc) continue;
|
if (!netName) continue;
|
||||||
// Try to find a net matching "<boardId>_<pinName>" by string lookup
|
const v = nodeVoltages[netName];
|
||||||
for (const { pinName, channel } of map) {
|
if (v == null) continue;
|
||||||
const probeKey = `${board.id}:${pinName}`;
|
const clamped = Math.max(0, Math.min(vMax, v));
|
||||||
// ngspice result keys are net names from NetlistBuilder — we don't
|
// setAdcVoltage handles AVR (pin 14-19), RP2040 (GPIO 26-29),
|
||||||
// yet have a direct pin→net lookup here. This is a forward-looking
|
// and ESP32 (bridge shim) transparently.
|
||||||
// scaffold; Phase 8.4 will add the map via storeAdapter.
|
const gpioPin = ADC_PIN_TO_GPIO[board.boardKind]?.(pinName, channel);
|
||||||
void probeKey;
|
if (gpioPin != null) setAdcVoltage(sim, gpioPin, clamped);
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -155,10 +231,53 @@ export function wireElectricalSolver(): () => void {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-inject whenever boards change (e.g. loadHex recreates AVRADC).
|
||||||
|
// loadHex() creates a fresh AVRADC *before* updating the store, so by the
|
||||||
|
// time this fires the new ADC already exists and needs the SPICE values.
|
||||||
|
const unsubBoards = useSimulatorStore.subscribe((state, prev) => {
|
||||||
|
if (state.boards !== prev.boards) {
|
||||||
|
const { nodeVoltages } = useElectricalStore.getState();
|
||||||
|
if (Object.keys(nodeVoltages).length > 0) {
|
||||||
|
injectVoltagesIntoADC();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic re-solve while any board is running, so SPICE picks up
|
||||||
|
// MCU pin-state changes (e.g. analogWrite → PWM → voltage source).
|
||||||
|
let solveInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const SOLVE_INTERVAL_MS = 200;
|
||||||
|
|
||||||
|
function updateSolveTimer() {
|
||||||
|
const { mode } = useElectricalStore.getState();
|
||||||
|
const anyRunning = useSimulatorStore.getState().boards.some((b) => b.running);
|
||||||
|
if (mode !== 'off' && anyRunning) {
|
||||||
|
if (!solveInterval) {
|
||||||
|
solveInterval = setInterval(maybeSolve, SOLVE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
} else if (solveInterval) {
|
||||||
|
clearInterval(solveInterval);
|
||||||
|
solveInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubRunning = useSimulatorStore.subscribe((state, prev) => {
|
||||||
|
const wasRunning = prev.boards.some((b) => b.running);
|
||||||
|
const nowRunning = state.boards.some((b) => b.running);
|
||||||
|
if (wasRunning !== nowRunning) updateSolveTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubModeTimer = useElectricalStore.subscribe((state, prev) => {
|
||||||
|
if (state.mode !== prev.mode) updateSolveTimer();
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubSim();
|
unsubSim();
|
||||||
unsubMode();
|
unsubMode();
|
||||||
unsubResult();
|
unsubResult();
|
||||||
void lastPinStates;
|
unsubBoards();
|
||||||
|
unsubRunning();
|
||||||
|
unsubModeTimer();
|
||||||
|
if (solveInterval) clearInterval(solveInterval);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,9 @@ export interface ElectricalSolveResult {
|
||||||
solveMs: number;
|
solveMs: number;
|
||||||
/** The netlist we submitted — useful for debugging in the UI. */
|
/** The netlist we submitted — useful for debugging in the UI. */
|
||||||
submittedNetlist: string;
|
submittedNetlist: string;
|
||||||
|
/**
|
||||||
|
* Maps "boardId:pinName" → SPICE net name, built from the same Union-Find
|
||||||
|
* used to generate the netlist. Used by ADC injection to locate voltages.
|
||||||
|
*/
|
||||||
|
pinNetMap: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ interface ElectricalState {
|
||||||
engineLoading: boolean;
|
engineLoading: boolean;
|
||||||
engineReady: boolean;
|
engineReady: boolean;
|
||||||
submittedNetlist: string;
|
submittedNetlist: string;
|
||||||
|
/** "boardId:pinName" → SPICE net name. Populated after each solve. */
|
||||||
|
pinNetMap: Map<string, string>;
|
||||||
|
|
||||||
setMode: (m: ElectricalMode) => Promise<void>;
|
setMode: (m: ElectricalMode) => Promise<void>;
|
||||||
triggerSolve: (input: BuildNetlistInput) => void;
|
triggerSolve: (input: BuildNetlistInput) => void;
|
||||||
|
|
@ -54,6 +56,7 @@ export const useElectricalStore = create<ElectricalState>((set, get) => {
|
||||||
error: r.error,
|
error: r.error,
|
||||||
lastSolveMs: r.solveMs,
|
lastSolveMs: r.solveMs,
|
||||||
submittedNetlist: r.submittedNetlist,
|
submittedNetlist: r.submittedNetlist,
|
||||||
|
pinNetMap: r.pinNetMap,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,6 +70,7 @@ export const useElectricalStore = create<ElectricalState>((set, get) => {
|
||||||
engineLoading: false,
|
engineLoading: false,
|
||||||
engineReady: false,
|
engineReady: false,
|
||||||
submittedNetlist: '',
|
submittedNetlist: '',
|
||||||
|
pinNetMap: new Map(),
|
||||||
|
|
||||||
async setMode(m) {
|
async setMode(m) {
|
||||||
if (!ELECTRICAL_SIM_ENABLED && m !== 'off') {
|
if (!ELECTRICAL_SIM_ENABLED && m !== 'off') {
|
||||||
|
|
@ -111,6 +115,7 @@ export const useElectricalStore = create<ElectricalState>((set, get) => {
|
||||||
error: null,
|
error: null,
|
||||||
lastSolveMs: 0,
|
lastSolveMs: 0,
|
||||||
submittedNetlist: '',
|
submittedNetlist: '',
|
||||||
|
pinNetMap: new Map(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ const CATEGORY_MAP: Record<string, ComponentCategory> = {
|
||||||
// Passive Components
|
// Passive Components
|
||||||
'resistor': 'passive',
|
'resistor': 'passive',
|
||||||
'capacitor': 'passive',
|
'capacitor': 'passive',
|
||||||
|
'inductor': 'passive',
|
||||||
'diode': 'passive',
|
'diode': 'passive',
|
||||||
'analog-multiplexer': 'passive',
|
'analog-multiplexer': 'passive',
|
||||||
'ir-receiver': 'passive',
|
'ir-receiver': 'passive',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 14296fa4b6797ce8ddedc55b47eafa0fe9c2a0e0
|
||||||
Loading…
Reference in New Issue