diff --git a/frontend/src/__tests__/runtime-burnout.test.ts b/frontend/src/__tests__/runtime-burnout.test.ts new file mode 100644 index 00000000..a3c48be5 --- /dev/null +++ b/frontend/src/__tests__/runtime-burnout.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 10deaa29..615a1eaf 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -252,6 +252,8 @@ export const DynamicComponent: React.FC = ({ // 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 = ({ // while simulation is live. return (
= ({ {/* Container for web component */}
+ {/* Runtime-burnout smoke badge (P4) */} + {isBurnt && ( + + )} + {/* Component label */}
=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; +} + +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, + pinNetMap: Map, +): { 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(); + +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(); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index d3823e32..17d6089b 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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; + /** 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((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() } : {}), ...(isActive ? { running: false, serialOutput: '', serialBaudRate: 0 } : {}), }; }); @@ -1959,6 +1969,7 @@ export const useSimulatorStore = create((set, get) => { compiledHex: null, hexEpoch: 0, sensorResetNonce: 0, + burntComponents: new Set(), serialOutput: '', serialBaudRate: 0, serialMonitorOpen: false, @@ -2195,7 +2206,14 @@ export const useSimulatorStore = create((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();