Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 16 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
# simantic

Python control of the [Simantic](https://simantic.dev) simulators — the
firmware engine hosted in your process, circuits via `analog-cli`. Everything
the CLIs can do, as objects and method calls: start a board or a multi-machine scenario, advance virtual
Python control of the [Simantic](https://simantic.dev) firmware simulator,
hosted in your process. Everything the `sim` CLI can do, as objects and method calls: start a board or a multi-machine scenario, advance virtual
time by exact amounts, inject UART/GPIO/CAN/radio, read memory and RTOS state,
and run as many simulations in parallel as you have cores. pytest is one way
to use it, not a requirement.

> **Alpha — not stable.** Version 0.1.x. The API, the CLI surface, and the
> **Alpha — not stable.** Version 0.2.x. The API, the CLI surface, and the
> report schema may change without a deprecation period, and any release may
> break the previous one. Pin an exact version (`simantic==0.1.0`) if you
> break the previous one. Pin an exact version (`simantic==0.2.0`) if you
> depend on it. Not recommended for production pipelines yet.

```bash
Expand All @@ -20,8 +19,7 @@ That is the whole setup for Python. The first `Sim(...)` fetches the
simulation engine (Simantic.Core plus a private .NET runtime — nothing else
to install) into `~/.simantic/engine/<version>/`, checksum-verified against
the public release manifest. `simantic install` fetches it up front, along
with the `sim` and `analog-cli` binaries if you also want the command-line
tools.
with the `sim` binary if you also want the command-line tool.

A Simantic account (`simantic auth`) is needed for one thing: resolving MCU
models by name (`mcu="STM32F401RE"`), which are fetched from your account
Expand All @@ -39,10 +37,9 @@ Every download — engine or binary — is verified against the checksum in the
release manifest. The package on PyPI contains only Python; the simulators
are never in the wheel.

Already have the binaries? Point `$SIMANTIC_ANALOG_CLI` and `$SIMANTIC_SIM`
at them, or put them on PATH — both take precedence over a managed install.
`simantic status` shows what is authenticated and which binary each name
resolves to.
Already have `sim`? Point `$SIMANTIC_SIM` at it, or put it on PATH — both
take precedence over a managed install. `simantic status` shows what is
authenticated and what resolved.

## Drive a simulation

Expand Down Expand Up @@ -71,70 +68,27 @@ One-shot runs ("run 5 s, give me the transcript") are `run_firmware(...)`.
## Using it from pytest (optional)

`Sim` needs no plugin — construct it inside any test. If you also keep
manifests, installing the package registers two collectors that turn them
into individually addressable pytest items:

- `*.sim.toml` — one item per `[[test]]` table (analog)
- `test.yaml` — one item per fixture (firmware)
`test.yaml` fixture manifests, installing the package registers a collector
that turns each fixture into an individually addressable pytest item.

```console
$ pytest hardware/ firmware/
hardware/psu/psu.sim.toml::schematic-erc PASSED
hardware/psu/psu.sim.toml::rails-op PASSED
hardware/psu/psu.sim.toml::startup-settling FAILED
hardware/psu/psu.sim.toml::board-drc SKIPPED (no .kicad_pcb)
$ pytest firmware/
firmware/tests/gpio-loopback/test.yaml::gpio-loopback PASSED
firmware/tests/uart-echo/test.yaml::uart-echo FAILED
```

Because these are ordinary pytest items you get `-k` filtering, `--junitxml`
for CI, xdist parallelism, and per-test durations. Failures print the
runner's own explanation rather than a Python traceback:

```
startup-settling (tran): fail
FAIL settle-time: V(OUT) measured 0.0082 (expected max 0.006, margin -0.0022)
```
runner's own explanation — the UART transcript and the expectation it
missed — rather than a Python traceback.

Tests that cannot run in the current environment skip rather than fail — a
missing binary, an unconfigured server, an analysis the installed CLI does
not support, a check inapplicable to the project. A red run means a
missing binary or an unconfigured server. A red run means a
simulation ran and disagreed with its expectations.

## Library

### Circuits

```python
import simantic

report = simantic.run_tests("hardware/psu")
print(f"{report.summary.passed}/{report.summary.total} passed")

for m in report.test("rails-op").measurements:
print(m.describe()) # out-dc: V(OUT) measured 1.597 (expected eq 1.597 +/- 0.02, margin 0.02)
```

A failing test is data, not an exception: it arrives in the report with its
measured value, declared bounds, and margin. Only conditions that prevent a
run at all — bad project, missing `kicad-cli`, invalid testplan — raise
`AnalogCliError`.

### Firmware

The shortest path is a pytest fixture — no manifest, no flags:

```python
def test_firmware_boots(pyrite):
run = pyrite("build/zephyr.elf", board="stm32f401",
expect=["Hello World!"], expect_absent=["FAULT"])
assert run.passed, run.failure_report()
```

`pyrite` runs the ELF offline on the pure-Rust backend and hands back the
UART transcript. The fixture skips when no binary is installed, so a suite
stays green on a machine that has not run `smtc install pyrite`.

The same runner is available as a plain function, and `sim` has its own:
The one-shot runner:

```python
run = simantic.run_firmware(
Expand Down Expand Up @@ -185,10 +139,6 @@ unaffected and nothing is printed.

## Compatibility

Speaks the `analog-cli.test-report/1` schema. Additive fields within that
revision are tolerated; a breaking revision raises `ReportError` rather than
silently misreading a report.

Multi-machine `test.yaml` fixtures — those with a `machines:` map — need the
`--scenario` runner and are not driven yet; they report as skips.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "simantic"
version = "0.2.0"
version = "0.2.1"
description = "Python SDK and pytest plugin for the Simantic circuit and firmware simulators"
# PyYAML reads sim-fixtures test.yaml manifests; pythonnet hosts the
# simulation engine (Simantic.Core, .NET) in-process for `simantic.Sim`.
Expand Down
49 changes: 8 additions & 41 deletions src/simantic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
"""Python control of the Simantic simulators.

One package covers both engines, because co-simulation puts them together:
`analog-cli` for circuits and `sim` for firmware.
The firmware engine, hosted in your process.

import simantic

with simantic.Sim(elf="fw.elf", repl="board.repl") as sim: # live control
sim.expect("ready"); sim.run_for(0.5)
run = simantic.run_firmware("fw.elf", repl="board.repl",
expect=["RESULT: PASS"]) # one-shot
report = simantic.run_tests("hardware/psu") # analog

Neither binary is bundled. Point `$SIMANTIC_ANALOG_CLI` and `$SIMANTIC_SIM`
at them, or put them on PATH.
The engine is fetched on first use. The `sim` CLI is optional; point
`$SIMANTIC_SIM` at one, or put it on PATH.

Installing this package also registers a pytest plugin that turns the
manifests a project already keeps — `*.sim.toml` testplans and `test.yaml`
fixture manifests — into individually addressable pytest items.

The MCP-based agent session lives in `simantic.agent` and is not re-exported
here: `Sim` is the Python surface; MCP is an adapter for chat clients.
Installing this package also registers a pytest plugin that turns `test.yaml`
fixture manifests into individually addressable pytest items.
"""

from ._locate import BinaryNotFound, analog_cli
from .analog import AnalogCliError, plan_path, plan_test_names, run_tests
from ._locate import BinaryNotFound
from .fixtures import (
Manifest,
ModelLibraryUnavailable,
Expand All @@ -32,36 +25,12 @@
)
from .mcu import ServerNotConfigured, SimError, SimRun, sim_binary
from .mcu import run as run_firmware
from .pyrite import pyrite_binary
from .pyrite import run as run_pyrite
from .engine import EngineNotFound, engine_dir
from .session import ExpectTimeout, Match, Sim
from .report import (
Expect,
Finding,
Measurement,
ReportError,
Summary,
Test,
TestReport,
)

__version__ = "0.2.0"
__version__ = "0.2.1"

__all__ = [
# analog
"AnalogCliError",
"Expect",
"Finding",
"Measurement",
"ReportError",
"Summary",
"Test",
"TestReport",
"analog_cli",
"plan_path",
"plan_test_names",
"run_tests",
# firmware
"Manifest",
"ModelLibraryUnavailable",
Expand All @@ -70,11 +39,9 @@
"SimRun",
"UnsupportedManifest",
"load_manifest",
"pyrite_binary",
"run_firmware",
"run_pyrite",
"sim_binary",
# scripted sessions (sim --control-stdio)
# scripted sessions
"Sim",
"Match",
"ExpectTimeout",
Expand Down
14 changes: 3 additions & 11 deletions src/simantic/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,8 @@
from ._locate import BinaryNotFound, locate
from .mcu import BINARY as SIM_BINARY
from .mcu import ENV_VAR as SIM_ENV
from ._locate import BINARY as ANALOG_BINARY
from ._locate import ENV_VAR as ANALOG_ENV
from .pyrite import BINARY as PYRITE_BINARY
from .pyrite import ENV_VAR as PYRITE_ENV

BINARIES = (
(ANALOG_BINARY, ANALOG_ENV),
(SIM_BINARY, SIM_ENV),
(PYRITE_BINARY, PYRITE_ENV),
)
BINARIES = ((SIM_BINARY, SIM_ENV),)


def _auth(args) -> int:
Expand Down Expand Up @@ -59,8 +51,8 @@ def _install(args) -> int:
except install.InstallError as exc:
print(f"{name}: {exc}", file=sys.stderr)
failures += 1
# Partial success is still useful — one engine may be published and the
# other not — so report it without discarding what did install.
# Partial success is still useful, so report it without discarding what
# did install.
return 1 if failures == len(names) else 0


Expand Down
8 changes: 0 additions & 8 deletions src/simantic/_locate.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,3 @@ def locate(
f"PATH, or set ${env_var} to its location."
)


ENV_VAR = "SIMANTIC_ANALOG_CLI"
BINARY = "analog-cli"


def analog_cli(explicit: str | os.PathLike[str] | None = None) -> Path:
"""Resolve the analog-cli binary, or raise BinaryNotFound."""
return locate(BINARY, ENV_VAR, explicit)
Loading
Loading