feat(sim): P4 runtime burnout for resistors + electrolytic capacitors

Generalizes the LED's burnout to passive parts via a centralized monitor that
watches the live electrical solve. When a part is stressed past its rating for
a sustained moment it's marked "destroyed": the canvas renders it charred with a
smoke badge and a fault is logged to the output console. Clears on Reset.

Follows the Fritzing-simulator precedent (smoke-on-component) wrapped in a
first-order thermal delay so a brief inrush spike doesn't destroy a part — only
sustained overload (or a catastrophic >=3x overload, instant) does.

- runtimeBurnout.ts: pure stress (resistor power, cap voltage / reverse) + a
  thermal-delay burn decision, plus a monitor subscribed to the electrical +
  simulator stores. Resistor burns past 2x rated (the verifier already warns at
  1x for intentional teaching over-power); a cap bursts over its voltage rating
  or on reverse polarity.
- useSimulatorStore: burntComponents set + mark/clear actions; cleared on
  Reset / restartParts.
- DynamicComponent + SimulatorCanvas.css: charred filter + smoke badge.

Tests: thermal-delay decision (instant / sustained / spike / cooldown) + stress
computation (resistor power, cap over-voltage, reverse, unwired -> null).
This commit is contained in:
David Montero 2026-06-18 04:35:56 +02:00
parent 6f3603d88b
commit cbcf6ba81e
6 changed files with 357 additions and 2 deletions

View File

@ -0,0 +1,110 @@
/**
* Runtime burnout (P4) — pure decision + stress-computation logic.
*/
import { describe, it, expect } from 'vitest';
import { componentStress, decideBurn } from '../simulation/parts/runtimeBurnout';
describe('decideBurn — thermal delay', () => {
it('destroys instantly at a catastrophic ratio (>=3x)', () => {
expect(decideBurn(5, 0, 1000).burn).toBe(true);
});
it('does not destroy on a first over-limit sample — starts the clock', () => {
expect(decideBurn(1.5, 0, 1000)).toEqual({ burn: false, overSince: 1000 });
});
it('destroys once sustained over the limit past the delay', () => {
expect(decideBurn(1.5, 1000, 1800).burn).toBe(true); // 800ms > 700ms
});
it('tolerates a brief spike (under the delay)', () => {
expect(decideBurn(1.5, 1000, 1500).burn).toBe(false); // 500ms < 700ms
});
it('cools down (resets the clock) when back under the limit', () => {
expect(decideBurn(0.5, 1000, 2000)).toEqual({ burn: false, overSince: 0 });
});
});
describe('componentStress — resistor power', () => {
const pinNetMap = new Map([
['r1:1', 'n0'],
['r1:2', '0'],
]);
it('100Ω across 9V dissipates 0.81W → over the 2x-safety burn threshold', () => {
const s = componentStress(
{ id: 'r1', metadataId: 'resistor', properties: { value: '100' } },
{ n0: 9, '0': 0 },
pinNetMap,
);
expect(s).not.toBeNull();
// 0.81W / (2 × 0.25W) = 1.62 → would burn after the sustained delay
expect(s!.ratio).toBeCloseTo(1.62, 1);
expect(s!.ratio).toBeGreaterThan(1);
});
it('1k across 9V is well within rating (no stress)', () => {
const s = componentStress(
{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } },
{ n0: 9, '0': 0 },
pinNetMap,
);
expect(s!.ratio).toBeLessThan(1);
});
it('respects a custom power property', () => {
const s = componentStress(
{ id: 'r1', metadataId: 'resistor', properties: { value: '100', power: 5 } },
{ n0: 9, '0': 0 },
pinNetMap,
);
expect(s!.ratio).toBeLessThan(1); // 0.81W / 5W
});
it('returns null when a terminal is not on a solved net', () => {
const s = componentStress(
{ id: 'r1', metadataId: 'resistor', properties: { value: '100' } },
{ n0: 9 },
new Map([['r1:1', 'n0']]), // pin 2 unwired
);
expect(s).toBeNull();
});
});
describe('componentStress — electrolytic capacitor', () => {
it('9V across a 6.3V cap → over-voltage (>1x)', () => {
const s = componentStress(
{ id: 'c1', metadataId: 'capacitor-electrolytic', properties: { voltage: '6.3' } },
{ n0: 9, '0': 0 },
new Map([
['c1:+', 'n0'],
['c1:−', '0'],
]),
);
expect(s!.ratio).toBeCloseTo(1.43, 1);
});
it('within rating → no stress', () => {
const s = componentStress(
{ id: 'c1', metadataId: 'capacitor-electrolytic', properties: { voltage: '25' } },
{ n0: 5, '0': 0 },
new Map([
['c1:+', 'n0'],
['c1:−', '0'],
]),
);
expect(s!.ratio).toBeLessThan(1);
});
it('reverse polarity is catastrophic regardless of magnitude', () => {
const s = componentStress(
{ id: 'c1', metadataId: 'capacitor-electrolytic', properties: { voltage: '25' } },
{ n0: 9, '0': 0 },
new Map([
['c1:+', '0'], // + at 0V, − at 9V → reverse-biased
['c1:−', 'n0'],
]),
);
expect(s!.ratio).toBeGreaterThanOrEqual(3);
expect(s!.message).toMatch(/backwards|reverse/i);
});
});
describe('componentStress — non-monitored parts', () => {
it('returns null for an LED (handled separately in BasicParts)', () => {
expect(
componentStress({ id: 'd1', metadataId: 'led', properties: {} }, {}, new Map()),
).toBeNull();
});
});

