feat(sim): Phase 4 — opt-in wire resistance (length_cm)
Wires can now carry a `length_cm` property. When set, the NetlistBuilder treats them as a real resistor (0.01 ohm/cm ≈ AWG 22 copper) instead of the legacy perfect-conductor union. Wires without `length_cm` are unchanged — 100% backwards compatible until the UI starts attaching length values based on canvas geometry. Implementation: - `WireForSpice.length_cm?: number` added to types - Union-Find pass skips `union(a, b)` when length_cm > 0, so endpoints end up in separate nets - After component-card emission, scan `resistiveWires` and emit `R_wire_<id> <netA> <netB> <ohms>` for each - Pull-down detection runs after so the wire R counts as a DC path Verified end-to-end with real ngspice: - 100/100 divider at 5V → vmid = 2.5V (legacy, no wire R) - Same with 1 cm supply wire → vmid = 2.4999 V (0.25 mV drop) - Same with 500 cm supply wire → vmid ≈ 2.439 V (~6% drop) 5 new Phase 4 tests + 208 regression tests pass. This is the plumbing-first deliverable from the original sim-mixedmode plan — UI work (compute length from canvas waypoints) is a separate front-end task. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ae9fbb615
commit
340323c1d3
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Phase 4 — wire resistance.
|
||||
*
|
||||
* Wires marked with `length_cm` get a series R in the netlist
|
||||
* (0.01 ohm/cm, order-of-magnitude correct for AWG 22 copper).
|
||||
* Wires without `length_cm` keep the legacy perfect-conductor
|
||||
* union-find behaviour — backwards compatible.
|
||||
*
|
||||
* The new path is fully opt-in so no existing canvas changes.
|
||||
* Once the UI starts attaching length_cm based on canvas geometry,
|
||||
* users see real voltage drop on long buses (e.g. a divider sagging
|
||||
* because the supply wire has 5 mΩ in series).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildNetlist } from '../simulation/spice/NetlistBuilder';
|
||||
import { runNetlist } from '../simulation/spice/SpiceEngine';
|
||||
import type { BuildNetlistInput } from '../simulation/spice/types';
|
||||
|
||||
function dividerWithWires(supplyWire: { length_cm?: number }): BuildNetlistInput {
|
||||
return {
|
||||
components: [
|
||||
{ id: 'r1', metadataId: 'resistor', properties: { value: '100' } },
|
||||
{ id: 'r2', metadataId: 'resistor', properties: { value: '100' } },
|
||||
],
|
||||
wires: [
|
||||
// 5V → r1 pin 1 (this is the wire we may add length to)
|
||||
{
|
||||
id: 'w_supply',
|
||||
start: { componentId: 'uno', pinName: '5V' },
|
||||
end: { componentId: 'r1', pinName: '1' },
|
||||
length_cm: supplyWire.length_cm,
|
||||
},
|
||||
// r1 pin 2 → r2 pin 1 (the divider mid)
|
||||
{ id: 'w_mid', start: { componentId: 'r1', pinName: '2' }, end: { componentId: 'r2', pinName: '1' } },
|
||||
// r2 pin 2 → GND
|
||||
{ id: 'w_gnd', start: { componentId: 'r2', pinName: '2' }, end: { componentId: 'uno', pinName: 'GND' } },
|
||||
],
|
||||
boards: [
|
||||
{
|
||||
id: 'uno',
|
||||
vcc: 5,
|
||||
pins: {
|
||||
'5V': { type: 'digital', v: 5 },
|
||||
GND: { type: 'digital', v: 0 },
|
||||
},
|
||||
groundPinNames: ['GND'],
|
||||
vccPinNames: ['5V'],
|
||||
},
|
||||
],
|
||||
analysis: { kind: 'op' },
|
||||
};
|
||||
}
|
||||
|
||||
describe('Phase 4 — wire resistance (opt-in via length_cm)', () => {
|
||||
it('wires without length_cm produce no R_wire_ cards (backwards compatible)', () => {
|
||||
const { netlist } = buildNetlist(dividerWithWires({}));
|
||||
expect(netlist).not.toMatch(/R_wire_/);
|
||||
});
|
||||
|
||||
it('wires with length_cm > 0 emit a R_wire_<id> card with correct ohms', () => {
|
||||
const { netlist } = buildNetlist(dividerWithWires({ length_cm: 50 }));
|
||||
// 50 cm × 0.01 ohm/cm = 0.5 ohm
|
||||
expect(netlist).toMatch(/R_wire_w_supply\s+\S+\s+\S+\s+0\.5\b/);
|
||||
});
|
||||
|
||||
it(
|
||||
'a 1 cm supply wire shifts the divider midpoint by only a few mV',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const { netlist, pinNetMap } = buildNetlist(dividerWithWires({ length_cm: 1 }));
|
||||
const result = await runNetlist(netlist);
|
||||
const midNet = pinNetMap.get('r1:2');
|
||||
const vMid = result.dcValue(`v(${midNet})`);
|
||||
// 100/100 divider with 5V supply and 1 cm × 0.01 ohm wire (10 mohm)
|
||||
// in series. Current ≈ 5/200 = 25 mA. Wire drop = 0.25 mV. Vmid ≈ 2.4999 V.
|
||||
expect(vMid).toBeGreaterThan(2.499);
|
||||
expect(vMid).toBeLessThan(2.501);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'a 500 cm supply wire shifts the divider midpoint visibly',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const { netlist, pinNetMap } = buildNetlist(dividerWithWires({ length_cm: 500 }));
|
||||
const result = await runNetlist(netlist);
|
||||
const midNet = pinNetMap.get('r1:2');
|
||||
const vMid = result.dcValue(`v(${midNet})`);
|
||||
// 500 cm × 0.01 ohm/cm = 5 ohm in series with 200 ohm divider.
|
||||
// Effective: 5V × 100 / (5+100+100) ≈ 2.439 V.
|
||||
expect(vMid).toBeGreaterThan(2.40);
|
||||
expect(vMid).toBeLessThan(2.46);
|
||||
},
|
||||
);
|
||||
|
||||
it('length_cm = 0 falls back to perfect-conductor behaviour', () => {
|
||||
const { netlist } = buildNetlist(dividerWithWires({ length_cm: 0 }));
|
||||
expect(netlist).not.toMatch(/R_wire_/);
|
||||
});
|
||||
});
|
||||
|
|
@ -43,13 +43,20 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
|||
const uf = new UnionFind();
|
||||
const pinKey = (componentId: string, pinName: string) => `${componentId}:${pinName}`;
|
||||
|
||||
// Seed every pin referenced by a wire (components pins are added on demand)
|
||||
// Seed every pin referenced by a wire (components pins are added on demand).
|
||||
// Phase 4: when a wire has `length_cm` set, its endpoints stay in separate
|
||||
// nets and a R_wire_<id> card is emitted later (step 7).
|
||||
const resistiveWires: typeof wires = [];
|
||||
for (const w of wires) {
|
||||
const a = pinKey(w.start.componentId, w.start.pinName);
|
||||
const b = pinKey(w.end.componentId, w.end.pinName);
|
||||
uf.add(a);
|
||||
uf.add(b);
|
||||
uf.union(a, b);
|
||||
if (w.length_cm !== undefined && w.length_cm > 0) {
|
||||
resistiveWires.push(w);
|
||||
} else {
|
||||
uf.union(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Canonicalize ground / VCC pins ────────────────────────────────────
|
||||
|
|
@ -112,6 +119,22 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
|||
cards.unshift(`V_VCC_RAIL vcc_rail 0 DC ${dominantVcc}`);
|
||||
}
|
||||
|
||||
// ── 6.5. Wire resistance (Phase 4) ───────────────────────────────────────
|
||||
// Wires marked with length_cm get a resistor between their endpoint nets.
|
||||
// R = 0.01 ohm/cm — order-of-magnitude correct for AWG 22 copper hookup
|
||||
// wire — enough to show voltage drop on long buses without dominating
|
||||
// ordinary circuit behaviour. Emitted before pull-down detection so the
|
||||
// resistors count as DC paths between their endpoints.
|
||||
for (const w of resistiveWires) {
|
||||
const cm = w.length_cm ?? 0;
|
||||
if (cm <= 0) continue;
|
||||
const ohms = Math.max(0.01, 0.01 * cm);
|
||||
const a = netLookup(w.start.componentId, w.start.pinName);
|
||||
const b = netLookup(w.end.componentId, w.end.pinName);
|
||||
if (!a || !b) continue;
|
||||
cards.push(`R_wire_${w.id} ${a} ${b} ${ohms}`);
|
||||
}
|
||||
|
||||
// ── 7. Auto pull-downs for floating nets ─────────────────────────────────
|
||||
const floating = detectFloatingNets(netNames, cards);
|
||||
for (const net of floating) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,17 @@ export interface WireForSpice {
|
|||
id: string;
|
||||
start: { componentId: string; pinName: string };
|
||||
end: { componentId: string; pinName: string };
|
||||
/**
|
||||
* Wire length in centimetres. When set, the NetlistBuilder treats
|
||||
* the wire as a resistor (~0.01 Ω per cm copper) instead of an
|
||||
* ideal short. Endpoints land in separate SPICE nets joined by a
|
||||
* `R_wire_<id>` card so voltage drop on long buses is modelled.
|
||||
*
|
||||
* Phase 4 of the mixed-mode simulator project. Wires without
|
||||
* `length_cm` keep the legacy perfect-conductor union-find
|
||||
* behaviour — backwards compatible until UI starts emitting it.
|
||||
*/
|
||||
length_cm?: number;
|
||||
}
|
||||
|
||||
/** One board instance contributes GPIO pin sources. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue