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>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user