View File

@ -252,6 +252,8 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
// re-created on every stop/play cycle — which previously caused the
// display to flash blank and lose its frame buffer.
const hexEpoch = useSimulatorStore((s) => s.hexEpoch);
// Runtime burnout (P4): destroyed parts render charred + a smoke badge.
const isBurnt = useSimulatorStore((s) => s.burntComponents.has(id));
// Track wires connected to this component so attachEvents re-runs when
// wires are added or removed (e.g. disconnecting an LED cathode from GND).
@ -600,7 +602,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
// while simulation is live.
return (
<div
className="dynamic-component-wrapper"
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}`}
style={{
position: 'absolute',
left: `${x}px`,
@ -625,6 +627,22 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
{/* Container for web component */}
<div ref={containerRef} className="web-component-container" />
{/* Runtime-burnout smoke badge (P4) */}
{isBurnt && (
<div
className="velxio-burnt-smoke"
aria-hidden="true"
style={{ position: 'absolute', top: '-7px', right: '-7px', pointerEvents: 'none', zIndex: 6 }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="8" cy="14" r="5" fill="#6b7280" opacity="0.85" />
<circle cx="14" cy="11" r="6" fill="#9ca3af" opacity="0.85" />
<circle cx="17" cy="16" r="4" fill="#4b5563" opacity="0.85" />
<circle cx="11" cy="8" r="3.5" fill="#9ca3af" opacity="0.7" />
</svg>
</div>
)}
{/* Component label */}
<div
className="component-label"

View File

@ -472,3 +472,16 @@
display: none;
}
}
/* Runtime burnout (P4): a component destroyed mid-simulation renders charred,
with a red dashed outline + a smoke badge (the badge is a sibling of the
container, so it stays full-colour). Cleared on Reset. */
.velxio-burnt .web-component-container {
filter: grayscale(0.85) brightness(0.4) sepia(0.5) contrast(1.15);
transition: filter 0.25s ease;
}
.velxio-burnt {
outline: 1px dashed rgba(220, 38, 38, 0.65);
outline-offset: 1px;
border-radius: 4px;
}

View File

@ -13,3 +13,5 @@ import './LogicGateParts';
import './ProtocolParts';
import './CustomChipPart';
import './EPaperPart';
// Side-effect: installs the runtime burnout monitor (P4) on the live solver.
import './runtimeBurnout';

View File

@ -0,0 +1,194 @@
/**
* Runtime burnout monitor (P4) — generalizes the LED's burnout to other parts.
*
* While the simulation runs, this watches the live electrical solve and, when a
* part is stressed past its rating for a sustained moment, marks it "destroyed"
* (a charred visual on the canvas + a fault in the output console). It is the
* runtime counterpart to the pre-flight verifier: the verifier warns BEFORE you
* run; this catches faults that only appear mid-run.
*
* Algorithm (see project/circuit-safety-2026-06/PLAN.md "P4 prior-art
* research"): the Fritzing simulator (the open-source precedent) compares the
* operating point to each part's rating and draws smoke on it. We do the same,
* but wrap the threshold in a first-order "thermal" delay so a brief inrush
* spike doesn't destroy a part — only a sustained overload (or a catastrophic
* >=3x overload, which is instant) does.
*
* The LED keeps its own burnout in BasicParts (it has a per-frame render loop);
* this monitor covers passive parts that don't: resistors (power) and
* electrolytic capacitors (over-voltage / reverse polarity).
*/
import { useElectricalStore } from '../../store/useElectricalStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { parseValueWithUnits } from '../spice/valueParser';
// ── Tuning ────────────────────────────────────────────────────────────────
/** Sustained over-limit time before destruction (the "thermal" delay), ms. */
const BURN_DELAY_MS = 700;
/** At or above this × the limit, destruction is instant (catastrophic). */
const CATASTROPHIC_RATIO = 3;
const RESISTOR_DEFAULT_W = 0.25;
/** Burn a resistor only past 2x its rating (verifier already warns at 1x). */
const RESISTOR_BURN_SAFETY = 2;
const CAP_DEFAULT_V = 25;
interface ComponentLike {
id: string;
metadataId: string;
properties?: Record<string, unknown>;
}
export function isBurnoutResistor(metadataId: string): boolean {
return metadataId === 'resistor' || metadataId === 'resistor-us' || metadataId.startsWith('resistor-');
}
export function isBurnoutElectrolyticCap(metadataId: string): boolean {
return metadataId === 'capacitor-electrolytic' || metadataId.startsWith('cap-elec');
}
function parseVolts(raw: unknown): number {
const m = /^\s*([0-9]*\.?[0-9]+)/.exec(String(raw ?? ''));
return m ? parseFloat(m[1]!) : NaN;
}
/**
* Stress ratio for a part (>1 means over its rating), plus a fault message.
* Pure: depends only on the component + the solved nets. Returns null when the
* part isn't a monitored type or its terminals aren't both on solved nets.
*/
export function componentStress(
comp: ComponentLike,
nodeVoltages: Record<string, number>,
pinNetMap: Map<string, string>,
): { ratio: number; message: string } | null {
const netV = (pin: string): number | undefined => {
const net = pinNetMap.get(`${comp.id}:${pin}`);
if (net === undefined) return undefined;
if (net === '0') return 0;
const v = nodeVoltages[net];
return Number.isFinite(v) ? v : undefined;
};
if (isBurnoutResistor(comp.metadataId)) {
const v1 = netV('1');
const v2 = netV('2');
if (v1 === undefined || v2 === undefined) return null;
const R = parseValueWithUnits(comp.properties?.value, 1000);
if (!Number.isFinite(R) || R <= 0) return null;
const power = (v1 - v2) ** 2 / R;
const rated =
typeof comp.properties?.power === 'number' ? (comp.properties.power as number) : RESISTOR_DEFAULT_W;
// 2x safety factor: the pre-flight verifier already WARNS (non-blocking) at
// 1x — teaching circuits often over-drive a resistor on purpose — so we
// only physically burn it at genuine overload (>2x rated sustained).
return {
ratio: power / (RESISTOR_BURN_SAFETY * rated),
message: `Resistor ${comp.id} overheated — dissipating ${power.toFixed(2)} W, above its ${rated} W rating. It burned out. Use a higher-wattage resistor or a larger value.`,
};
}
if (isBurnoutElectrolyticCap(comp.metadataId)) {
const vp = netV('+');
const vn = netV('−') ?? netV('-');
if (vp === undefined || vn === undefined) return null;
const v = vp - vn;
const rated = parseVolts(comp.properties?.voltage) || CAP_DEFAULT_V;
if (v < -0.5) {
// Reverse polarity destroys an electrolytic regardless of magnitude.
return {
ratio: CATASTROPHIC_RATIO,
message: `Electrolytic capacitor ${comp.id} was connected backwards (${Math.abs(v).toFixed(1)} V reverse) and burst. Swap its + and - terminals.`,
};
}
return {
ratio: v / rated,
message: `Electrolytic capacitor ${comp.id} burst — ${v.toFixed(1)} V across it, above its ${rated} V rating. Use a higher-voltage capacitor.`,
};
}
return null;
}
/**
* Pure burn decision with a first-order thermal delay. Given the current stress
* ratio and the timestamp the part first went over-limit (0 = not over), return
* whether it's destroyed now and the updated over-since timestamp.
*/
export function decideBurn(
ratio: number,
overSince: number,
now: number,
): { burn: boolean; overSince: number } {
if (ratio >= CATASTROPHIC_RATIO) return { burn: true, overSince };
if (ratio > 1) {
const since = overSince === 0 ? now : overSince;
return { burn: now - since > BURN_DELAY_MS, overSince: since };
}
return { burn: false, overSince: 0 }; // cooled below the limit
}
// ── Live monitor ────────────────────────────────────────────────────────────
const overSinceByComponent = new Map<string, number>();
function emitFault(id: string, message: string): void {
// eslint-disable-next-line no-console
console.warn(`[burnout] ${message}`);
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
try {
window.dispatchEvent(
new CustomEvent('velxio-circuit-fault', { detail: { componentId: id, kind: 'burnout', message } }),
);
} catch {
/* CustomEvent unavailable — the console.warn is enough */
}
}
}
function check(): void {
const sim = useSimulatorStore.getState();
const { nodeVoltages, pinNetMap } = useElectricalStore.getState();
const now = Date.now();
for (const comp of sim.components) {
if (sim.burntComponents.has(comp.id)) continue;
const stress = componentStress(comp as ComponentLike, nodeVoltages, pinNetMap);
if (!stress) {
overSinceByComponent.delete(comp.id);
continue;
}
const { burn, overSince } = decideBurn(stress.ratio, overSinceByComponent.get(comp.id) ?? 0, now);
if (burn) {
overSinceByComponent.delete(comp.id);
sim.markComponentBurnt(comp.id);
emitFault(comp.id, stress.message);
} else if (overSince === 0) {
overSinceByComponent.delete(comp.id);
} else {
overSinceByComponent.set(comp.id, overSince);
}
}
}
let installed = false;
let lastHexEpoch = -1;
/** Subscribe the monitor to the live solver. Idempotent. */
export function installRuntimeBurnoutMonitor(): void {
if (installed) return;
installed = true;
// Reset accumulators whenever the sim re-arms (Run / Reset bump hexEpoch).
useSimulatorStore.subscribe((s) => {
if (s.hexEpoch !== lastHexEpoch) {
lastHexEpoch = s.hexEpoch;
overSinceByComponent.clear();
}
});
// Re-check on every fresh solve (the store publishes a new object each solve).
useElectricalStore.subscribe((state, prev) => {
if (state.nodeVoltages !== prev.nodeVoltages || state.timeWaveforms !== prev.timeWaveforms) {
check();
}
});
}
// Self-install on import (the parts index imports this module). Guarded so the
// Node test environment (no live stores driving it) is unaffected.
installRuntimeBurnoutMonitor();

View File

@ -822,6 +822,13 @@ interface SimulatorState {
/** Bumped on every Reset so the open SensorControlPanel remounts and
* re-reads each interactive sensor's freshly-defaulted value. */
sensorResetNonce: number;
/** Ids of components destroyed at runtime (P4 burnout) — the canvas renders
* them charred. Cleared on Reset / restart. */
burntComponents: Set<string>;
/** Mark a component destroyed (called by the runtime burnout monitor). */
markComponentBurnt: (componentId: string) => void;
/** Clear all runtime-destroyed components (on Reset / restart). */
clearBurntComponents: () => void;
serialOutput: string;
serialBaudRate: number;
serialMonitorOpen: boolean;
@ -1922,6 +1929,9 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
return {
boards,
hexEpoch: s.hexEpoch + 1,
// A Reset un-chars any runtime-destroyed parts so a fixed circuit
// comes back to life (mirrors the LED's burnt-latch clearing).
...(s.burntComponents.size > 0 ? { burntComponents: new Set<string>() } : {}),
...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}),
};
});
@ -1959,6 +1969,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
compiledHex: null,
hexEpoch: 0,
sensorResetNonce: 0,
burntComponents: new Set<string>(),
serialOutput: '',
serialBaudRate: 0,
serialMonitorOpen: false,
@ -2195,7 +2206,14 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
get().startBoard(boardId);
},
restartParts: () => set((s) => ({ hexEpoch: s.hexEpoch + 1 })),
restartParts: () => set((s) => ({ hexEpoch: s.hexEpoch + 1, burntComponents: new Set() })),
markComponentBurnt: (componentId: string) =>
set((s) =>
s.burntComponents.has(componentId)
? {}
: { burntComponents: new Set(s.burntComponents).add(componentId) },
),
clearBurntComponents: () => set((s) => (s.burntComponents.size === 0 ? {} : { burntComponents: new Set() })),
stopSimulation: () => {
const { activeBoardId } = get();