2026-04-21 02:38:31 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* End-to-end regression test for the `mosfet-pwm-led` example.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Reproduces the exact topology of the gallery example (5V → R220 → LED →
|
|
|
|
|
|
* MOSFET drain, source-to-GND low-side switch, gate driven by an Arduino
|
|
|
|
|
|
* pin with a 100 kΩ pull-down) and verifies that:
|
|
|
|
|
|
*
|
|
|
|
|
|
* 1. The LED's V-sense source is emitted so ngspice exposes the branch
|
|
|
|
|
|
* current under `i(v_led1_sense)` — the key `BasicParts.ts` reads.
|
|
|
|
|
|
* 2. With the gate held LOW, almost no current flows through the LED.
|
|
|
|
|
|
* 3. With the gate driven HIGH (5V), a realistic LED current flows (a
|
|
|
|
|
|
* few mA, bounded by the 220 Ω series resistor and the LED forward
|
|
|
|
|
|
* drop + MOSFET R_DS(on)).
|
|
|
|
|
|
* 4. Intermediate gate voltages produce monotonically increasing current,
|
|
|
|
|
|
* i.e. the analog PWM-dimming behaviour that was previously broken.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
|
import { buildNetlist } from '../simulation/spice/NetlistBuilder';
|
|
|
|
|
|
import { runNetlist } from '../simulation/spice/SpiceEngine';
|
|
|
|
|
|
import type { BuildNetlistInput } from '../simulation/spice/types';
|
|
|
|
|
|
|
|
|
|
|
|
function mosfetPwmLedNetlist(gateVolts: number) {
|
|
|
|
|
|
const input: BuildNetlistInput = {
|
|
|
|
|
|
components: [
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{ id: 'rl', metadataId: 'resistor', properties: { value: '220' } },
|
|
|
|
|
|
{ id: 'led1', metadataId: 'led', properties: { color: 'white' } },
|
|
|
|
|
|
{ id: 'q1', metadataId: 'mosfet-2n7000', properties: {} },
|
|
|
|
|
|
{ id: 'rg', metadataId: 'resistor', properties: { value: '100000' } },
|
2026-04-21 02:38:31 +07:00
|
|
|
|
],
|
|
|
|
|
|
wires: [
|
|
|
|
|
|
// 5V → R → LED anode
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{
|
|
|
|
|
|
id: 'w1',
|
|
|
|
|
|
start: { componentId: 'uno', pinName: '5V' },
|
|
|
|
|
|
end: { componentId: 'rl', pinName: '1' },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'w2',
|
|
|
|
|
|
start: { componentId: 'rl', pinName: '2' },
|
|
|
|
|
|
end: { componentId: 'led1', pinName: 'A' },
|
|
|
|
|
|
},
|
2026-04-21 02:38:31 +07:00
|
|
|
|
// LED cathode → MOSFET drain
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{
|
|
|
|
|
|
id: 'w3',
|
|
|
|
|
|
start: { componentId: 'led1', pinName: 'C' },
|
|
|
|
|
|
end: { componentId: 'q1', pinName: 'D' },
|
|
|
|
|
|
},
|
2026-04-21 02:38:31 +07:00
|
|
|
|
// Source to GND (low-side)
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{
|
|
|
|
|
|
id: 'w4',
|
|
|
|
|
|
start: { componentId: 'q1', pinName: 'S' },
|
|
|
|
|
|
end: { componentId: 'uno', pinName: 'GND' },
|
|
|
|
|
|
},
|
2026-04-21 02:38:31 +07:00
|
|
|
|
// Gate driven from GPIO 9, plus pull-down to GND
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{
|
|
|
|
|
|
id: 'w5',
|
|
|
|
|
|
start: { componentId: 'uno', pinName: '9' },
|
|
|
|
|
|
end: { componentId: 'q1', pinName: 'G' },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'w6',
|
|
|
|
|
|
start: { componentId: 'q1', pinName: 'G' },
|
|
|
|
|
|
end: { componentId: 'rg', pinName: '1' },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'w7',
|
|
|
|
|
|
start: { componentId: 'rg', pinName: '2' },
|
|
|
|
|
|
end: { componentId: 'uno', pinName: 'GND' },
|
|
|
|
|
|
},
|
2026-04-21 02:38:31 +07:00
|
|
|
|
],
|
|
|
|
|
|
boards: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'uno',
|
|
|
|
|
|
vcc: 5,
|
|
|
|
|
|
pins: {
|
|
|
|
|
|
'5V': { type: 'digital', v: 5 },
|
2026-04-22 02:45:45 +07:00
|
|
|
|
GND: { type: 'digital', v: 0 },
|
|
|
|
|
|
'9': { type: 'digital', v: gateVolts },
|
2026-04-21 02:38:31 +07:00
|
|
|
|
},
|
|
|
|
|
|
groundPinNames: ['GND'],
|
|
|
|
|
|
vccPinNames: ['5V'],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
analysis: { kind: 'op' },
|
|
|
|
|
|
};
|
|
|
|
|
|
return buildNetlist(input).netlist;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe('MOSFET PWM LED dimmer (mosfet-pwm-led example)', () => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
it('emits V-sense card so ngspice exposes i(v_led1_sense)', { timeout: 30_000 }, async () => {
|
|
|
|
|
|
const netlist = mosfetPwmLedNetlist(5);
|
|
|
|
|
|
expect(netlist).toMatch(/V_led1_sense /);
|
|
|
|
|
|
expect(netlist).toMatch(/D_led1 led1_sense_mid /);
|
2026-04-21 02:38:31 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const { variableNames } = await runNetlist(netlist);
|
|
|
|
|
|
const lowered = variableNames.map((n) => n.toLowerCase());
|
|
|
|
|
|
expect(lowered).toContain('i(v_led1_sense)');
|
|
|
|
|
|
});
|
2026-04-21 02:38:31 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
it('gate LOW → LED current is ~0 (MOSFET off)', { timeout: 30_000 }, async () => {
|
|
|
|
|
|
const netlist = mosfetPwmLedNetlist(0);
|
|
|
|
|
|
const { dcValue } = await runNetlist(netlist);
|
|
|
|
|
|
// Convention inside the builder: V-sense sources are oriented from
|
|
|
|
|
|
// anode → mid-net, so conducting current is *negative* (flows into
|
|
|
|
|
|
// the V+ terminal). Compare magnitudes.
|
|
|
|
|
|
const i = Math.abs(dcValue('i(v_led1_sense)'));
|
|
|
|
|
|
expect(i).toBeLessThan(1e-6); // sub-µA leakage is fine
|
|
|
|
|
|
});
|
2026-04-21 02:38:31 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
it('gate HIGH → LED conducts a realistic current (2–20 mA)', { timeout: 30_000 }, async () => {
|
|
|
|
|
|
const netlist = mosfetPwmLedNetlist(5);
|
|
|
|
|
|
const { dcValue } = await runNetlist(netlist);
|
|
|
|
|
|
const i = Math.abs(dcValue('i(v_led1_sense)'));
|
|
|
|
|
|
expect(i).toBeGreaterThan(2e-3);
|
|
|
|
|
|
expect(i).toBeLessThan(20e-3);
|
|
|
|
|
|
});
|
2026-04-21 02:38:31 +07:00
|
|
|
|
|
|
|
|
|
|
it(
|
|
|
|
|
|
'LED current increases monotonically as the gate voltage ramps 0 → 5V',
|
|
|
|
|
|
{ timeout: 60_000 },
|
|
|
|
|
|
async () => {
|
|
|
|
|
|
const gatePoints = [0, 1.0, 1.5, 2.0, 2.5, 3.5, 5.0];
|
|
|
|
|
|
const currents: number[] = [];
|
|
|
|
|
|
for (const vg of gatePoints) {
|
|
|
|
|
|
const { dcValue } = await runNetlist(mosfetPwmLedNetlist(vg));
|
|
|
|
|
|
currents.push(Math.abs(dcValue('i(v_led1_sense)')));
|
|
|
|
|
|
}
|
|
|
|
|
|
// 0V and 1V are below the MOSFET Vto (1.6V) — both near-zero.
|
|
|
|
|
|
expect(currents[0]).toBeLessThan(1e-6);
|
|
|
|
|
|
expect(currents[1]).toBeLessThan(1e-5);
|
|
|
|
|
|
// By 5V the MOSFET is fully on.
|
|
|
|
|
|
expect(currents[currents.length - 1]).toBeGreaterThan(1e-3);
|
|
|
|
|
|
// Each step above threshold should be ≥ the previous (within noise).
|
|
|
|
|
|
for (let i = 2; i < currents.length; i++) {
|
|
|
|
|
|
expect(currents[i]).toBeGreaterThanOrEqual(currents[i - 1] - 1e-6);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|