velxio/test/test_circuit/test/spice_boards.test.js

282 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import { runNetlist } from '../src/spice/SpiceEngine.js';
import { AVRHarness } from '../src/avr/AVRHarness.js';
import { adcReadProgram, potToPwmProgram } from '../src/avr/programs.js';
/**
* Board-level integration tests: exercise the Velxio supported boards under
* realistic external circuits through ngspice. Boards split into two groups:
*
* 5 V group — Arduino Uno, Nano, Mega, Raspberry Pi 3 (GPIO @ 3.3 V but
* often powers 5 V peripherals through its 5V rail).
* Uno/Nano use the ATmega328P to AVRHarness (cycle-accurate).
* Mega (ATmega2560) is modelled at the circuit level only
* since we don't have a Mega AVR firmware harness here.
*
* 3.3 V group — ESP32 variants, Raspberry Pi Pico, Pico W, Xiao boards.
* Validated by running the same topologies at 3.3 V and
* checking that logic-level MOSFETs / BJTs still switch.
*
* These tests validate that the SPICE netlists produced by Velxio's pin-group
* config (see frontend/src/simulation/spice/boardPinGroups.ts) stay internally
* consistent: GND at 0 V, supply rail at the expected Vcc, peripherals behave.
*/
// ── Arduino Uno / Nano (ATmega328P, 5 V) ──────────────────────────────────
describe('Arduino Uno — AVR + SPICE mixed-signal (5V)', () => {
it('reads a potentiometer via ADC: wiper position ⇔ ADC value', { timeout: 60_000 }, async () => {
const wiperPositions = [
{ pos: 0.1, expectedAdcApprox: 102 },
{ pos: 0.5, expectedAdcApprox: 511 },
{ pos: 0.9, expectedAdcApprox: 920 },
];
for (const wp of wiperPositions) {
// ngspice: build a 10 kΩ pot divider at 5 V with the wiper at position wp
const Rtop = Math.max(1, (1 - wp.pos) * 10000);
const Rbot = Math.max(1, wp.pos * 10000);
const { dcValue } = await runNetlist(`Uno pot divider
Vcc vcc 0 DC 5
Rtop vcc a0 ${Rtop}
Rbot a0 0 ${Rbot}
.op
.end`);
const va0 = dcValue('v(a0)');
// AVR reads it
const avr = new AVRHarness();
avr.loadProgram(adcReadProgram());
avr.setAnalogVoltage(0, va0);
avr.runCycles(500_000);
const ADCH = avr.cpu.data[0x79];
const ADCL = avr.cpu.data[0x78];
const raw = (ADCH << 2) | (ADCL >> 6);
expect(Math.abs(raw - wp.expectedAdcApprox)).toBeLessThan(10);
}
});
it('5 V rail sags < 0.1 V when driving a 220Ω-series LED (sanity for pin-drive tests)', { timeout: 30_000 }, async () => {
// Model a pin outputting 5V with a realistic 40Ω output impedance (ATmega328P
// typical) driving a red LED through a 220Ω series resistor.
// Use a standard diode model (Is=1e-14) — the ultra-small Is values used
// for LED Vf-tuning in Velxio's SPICE layer give ngspice convergence
// trouble under Newton's method during .op; for system-level verification
// a generic diode captures the topology fine.
const netlist = `Uno pin to LED
Vpin pin_src 0 DC 5
Rpin pin_src pin 40
R1 pin anode 220
D1 anode 0 DLED
.model DLED D(Is=1e-14 N=1.8)
.op
.end`;
const { dcValue } = await runNetlist(netlist);
const vpin = dcValue('v(pin)');
// Loaded pin voltage should stay above 4.4 V (40·I drop + LED forward drop + Rs)
expect(vpin).toBeGreaterThan(4.4);
expect(vpin).toBeLessThan(5.0);
});
});
describe('Arduino Nano — ATmega328P-compatible firmware (5V)', () => {
it('PWM (Timer0A pin 6) to LED through 220Ω produces proportional brightness', { timeout: 60_000 }, async () => {
// Set the ADC input to 4 V so the potToPwm sketch commands ~80% duty.
const avr = new AVRHarness();
avr.loadProgram(potToPwmProgram());
avr.setAnalogVoltage(0, 4.0);
avr.runCycles(400_000);
const duty = avr.getPWMDuty(6);
expect(duty).toBeGreaterThan(0.78);
expect(duty).toBeLessThan(0.86);
// Model the time-average of that PWM as V = duty · 5 feeding an LED driver
const Vavg = duty * 5;
const { dcValue } = await runNetlist(`Nano PWM-avg LED
Vavg pin 0 DC ${Vavg.toFixed(4)}
R1 pin anode 220
D1 anode 0 LED_GREEN
.model LED_GREEN D(Is=1e-14 N=2.0)
.op
.end`);
const vled = dcValue('v(anode)');
// LED forward drop ≈ 0.52.5 V depending on current through 220 Ω
expect(vled).toBeGreaterThan(0.5);
expect(vled).toBeLessThan(2.6);
});
});
// ── Arduino Mega (ATmega2560, 5 V) — ngspice-only ─────────────────────────
describe('Arduino Mega — multi-output circuits (5V, ngspice-only)', () => {
it('4-channel PWM drives 4 LEDs independently — all currents > 5 mA when duty ≥ 25%', { timeout: 30_000 }, async () => {
// Four PWM-averaged voltages feed four LEDs in parallel, each through 220Ω.
// The Mega has 15 PWM pins total — we stress-test 4 to confirm the netlist
// stays solvable with many independent branches.
const netlist = `Mega 4-channel PWM LEDs
V1 pwm1 0 DC 1.25
V2 pwm2 0 DC 2.5
V3 pwm3 0 DC 3.75
V4 pwm4 0 DC 5.0
R1 pwm1 a1 220
R2 pwm2 a2 220
R3 pwm3 a3 220
R4 pwm4 a4 220
D1 a1 0 LED_RED
D2 a2 0 LED_RED
D3 a3 0 LED_RED
D4 a4 0 LED_RED
.model LED_RED D(Is=1e-14 N=1.8)
.op
.end`;
const { dcValue } = await runNetlist(netlist);
// Each LED anode voltage ≈ Vf ≈ 1.8 V when conducting
// Brightness order: V1 < V2 < V3 < V4
const va1 = dcValue('v(a1)');
const va2 = dcValue('v(a2)');
const va3 = dcValue('v(a3)');
const va4 = dcValue('v(a4)');
// All forward-biased: anode must be ≥ 0.5V (some conduction)
for (const v of [va1, va2, va3, va4]) {
expect(v).toBeGreaterThan(0.5);
expect(v).toBeLessThan(2.5);
}
// Brighter channel has lower anode voltage drop across LED (more current = clamp near Vf)
// but higher current through R means less drop across LED becomes negligible.
// Monotonic test: channels 24 all higher than channel 1 (which barely conducts).
expect(va4).toBeGreaterThan(va1);
});
it('Mega driving a 4-relay board: each BJT switch engages its coil independently', { timeout: 45_000 }, async () => {
// Common Mega use-case: a 4-relay module. Each relay has a 2N2222 driving a
// coil (here modelled as 400Ω resistor), with the pin input through 1kΩ.
const cases = [
{ drives: [5, 0, 0, 0], onIdx: 0 },
{ drives: [0, 5, 0, 0], onIdx: 1 },
{ drives: [0, 0, 5, 0], onIdx: 2 },
{ drives: [0, 0, 0, 5], onIdx: 3 },
];
for (const c of cases) {
const netlist = `Mega 4-relay
Vcc vcc 0 DC 5
V1 pin1 0 DC ${c.drives[0]}
V2 pin2 0 DC ${c.drives[1]}
V3 pin3 0 DC ${c.drives[2]}
V4 pin4 0 DC ${c.drives[3]}
RB1 pin1 b1 1k
RB2 pin2 b2 1k
RB3 pin3 b3 1k
RB4 pin4 b4 1k
Rcoil1 vcc c1 400
Rcoil2 vcc c2 400
Rcoil3 vcc c3 400
Rcoil4 vcc c4 400
Q1 c1 b1 0 Q2N2222
Q2 c2 b2 0 Q2N2222
Q3 c3 b3 0 Q2N2222
Q4 c4 b4 0 Q2N2222
.model Q2N2222 NPN(Is=14.34f Bf=200 Vaf=74)
.op
.end`;
const { dcValue } = await runNetlist(netlist);
const vc = [dcValue('v(c1)'), dcValue('v(c2)'), dcValue('v(c3)'), dcValue('v(c4)')];
for (let i = 0; i < 4; i++) {
if (i === c.onIdx) {
// Relay ON to collector near 0
expect(vc[i]).toBeLessThan(0.3);
} else {
// Relay OFF to collector near Vcc
expect(vc[i]).toBeGreaterThan(4.5);
}
}
}
});
});
// ── ESP32 / Pi Pico (3.3 V group) — ngspice-only ──────────────────────────
describe('ESP32 / Pi Pico — 3.3V logic-level circuits', () => {
it('logic-level MOSFET (Vth=1.6 V) fully switches a 5 V load from a 3.3 V GPIO', { timeout: 30_000 }, async () => {
// Key use-case: 3.3 V MCUs driving peripherals through a logic-level MOSFET.
// Vgs_th ≈ 1.6 V to fully on with 3.3 V gate. Uses Level-1 with
// numerically stable W/L to avoid ngspice convergence issues.
const netlist = `3.3V GPIO to logic-level NMOS to 5V load
V_sys vsys 0 DC 5
Vgpio gate 0 DC 3.3
RL vsys drain 1k
M1 drain gate 0 0 NLOGIC L=2u W=200u
.model NLOGIC NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01)
.op
.end`;
const { dcValue } = await runNetlist(netlist);
const vd = dcValue('v(drain)');
// MOSFET firmly in linear region to drain near 0
expect(vd).toBeLessThan(1.0);
});
it('non-logic-level MOSFET (Vth=3.0 V) barely responds to a 3.3 V GPIO: drain stays high', { timeout: 30_000 }, async () => {
// A 3 V-threshold FET with only 0.3 V of overdrive from a 3.3 V GPIO sits
// in weak inversion — the drain barely drops.
const netlist = `3.3V GPIO to non-logic-level NMOS to 5V load
V_sys vsys 0 DC 5
Vgpio gate 0 DC 3.3
RL vsys drain 1k
M1 drain gate 0 0 NSTD L=2u W=200u
.model NSTD NMOS(Level=1 Vto=3.0 Kp=50u Lambda=0.01)
.op
.end`;
const { dcValue } = await runNetlist(netlist);
const vd = dcValue('v(drain)');
// Overdrive only 0.3 V to minimal conduction to drain stays near Vsys.
expect(vd).toBeGreaterThan(3.0);
});
it('3.3V ↔ 5V bidirectional level shifter (N-MOSFET + pull-ups)', { timeout: 30_000 }, async () => {
// Classic I2C level shifter: N-MOS + pull-ups on each side.
// When the LV side is pulled low, HV side follows low (through body diode + MOS).
// When LV side is high (3.3V), HV side is pulled up to 5V by its pull-up.
for (const driveLow of [false, true]) {
const netlist = `Level shifter lowSide=${driveLow ? 'DRIVE_LOW' : 'FLOAT'}
V_lv lv_rail 0 DC 3.3
V_hv hv_rail 0 DC 5
Rpu_lv lv_rail lv_node 10k
Rpu_hv hv_rail hv_node 10k
M1 hv_node lv_rail lv_node 0 NLOGIC L=2u W=200u
.model NLOGIC NMOS(Level=1 Vto=1.6 Kp=50u Lambda=0.01)
${driveLow ? 'Vdrv lv_node 0 DC 0' : 'Rfloat lv_node 0 10Meg'}
.op
.end`;
const { dcValue } = await runNetlist(netlist);
const hv = dcValue('v(hv_node)');
const lv = dcValue('v(lv_node)');
if (driveLow) {
// Pulling LV side low should translate to HV side low (through MOSFET body diode)
expect(lv).toBeLessThan(0.1);
expect(hv).toBeLessThan(1.0);
} else {
// Both sides pulled high: LV ≈ 3.3, HV ≈ 5
expect(lv).toBeGreaterThan(3.2);
expect(hv).toBeGreaterThan(4.8);
}
}
});
});
// ── Cross-board: identical topology at 5 V vs 3.3 V ───────────────────────
describe('Cross-board — same divider at 5V vs 3.3V supplies', () => {
it('voltage divider scales linearly with supply: Vo/Vcc stays constant', { timeout: 30_000 }, async () => {
for (const vcc of [5, 3.3]) {
const netlist = `Divider Vcc=${vcc}
V1 vcc 0 DC ${vcc}
R1 vcc mid 1k
R2 mid 0 2k
.op
.end`;
const { dcValue } = await runNetlist(netlist);
const vmid = dcValue('v(mid)');
// Ratio is 2/3 — must hold for any Vcc
expect(vmid / vcc).toBeCloseTo(2 / 3, 3);
}
});
});