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>
362 lines
12 KiB
TypeScript
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;
|
|
}
|