- SVG waveform plots (svg_plot.py): pure-SVG timeseries, Bode, spectrum
generation with plot_waveform MCP tool — no matplotlib dependency
- Circuit tuning tool (tune_circuit): single-shot simulate → measure →
compare targets → suggest adjustments workflow for iterative design
- 5 new circuit templates: Sallen-Key lowpass, boost converter,
instrumentation amplifier, current mirror, transimpedance amplifier
(both netlist and .asc schematic generators, 15 total templates)
- Fix all 6 prompts to return list[Message] per FastMCP 2.x spec
- Add ltspice://templates and ltspice://template/{name} resources
- Add troubleshoot_simulation prompt
- Integration tests for RC lowpass and non-inverting amp (2/4 pass;
CE amp and Colpitts oscillator have pre-existing schematic bugs)
- 360 unit tests passing, ruff clean
166 lines
6.0 KiB
Python
166 lines
6.0 KiB
Python
"""Tests for waveform_expr module: tokenizer, parser, expression evaluator."""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from mcp_ltspice.waveform_expr import (
|
|
WaveformCalculator,
|
|
_tokenize,
|
|
_TokenType,
|
|
evaluate_expression,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tokenizer tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTokenizer:
|
|
def test_number_tokens(self):
|
|
tokens = _tokenize("42 3.14 1e-3")
|
|
nums = [t for t in tokens if t.type == _TokenType.NUMBER]
|
|
assert len(nums) == 3
|
|
assert nums[0].value == "42"
|
|
assert nums[1].value == "3.14"
|
|
assert nums[2].value == "1e-3"
|
|
|
|
def test_signal_tokens(self):
|
|
tokens = _tokenize("V(out) + I(R1)")
|
|
signals = [t for t in tokens if t.type == _TokenType.SIGNAL]
|
|
assert len(signals) == 2
|
|
assert signals[0].value == "V(out)"
|
|
assert signals[1].value == "I(R1)"
|
|
|
|
def test_operator_tokens(self):
|
|
tokens = _tokenize("1 + 2 - 3 * 4 / 5")
|
|
ops = [t for t in tokens if t.type not in (_TokenType.NUMBER, _TokenType.EOF)]
|
|
types = [t.type for t in ops]
|
|
assert types == [_TokenType.PLUS, _TokenType.MINUS, _TokenType.STAR, _TokenType.SLASH]
|
|
|
|
def test_function_tokens(self):
|
|
tokens = _tokenize("abs(V(out))")
|
|
funcs = [t for t in tokens if t.type == _TokenType.FUNC]
|
|
assert len(funcs) == 1
|
|
assert funcs[0].value == "abs"
|
|
|
|
def test_case_insensitive_functions(self):
|
|
tokens = _tokenize("dB(V(out))")
|
|
funcs = [t for t in tokens if t.type == _TokenType.FUNC]
|
|
assert funcs[0].value == "db"
|
|
|
|
def test_bare_identifier(self):
|
|
tokens = _tokenize("time + 1")
|
|
signals = [t for t in tokens if t.type == _TokenType.SIGNAL]
|
|
assert len(signals) == 1
|
|
assert signals[0].value == "time"
|
|
|
|
def test_invalid_character_raises(self):
|
|
with pytest.raises(ValueError, match="Unexpected character"):
|
|
_tokenize("V(out) @ 2")
|
|
|
|
def test_eof_token(self):
|
|
tokens = _tokenize("1")
|
|
assert tokens[-1].type == _TokenType.EOF
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Expression evaluator tests (scalar via numpy scalars)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEvaluateExpression:
|
|
def test_addition(self):
|
|
result = evaluate_expression("2 + 3", {})
|
|
assert float(result) == pytest.approx(5.0)
|
|
|
|
def test_multiplication(self):
|
|
result = evaluate_expression("4 * 5", {})
|
|
assert float(result) == pytest.approx(20.0)
|
|
|
|
def test_precedence(self):
|
|
"""Multiplication binds tighter than addition: 2+3*4=14."""
|
|
result = evaluate_expression("2 + 3 * 4", {})
|
|
assert float(result) == pytest.approx(14.0)
|
|
|
|
def test_unary_minus(self):
|
|
result = evaluate_expression("-5 + 3", {})
|
|
assert float(result) == pytest.approx(-2.0)
|
|
|
|
def test_nested_parens(self):
|
|
result = evaluate_expression("(2 + 3) * (4 - 1)", {})
|
|
assert float(result) == pytest.approx(15.0)
|
|
|
|
def test_division_by_near_zero(self):
|
|
"""Division by near-zero uses a safe floor to avoid inf."""
|
|
result = evaluate_expression("1 / 0", {})
|
|
# Should return a very large number, not inf
|
|
assert np.isfinite(result)
|
|
|
|
def test_db_function(self):
|
|
"""dB(x) = 20 * log10(|x|)."""
|
|
result = evaluate_expression("db(10)", {})
|
|
assert float(result) == pytest.approx(20.0, rel=0.01)
|
|
|
|
def test_abs_function(self):
|
|
result = evaluate_expression("abs(-7)", {})
|
|
assert float(result) == pytest.approx(7.0)
|
|
|
|
def test_sqrt_function(self):
|
|
result = evaluate_expression("sqrt(16)", {})
|
|
assert float(result) == pytest.approx(4.0)
|
|
|
|
def test_log10_function(self):
|
|
result = evaluate_expression("log10(1000)", {})
|
|
assert float(result) == pytest.approx(3.0)
|
|
|
|
def test_signal_lookup(self):
|
|
"""Expression referencing a variable by name."""
|
|
variables = {"V(out)": np.array([1.0, 2.0, 3.0])}
|
|
result = evaluate_expression("V(out) * 2", variables)
|
|
np.testing.assert_array_almost_equal(result, [2.0, 4.0, 6.0])
|
|
|
|
def test_unknown_signal_raises(self):
|
|
with pytest.raises(ValueError, match="Unknown signal"):
|
|
evaluate_expression("V(missing)", {"V(out)": np.array([1.0])})
|
|
|
|
def test_unknown_function_raises(self):
|
|
# 'sin' is not in the supported function set -- the tokenizer treats
|
|
# it as a signal name "sin(1)", so the error is "Unknown signal"
|
|
with pytest.raises(ValueError, match="Unknown signal"):
|
|
evaluate_expression("sin(1)", {})
|
|
|
|
def test_malformed_expression(self):
|
|
with pytest.raises(ValueError):
|
|
evaluate_expression("2 +", {})
|
|
|
|
def test_case_insensitive_signal(self):
|
|
"""Signal lookup is case-insensitive."""
|
|
variables = {"V(OUT)": np.array([10.0])}
|
|
result = evaluate_expression("V(out)", variables)
|
|
np.testing.assert_array_almost_equal(result, [10.0])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WaveformCalculator tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWaveformCalculator:
|
|
def test_calc_available_signals(self, mock_rawfile):
|
|
calc = WaveformCalculator(mock_rawfile)
|
|
signals = calc.available_signals()
|
|
assert "time" in signals
|
|
assert "V(out)" in signals
|
|
|
|
def test_calc_expression(self, mock_rawfile):
|
|
calc = WaveformCalculator(mock_rawfile)
|
|
result = calc.calc("V(out) * 2")
|
|
expected = np.real(mock_rawfile.data[1]) * 2
|
|
np.testing.assert_array_almost_equal(result, expected)
|
|
|
|
def test_calc_db(self, mock_rawfile):
|
|
calc = WaveformCalculator(mock_rawfile)
|
|
result = calc.calc("db(V(out))")
|
|
# db should produce real values
|
|
assert np.all(np.isfinite(result))
|