Files
project-math/src/components/workbench/modules/electronics/simulateElectronics.ts
Jose Luis Montañes 8d8a811ede feat: circuit builder, electronics lab, SPICE simulator, expanded skill tree
Workbenches:
- Circuit Builder: drag-and-drop logic gates, wire connections, truth table verification, fullscreen mode
- Electronics Lab: SPICE-like DC simulator with MNA solver, voltage sources, resistors, capacitors, LEDs, switches, NMOS/PMOS transistors, voltmeter, ammeter, play/pause simulation, fullscreen mode
- Explanation renderer: auto-detects ASCII truth tables and renders them as styled HTML

Skill tree:
- 65+ nodes across 19 groups spanning math → electronics → CPU → ASM → OS → networking → web
- Groups: Aritmética, Álgebra, Lógica, Electrónica, Circuitos Digitales, Secuenciales, Tu CPU, Verilog/HDL, Arquitectura Extendida, Sistemas Operativos, Programación en C, Redes, La Web, Señales, Síntesis Audio, Gráficos, Tu Consola
- Dependency highlighting: clicking a node dims all others and highlights the full path
- Group boxes with colored borders around related nodes
- Dependency chain audit: fixed illogical prerequisites throughout the tree

Content:
- 24 electronics challenges (basics, series/parallel, capacitors, diodes, transistors, op-amps, power supplies)
- 12 circuit builder challenges (logic gates, NAND universality, combinational circuits)
- Fixed all explanation spoilers: examples now use different numbers than the challenge questions
- Probe system now requires voltmeter/ammeter instruments instead of checking arbitrary node IDs

UX:
- Custom dark-themed scrollbars
- Fullscreen mode for circuit/electronics editors (portal-based, Esc to exit)
- SVG coordinate fix using getScreenCTM for accurate wire placement in fullscreen
- Meter reading labels positioned correctly regardless of component rotation
- Scratchpad defaults to closed, persists open/close state in localStorage
- Empty placeholder nodes show "Próximamente" instead of appearing completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 03:50:07 +01:00

362 lines
12 KiB
TypeScript

