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:
parent
6f3603d88b
commit
cbcf6ba81e
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue