282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
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.5–2.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 2–4 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);
|
||
}
|
||
});
|
||
});
|