Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
97c6f2bbeThis file is pinned to the selected release. Download the original to open it in your design tool.
"""CLI-layer tests for `akcli sim` (deck emission, exit codes, live ngspice).
The offline tests cover the usage-error, ``--deck-only`` deck rendering,
``--gnd`` override, the unmodeled-warning path, the engine-missing exit-7
branch and the JSON envelope shape. The live tests (skipped when libngspice is
absent) drive the real engine end-to-end against the ``board_v8`` RC divider
and assert the measured node voltage and a written waveform file.
"""
from __future__ import annotations
import json
import pytest
from akcli import cli
from akcli.model import Component, Net, Pin, PinType, Schematic
from akcli.sim import engine
_BOARD = "tests/fixtures/kicad/board_v8.kicad_sch"
_HAVE_NGSPICE = engine.available() is not None
_needs_ngspice = pytest.mark.skipif(
not _HAVE_NGSPICE, reason="libngspice not installed on this machine"
)
def _write_sim(tmp_path, doc: dict):
p = tmp_path / "sim.json"
p.write_text(json.dumps(doc), encoding="utf-8")
return str(p)
def _find_value(lines, name):
import re
pat = re.compile(re.escape(name) + r"\s*=\s*([-+0-9.eE]+)")
for ln in lines:
m = pat.search(ln)
if m:
return float(m.group(1))
return None
# --------------------------------------------------------------------------- #
# usage / deck-only (offline)
# --------------------------------------------------------------------------- #
def test_sim_requires_sim_or_deck_only(capsys):
# Neither --sim nor --deck-only is a usage error explaining both.
assert cli.main(["sim", _BOARD]) == 2
err = capsys.readouterr().err
assert "--sim" in err and "--deck-only" in err
def test_sim_deck_only_stdout(capsys):
assert cli.main(["sim", _BOARD, "--deck-only"]) == 0
out = capsys.readouterr().out
# RC divider fixture: +3V3 -> R1 -> MID -> R2 -> GND, C1 on MID.
assert out.startswith("* akcli sim:")
assert "R1 _3V3 MID 10k" in out
assert "R2 MID 0 10k" in out
assert "C1 MID 0 100n" in out
assert out.rstrip().endswith(".end")
def test_sim_deck_only_out_file(tmp_path, capsys):
out_file = tmp_path / "board.cir"
assert cli.main(["sim", _BOARD, "--deck-only", "--out", str(out_file)]) == 0
text = out_file.read_text(encoding="utf-8")
assert "R1 _3V3 MID 10k" in text
# nothing on stdout when writing to a file; the notice goes to stderr
captured = capsys.readouterr()
assert captured.out.strip() == ""
assert str(out_file) in captured.err
def test_sim_gnd_override_remaps_ground(capsys):
# --gnd MID makes MID become node 0; GND becomes an ordinary node.
assert cli.main(["sim", _BOARD, "--deck-only", "--gnd", "MID"]) == 0
out = capsys.readouterr().out
assert "R1 _3V3 0 10k" in out
assert "R2 0 GND 10k" in out
def test_sim_no_ground_exits_6(tmp_path, capsys, monkeypatch):
# A deck with no ground net is SIM_NO_GROUND -> op-list-class exit 6.
assert cli.main(["sim", _BOARD, "--deck-only", "--gnd", "NOSUCHNET"]) == 6
err = capsys.readouterr().err
assert "SIM_NO_GROUND" in err
def test_sim_deck_only_json(capsys):
assert cli.main(["sim", _BOARD, "--deck-only", "--json"]) == 0
doc = json.loads(capsys.readouterr().out)
assert "R1 _3V3 MID 10k" in doc["deck"]
assert len(doc["deck_sha"]) == 12
assert doc["warnings"] == []
assert doc["unmodeled"] == []
# --------------------------------------------------------------------------- #
# unmodeled-warning path (offline, synthetic schematic)
# --------------------------------------------------------------------------- #
def _synthetic_with_diode() -> Schematic:
d1 = Component("D1", "Device:D", 0, 0, value="1N4148",
pins=[Pin("1", None, 0, 0, PinType.PASSIVE),
Pin("2", None, 0, 0, PinType.PASSIVE)])
r1 = Component("R1", "Device:R", 0, 0, value="1k",
pins=[Pin("1", None, 0, 0, PinType.PASSIVE),
Pin("2", None, 0, 0, PinType.PASSIVE)])
nets = [
Net("IN", [("R1", "1")]),
Net("MID", [("R1", "2"), ("D1", "1")]),
Net("GND", [("D1", "2")], source_names=["GND"]),
]
return Schematic("synthetic.kicad_sch", "kicad", [d1, r1], nets)
def test_sim_deck_only_unmodeled_warns_but_passes(capsys, monkeypatch):
from akcli.commands import sim as sim_cmd
monkeypatch.setattr(sim_cmd, "_load_schematic", lambda _p: _synthetic_with_diode())
# An unmodeled diode warns on stderr but never fails --deck-only.
assert cli.main(["sim", "synthetic.kicad_sch", "--deck-only"]) == 0
captured = capsys.readouterr()
assert "unmodeled D1" in captured.out # deck comment
assert "SIM_UNMODELED" in captured.err # warning line
assert "R1 IN MID 1k" in captured.out
def test_sim_deck_only_unmodeled_json_lists_it(capsys, monkeypatch):
from akcli.commands import sim as sim_cmd
monkeypatch.setattr(sim_cmd, "_load_schematic", lambda _p: _synthetic_with_diode())
assert cli.main(["sim", "synthetic.kicad_sch", "--deck-only", "--json"]) == 0
doc = json.loads(capsys.readouterr().out)
assert doc["unmodeled"] == ["D1"]
assert any(w["code"] == "SIM_UNMODELED" for w in doc["warnings"])
# --------------------------------------------------------------------------- #
# engine-missing (offline, monkeypatched)
# --------------------------------------------------------------------------- #
def test_sim_engine_missing_exit_7(tmp_path, capsys, monkeypatch):
from akcli.sim import engine as engine_mod
monkeypatch.setattr(engine_mod, "available", lambda: None)
sim = _write_sim(tmp_path, {
"protocol_version": 1,
"stimuli": [{"kind": "vsource", "name": "Vsup",
"node": "+3V3", "node2": "0", "value": "3.3"}],
"analyses": {"tran": "10u 10m"},
"assert": [{"name": "vmid", "meas": "MAX v(MID) from=5m to=10m",
"approx": "1.65", "tol": "0.02"}],
})
assert cli.main(["sim", _BOARD, "--sim", sim]) == 7
assert "NGSPICE_MISSING" in capsys.readouterr().err
def test_sim_bad_spec_exits_2(tmp_path, capsys):
# A malformed sim.json is BAD_CONFIG -> usage exit 2 via cli.main.
sim = _write_sim(tmp_path, {"protocol_version": 1, "assert": [{"bogus": 1}]})
assert cli.main(["sim", _BOARD, "--sim", sim]) == 2
# --------------------------------------------------------------------------- #
# item 4: an engine failure (unparsed deck) exits 7 with the error visible,
# even for a zero-assert spec that would otherwise "pass" trivially.
# --------------------------------------------------------------------------- #
def test_sim_engine_failure_exits_7_even_with_zero_asserts(tmp_path, capsys,
monkeypatch):
from akcli.sim import engine as engine_mod
monkeypatch.setattr(engine_mod, "available", lambda: "/fake/libngspice.dylib")
monkeypatch.setattr(
engine_mod, "run",
lambda *a, **k: engine_mod.EngineResult(
ok=False, error="engine exited with code 1: Error: circuit not parsed."),
)
sim = _write_sim(tmp_path, {
"protocol_version": 1, "analyses": {"op": ""}, "assert": [],
})
assert cli.main(["sim", _BOARD, "--sim", sim]) == 7
err = capsys.readouterr().err
assert "NGSPICE_FAILED" in err
assert "circuit not parsed" in err
# --------------------------------------------------------------------------- #
# item 1: a diode is emitted in SPICE anode/cathode order derived from pin
# NAMES, not schematic pin numbers (real models stage, no stub).
# --------------------------------------------------------------------------- #
def _rectifier_sch() -> Schematic:
# D4 with KiCad stock numbering (pin1='K' cathode, pin2='A' anode).
d4 = Component("D4", "Device:D_Schottky", 0, 0,
pins=[Pin("1", "K", 0, 0, PinType.PASSIVE),
Pin("2", "A", 0, 0, PinType.PASSIVE)])
r1 = Component("R1", "Device:R", 0, 0, value="100k",
pins=[Pin("1", None, 0, 0, PinType.PASSIVE),
Pin("2", None, 0, 0, PinType.PASSIVE)])
c1 = Component("C1", "Device:C", 0, 0, value="100n",
pins=[Pin("1", None, 0, 0, PinType.PASSIVE),
Pin("2", None, 0, 0, PinType.PASSIVE)])
nets = [
Net("HP", [("D4", "2")], source_names=["HP"]), # anode
Net("PEAK", [("D4", "1"), ("R1", "1"), ("C1", "1")],
source_names=["PEAK"]), # cathode
Net("GND", [("R1", "2"), ("C1", "2")], source_names=["GND"]),
]
return Schematic("rect.kicad_sch", "kicad", [d4, r1, c1], nets)
def _rectifier_spec():
from akcli.sim import assertions
return assertions.SimSpec(
stimuli=[{"kind": "vsource", "name": "Vin", "node": "HP",
"value": "SIN(0 1 10k)"}],
analyses={"tran": "2u 2m"},
models={"D4": {"device": "D", "model_name": "DBAT",
"model_card": ".model DBAT D(IS=2.4e-8 N=1.05)"}},
)
def test_deck_diode_uses_pin_name_anode_cathode_order():
from akcli.sim import deck as deckmod
d = deckmod.build(_rectifier_sch(), _rectifier_spec())
# anode (pin2 -> HP) first, cathode (pin1 -> PEAK) second.
assert "D4 HP PEAK DBAT" in d.text
assert not any(f.code == "SIM_PIN_ORDER_ASSUMED" for f in d.warnings)
# --------------------------------------------------------------------------- #
# fit-diode --apply round-trip: a native Sim.Device=D + Sim.Params (exactly what
# `fit-diode --apply --write` stamps) must resolve into a *modeled*, working deck
# — the tool's own applied fit must not brick its own simulation.
# --------------------------------------------------------------------------- #
def _native_diode_sch() -> Schematic:
# D4 carries the native Sim.* fields fit-diode --apply writes: Sim.Device=D
# + Sim.Params, NO Sim.Name (so a model must be synthesized).
d4 = Component("D4", "Device:D_Schottky", 0, 0,
pins=[Pin("1", "K", 0, 0, PinType.PASSIVE),
Pin("2", "A", 0, 0, PinType.PASSIVE)],
parameters={"Sim.Device": "D",
"Sim.Params": "IS=2.4e-8 N=1.05"})
r1 = Component("R1", "Device:R", 0, 0, value="100k",
pins=[Pin("1", None, 0, 0, PinType.PASSIVE),
Pin("2", None, 0, 0, PinType.PASSIVE)])
c1 = Component("C1", "Device:C", 0, 0, value="100n",
pins=[Pin("1", None, 0, 0, PinType.PASSIVE),
Pin("2", None, 0, 0, PinType.PASSIVE)])
nets = [
Net("HP", [("D4", "2")], source_names=["HP"]), # anode
Net("PEAK", [("D4", "1"), ("R1", "1"), ("C1", "1")],
source_names=["PEAK"]), # cathode
Net("GND", [("R1", "2"), ("C1", "2")], source_names=["GND"]),
]
return Schematic("native_rect.kicad_sch", "kicad", [d4, r1, c1], nets)
def _native_diode_spec():
from akcli.sim import assertions
return assertions.SimSpec(
stimuli=[{"kind": "vsource", "name": "Vin", "node": "HP",
"value": "SIN(0 1 10k)"}],
analyses={"tran": "2u 2m"},
)
def test_deck_native_diode_sim_params_is_modeled():
from akcli.sim import deck as deckmod
d = deckmod.build(_native_diode_sch(), _native_diode_spec())
# element references a synthesized model name; the matching card is injected.
assert "D4 HP PEAK AKCLI_D4" in d.text
assert ".model AKCLI_D4 D(IS=2.4e-8 N=1.05)" in d.text
assert not any(f.code == "SIM_UNMODELED" for f in d.warnings)
@_needs_ngspice
def test_live_native_diode_sim_params_charges_positive(tmp_path):
# The end-to-end proof of the round-trip fix: a bare Sim.Device=D + Sim.Params
# simulates (the diode conducts, peak detector charges positive) instead of
# emitting a modelless element ngspice rejects with 'circuit not parsed'.
from akcli.sim import deck as deckmod, engine as engine_mod
d = deckmod.build(_native_diode_sch(), _native_diode_spec())
res = engine_mod.run(
d.text, ["run", "meas tran vpk MAX v(PEAK)"],
timeout=30, workdir=tmp_path,
)
assert res.ok, res.error or res.log
vpk = _find_value(res.meas_lines, "vpk")
assert vpk is not None and vpk > 0.1, f"diode did not conduct: {vpk!r}\n{res.log}"
@_needs_ngspice
def test_live_half_wave_rectifier_charges_positive(tmp_path):
# The end-to-end proof of item 1: correct anode/cathode order => the peak
# detector charges POSITIVE. Reversed polarity would drive it negative.
from akcli.sim import deck as deckmod, engine as engine_mod
d = deckmod.build(_rectifier_sch(), _rectifier_spec())
res = engine_mod.run(
d.text, ["run", "meas tran vpk MAX v(PEAK)", "meas tran vmin MIN v(PEAK)"],
timeout=30, workdir=tmp_path,
)
assert res.ok, res.log
vpk = _find_value(res.meas_lines, "vpk")
assert vpk is not None, res.log
assert vpk > 0.1, f"peak detector did not charge positive: vpk={vpk!r}\n{res.log}"
# --------------------------------------------------------------------------- #
# live engine (skipped without libngspice)
# --------------------------------------------------------------------------- #
def _divider_sim(tmp_path) -> str:
return _write_sim(tmp_path, {
"protocol_version": 1,
"stimuli": [{"kind": "vsource", "name": "Vsup",
"node": "+3V3", "node2": "0", "value": "3.3"}],
"analyses": {"tran": "10u 10m"},
"assert": [{"name": "vmid", "meas": "MAX v(MID) from=5m to=10m",
"approx": "1.65", "tol": "0.02"}],
})
@_needs_ngspice
def test_sim_live_divider_passes(tmp_path, capsys):
sim = _divider_sim(tmp_path)
# Equal 10k/10k divider off 3.3 V settles MID at ~1.65 V.
assert cli.main(["sim", _BOARD, "--sim", sim]) == 0
out = capsys.readouterr().out
assert "vmid" in out and "PASS" in out
assert "measured values:" in out
# metadata header carries the engine lib path + short deck sha1
assert "engine:" in out and "deck sha1:" in out
@_needs_ngspice
def test_sim_live_json_envelope(tmp_path, capsys):
sim = _divider_sim(tmp_path)
assert cli.main(["sim", _BOARD, "--sim", sim, "--json"]) == 0
doc = json.loads(capsys.readouterr().out)
assert doc["ok"] is True
assert len(doc["deck_sha"]) == 12
assert doc["engine"].endswith(".dylib") or "ngspice" in doc["engine"]
assert abs(doc["measured"]["vmid"] - 1.65) < 0.05
assert doc["findings"] == []
@_needs_ngspice
def test_sim_live_assertion_failure_exits_1(tmp_path, capsys):
# Same run, an impossible bound -> SIM_ASSERT_FAIL -> exit 1.
sim = _write_sim(tmp_path, {
"protocol_version": 1,
"stimuli": [{"kind": "vsource", "name": "Vsup",
"node": "+3V3", "node2": "0", "value": "3.3"}],
"analyses": {"tran": "10u 10m"},
"assert": [{"name": "vmid", "meas": "MAX v(MID) from=5m to=10m",
"gt": "3.0"}],
})
assert cli.main(["sim", _BOARD, "--sim", sim]) == 1
out = capsys.readouterr().out
assert "FAIL" in out
# --exit-zero forces a clean exit even with a failed assertion.
assert cli.main(["sim", _BOARD, "--sim", sim, "--exit-zero"]) == 0
@_needs_ngspice
def test_sim_live_multi_analysis_tran_and_ac(tmp_path, capsys):
# tran + ac in one spec, one assert each: a single 'run' would run only tran
# and the ac meas would error out the whole run (exit 7). Per-analysis
# dispatch makes both pass end-to-end.
sim = _write_sim(tmp_path, {
"protocol_version": 1,
"stimuli": [{"kind": "vsource", "name": "Vsup",
"node": "+3V3", "node2": "0", "value": "dc 3.3 ac 1"}],
"analyses": {"tran": "10u 10m", "ac": "dec 10 100 100k"},
"assert": [
{"name": "vmid", "meas": "MAX v(MID) from=5m to=10m",
"approx": "1.65", "tol": "0.05"},
{"name": "gain", "meas": "FIND vm(MID) AT=1k", "analysis": "ac",
"ge": "0"},
],
})
assert cli.main(["sim", _BOARD, "--sim", sim]) == 0
out = capsys.readouterr().out
assert "vmid" in out and "gain" in out
assert "FAIL" not in out
@_needs_ngspice
def test_sim_live_wave_writes_file(tmp_path, capsys):
sim = _write_sim(tmp_path, {
"protocol_version": 1,
"stimuli": [{"kind": "vsource", "name": "Vsup",
"node": "+3V3", "node2": "0", "value": "3.3"}],
"analyses": {"tran": "10u 10m"},
"options": {"wave_vectors": ["v(MID)"]},
"assert": [{"name": "vmid", "meas": "MAX v(MID) from=5m to=10m",
"approx": "1.65", "tol": "0.02"}],
})
wave = tmp_path / "wave.csv"
assert cli.main(["sim", _BOARD, "--sim", sim, "--wave", str(wave)]) == 0
assert wave.exists() and wave.stat().st_size > 0
# --wave now emits the tidy CSV from wave.rewrite_wrdata: a single 'time'
# column + one verbatim column per options.wave_vectors entry.
lines = wave.read_text(encoding="utf-8").splitlines()
assert lines[0] == "time,v(MID)"
# every data row is a clean 2-field CSV (no repeated scale column).
assert all(len(row.split(",")) == 2 for row in lines[1:] if row)
@_needs_ngspice
def test_sim_live_wave_path_with_spaces(tmp_path):
# item 11: a --wave path containing spaces must still produce the file
# (ngspice would split an unquoted path; we wrdata to a fixed name + move).
sim = _write_sim(tmp_path, {
"protocol_version": 1,
"stimuli": [{"kind": "vsource", "name": "Vsup",
"node": "+3V3", "node2": "0", "value": "3.3"}],
"analyses": {"tran": "10u 10m"},
"options": {"wave_vectors": ["v(MID)"]},
"assert": [{"name": "vmid", "meas": "MAX v(MID) from=5m to=10m",
"approx": "1.65", "tol": "0.02"}],
})
spaced = tmp_path / "has space"
spaced.mkdir()
wave = spaced / "out wave.csv"
assert cli.main(["sim", _BOARD, "--sim", sim, "--wave", str(wave)]) == 0
assert wave.exists() and wave.stat().st_size > 0
# --------------------------------------------------------------------------- #
# measured-value table: a two-sided window shows BOTH bounds (offline)
# --------------------------------------------------------------------------- #
def test_bound_desc_two_sided_window_shows_both_bounds():
from akcli.commands import sim as sim_cmd
assert sim_cmd._bound_desc({"ge": 3.0, "le": 3.6}) == ">= 3 & <= 3.6"
assert sim_cmd._bound_desc({"gt": 0.35}) == "> 0.35"
assert sim_cmd._bound_desc({"approx": 1.65, "tol": 0.02}) == "~1.65 (tol 2%)"
def test_measured_table_window_fail_shows_the_upper_half_too():
# Value 3.8 fails the 'le 3.6' side; the table must not print only '>= 3'
# next to a FAIL verdict (the earlier first-key-wins bug).
from akcli.commands import sim as sim_cmd
from akcli import report as _report
class _Spec:
asserts = [{"name": "v33", "meas": "MAX v(x)", "ge": 3.0, "le": 3.6}]
findings = [_report.Finding(
"SIM_ASSERT_FAIL", _report.Severity.ERROR,
"v33 = 3.8 violates <= 3.6", refs=["v33"])]
table = sim_cmd._measured_table(_Spec(), {"v33": 3.8}, findings)
assert ">= 3" in table and "<= 3.6" in table
assert "FAIL" in table
# --------------------------------------------------------------------------- #
# fit-diode mode ('akcli sim fit-diode ...') — offline, no ngspice needed
# --------------------------------------------------------------------------- #
def test_fit_diode_single_point_text(capsys):
assert cli.main(["sim", "fit-diode", "--point", "0.3@1m"]) == 0
out = capsys.readouterr().out
assert ".model DFIT D(" in out
assert "IS=" in out and "N=1.0500" in out
assert "Sim.Params:" in out
def test_fit_diode_json_envelope(capsys):
assert cli.main(["sim", "fit-diode", "--point", "0.3@1m", "--json"]) == 0
doc = json.loads(capsys.readouterr().out)
assert doc["name"] == "DFIT"
assert doc["model_card"].startswith(".model DFIT D(")
assert abs(doc["params"]["N"] - 1.05) < 1e-9
assert doc["params"]["IS"] > 0
# 'Sim.Params' mirrors the model card's inner parameters.
assert "IS=" in doc["sim_params"] and "N=" in doc["sim_params"]
def test_fit_diode_rs_and_cjo_and_name(capsys):
# A high-current rs-point solves RS; --cjo adds CJO; --name renames the card.
assert cli.main(["sim", "fit-diode", "--point", "0.3@1m",
"--rs-point", "1.0@1", "--cjo", "50p",
"--name", "DBAT", "--json"]) == 0
doc = json.loads(capsys.readouterr().out)
assert doc["name"] == "DBAT"
assert doc["model_card"].startswith(".model DBAT D(")
assert doc["params"]["RS"] > 0
assert doc["params"]["CJO"] == 50e-12
assert "RS=" in doc["sim_params"] and "CJO=" in doc["sim_params"]
def test_fit_diode_requires_a_point(capsys):
assert cli.main(["sim", "fit-diode"]) == 2
assert "--point" in capsys.readouterr().err
def test_fit_diode_bad_point_format_exits_2(capsys):
# No '@' separator is a usage error naming the offending token.
assert cli.main(["sim", "fit-diode", "--point", "0.3"]) == 2
err = capsys.readouterr().err
assert "V@I" in err and "0.3" in err
def test_fit_diode_apply_dry_run_prints_oplist(capsys):
# --apply without --write prints the op-list it WOULD apply (nothing written).
assert cli.main(["sim", "fit-diode", "--point", "0.3@1m",
"--apply", _BOARD, "--designator", "R1"]) == 0
out = capsys.readouterr().out
assert "dry-run" in out
assert '"set_component_parameters"' in out
assert '"Sim.Device": "D"' in out
assert '"designator": "R1"' in out
assert "--write" in out
def test_fit_diode_apply_needs_designator(capsys):
assert cli.main(["sim", "fit-diode", "--point", "0.3@1m",
"--apply", _BOARD]) == 2
assert "--designator" in capsys.readouterr().err
def test_fit_diode_apply_write_commits(tmp_path, capsys):
# --write commits the Sim.* fields through the KiCad writer with a .bak.
import shutil as _sh
target = tmp_path / "board.kicad_sch"
_sh.copy(_BOARD, target)
assert cli.main(["sim", "fit-diode", "--point", "0.3@1m",
"--apply", str(target), "--designator", "R1",
"--write"]) == 0
text = target.read_text(encoding="utf-8")
assert "Sim.Params" in text and "Sim.Device" in text
assert (tmp_path / ".akcli" / "backups" / "board.kicad_sch.bak").exists()
assert "applied" in capsys.readouterr().out