import {
ElectronicCircuitState,
ElectronicComponent,
SimulationResult,
} from '@/types/electronics';
/**
* Simplified DC circuit simulator using node-voltage analysis.
*
* Supports: voltage sources, resistors, ground, switches, LEDs, NMOS/PMOS (ideal).
* MOSFETs are modeled as ideal switches:
* NMOS: ON (low Rds) when Vgs > threshold (2V), OFF (high Rds) otherwise
* PMOS: ON when Vgs < -threshold (-2V), OFF otherwise
*
* Uses iterative approach for nonlinear elements (transistors):
* 1. Assume all transistors OFF
* 2. Solve linear circuit
* 3. Check transistor states, update if changed
* 4. Repeat until stable (max 10 iterations)
*/
const VTH = 2; // MOSFET threshold voltage
const R_ON = 1; // MOSFET ON resistance (Ω)
const R_OFF = 1e9; // MOSFET OFF resistance (Ω)
const R_LED = 100; // LED effective resistance when forward biased
const V_LED = 1.8; // LED forward voltage drop
export function simulateElectronics(circuit: ElectronicCircuitState): SimulationResult {
const { components, wires } = circuit;
if (components.length === 0) {
return { success: false, error: 'No hay componentes', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
// Build net list: group connected terminals into nodes
const terminalToNode = new Map<string, string>();
let nodeCounter = 0;
function getTerminalId(compId: string, terminal: string): string {
return `${compId}:${terminal}`;
}
function findNode(termId: string): string {
let node = terminalToNode.get(termId);
if (!node) {
node = `n${nodeCounter++}`;
terminalToNode.set(termId, node);
}
return node;
}
function mergeNodes(a: string, b: string) {
const nodeA = findNode(a);
const nodeB = findNode(b);
if (nodeA === nodeB) return;
// Replace all nodeB references with nodeA
for (const [key, val] of terminalToNode.entries()) {
if (val === nodeB) terminalToNode.set(key, nodeA);
}
}
// Initialize all component terminals
for (const comp of components) {
const terminals = getComponentTerminals(comp.type);
for (const t of terminals) {
findNode(getTerminalId(comp.id, t));
}
}
// Process wires to merge nodes
for (const wire of wires) {
mergeNodes(wire.from, wire.to);
}
// Find ground node
let groundNode: string | null = null;
for (const comp of components) {
if (comp.type === 'ground') {
groundNode = findNode(getTerminalId(comp.id, 'gnd'));
break;
}
}
if (!groundNode) {
return { success: false, error: 'Necesitas un nodo de tierra (GND)', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
// Get unique node names (excluding ground)
const allNodes = [...new Set(terminalToNode.values())];
const nodeList = allNodes.filter((n) => n !== groundNode);
const nodeIndex = new Map<string, number>();
nodeList.forEach((n, i) => nodeIndex.set(n, i));
const N = nodeList.length;
if (N === 0) {
return { success: true, nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
// Count voltage sources for MNA
const voltageSources = components.filter((c) => c.type === 'voltage-source');
const M = voltageSources.length;
const size = N + M;
// Transistor states (start all OFF)
const mosfetState = new Map<string, boolean>();
for (const comp of components) {
if (comp.type === 'nmos' || comp.type === 'pmos') {
mosfetState.set(comp.id, false);
}
}
let voltages: Float64Array = new Float64Array(size);
let converged = false;
for (let iter = 0; iter < 10; iter++) {
// Build MNA matrix: [G B; C D] * [v; i] = [I; E]
const A = Array.from({ length: size }, () => new Float64Array(size));
const b = new Float64Array(size);
function nodeIdx(termId: string): number {
const node = terminalToNode.get(termId);
if (!node || node === groundNode) return -1;
return nodeIndex.get(node) ?? -1;
}
// Stamp resistor: G(i,i) += 1/R, G(j,j) += 1/R, G(i,j) -= 1/R, G(j,i) -= 1/R
function stampResistor(termA: string, termB: string, R: number) {
const i = nodeIdx(termA);
const j = nodeIdx(termB);
const g = 1 / R;
if (i >= 0) A[i][i] += g;
if (j >= 0) A[j][j] += g;
if (i >= 0 && j >= 0) { A[i][j] -= g; A[j][i] -= g; }
}
// Stamp voltage source
function stampVoltageSource(posTerminal: string, negTerminal: string, voltage: number, vsIdx: number) {
const i = nodeIdx(posTerminal);
const j = nodeIdx(negTerminal);
const k = N + vsIdx;
if (i >= 0) { A[i][k] += 1; A[k][i] += 1; }
if (j >= 0) { A[j][k] -= 1; A[k][j] -= 1; }
b[k] = voltage;
}
// Process each component
let vsCount = 0;
for (const comp of components) {
switch (comp.type) {
case 'resistor': {
const R = comp.value ?? 1000;
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), R);
break;
}
case 'voltage-source': {
const V = comp.value ?? 5;
stampVoltageSource(
getTerminalId(comp.id, 'pos'),
getTerminalId(comp.id, 'neg'),
V, vsCount++
);
break;
}
case 'capacitor': {
// In DC steady state, capacitor = open circuit (very high R)
// But we model it so voltage across it can be measured
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), R_OFF);
break;
}
case 'led': {
// Simplified: resistor + voltage drop (model as resistor for now)
stampResistor(getTerminalId(comp.id, 'anode'), getTerminalId(comp.id, 'cathode'), R_LED);
break;
}
case 'switch': {
// Switches modeled as low resistance when "on" (value=1), high when "off" (value=0)
const isOn = (comp.value ?? 0) > 0;
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), isOn ? 0.01 : R_OFF);
break;
}
case 'nmos': {
const isOn = mosfetState.get(comp.id) ?? false;
const R = isOn ? R_ON : R_OFF;
stampResistor(getTerminalId(comp.id, 'drain'), getTerminalId(comp.id, 'source'), R);
break;
}
case 'pmos': {
const isOn = mosfetState.get(comp.id) ?? false;
const R = isOn ? R_ON : R_OFF;
stampResistor(getTerminalId(comp.id, 'drain'), getTerminalId(comp.id, 'source'), R);
break;
}
case 'voltmeter': {
// Voltmeter = very high resistance (doesn't affect circuit)
stampResistor(getTerminalId(comp.id, 'pos'), getTerminalId(comp.id, 'neg'), 1e9);
break;
}
case 'ammeter': {
// Ammeter = very low resistance (passes all current)
stampResistor(getTerminalId(comp.id, 'a'), getTerminalId(comp.id, 'b'), 0.001);
break;
}
case 'ground':
break;
}
}
// Solve A * x = b using Gaussian elimination
const solved = solveLinearSystem(A, b, size);
if (!solved) {
return { success: false, error: 'El circuito no tiene solución (¿cortocircuito?)', nodeVoltages: new Map(), branchCurrents: new Map(), meterReadings: new Map() };
}
voltages = solved;
// Update MOSFET states based on solved voltages
let changed = false;
for (const comp of components) {
if (comp.type !== 'nmos' && comp.type !== 'pmos') continue;
const gateNode = terminalToNode.get(getTerminalId(comp.id, 'gate'));
const sourceNode = terminalToNode.get(getTerminalId(comp.id, 'source'));
const vGate = getNodeVoltage(gateNode, groundNode, nodeIndex, voltages);
const vSource = getNodeVoltage(sourceNode, groundNode, nodeIndex, voltages);
const vgs = vGate - vSource;
let shouldBeOn = false;
if (comp.type === 'nmos') shouldBeOn = vgs > VTH;
if (comp.type === 'pmos') shouldBeOn = vgs < -VTH;
const wasOn = mosfetState.get(comp.id) ?? false;
if (shouldBeOn !== wasOn) {
mosfetState.set(comp.id, shouldBeOn);
changed = true;
}
}
if (!changed) {
converged = true;
break;
}
}
if (!converged && mosfetState.size > 0) {
// Still usable, just warn
}
// Build result maps
const nodeVoltages = new Map<string, number>();
for (const [termId, node] of terminalToNode.entries()) {
const v = getNodeVoltage(node, groundNode!, nodeIndex, voltages);
nodeVoltages.set(termId, Math.round(v * 1000) / 1000);
}
const branchCurrents = new Map<string, number>();
// Calculate currents through resistors
for (const comp of components) {
if (comp.type === 'resistor') {
const va = nodeVoltages.get(getTerminalId(comp.id, 'a')) ?? 0;
const vb = nodeVoltages.get(getTerminalId(comp.id, 'b')) ?? 0;
const R = comp.value ?? 1000;
branchCurrents.set(comp.id, Math.round(((va - vb) / R) * 10000) / 10000);
}
}
// Compute meter readings
const meterReadings = new Map<string, { value: number; unit: string }>();
for (const comp of components) {
if (comp.type === 'voltmeter') {
const vPos = nodeVoltages.get(getTerminalId(comp.id, 'pos')) ?? 0;
const vNeg = nodeVoltages.get(getTerminalId(comp.id, 'neg')) ?? 0;
const diff = Math.round((vPos - vNeg) * 1000) / 1000;
meterReadings.set(comp.id, { value: diff, unit: 'V' });
}
if (comp.type === 'ammeter') {
const vA = nodeVoltages.get(getTerminalId(comp.id, 'a')) ?? 0;
const vB = nodeVoltages.get(getTerminalId(comp.id, 'b')) ?? 0;
const current = Math.round(((vA - vB) / 0.001) * 10000) / 10000;
meterReadings.set(comp.id, { value: Math.abs(current) * 1000, unit: 'mA' });
}
}
return { success: true, nodeVoltages, branchCurrents, meterReadings };
}
function getNodeVoltage(
node: string | undefined,
groundNode: string,
nodeIndex: Map<string, number>,
voltages: Float64Array
): number {
if (!node || node === groundNode) return 0;
const idx = nodeIndex.get(node);
if (idx === undefined) return 0;
return voltages[idx];
}
function getComponentTerminals(type: string): string[] {
switch (type) {
case 'voltage-source': return ['pos', 'neg'];
case 'resistor': return ['a', 'b'];
case 'capacitor': return ['a', 'b'];
case 'led': return ['anode', 'cathode'];
case 'switch': return ['a', 'b'];
case 'ground': return ['gnd'];
case 'nmos': return ['gate', 'drain', 'source'];
case 'pmos': return ['gate', 'drain', 'source'];
case 'voltmeter': return ['pos', 'neg'];
case 'ammeter': return ['a', 'b'];
default: return [];
}
}
/** Gaussian elimination with partial pivoting */
function solveLinearSystem(A: Float64Array[], b: Float64Array, n: number): Float64Array | null {
// Augmented matrix
const aug = A.map((row, i) => {
const r = new Float64Array(n + 1);
r.set(row);
r[n] = b[i];
return r;
});
for (let col = 0; col < n; col++) {
// Pivot
let maxRow = col;
let maxVal = Math.abs(aug[col][col]);
for (let row = col + 1; row < n; row++) {
if (Math.abs(aug[row][col]) > maxVal) {
maxVal = Math.abs(aug[row][col]);
maxRow = row;
}
}
if (maxVal < 1e-12) continue; // singular, skip
[aug[col], aug[maxRow]] = [aug[maxRow], aug[col]];
// Eliminate
for (let row = col + 1; row < n; row++) {
const factor = aug[row][col] / aug[col][col];
for (let j = col; j <= n; j++) {
aug[row][j] -= factor * aug[col][j];
}
}
}
// Back substitution
const x = new Float64Array(n);
for (let row = n - 1; row >= 0; row--) {
if (Math.abs(aug[row][row]) < 1e-12) {
x[row] = 0;
continue;
}
let sum = aug[row][n];
for (let j = row + 1; j < n; j++) {
sum -= aug[row][j] * x[j];
}
x[row] = sum / aug[row][row];
}
return x;
}