From cd29dabc1689120ee87268fed3af00cbd907d8f4 Mon Sep 17 00:00:00 2001 From: Miklos Bagi <68903245+miklosbagi@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:36:12 +0200 Subject: [PATCH 1/4] InvForge: multi-vendor inverter/BESS Modbus TCP emulator Vendor-agnostic core engine (register/scenario/state/generator/ connectivity), a Sigenergy SigenStor profile built from real device captures, Docker/docker-compose support, and a unit+integration test suite runnable locally or in CI. --- .claude/skills/invforge-review/SKILL.md | 97 +++++++ .dockerignore | 8 + .github/workflows/ci.yml | 37 +++ .gitignore | 4 + CLAUDE.md | 9 + Dockerfile | 20 ++ LICENSE | 21 ++ README.md | 261 ++++++++++++++++++ docker-compose.yml | 12 + docs/coding-standards.md | 104 +++++++ invforge/__init__.py | 0 invforge/__main__.py | 98 +++++++ invforge/core/__init__.py | 0 invforge/core/connectivity.py | 147 ++++++++++ invforge/core/control_api.py | 168 +++++++++++ invforge/core/generator.py | 66 +++++ invforge/core/profile.py | 61 ++++ invforge/core/registers.py | 94 +++++++ invforge/core/scenario.py | 161 +++++++++++ invforge/core/server.py | 147 ++++++++++ invforge/core/state.py | 82 ++++++ invforge/profiles/__init__.py | 46 +++ invforge/profiles/sigenergy/__init__.py | 10 + .../firmwares/V100R001C21SPC116/__init__.py | 25 ++ .../firmwares/V100R001C21SPC116/ramps.py | 73 +++++ .../firmwares/V100R001C21SPC116/registers.py | 116 ++++++++ .../2026-08-13-idle-disconnected.yaml | 170 ++++++++++++ .../2026-08-13-live-eps-discharge.yaml | 74 +++++ .../recorded/2026-08-14-idle-full-soc.yaml | 101 +++++++ .../synthetic/bad-data-pv-power-negative.yaml | 39 +++ .../synthetic/bad-data-soc-over-100.yaml | 35 +++ .../synthetic/bad-data-soh-over-100.yaml | 35 +++ .../scenarios/synthetic/offline-window.yaml | 38 +++ .../synthetic/ramp-discharge-100-to-0.yaml | 35 +++ .../profiles/sigenergy/firmwares/__init__.py | 0 pyproject.toml | 19 ++ requirements-dev.txt | 9 + requirements.txt | 10 + scripts/integration-test.sh | 19 ++ scripts/wait-healthy.sh | 24 ++ tests/__init__.py | 0 tests/integration/__init__.py | 0 tests/integration/conftest.py | 65 +++++ tests/integration/test_bad_data_scenarios.py | 44 +++ tests/integration/test_fault_injection.py | 39 +++ tests/integration/test_generator_playback.py | 50 ++++ tests/integration/test_health_and_state.py | 40 +++ tests/integration/test_offline_scenario.py | 24 ++ tests/integration/test_scenario_playback.py | 29 ++ tests/unit/__init__.py | 0 tests/unit/test_connectivity.py | 37 +++ tests/unit/test_generator.py | 48 ++++ tests/unit/test_profile_registry.py | 32 +++ tests/unit/test_scenario_offline.py | 55 ++++ tests/unit/test_sigenergy_profile.py | 86 ++++++ 55 files changed, 3024 insertions(+) create mode 100644 .claude/skills/invforge-review/SKILL.md create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 docker-compose.yml create mode 100644 docs/coding-standards.md create mode 100644 invforge/__init__.py create mode 100644 invforge/__main__.py create mode 100644 invforge/core/__init__.py create mode 100644 invforge/core/connectivity.py create mode 100644 invforge/core/control_api.py create mode 100644 invforge/core/generator.py create mode 100644 invforge/core/profile.py create mode 100644 invforge/core/registers.py create mode 100644 invforge/core/scenario.py create mode 100644 invforge/core/server.py create mode 100644 invforge/core/state.py create mode 100644 invforge/profiles/__init__.py create mode 100644 invforge/profiles/sigenergy/__init__.py create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/__init__.py create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/registers.py create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-idle-disconnected.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-live-eps-discharge.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-14-idle-full-soc.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-pv-power-negative.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soc-over-100.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soh-over-100.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/offline-window.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/ramp-discharge-100-to-0.yaml create mode 100644 invforge/profiles/sigenergy/firmwares/__init__.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100755 scripts/integration-test.sh create mode 100755 scripts/wait-healthy.sh create mode 100644 tests/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_bad_data_scenarios.py create mode 100644 tests/integration/test_fault_injection.py create mode 100644 tests/integration/test_generator_playback.py create mode 100644 tests/integration/test_health_and_state.py create mode 100644 tests/integration/test_offline_scenario.py create mode 100644 tests/integration/test_scenario_playback.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_connectivity.py create mode 100644 tests/unit/test_generator.py create mode 100644 tests/unit/test_profile_registry.py create mode 100644 tests/unit/test_scenario_offline.py create mode 100644 tests/unit/test_sigenergy_profile.py diff --git a/.claude/skills/invforge-review/SKILL.md b/.claude/skills/invforge-review/SKILL.md new file mode 100644 index 0000000..9b11ba0 --- /dev/null +++ b/.claude/skills/invforge-review/SKILL.md @@ -0,0 +1,97 @@ +--- +name: invforge-review +description: Mandatory pre-push review for InvForge (Python). Runs ruff/mypy/pytest gates and reviews changed files against docs/coding-standards.md. Must be run and must pass before any `git push` in this repo. +--- + +# InvForge pre-push review + +This is a **mandatory gate**, not an optional lint pass. No commit in this +repo gets pushed without this skill having been run against it and having +reported a clean result. If you are about to `git push` in this repo and +haven't run this skill against the commits being pushed, run it first. + +## 1. Scope the review + +Determine what's changed and being pushed: + +```sh +git status --short +git diff --stat $(git merge-base HEAD origin/main 2>/dev/null || echo HEAD)..HEAD +``` + +If there's no `origin/main` yet (new repo, nothing pushed), review +everything staged/committed so far instead. + +## 2. Mechanical gates — all must pass with zero errors/warnings + +Run from the repo root, using the project venv: + +```sh +.venv/bin/ruff format --check invforge tests +.venv/bin/ruff check invforge tests +.venv/bin/mypy +.venv/bin/python -m pytest tests/unit -q +``` + +`tests/unit/` is fast and network-free — always run it. If the change +under review touches anything in `invforge/core/connectivity.py`, +`invforge/core/server.py`, `invforge/core/control_api.py`, a scenario +fixture, or the Dockerfile/compose file, also run the integration suite +(needs Docker): + +```sh +scripts/integration-test.sh +``` + +Any failure here is an automatic FAIL — fix it (or, for `ruff format`, +just run `ruff format invforge tests` and re-check) before doing the +semantic review below. Don't hand-wave a mypy or ruff error as +"pre-existing" without confirming it actually predates the change under +review — the whole codebase is expected to be clean at all times, see +`docs/coding-standards.md`. + +## 3. Semantic review + +Read `docs/coding-standards.md` in full, then walk every changed file +against it. In particular: + +- **Correctness against real fixtures**, not just synthetic ones — does + the change still work against `scenarios/recorded/*.yaml`, not only + invented scenarios? +- **Boundary conditions**: empty scenario, register at a unit's block + edge, malformed/partial YAML, an HTTP request with a missing/wrong-typed + field. +- **Resource cleanup** on every exit path — threads, sockets, the + ticker's stop_event. +- **Type-hint accuracy** — does the annotation actually match runtime + behavior, or did mypy only pass because of an `Any` escape hatch + (`# type: ignore`, unannotated `object`, etc.) that hides a real gap? + Every `# type: ignore` in this codebase should have a comment + explaining why (see `core/server.py`'s pymodbus-subclassing comment as + the reference example) — a bare, unexplained one is itself a finding. +- **Test coverage** for the behavior actually changed — a new code path + with no corresponding test is a finding, not just a suggestion. +- **Consistency with `invforge/core/`'s conventions** — did a + vendor-specific quirk leak into `core/` instead of staying inside its + `invforge/profiles//`? That's an architecture violation, not a + style nit. +- **New dependency scrutiny** — anything added to `requirements.txt` + that the stdlib or an existing dependency already covers is a finding. + +## 4. Report + +Use the `ReportFindings` tool with the verified findings, most severe +first. If nothing survives review, call it with an empty findings list — +don't skip the call. + +## 5. Verdict + +State explicitly, in plain text after the findings: + +- **PASS — safe to push** if all mechanical gates passed and no + CONFIRMED correctness finding remains, or +- **FAIL — do not push** otherwise, naming exactly what has to change + first. + +Do not push on a FAIL verdict, even if the user seems to be in a hurry — +say so and let them explicitly override if they choose to. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b485e7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.git/ +tests/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66985a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: [push, pull_request] + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -r requirements-dev.txt + - run: ruff format --check invforge tests + - run: ruff check invforge tests + - run: mypy + - run: python -m pytest tests/unit -q + + integration: + runs-on: ubuntu-latest + needs: unit + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -r requirements-dev.txt + - run: docker compose up -d --build + - run: scripts/wait-healthy.sh invforge + - run: python -m pytest tests/integration -q + env: + INVFORGE_MODBUS_PORT: "5020" + INVFORGE_CONTROL_PORT: "8080" + - if: failure() + run: docker compose logs + - if: always() + run: docker compose down diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5f38aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +*.egg-info/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..39249b9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# InvForge — agent instructions + +Before any `git push` in this repo, run the `invforge-review` skill +against the commits being pushed and get a PASS verdict. This is a hard +requirement (see `.claude/skills/invforge-review/SKILL.md`), not a +suggestion — do not push on a FAIL verdict without the user explicitly +overriding it. + +See `docs/coding-standards.md` for the rules that review enforces. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eac87e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY invforge ./invforge + +EXPOSE 502 8080 + +HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=5 \ + CMD curl -sf http://127.0.0.1:8080/health || exit 1 + +ENTRYPOINT ["python", "-m", "invforge"] +CMD ["--vendor", "sigenergy", "--firmware", "V100R001C21SPC116", "--modbus-port", "502", "--control-port", "8080"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41ba56f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InvForge contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8b13789..f612a7d 100644 --- a/README.md +++ b/README.md @@ -1 +1,262 @@ +# InvForge +A fake Modbus TCP inverter/BESS, for testing Modbus-speaking clients +(NUT drivers, monitoring tools, anything else) without real hardware. +Multi-vendor by design: the Modbus/scenario/control-API engine is +vendor-agnostic (`invforge/core/`), and each vendor's register map, +units, and scenario fixtures live in their own profile +(`invforge/profiles//`). + +Originally extracted and generalized from a single-vendor Sigenergy +simulator built for [sigennut](../sigennut); ported here as the first +profile (`invforge/profiles/sigenergy/`) so the same emulator can grow to +cover other inverter/BESS families (Deye/SunSynk, Victron GX, Growatt, +SolarEdge, ...) as they're needed by [nut-sigenergy](../nut-sigenergy) +or other consumers. + +## Quickstart + +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +.venv/bin/python -m invforge --vendor sigenergy --firmware V100R001C21SPC116 \ + --modbus-port 5020 --control-port 8080 [--scenario ] +``` + +`--firmware` can be omitted when a vendor has exactly one confirmed +firmware (as Sigenergy does today) — `--vendor sigenergy` alone resolves +to it. Once a second firmware is registered for a vendor, `--firmware` +becomes required and an unknown one exits with a clear list of what's +available, rather than silently guessing. + +Then point any Modbus TCP client at `127.0.0.1:5020` — same units/register +addresses as the real device the profile models. Sigenergy's SigenStor +uses unit 247 (plant) and unit 1 (inverter strings). + +## HTTP control API (port 8080 by default) + +The test runner's remote control channel — HTTP+JSON, not gRPC (this is +low-throughput, single-consumer, and this way it's `curl`-able for free +while debugging a failed test). + +- `GET /health` — for Docker healthchecks. +- `GET /scenarios` — `{"library": {name: relative_path, ...}, + "generators": [, ...]}` for the running profile. +- `POST /scenario {"name": "...", "speed": 1.0}` — loads and starts + playing back a named scenario's timeseries (wall-clock, scaled by + `speed`), looping at its duration. `name` resolves against the static + YAML library first, then the parametric ramp generator (see below) if + no library fixture matches. Also clears any forced `/fault` override + back to "auto". +- `POST /fault {"connectivity": "offline"|"online"|"auto"}` — on-demand + connection-level fault injection: forces the Modbus TCP listener + offline/online, or clears back to scenario-driven ("auto"). This drops + the real socket (already-connected clients too), not just a + register-level Modbus exception. Sticky: takes precedence over a + scenario's own `offline:` windows until cleared or a new scenario is + loaded. +- `POST /state {"registers": {"30014": 50, "1:30500": "..."}}` — + **instant override**: writes directly into the datastore right now and + freezes the ticker (`mode: "manual"`) so nothing overwrites it until the + next `/scenario` or `/state` call. A bare address key targets the + profile's default unit; `":
"` targets another unit + explicitly. Values are raw wire values (bare int for single-register + fields, `[hi, lo]` for S32/U32), same convention as scenario YAML files. +- `GET /state` — current raw+decoded value of every known register on + every unit, plus current mode/scenario/speed/connectivity — for + debugging. + +## How it works + +- `invforge/core/registers.py` — vendor-agnostic register model + (address/count/dtype/gain/unit/dynamic) plus encode/decode helpers. +- `invforge/core/profile.py` — a `Profile` bundles one vendor+firmware's + register list, default unit, and scenario fixtures directory. Register + addresses/layouts shift across firmware versions in the real world + (see `nut-sigenergy/docs/driver-coding-standards.md`'s note on + Sigenergy's own firmware history), so a `Profile` is scoped to one + specific firmware, not a whole vendor — see "Adding a new vendor + profile" below. +- `invforge/core/scenario.py` — loads a scenario YAML (`static` / + `timeseries` / `exceptions`), linearly interpolates numeric registers + between consecutive timeseries samples in raw wire-value space. +- `invforge/core/server.py` — a pymodbus TCP server with one datastore + block per unit id the profile defines. A background thread ticks + registers marked `dynamic=True` forward on a wall-clock timer + (`--speed` multiplier, `--tick` interval), decoupled from request + handling — same as a real device's telemetry loop being independent of + what a client happens to poll. Loops back to `t=0` once a scenario's + duration elapses. +- `invforge/core/generator.py` — parametric ramp-scenario generator + (see "Parametric ramp scenarios" below); vendor-agnostic mechanism + only, a profile supplies the actual ramp-building logic via + `Profile.ramp_builder`. +- `invforge/core/connectivity.py` — owns the live Modbus TCP listener's + online/offline lifecycle (see "Offline / connection-drop simulation" + below). +- `invforge/core/control_api.py` — the FastAPI control surface above. +- `invforge/profiles//firmwares//` — a `registers.py` + (the `RegisterDef` list), a `scenarios/` directory, and an + `__init__.py` exporting `PROFILE: Profile`. See + `invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/` as the + reference example. + +Unmapped registers fail Modbus reads the same way a real device would +(illegal data address) — a datastore's own address bounds handle this +natively for registers never defined at all; a scenario's `exceptions:` +block can mark any other address as always-illegal too, for asserting a +client handles a real firmware's odd 400-response quirks correctly. + +Some real firmware also rejects specific addresses as a read's +*starting* address specifically — a legitimate, in-range register that +simply can't be the first address of a Modbus request, even though a +range read starting elsewhere and covering it works fine. Confirmed +empirically against real Sigenergy hardware (register `30281`). This is +a permanent per-firmware trait, not scenario data, so it's modeled +separately: `Profile.non_anchor_addresses` (a frozenset of addresses), +enforced by every block regardless of which scenario is loaded. See +`invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/registers.py`'s +module docstring for the full story. + +Sigenergy's own register table is deliberately not the full ~280-entry +spec — see that same module docstring for scope (the subset relevant to +a UPS/battery-backup driver, matching what the specific real +installation this was validated against actually has). + +## Adding a new vendor profile + +1. Create `invforge/profiles//firmwares//registers.py` + with a `REGISTERS: list[RegisterDef]` and a `DEFAULT_UNIT: int`. Use + the exact firmware version string as the directory name — this is + also what `Profile.firmware` and `--firmware` resolve against, no + slugifying/casing translation. +2. Create `invforge/profiles//firmwares//__init__.py` + exporting a module-level `PROFILE: Profile` (see + `invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/__init__.py`). +3. Add `invforge/profiles//__init__.py` exporting a + `FIRMWARES: dict[str, Profile]` (see + `invforge/profiles/sigenergy/__init__.py`), and register it in + `invforge/profiles/__init__.py`'s `_load()`. +4. Add `scenarios/recorded/` (real captures, ground truth — validate + anything testing against this profile against these before trusting a + synthetic scenario) and/or `scenarios/synthetic/` (invented ramps, + edge cases, bad-data — see below) YAML fixtures under + `invforge/profiles//firmwares//scenarios/`. Since + raw register addresses are firmware-specific, scenarios live under + the firmware they were captured against/written for, not shared + loosely across a vendor's firmware versions — a second firmware with + different addresses must not be able to silently misinterpret an old + scenario file. +5. A second confirmed firmware for an existing vendor is a new sibling + directory under that vendor's `firmwares/`, nothing else changes. + +## Scenario YAML format + +```yaml +static: + unit_: {
: , ... } +timeseries: + - t: +
: # omit a register at a sample to mean + ... # "not sampled here", not "zero" -- + # carries forward. Applies to the + # profile's default_unit. +exceptions: + unit_: {
: {function_code: , exception_code: } } +offline: + - { start: , end: } # Modbus TCP is unreachable + ... # during each [start, end) + # window, elapsed scenario + # time. +``` + +All numeric values are RAW wire values (pre-gain), never the decoded +real-world value — a bare int for single-register fields, a `[hi, lo]` +word list for multi-register (S32/U32) fields. + +## Parametric ramp scenarios + +For common linear ramps, a scenario name matching +`linear---to--s` (e.g. +`linear-drain-100-to-0-60s`) is computed on the fly instead of needing a +hand-written YAML file per exact numeric variant — see +`invforge/core/generator.py`. Purely additive to the YAML library: an +existing fixture of the same name always wins on a collision. Requires +the profile to supply a `ramp_builder` (see +`invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py` for +the reference example — it derives ramp power from the rated capacity +and requested SoC delta/duration rather than a guessed constant). +Invalid ramps (non-positive duration, a `drain` that doesn't decrease, +a `charge` that doesn't increase) are rejected with `ValueError` +(`POST /scenario` → HTTP 400). + +## Offline / connection-drop simulation + +A scenario's `offline:` section (above), or an on-demand +`POST /fault` call, makes the Modbus TCP listener genuinely unreachable +for a window — a real dropped/refused connection, not a Modbus-level +exception — simulating a device actually going offline (power loss, +network outage, Modbus support revoked). See +`invforge/core/connectivity.py`'s module docstring for how this is +implemented against pymodbus (constructing `ModbusTcpServer` directly +inside its own asyncio loop, since the blocking `StartTcpServer` +convenience wrapper can't be stopped/restarted from outside once +running) and why other Modbus exception codes (`SlaveBusy`, +`GatewayNoResponse`, etc.) aren't reachable through pymodbus's datastore +`validate()` path the way `IllegalAddress` is. + +## Bad-data scenarios + +A category of `scenarios/synthetic/` fixtures that need zero new engine +mechanism — just a deliberately out-of-spec `static:` value, since the +engine already writes whatever raw value a YAML gives it. These exist to +test whether a driver validates data rather than trusting it blindly: +wire-legal register encodings can still decode to values that are +physically/semantically impossible (a real firmware bug or a corrupted +read could hand a driver exactly this). See +`invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/`: + +- `bad-data-soc-over-100.yaml` — `battery.charge`-equivalent register + decodes to 101.0%. +- `bad-data-soh-over-100.yaml` — state-of-health register decodes to + 150.0%. +- `bad-data-pv-power-negative.yaml` — a magnitude-only (generation) + power register decodes negative. Chosen over the bidirectional + charge/discharge or import/export power registers, where negative is + legitimately meaningful by design. + +## Docker + +```bash +docker compose up -d --build +curl http://127.0.0.1:8080/health +``` + +Maps modbus `5020:502` and control-API `8080:8080` (matching the +Quickstart above). The image's `ENTRYPOINT` is `python -m invforge` +with a `CMD` giving sane defaults (`--vendor sigenergy --firmware +V100R001C21SPC116 ...`); override vendor/firmware/scenario per the +"param via Docker" pattern: + +```bash +docker run --vendor sigenergy --firmware V100R001C21SPC116 --scenario ramp-discharge-100-to-0 +``` + +or a `command:` override in `docker-compose.yml`. `GET /health` backs +the container `HEALTHCHECK`. + +## Testing + +- `tests/unit/` — fast, no network: `pytest tests/unit -q`. +- `tests/integration/` — real Modbus TCP + HTTP against a running + instance. One-command local run (build, wait for healthy, run, tear + down): `scripts/integration-test.sh`. In CI + (`.github/workflows/ci.yml`), the same steps run as two jobs (`unit`, + `integration`) so a lint/type/unit failure fails fast before Docker + ever builds. +- Every push to this repo must pass the `invforge-review` skill + (`.claude/skills/invforge-review/`) first — see `CLAUDE.md`. + +## License + +MIT (`LICENSE`) — this is test tooling, not a sellable artifact; no +reason to restrict use. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2b12147 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + invforge: + build: . + ports: + - "5020:502" + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:8080/health"] + interval: 2s + timeout: 2s + retries: 5 + start_period: 5s diff --git a/docs/coding-standards.md b/docs/coding-standards.md new file mode 100644 index 0000000..e09a6e2 --- /dev/null +++ b/docs/coding-standards.md @@ -0,0 +1,104 @@ +# Coding standards — InvForge (Python) + +InvForge is internal test tooling (a fake Modbus TCP inverter/BESS for +integration testing), not the contributed/sellable artifact — the +[nut-sigenergy](../../nut-sigenergy) driver is. That changes the risk +profile (no hardware safety consequence from a bug here, no upstream +maintainers to satisfy) but not the bar for correctness: a wrong or +flaky emulator gives false confidence in the driver it's supposed to be +validating, which is worse than no emulator at all. + +Enforced by the `invforge-review` skill (`.claude/skills/invforge-review/`), +which **must** be run before any push. + +## Language choice + +Python, confirmed 2026-08-14 (not re-litigated per push — see +[nut-sigenergy](../../nut-sigenergy)'s memory for context; revisit only on +a real limitation, not a preference). Rationale: `pymodbus` gives a +mature Modbus TCP *server* implementation for free, and Python's +ecosystem (`pyyaml`, `fastapi`) is well suited to declarative +YAML-driven scenario scripting and a small HTTP control surface — the +same reasoning `sigennut`'s original PRD used to keep its simulator in +Python even after its driver moved to Go. There is no comparable +lower-effort story for this specific job in a compiled/typed language. + +## General engineering rules (adapted from the driver's C rules) + +- Optimize first for correctness, simplicity, readability, and + maintainability. Optimize for cleverness last. +- Before implementation, understand the existing engine + (`invforge/core/`) — registers, profile, scenario, server, control + API — and extend it rather than introducing a parallel mechanism. + Vendor differences belong in `invforge/profiles//`, not as + special-casing inside `core/`. +- Follow the existing style in this repo exactly (see "Tooling" below) + over generic PEP 8 preference where they conflict. +- Keep control flow obvious. Prefer small, cohesive functions/methods + with one clear responsibility. Avoid unnecessary abstraction, + indirection, and premature generalization — this codebase generalized + once already (single-vendor → multi-vendor); do not generalize a + second time ahead of a second concrete need. +- Use type hints everywhere (`from __future__ import annotations` + + annotations, per the existing modules) — this is a small codebase + without a runtime type system, hints are the primary defense against + wrong-shape data crossing module boundaries. +- Treat every external boundary as untrusted input and validate there: + scenario YAML content, HTTP request bodies (already Pydantic-modeled — + keep it that way, don't hand-parse JSON), Modbus wire data. Don't + re-validate internal values whose shape a prior boundary already + established. +- Check every operation that can meaningfully fail (file I/O, YAML + parsing, dict lookups against attacker/test-controlled register + addresses). Never silently swallow an unexpected exception. +- Names communicate domain meaning (`reg`, `raw`, `unit`, `elapsed` are + fine in the narrow scopes the existing code uses them in — vague names + like `data`/`tmp`/`thing` are not). +- Comments explain *why* — a hardware quirk, a protocol constraint, a + non-obvious invariant (see `registers.py`'s comment on `30281`) — never + what the code already says. +- Keep patches cohesive: don't mix a scenario/register change with an + unrelated refactor. +- Do not add a dependency for something the standard library or an + already-present dependency (`pymodbus`, `pyyaml`, `fastapi`, + `uvicorn`) already does adequately. + +## Before declaring a change complete, review it for + +- correctness against the fixture(s) it touches — run the real recorded + captures, not just synthetic scenarios; +- boundary conditions (empty scenario, register at a unit's block + boundary, malformed/partial YAML); +- resource cleanup (threads, sockets) on every exit path; +- type-hint accuracy; +- test coverage (`tests/`) for the behavior actually changed; +- consistency with `invforge/core/`'s existing conventions; +- whether a vendor-specific quirk leaked into `core/` instead of staying + in its profile. + +## Tooling + +- **Format/lint**: `ruff` (format + lint in one tool, fast, minimal + config surface — add `pyproject.toml` config the first time a rule + needs tuning, don't pre-configure speculatively). +- **Types**: `mypy --strict` on `invforge/`. +- **Tests**: `tests/unit/` (fast, no network — `pytest tests/unit`, + `test_sigenergy_profile.py` is the reference example: loads every + scenario YAML, decodes known values from a real capture, and confirms + the real illegal-address exception reproduces) and `tests/integration/` + (real Modbus TCP + HTTP against a running instance — see + `scripts/integration-test.sh`, needs Docker). Run against real + fixtures where they exist, not just synthetic ones. +- Treat new lint/type warnings as defects, same as the driver's compiler + warnings. + +## Adding a vendor profile + +See the main `README.md`'s "Adding a new vendor profile" section for the +mechanical steps. In addition: every new profile needs at least one +`scenarios/recorded/` fixture before it's trusted for anything beyond +"the engine can load it" — a profile built entirely from invented +register values gives the same false confidence a driver built against +unverified register addresses would (see +`nut-sigenergy/docs/driver-coding-standards.md`'s note on `sigennut`'s +R1/R11). diff --git a/invforge/__init__.py b/invforge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invforge/__main__.py b/invforge/__main__.py new file mode 100644 index 0000000..90a107e --- /dev/null +++ b/invforge/__main__.py @@ -0,0 +1,98 @@ +"""Entrypoint: starts the Modbus TCP server lifecycle (background +thread, via core/connectivity.py), the scenario ticker (background +thread), and the HTTP control API (main thread, via uvicorn) for a +chosen vendor profile. See core/control_api.py for the remote-control +surface a test runner uses. +""" + +from __future__ import annotations + +import argparse +import logging +import threading + +import uvicorn + +from . import profiles +from .core import server as sim_server +from .core.connectivity import ConnectivityController, run_forever +from .core.control_api import create_app +from .core.state import SimState + +log = logging.getLogger("invforge") + + +def main() -> None: + parser = argparse.ArgumentParser(description="InvForge -- multi-vendor inverter/BESS Modbus TCP emulator") + parser.add_argument( + "--vendor", default="sigenergy", choices=sorted(profiles.VENDORS), help="vendor profile to emulate" + ) + parser.add_argument( + "--firmware", + default=None, + help="firmware version to emulate (default: the vendor's only confirmed firmware, if exactly one)", + ) + parser.add_argument("--modbus-host", default="0.0.0.0") + parser.add_argument("--modbus-port", type=int, default=502) + parser.add_argument("--control-host", default="0.0.0.0") + parser.add_argument("--control-port", type=int, default=8080) + parser.add_argument("--scenario", default=None, help="optional scenario name to preload at startup") + parser.add_argument("--speed", type=float, default=1.0) + parser.add_argument("--tick", type=float, default=0.25) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING) + + try: + firmware = args.firmware or profiles.default_firmware(args.vendor) + profile = profiles.get(args.vendor, firmware) + except KeyError as e: + parser.error(str(e)) + blocks = sim_server.build_blocks(profile) + context = sim_server.build_context(blocks) + state = SimState() + + if args.scenario: + from .core.state import resolve_scenario + + try: + scenario = resolve_scenario(args.scenario, profile) + except (KeyError, ValueError) as e: + parser.error(str(e)) + sim_server.apply_exceptions(blocks, scenario) + sim_server.write_scenario_statics(blocks, profile, scenario) + state.set_scenario(scenario, speed=args.speed) + + stop_event = threading.Event() + ticker = threading.Thread( + target=sim_server.run_ticker, + args=(state, blocks, profile, args.tick, stop_event), + daemon=True, + ) + ticker.start() + + connectivity = ConnectivityController() + modbus_ready = threading.Event() + modbus_thread = threading.Thread( + target=run_forever, + args=(connectivity, state, context, args.modbus_host, args.modbus_port, args.tick, stop_event, modbus_ready), + daemon=True, + ) + modbus_thread.start() + if not modbus_ready.wait(timeout=5): + log.warning("Modbus TCP server did not report ready within 5s -- continuing anyway") + + app = create_app(profile, blocks, state, connectivity) + try: + uvicorn.run( + app, host=args.control_host, port=args.control_port, log_level="info" if args.verbose else "warning" + ) + finally: + stop_event.set() + ticker.join(timeout=2) + modbus_thread.join(timeout=2) + + +if __name__ == "__main__": + main() diff --git a/invforge/core/__init__.py b/invforge/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invforge/core/connectivity.py b/invforge/core/connectivity.py new file mode 100644 index 0000000..52979ef --- /dev/null +++ b/invforge/core/connectivity.py @@ -0,0 +1,147 @@ +"""Owns the live Modbus TCP listener's online/offline lifecycle, so a +scenario (or an on-demand control-API call) can make the emulator go +genuinely unreachable for a window -- simulating a real device dropping +off the network, not just a register-level Modbus exception. + +pymodbus's blocking `StartTcpServer` convenience wrapper can't be +stopped/restarted from outside once running, so this constructs +`pymodbus.server.async_io.ModbusTcpServer` directly inside its own +asyncio event loop: `await server.listen()` to come online, +`await server.shutdown()` to go offline (this also drops +already-connected clients, not just refuses new ones -- confirmed +against the pymodbus 3.6.9 source, the right behavior for "device went +offline mid-session"). A shut-down server object is not reused -- +`_bring_online()` always constructs a fresh one. + +Only the run() coroutine itself -- and coroutines scheduled onto its +loop via asyncio.run_coroutine_threadsafe() from set_override() -- ever +touch the live pymodbus server object, so it needs no lock. `_override` +and `_online` are read from arbitrary threads (the control API, running +under uvicorn) and are guarded by `self._lock`. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading + +from pymodbus.datastore import ModbusServerContext +from pymodbus.framer import Framer +from pymodbus.server.async_io import ModbusTcpServer + +from .server import elapsed_seconds +from .state import SimState + +log = logging.getLogger("invforge") + + +class ConnectivityController: + def __init__(self) -> None: + self._lock = threading.Lock() + self._override: bool | None = None # None=auto (scenario-driven), True=forced offline, False=forced online + self._online = False + self._server: ModbusTcpServer | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._state: SimState | None = None + self._context: ModbusServerContext | None = None + self._host = "" + self._port = 0 + + async def run( + self, + state: SimState, + context: ModbusServerContext, + host: str, + port: int, + tick_interval: float, + stop_event: threading.Event, + ready: threading.Event, + ) -> None: + self._state = state + self._context = context + self._host = host + self._port = port + + # Assigning self._loop is the hand-off point after which + # set_override() (called from other threads) may safely schedule + # work onto this loop -- safe by construction: __main__.py only + # starts the control API (the only other caller) after `ready` + # is set below, which happens strictly after this assignment. + self._loop = asyncio.get_running_loop() + await self._bring_online() + ready.set() + + while not stop_event.is_set(): + await self._apply_now() + await asyncio.sleep(tick_interval) + + await self._bring_offline() + + def set_override(self, offline: bool | None) -> None: + """offline=True forces offline, False forces online, None clears + back to auto (scenario-driven). Blocks until the change is + actually applied on the connectivity loop, so a caller (e.g. the + control API) only returns once it's really taken effect.""" + with self._lock: + self._override = offline + loop = self._loop + if loop is None: + return # run() hasn't started yet -- its first _apply_now() will pick up the initial value + future = asyncio.run_coroutine_threadsafe(self._apply_now(), loop) + future.result(timeout=5) + + def status(self) -> dict[str, object]: + with self._lock: + return {"override": self._override, "online": self._online} + + def _desired_online(self) -> bool: + with self._lock: + override = self._override + if override is not None: + return not override + assert self._state is not None + mode, scenario, start_time, speed = self._state.snapshot() + if mode == "scenario" and scenario is not None: + elapsed = elapsed_seconds(scenario, start_time, speed) + return not scenario.is_offline_at(elapsed) + return True + + async def _apply_now(self) -> None: + desired = self._desired_online() + if desired and self._server is None: + await self._bring_online() + elif not desired and self._server is not None: + await self._bring_offline() + + async def _bring_online(self) -> None: + assert self._context is not None + server = ModbusTcpServer(self._context, Framer.SOCKET, None, (self._host, self._port)) + await server.listen() + self._server = server + with self._lock: + self._online = True + log.info("Modbus TCP server online on %s:%d", self._host, self._port) + + async def _bring_offline(self) -> None: + if self._server is not None: + await self._server.shutdown() + self._server = None + with self._lock: + self._online = False + log.info("Modbus TCP server offline") + + +def run_forever( + controller: ConnectivityController, + state: SimState, + context: ModbusServerContext, + host: str, + port: int, + tick_interval: float, + stop_event: threading.Event, + ready: threading.Event, +) -> None: + """Thread target: owns this thread's asyncio event loop for the + lifetime of the connectivity controller's run() coroutine.""" + asyncio.run(controller.run(state, context, host, port, tick_interval, stop_event, ready)) diff --git a/invforge/core/control_api.py b/invforge/core/control_api.py new file mode 100644 index 0000000..6e7b338 --- /dev/null +++ b/invforge/core/control_api.py @@ -0,0 +1,168 @@ +"""HTTP+JSON control API for the simulator -- the test runner's remote +control channel. Chosen over gRPC deliberately: this is low-throughput, +single-consumer, and this way it's `curl`-able for free while debugging a +failed test. + +Endpoints: + GET /health -> {"status": "ok"} + GET /scenarios -> {"library": {name: relative_path, ...}, + "generators": [, ...]} + POST /scenario {name, speed=1.0} + Loads and starts playing back a named scenario's timeseries. + `name` resolves against the static YAML library first, then the + parametric generator (see core/generator.py) if no library + fixture matches -- e.g. "linear-drain-100-to-0-60s". + POST /state {registers: {"
": , ":
": , ...}} + Instant override -- writes directly into the datastore right now + and freezes the ticker (mode="manual") so nothing overwrites it + until the next /scenario or /state call. A bare address key is + assumed to mean the profile's default_unit; use ":
" + to target another unit explicitly. + GET /state -> current raw+decoded value of every known + register on every unit, plus connectivity + status, for debugging a failed test. + POST /fault {"connectivity": "offline"|"online"|"auto"} + On-demand connection-level fault injection -- forces the Modbus + TCP listener offline/online, or clears back to scenario-driven + ("auto"). Sticky: takes precedence over a scenario's own + `offline:` windows until cleared or a new scenario is loaded (see + /scenario, which resets it to "auto"). +""" + +from __future__ import annotations + +from typing import Literal, TypeGuard + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +from . import server as sim_server +from .connectivity import ConnectivityController +from .profile import Profile +from .registers import DType, RegisterDef, decode_numeric, normalize_scenario_value +from .state import SimState, list_scenarios, resolve_scenario + + +class ScenarioRequest(BaseModel): + name: str + speed: float = 1.0 + + +class StateRequest(BaseModel): + registers: dict[str, object] # "
" or ":
" -> raw int | [hi, lo] + + +class FaultRequest(BaseModel): + connectivity: Literal["offline", "online", "auto"] + + +def create_app( + profile: Profile, + blocks: dict[int, sim_server.IllegalAwareBlock], + state: SimState, + connectivity: ConnectivityController, +) -> FastAPI: + app = FastAPI(title=f"invforge control API ({profile.name})") + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/scenarios") + def scenarios() -> dict[str, object]: + generator_patterns = ["linear---to--s"] if profile.ramp_builder else [] + return {"library": list_scenarios(profile.scenarios_dir), "generators": generator_patterns} + + @app.post("/scenario") + def load_scenario(req: ScenarioRequest) -> dict[str, object]: + try: + scenario = resolve_scenario(req.name, profile) + except KeyError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + sim_server.apply_exceptions(blocks, scenario) + sim_server.write_scenario_statics(blocks, profile, scenario) + state.set_scenario(scenario, speed=req.speed) + connectivity.set_override(None) # a fresh scenario starts in "auto" -- no stale forced fault carries over + return {"loaded": req.name, "duration": scenario.duration(), "speed": req.speed} + + @app.post("/fault") + def set_fault(req: FaultRequest) -> dict[str, object]: + override = {"offline": True, "online": False, "auto": None}[req.connectivity] + try: + connectivity.set_override(override) + except TimeoutError as e: + raise HTTPException(status_code=500, detail=f"connectivity change did not apply in time: {e}") from e + return {"connectivity": req.connectivity} + + @app.post("/state") + def set_state(req: StateRequest) -> dict[str, object]: + by_unit: dict[int, dict[int, int]] = {} + for key, raw_value in req.registers.items(): + unit, addr = _parse_key(key, profile.default_unit) + reg = profile.find(unit, addr) + if reg is None: + raise HTTPException(status_code=400, detail=f"unknown register {unit}:{addr}") + if reg.dtype == DType.STRING: + raise HTTPException( + status_code=400, detail=f"{reg.name} is a string register, use /scenario static instead" + ) + if not _is_raw_register_value(raw_value): + raise HTTPException( + status_code=400, detail=f"{reg.name}: expected an int or [hi, lo] word list, got {raw_value!r}" + ) + by_unit.setdefault(unit, {})[addr] = normalize_scenario_value(reg, raw_value) + for unit, values in by_unit.items(): + sim_server.write_manual_registers(blocks, profile, unit, values) + state.freeze_manual() + applied = {f"{u}:{a}": v for u, vals in by_unit.items() for a, v in vals.items()} + return {"applied": applied, "mode": "manual"} + + @app.get("/state") + def get_state() -> dict[str, object]: + out = {} + for unit, block in blocks.items(): + for reg in profile.registers_for(unit): + out[f"{unit}:{reg.name}"] = _read_register(block, reg) + mode, scenario, _, speed = state.snapshot() + return { + "mode": mode, + "scenario": scenario.name if scenario else None, + "speed": speed, + "connectivity": connectivity.status(), + "registers": out, + } + + return app + + +def _parse_key(key: str, default_unit: int) -> tuple[int, int]: + if ":" in key: + unit_s, addr_s = key.split(":", 1) + return int(unit_s), int(addr_s) + return default_unit, int(key) + + +def _is_raw_register_value(value: object) -> TypeGuard[int | list[int]]: + """A POST /state raw value is either a bare int or a [hi, lo] word + list -- the same shape normalize_scenario_value() expects.""" + if isinstance(value, bool): + return False + if isinstance(value, int): + return True + return isinstance(value, list) and all(isinstance(v, int) and not isinstance(v, bool) for v in value) + + +def _read_register(block: sim_server.IllegalAwareBlock, reg: RegisterDef) -> dict[str, object]: + if not block.validate(reg.address, reg.count): + return {"address": reg.address, "illegal": True} + raw_words = block.getValues(reg.address, reg.count) + if reg.dtype == DType.STRING: + raw_bytes = b"" + for w in raw_words: + raw_bytes += bytes([(w >> 8) & 0xFF, w & 0xFF]) + text = raw_bytes.decode("utf-8", errors="replace").rstrip("\x00") + return {"address": reg.address, "text": text} + decoded = decode_numeric(reg, raw_words) + return {"address": reg.address, "raw": raw_words, "decoded": decoded} diff --git a/invforge/core/generator.py b/invforge/core/generator.py new file mode 100644 index 0000000..7fee6cf --- /dev/null +++ b/invforge/core/generator.py @@ -0,0 +1,66 @@ +"""Parametric scenario generator -- computes common linear ramp +scenarios on the fly (e.g. "linear-drain-100-to-0-60s") instead of +requiring a hand-written YAML file per exact numeric variant. Purely +additive to the static YAML library (core/scenario.py's +Scenario.from_yaml) -- see state.py's resolve_scenario() for how the two +are combined; an existing YAML of the same name always wins. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal + +from .profile import Profile +from .scenario import Scenario + +_RAMP_RE = re.compile( + r"^linear-(?Pdrain|charge)-(?P\d+(?:\.\d+)?)-to-" + r"(?P\d+(?:\.\d+)?)-(?P\d+(?:\.\d+)?)s$" +) + + +@dataclass(frozen=True) +class RampParams: + direction: Literal["drain", "charge"] + start_pct: float + end_pct: float + duration_s: float + + +def resolve_generated(name: str, profile: Profile) -> Scenario | None: + """None if `name` doesn't match the generator's naming convention, or + the profile has no ramp_builder -- caller falls back to the static + YAML library. Raises ValueError for a name that matches the pattern + but describes an invalid ramp (an HTTP/CLI-boundary input, validated + here rather than trusted).""" + if profile.ramp_builder is None: + return None + match = _RAMP_RE.match(name) + if match is None: + return None + + direction_str = match["direction"] + direction: Literal["drain", "charge"] + if direction_str == "drain": + direction = "drain" + elif direction_str == "charge": + direction = "charge" + else: + raise AssertionError(f"regex guarantees drain|charge, got {direction_str!r}") + + params = RampParams( + direction=direction, + start_pct=float(match["start"]), + end_pct=float(match["end"]), + duration_s=float(match["duration"]), + ) + if params.duration_s <= 0: + raise ValueError(f"{name!r}: duration must be > 0") + if params.direction == "drain" and params.start_pct < params.end_pct: + raise ValueError(f"{name!r}: direction 'drain' but {params.start_pct} -> {params.end_pct} increases") + if params.direction == "charge" and params.start_pct > params.end_pct: + raise ValueError(f"{name!r}: direction 'charge' but {params.start_pct} -> {params.end_pct} decreases") + + return Scenario(name, profile.ramp_builder(params), profile) diff --git a/invforge/core/profile.py b/invforge/core/profile.py new file mode 100644 index 0000000..fb3101b --- /dev/null +++ b/invforge/core/profile.py @@ -0,0 +1,61 @@ +"""A vendor profile: the set of registers, their units, and where a +profile's scenario fixtures live. Everything vendor-specific lives in +invforge/profiles//; this dataclass is what the vendor-agnostic +core (scenario.py, server.py, control_api.py) consumes.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +from .registers import RegisterDef + +if TYPE_CHECKING: + from .generator import RampParams + + +@dataclass(frozen=True) +class Profile: + name: str + firmware: str + registers: list[RegisterDef] + scenarios_dir: Path + default_unit: int # unit assumed when a scenario/control-API request omits one + ramp_builder: Callable[[RampParams], dict[str, object]] | None = None + # Addresses that are real, individually-documented registers but that + # this firmware rejects as the STARTING address of a Modbus read + # (any count) -- only valid when covered by a range read that starts + # elsewhere. Confirmed empirically against real hardware for at least + # one address (Sigenergy's general_alarm7, 30281); mechanism kept + # generic since it's a real firmware behavior, not scenario data. + non_anchor_addresses: frozenset[int] = frozenset() + + _by_unit_address: dict[int, dict[int, RegisterDef]] = field(init=False, repr=False, compare=False) + _by_name: dict[str, RegisterDef] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + by_unit: dict[int, dict[int, RegisterDef]] = {} + by_name: dict[str, RegisterDef] = {} + for reg in self.registers: + by_unit.setdefault(reg.unit, {})[reg.address] = reg + by_name[reg.name] = reg + object.__setattr__(self, "_by_unit_address", by_unit) + object.__setattr__(self, "_by_name", by_name) + + @property + def units(self) -> list[int]: + return sorted(self._by_unit_address) + + def by_address(self, unit: int) -> dict[int, RegisterDef]: + return self._by_unit_address.get(unit, {}) + + def registers_for(self, unit: int) -> list[RegisterDef]: + return list(self._by_unit_address.get(unit, {}).values()) + + def find(self, unit: int, address: int) -> RegisterDef | None: + return self._by_unit_address.get(unit, {}).get(address) + + def find_by_name(self, name: str) -> RegisterDef | None: + return self._by_name.get(name) diff --git a/invforge/core/registers.py b/invforge/core/registers.py new file mode 100644 index 0000000..c5f2dd8 --- /dev/null +++ b/invforge/core/registers.py @@ -0,0 +1,94 @@ +"""Vendor-agnostic register model shared by every profile. + +A vendor profile (invforge/profiles//) supplies a list of +RegisterDef plus a default Modbus unit id; this module only knows how to +encode/decode/interpolate values generically, with no vendor-specific +knowledge. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class DType(Enum): + U16 = "U16" + S16 = "S16" + U32 = "U32" + S32 = "S32" + STRING = "STRING" + + +@dataclass(frozen=True) +class RegisterDef: + address: int + name: str + count: int # in 16-bit registers + dtype: DType + unit: int # Modbus slave/unit id this register lives on + gain: float = 1 + dynamic: bool = False # tickable by a scenario timeseries vs. fixed-at-load static + + +def encode_numeric(reg: RegisterDef, decoded_value: float) -> list[int]: + """decoded (real-world) value -> raw 16-bit register words, big-endian.""" + raw = round(decoded_value * reg.gain) + if reg.dtype in (DType.U16, DType.S16): + return [raw & 0xFFFF] + if reg.dtype in (DType.U32, DType.S32): + raw &= 0xFFFFFFFF + return [(raw >> 16) & 0xFFFF, raw & 0xFFFF] + raise ValueError(f"encode_numeric called on non-numeric register {reg.name}") + + +def encode_string(reg: RegisterDef, text: str) -> list[int]: + data = text.encode("utf-8") + data = data[: reg.count * 2].ljust(reg.count * 2, b"\x00") + return [(data[i] << 8) | data[i + 1] for i in range(0, len(data), 2)] + + +def combine_words(reg: RegisterDef, words: list[int]) -> int: + """Raw 16-bit words -> a single combined integer (sign-applied for + S32/S16, gain NOT applied) -- the natural unit to linearly interpolate + a ramp scenario in, since it's just the wire value.""" + if reg.dtype in (DType.U16, DType.S16): + v = words[0] + if reg.dtype == DType.S16 and v >= 0x8000: + v -= 0x10000 + return v + combined = (words[0] << 16) | words[1] + if reg.dtype == DType.S32 and combined >= 0x80000000: + combined -= 0x100000000 + return combined + + +def split_to_words(reg: RegisterDef, combined: int) -> list[int]: + """Inverse of combine_words.""" + if reg.dtype in (DType.U16, DType.S16): + return [combined & 0xFFFF] + combined &= 0xFFFFFFFF + return [(combined >> 16) & 0xFFFF, combined & 0xFFFF] + + +def normalize_scenario_value(reg: RegisterDef, value: int | list[int]) -> int: + """A scenario YAML numeric entry is either a bare int (single-register + fields) or a [hi, lo] word list (multi-register fields) -- always the + raw wire value, never gain-divided. Normalize either shape to the + combined-integer form combine_words()/split_to_words() work in.""" + if isinstance(value, list): + return combine_words(reg, value) + return combine_words(reg, [value & 0xFFFF]) if reg.count == 1 else value + + +def decode_numeric(reg: RegisterDef, raw_words: list[int]) -> float: + if reg.dtype in (DType.U16, DType.S16): + v = raw_words[0] + if reg.dtype == DType.S16 and v >= 0x8000: + v -= 0x10000 + else: + combined = (raw_words[0] << 16) | raw_words[1] + v = combined + if reg.dtype == DType.S32 and v >= 0x80000000: + v -= 0x100000000 + return v / reg.gain diff --git a/invforge/core/scenario.py b/invforge/core/scenario.py new file mode 100644 index 0000000..f5a17df --- /dev/null +++ b/invforge/core/scenario.py @@ -0,0 +1,161 @@ +"""Scenario YAML loading + playback (elapsed-time -> current register state). +Vendor-agnostic: takes a Profile to resolve register addresses per unit. + +Format (see any profile's scenarios/recorded/*.yaml for a real example): + + static: + unit_: {
: , ... } + timeseries: + - t: +
: # omit a register at a sample to + ... # mean "not sampled here", not + # "zero" -- carries forward. + # Applies to the profile's + # default_unit. + exceptions: + unit_: {
: {function_code: , exception_code: } } + offline: + - { start: , end: } # Modbus TCP connection + ... # is unreachable during + # each [start, end) + # window, elapsed + # scenario time. + +All numeric values are RAW wire values (pre-gain), never the decoded +real-world value -- a bare int for single-register fields, a [hi, lo] word +list for multi-register (S32/U32) fields. +""" + +from __future__ import annotations + +from bisect import bisect_right +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +import yaml + +from .profile import Profile +from .registers import DType, RegisterDef, normalize_scenario_value + + +@dataclass +class RegisterTimeseries: + reg: RegisterDef + samples: list[tuple[float, int]] = field(default_factory=list) # (t, combined_raw) + + def value_at(self, t: float) -> int: + if not self.samples: + raise ValueError(f"no samples at all for {self.reg.name}") + times = [s[0] for s in self.samples] + idx = bisect_right(times, t) - 1 + if idx < 0: + return self.samples[0][1] + if idx >= len(self.samples) - 1: + return self.samples[-1][1] + t0, v0 = self.samples[idx] + t1, v1 = self.samples[idx + 1] + if t1 == t0: + return v1 + frac = (t - t0) / (t1 - t0) + return round(v0 + frac * (v1 - v0)) + + +class Scenario: + def __init__(self, name: str, data: dict[str, object], profile: Profile) -> None: + """Build a scenario from already-loaded data -- the same shape a + scenario YAML parses into (see module docstring), whether it + actually came from YAML (via from_yaml()) or was computed on the + fly by a generator (see core/generator.py). Keeping one + constructor for both means generated and loaded scenarios are + never two implementations that could drift apart.""" + self.name = name + self.profile = profile + + self.static: dict[int, dict[int, int | str]] = {} # unit -> address -> raw + # `data`'s shape is established by its two producers (YAML parse + # in from_yaml(), or a generator's own construction) -- the casts + # below describe that established shape rather than re-validating + # a trust boundary already covered there. + raw_exceptions = cast("dict[str, dict[int, dict[str, int]]]", data.get("exceptions") or {}) + self.exceptions = {_unit_from_key(k): v for k, v in raw_exceptions.items()} + self._series: dict[int, dict[int, RegisterTimeseries]] = {} # unit -> address -> series + + raw_static = cast("dict[str, dict[int | str, object]]", data.get("static") or {}) + for unit_key, block in raw_static.items(): + unit = _unit_from_key(unit_key) + self.static[unit] = {} + for addr, value in (block or {}).items(): + reg = profile.find(unit, int(addr)) + if reg is None: + continue + if reg.dtype == DType.STRING: + self.static[unit][int(addr)] = str(value) + else: + self.static[unit][int(addr)] = normalize_scenario_value(reg, cast("int | list[int]", value)) + + # timeseries entries apply to the profile's default_unit + unit = profile.default_unit + raw_timeseries = cast("list[dict[str, object]]", data.get("timeseries") or []) + for sample in raw_timeseries: + t = float(str(sample["t"]).lstrip("~")) + for key, value in sample.items(): + if key == "t" or value is None: + continue + addr = int(key) + reg = profile.find(unit, addr) + if reg is None or reg.dtype == DType.STRING: + continue + series = self._series.setdefault(unit, {}).setdefault(addr, RegisterTimeseries(reg)) + series.samples.append((t, normalize_scenario_value(reg, cast("int | list[int]", value)))) + + for unit_series in self._series.values(): + for series in unit_series.values(): + series.samples.sort(key=lambda s: s[0]) + + raw_offline = cast("list[dict[str, float]]", data.get("offline") or []) + self.offline_windows: list[tuple[float, float]] = [] + for window in raw_offline: + start, end = float(window["start"]), float(window["end"]) + if start < 0: + raise ValueError(f"{name!r}: offline window start {start} must be >= 0") + if end <= start: + raise ValueError(f"{name!r}: offline window end {end} must be > start {start}") + self.offline_windows.append((start, end)) + + @classmethod + def from_yaml(cls, path: str | Path, profile: Profile) -> Scenario: + path = Path(path) + with path.open() as f: + data = yaml.safe_load(f) or {} + return cls(path.stem, data, profile) + + def duration(self) -> float: + max_t = 0.0 + for unit_series in self._series.values(): + for series in unit_series.values(): + if series.samples: + max_t = max(max_t, series.samples[-1][0]) + for _start, end in self.offline_windows: + max_t = max(max_t, end) + return max_t + + def is_offline_at(self, t: float) -> bool: + return any(start <= t < end for start, end in self.offline_windows) + + def value_at(self, unit: int, address: int, t: float) -> int | str | None: + """Combined raw value (or string) for one register at elapsed time + t, or None if this register isn't defined for this unit at all + (caller should then fall back to a static default / illegal + address, per the datastore's own construction).""" + series = (self._series.get(unit) or {}).get(address) + if series is not None: + return series.value_at(t) + if address in (self.static.get(unit) or {}): + return self.static[unit][address] + return None + + +def _unit_from_key(key: str) -> int: + # "unit_247" -> 247 + return int(str(key).rsplit("_", 1)[-1]) diff --git a/invforge/core/server.py b/invforge/core/server.py new file mode 100644 index 0000000..4051705 --- /dev/null +++ b/invforge/core/server.py @@ -0,0 +1,147 @@ +"""Fake Modbus TCP datastore construction and the background register +ticker. Vendor-agnostic: builds one datastore block per unit id present +in a Profile's registers. No CLI/argparse here (see __main__.py) -- this +module is a library used by the control API and by +core/connectivity.py, which owns the actual Modbus TCP server's +online/offline lifecycle. +""" + +from __future__ import annotations + +import threading +import time + +from pymodbus.datastore import ModbusSequentialDataBlock, ModbusServerContext, ModbusSlaveContext + +from .profile import Profile +from .registers import DType, encode_string, split_to_words +from .scenario import Scenario +from .state import SimState + + +class IllegalAwareBlock(ModbusSequentialDataBlock): # type: ignore[misc] + """Same as ModbusSequentialDataBlock, but with two ways an address can + still fail validation despite being in the block's numeric range: + + - `illegal_addresses`: scenario-driven (see apply_exceptions()) -- + e.g. a register a real device 400s on for a specific captured + scenario. Any address in the requested range fails the read. + - `non_anchor_addresses`: profile-driven, permanent regardless of + scenario -- addresses real firmware rejects as a read's STARTING + address specifically (confirmed empirically for at least one + Sigenergy register), even though covering them via a range read + that starts elsewhere works fine. Only the requested start address + is checked against this set, not the whole range. + """ + + def __init__( + self, + address: int, + values: list[int], + illegal_addresses: set[int] | None = None, + non_anchor_addresses: frozenset[int] = frozenset(), + ) -> None: + super().__init__(address, values) + self.illegal_addresses = illegal_addresses or set() + self.non_anchor_addresses = non_anchor_addresses + + def validate(self, address: int, count: int = 1) -> bool: + for a in range(address, address + count): + if a in self.illegal_addresses: + return False + if address in self.non_anchor_addresses: + return False + return bool(super().validate(address, count)) + + +def build_blocks(profile: Profile) -> dict[int, IllegalAwareBlock]: + """Empty (all-zero) datastore block per unit id in the profile. Illegal + addresses (e.g. a register a real device 400s on) get set separately + via apply_exceptions(), since they can vary per scenario.""" + blocks: dict[int, IllegalAwareBlock] = {} + for unit in profile.units: + regs = profile.registers_for(unit) + start = min(r.address for r in regs) + end = max(r.address + r.count for r in regs) # exclusive + blocks[unit] = IllegalAwareBlock(start, [0] * (end - start), non_anchor_addresses=profile.non_anchor_addresses) + return blocks + + +def build_context(blocks: dict[int, IllegalAwareBlock]) -> ModbusServerContext: + slaves = {unit: ModbusSlaveContext(hr=block, zero_mode=True) for unit, block in blocks.items()} + return ModbusServerContext(slaves=slaves, single=False) + + +def apply_exceptions(blocks: dict[int, IllegalAwareBlock], scenario: Scenario) -> None: + for unit, block in blocks.items(): + block.illegal_addresses = set((scenario.exceptions.get(unit) or {}).keys()) + + +def write_scenario_statics(blocks: dict[int, IllegalAwareBlock], profile: Profile, scenario: Scenario) -> None: + """One-time write of every register's t=0 value (covers both + genuinely-static registers and any dynamic register the scenario only + defined in its `static:` block) plus any identification strings.""" + for unit, block in blocks.items(): + for reg in profile.registers_for(unit): + if reg.dtype == DType.STRING: + text = (scenario.static.get(unit) or {}).get(reg.address) + if text is None: + continue + block.setValues(reg.address, encode_string(reg, str(text))) + else: + raw = scenario.value_at(unit, reg.address, 0.0) + if raw is None: + continue + assert isinstance(raw, int), f"{reg.name} is non-STRING but scenario value is {type(raw)}" + block.setValues(reg.address, split_to_words(reg, raw)) + + +def write_manual_registers( + blocks: dict[int, IllegalAwareBlock], profile: Profile, unit: int, values: dict[int, int] +) -> None: + """POST /state -- direct, immediate write, no scenario/interpolation.""" + block = blocks.get(unit) + if block is None: + return + for address, raw in values.items(): + reg = profile.find(unit, address) + if reg is None or reg.dtype == DType.STRING: + continue + block.setValues(reg.address, split_to_words(reg, raw)) + + +def elapsed_seconds(scenario: Scenario, start_time: float, speed: float) -> float: + """Elapsed time into a scenario's playback, wrapped to its duration + once it loops -- the one time model both the register ticker and + core/connectivity.py's offline-window watcher use, so they can never + disagree about "what time is it" within a running scenario.""" + elapsed = (time.monotonic() - start_time) * speed + duration = scenario.duration() + if duration > 0 and elapsed > duration: + elapsed = elapsed % duration + return elapsed + + +def run_ticker( + state: SimState, + blocks: dict[int, IllegalAwareBlock], + profile: Profile, + tick_interval: float, + stop_event: threading.Event, +) -> None: + while not stop_event.is_set(): + mode, scenario, start_time, speed = state.snapshot() + if mode == "scenario" and scenario is not None: + elapsed = elapsed_seconds(scenario, start_time, speed) + for unit, block in blocks.items(): + for reg in profile.registers_for(unit): + if not reg.dynamic: + continue + raw = scenario.value_at(unit, reg.address, elapsed) + if raw is None: + continue + assert isinstance(raw, int), f"{reg.name} is dynamic but scenario value is {type(raw)}" + block.setValues(reg.address, split_to_words(reg, raw)) + # mode == "manual": ticker does nothing, datastore already holds + # whatever /state wrote directly. + stop_event.wait(tick_interval) diff --git a/invforge/core/state.py b/invforge/core/state.py new file mode 100644 index 0000000..4dbcd0f --- /dev/null +++ b/invforge/core/state.py @@ -0,0 +1,82 @@ +"""Shared, thread-safe simulator state -- the Modbus ticker thread reads +it, the HTTP control API mutates it. Two modes: + +- "scenario": ticker advances elapsed wall-clock time (scaled by `speed`) + through a loaded Scenario's timeseries, looping at its duration. +- "manual": ticker does nothing; POST /state writes directly into the + datastore and freezes it there until the next /scenario or /state call. + This is what lets a test jump straight to "SoC=5%" without waiting + through a ramp. +""" + +from __future__ import annotations + +import threading +import time +from pathlib import Path + +from .profile import Profile +from .scenario import Scenario + + +class SimState: + def __init__(self) -> None: + self.lock = threading.Lock() + self.mode = "manual" # start frozen/all-zero until a scenario or state is set + self.scenario: Scenario | None = None + self.scenario_start = time.monotonic() + self.speed = 1.0 + + def set_scenario(self, scenario: Scenario, speed: float = 1.0) -> None: + with self.lock: + self.scenario = scenario + self.scenario_start = time.monotonic() + self.speed = speed + self.mode = "scenario" + + def freeze_manual(self) -> None: + with self.lock: + self.mode = "manual" + + def snapshot(self) -> tuple[str, Scenario | None, float, float]: + with self.lock: + return self.mode, self.scenario, self.scenario_start, self.speed + + +def list_scenarios(scenarios_dir: Path) -> dict[str, str]: + """name -> relative path, for both recorded/ and synthetic/ under a + profile's scenarios_dir.""" + found = {} + for sub in ("recorded", "synthetic"): + d = scenarios_dir / sub + if not d.is_dir(): + continue + for f in sorted(d.glob("*.yaml")): + found[f.stem] = str(f.relative_to(scenarios_dir.parent)) + return found + + +def resolve_scenario_path(scenarios_dir: Path, name: str) -> Path: + found = list_scenarios(scenarios_dir) + if name not in found: + raise KeyError(f"unknown scenario {name!r}, available: {sorted(found)}") + return scenarios_dir.parent / found[name] + + +def resolve_scenario(name: str, profile: Profile) -> Scenario: + """Resolve a scenario by name: the static YAML library is tried + first -- an existing fixture always wins on a name collision, a + deliberate escape hatch -- then the parametric generator (see + core/generator.py). Raises KeyError if neither resolves the name, + ValueError if the generator matched the name but the ramp it + describes is invalid.""" + try: + path = resolve_scenario_path(profile.scenarios_dir, name) + except KeyError: + from .generator import resolve_generated + + generated = resolve_generated(name, profile) + if generated is not None: + return generated + raise + return Scenario.from_yaml(path, profile) diff --git a/invforge/profiles/__init__.py b/invforge/profiles/__init__.py new file mode 100644 index 0000000..893687c --- /dev/null +++ b/invforge/profiles/__init__.py @@ -0,0 +1,46 @@ +"""Vendor/firmware profile registry. Each vendor subpackage exposes a +module-level `FIRMWARES: dict[str, invforge.core.profile.Profile]`, +keyed by the exact firmware string that Profile's own `firmware` field +carries; add it to `_load()` below to register it under `--vendor +`. +""" + +from __future__ import annotations + +from invforge.core.profile import Profile + + +def _load() -> dict[str, dict[str, Profile]]: + from . import sigenergy + + return {"sigenergy": sigenergy.FIRMWARES} + + +VENDORS: dict[str, dict[str, Profile]] = _load() + + +def get(vendor: str, firmware: str) -> Profile: + try: + firmwares = VENDORS[vendor] + except KeyError: + raise KeyError(f"unknown vendor {vendor!r}, available: {sorted(VENDORS)}") from None + try: + return firmwares[firmware] + except KeyError: + raise KeyError(f"unknown firmware {firmware!r} for vendor {vendor!r}, available: {sorted(firmwares)}") from None + + +def default_firmware(vendor: str) -> str: + """Only resolvable when a vendor has exactly one confirmed firmware -- + once a second one is registered, --firmware becomes effectively + required rather than silently guessed.""" + try: + firmwares = VENDORS[vendor] + except KeyError: + raise KeyError(f"unknown vendor {vendor!r}, available: {sorted(VENDORS)}") from None + if len(firmwares) != 1: + raise KeyError( + f"vendor {vendor!r} has {len(firmwares)} known firmwares, --firmware is required, " + f"available: {sorted(firmwares)}" + ) + return next(iter(firmwares)) diff --git a/invforge/profiles/sigenergy/__init__.py b/invforge/profiles/sigenergy/__init__.py new file mode 100644 index 0000000..9b3b225 --- /dev/null +++ b/invforge/profiles/sigenergy/__init__.py @@ -0,0 +1,10 @@ +from invforge.core.profile import Profile + + +def _load_firmwares() -> dict[str, Profile]: + from .firmwares import V100R001C21SPC116 + + return {p.firmware: p for p in [V100R001C21SPC116.PROFILE]} + + +FIRMWARES: dict[str, Profile] = _load_firmwares() diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/__init__.py b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/__init__.py new file mode 100644 index 0000000..8b66830 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path + +from invforge.core.profile import Profile + +from .ramps import build_ramp_scenario_data +from .registers import DEFAULT_UNIT, REGISTERS + +FIRMWARE = "V100R001C21SPC116" + +# Confirmed empirically against real hardware (2026-08-14): a solo +# (count=1) read starting at 30281 (general_alarm7) fails with +# IllegalAddress, but a range read covering it that starts at or before +# 30280 succeeds. Unexplained by the spec text; reproduced here rather +# than silently working around it. See registers.py's module docstring. +NON_ANCHOR_ADDRESSES = frozenset({30281}) + +PROFILE = Profile( + name="sigenergy", + firmware=FIRMWARE, + registers=REGISTERS, + default_unit=DEFAULT_UNIT, + scenarios_dir=Path(__file__).parent / "scenarios", + ramp_builder=build_ramp_scenario_data, + non_anchor_addresses=NON_ANCHOR_ADDRESSES, +) diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py new file mode 100644 index 0000000..7ccf753 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py @@ -0,0 +1,73 @@ +"""Parametric ramp-scenario data builder for firmware V100R001C21SPC116 +(see invforge/core/generator.py). Returns the same static:/timeseries: +shape a hand-written scenario YAML parses into -- Scenario itself can't +tell the difference between this and a loaded YAML. + +Power for the ramp is derived from the rated capacity and the SoC delta +over the requested duration (constant-power discharge/charge assumption) +rather than a hand-picked constant -- physically consistent with the +SoC ramp it accompanies. Since only two timeseries samples are given +(start, end) as elsewhere in this profile's synthetic fixtures, the +reported power interpolates linearly from that constant value down to 0 +over the ramp rather than staying flat until the very last instant -- an +accepted simplification; battery.charge tracking is the primary signal +these scenarios exercise, not power-reading fidelity. +""" + +from __future__ import annotations + +from invforge.core.generator import RampParams + +_RATED_CAPACITY_KWH = 18.0 # matches this firmware's other synthetic fixtures' 30083 (gain 100 -> raw 1800) + + +def build_ramp_scenario_data(params: RampParams) -> dict[str, object]: + energy_used_kwh = _RATED_CAPACITY_KWH * abs(params.start_pct - params.end_pct) / 100.0 + power_kw = energy_used_kwh / (params.duration_s / 3600.0) + sign = -1 if params.direction == "drain" else 1 + power_raw = sign * round(power_kw * 1000) + + return { + "static": { + "unit_247": { + 30047: 8800, # ess_avail_max_charge_power (gain 1000 -> 8.8 kW) + 30049: 9600, # ess_avail_max_discharge_power (gain 1000 -> 9.6 kW) + 30064: 1800, # ess_avail_max_charge_capacity (gain 100 -> 18.0 kWh) + 30066: 1800, # ess_avail_max_discharge_capacity (gain 100 -> 18.0 kWh) + 30068: 8800, # ess_rated_charge_power + 30070: 9600, # ess_rated_discharge_power + 30083: 1800, # ess_rated_energy_capacity (gain 100 -> 18.0 kWh) + 30085: 1000, # ess_charge_cutoff_soc (gain 10 -> 100.0%) + 30086: 100, # ess_discharge_cutoff_soc (gain 10 -> 10.0%) + 30087: 980, # ess_soh (gain 10 -> 98.0%) + }, + "unit_1": { + 30500: "SigenStor SYNTHETIC-GENERATED", + 30515: "SYNTH-GEN-0001", + 30525: "0.0.0-synthetic", + }, + }, + "timeseries": [ + { + "t": 0, + 30004: 1, # grid_sensor_status + 30005: 0, # grid_active_power + 30009: 1, # on_off_grid_status (off-grid/EPS) + 30014: round(params.start_pct * 10), # ess_soc + 30027: 0, + 30028: 0, + 30029: 0, + 30030: 0, + 30035: 0, # pv_plant_power + 30037: power_raw, # ess_power + 30051: 1, # plant_running_state + 30072: 0, + 30280: 0, + }, + { + "t": params.duration_s, + 30014: round(params.end_pct * 10), # ess_soc + 30037: 0, # ess_power -- ramp complete, no further charge/discharge + }, + ], + } diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/registers.py b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/registers.py new file mode 100644 index 0000000..703d3c3 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/registers.py @@ -0,0 +1,116 @@ +"""Sigenergy SigenStor register table, unit 247 ("plant") and unit 1 +("inverter"). Sourced from the official Sigenergy Modbus Protocol spec +(V2.5/V2.8/V2.9, see ../../../../../../sigennut/docs/register-map.md +and docs/vendor/ in that same repo for the full cross-version +verification) and cross-checked against real device captures (SigenStor +EC 12.0 TP, serial CMU110A144L0174, this firmware). Deliberately not the +full spec (~280 registers across both units, including PSS/PID/AC- and +DC-Charger sections, 24 smart loads, all 36 PV string slots) -- this is +the subset actually relevant to a UPS/battery-backup driver, matching +what this specific real installation has (3 PV strings, 2 battery +packs, 3-phase L1/L2/L3/N output, no smart-load metering, no grid CT +configured). + +30281 (general_alarm7) is deliberately marked `dynamic` and included as +a real register -- earlier revisions of this table excluded it entirely +based on it always failing a solo (count=1) read. That conclusion was +wrong: the spec documents it as a real, independent register, and it +decodes real data (0xFFFF, an idle/no-alarm sentinel) when read as part +of a range starting at or before it. The actual firmware behavior -- +confirmed empirically, unexplained by the spec text itself -- is that +this specific address is rejected as a read's *starting* address, even +though it's a legitimate register. See this profile's `__init__.py`, +which registers 30281 in `Profile.non_anchor_addresses` to reproduce +that quirk in the simulator rather than papering over it. +""" + +from __future__ import annotations + +from invforge.core.registers import DType, RegisterDef + +PLANT_UNIT = 247 +INVERTER_UNIT = 1 +DEFAULT_UNIT = PLANT_UNIT + +REGISTERS: list[RegisterDef] = [ + # ---- Unit 247 ("plant") -- static: ratings/config, unchanging within a scenario ---- + RegisterDef(30047, "ess_avail_max_charge_power", 2, DType.U32, PLANT_UNIT, gain=1000), + RegisterDef(30049, "ess_avail_max_discharge_power", 2, DType.U32, PLANT_UNIT, gain=1000), + RegisterDef(30064, "ess_avail_max_charge_capacity", 2, DType.U32, PLANT_UNIT, gain=100), + RegisterDef(30066, "ess_avail_max_discharge_capacity", 2, DType.U32, PLANT_UNIT, gain=100), + RegisterDef(30068, "ess_rated_charge_power", 2, DType.U32, PLANT_UNIT, gain=1000), + RegisterDef(30070, "ess_rated_discharge_power", 2, DType.U32, PLANT_UNIT, gain=1000), + RegisterDef(30083, "ess_rated_energy_capacity", 2, DType.U32, PLANT_UNIT, gain=100), + RegisterDef(30085, "ess_charge_cutoff_soc", 1, DType.U16, PLANT_UNIT, gain=10), + RegisterDef(30086, "ess_discharge_cutoff_soc", 1, DType.U16, PLANT_UNIT, gain=10), + RegisterDef(30087, "ess_soh", 1, DType.U16, PLANT_UNIT, gain=10), + # [Grid code] rated values -- configured/nameplate, NOT live measurements + # (confirmed by spec text + cross-version diff, see register-map.md). + RegisterDef(30276, "grid_code_rated_frequency", 1, DType.U16, PLANT_UNIT, gain=100), + RegisterDef(30277, "grid_code_rated_voltage", 2, DType.U32, PLANT_UNIT, gain=100), + # ---- Unit 247 ("plant") -- dynamic: worth varying over a scenario's timeseries ---- + RegisterDef(30004, "grid_sensor_status", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30005, "grid_active_power", 2, DType.S32, PLANT_UNIT, gain=1000, dynamic=True), + RegisterDef(30009, "on_off_grid_status", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30014, "ess_soc", 1, DType.U16, PLANT_UNIT, gain=10, dynamic=True), + RegisterDef(30027, "general_alarm1", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30028, "general_alarm2", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30029, "general_alarm3", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30030, "general_alarm4", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30035, "pv_plant_power", 2, DType.S32, PLANT_UNIT, gain=1000, dynamic=True), + RegisterDef(30037, "ess_power", 2, DType.S32, PLANT_UNIT, gain=1000, dynamic=True), + RegisterDef(30051, "plant_running_state", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30072, "general_alarm5", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30272, "pv_total_daily_generation", 2, DType.U32, PLANT_UNIT, gain=100, dynamic=True), + RegisterDef(30274, "pv_total_generation_previous_day", 2, DType.U32, PLANT_UNIT, gain=100, dynamic=True), + RegisterDef(30279, "current_control_command_value", 1, DType.U16, PLANT_UNIT, gain=100, dynamic=True), + RegisterDef(30280, "general_alarm6", 1, DType.U16, PLANT_UNIT, dynamic=True), + RegisterDef(30281, "general_alarm7", 1, DType.U16, PLANT_UNIT, dynamic=True), # see module docstring + RegisterDef(30282, "general_load_power", 2, DType.S32, PLANT_UNIT, gain=1000, dynamic=True), + RegisterDef(30284, "total_load_power", 2, DType.S32, PLANT_UNIT, gain=1000, dynamic=True), + RegisterDef(30286, "ess_average_cell_temperature", 1, DType.S16, PLANT_UNIT, gain=10, dynamic=True), + # ---- Unit 1 ("inverter") -- identification strings ---- + RegisterDef(30500, "inverter_model_type", 15, DType.STRING, INVERTER_UNIT), + RegisterDef(30515, "inverter_serial_number", 10, DType.STRING, INVERTER_UNIT), + RegisterDef(30525, "inverter_firmware_version", 15, DType.STRING, INVERTER_UNIT), + # ---- Unit 1 ("inverter") -- static: ratings/hardware inventory ---- + RegisterDef(30540, "inverter_rated_active_power", 2, DType.U32, INVERTER_UNIT, gain=1000), + RegisterDef(31000, "rated_grid_voltage", 1, DType.U16, INVERTER_UNIT, gain=10), + RegisterDef(31001, "rated_grid_frequency", 1, DType.U16, INVERTER_UNIT, gain=100), + RegisterDef(31004, "output_type", 1, DType.U16, INVERTER_UNIT), + RegisterDef(31024, "pack_count", 1, DType.U16, INVERTER_UNIT), + RegisterDef(31025, "pv_string_count", 1, DType.U16, INVERTER_UNIT), + RegisterDef(31026, "mppt_count", 1, DType.U16, INVERTER_UNIT), + # ---- Unit 1 ("inverter") -- dynamic: live telemetry ---- + RegisterDef(30578, "inverter_running_state", 1, DType.U16, INVERTER_UNIT, dynamic=True), + RegisterDef(30587, "inverter_active_power", 2, DType.S32, INVERTER_UNIT, gain=1000, dynamic=True), + RegisterDef(30599, "ess_charge_discharge_power", 2, DType.S32, INVERTER_UNIT, gain=1000, dynamic=True), + RegisterDef(30601, "ess_battery_soc", 1, DType.U16, INVERTER_UNIT, gain=10, dynamic=True), + RegisterDef(30602, "ess_battery_soh", 1, DType.U16, INVERTER_UNIT, gain=10, dynamic=True), + RegisterDef(30603, "ess_average_cell_temperature_inv", 1, DType.S16, INVERTER_UNIT, gain=10, dynamic=True), + RegisterDef(30604, "ess_average_cell_voltage", 1, DType.U16, INVERTER_UNIT, gain=1000, dynamic=True), + RegisterDef(30605, "inverter_alarm1", 1, DType.U16, INVERTER_UNIT, dynamic=True), + RegisterDef(30606, "inverter_alarm2", 1, DType.U16, INVERTER_UNIT, dynamic=True), + RegisterDef(30607, "inverter_alarm3", 1, DType.U16, INVERTER_UNIT, dynamic=True), + RegisterDef(30608, "inverter_alarm4", 1, DType.U16, INVERTER_UNIT, dynamic=True), + RegisterDef(30609, "inverter_alarm5", 1, DType.U16, INVERTER_UNIT, dynamic=True), + RegisterDef(31002, "grid_frequency", 1, DType.U16, INVERTER_UNIT, gain=100, dynamic=True), # live + RegisterDef(31003, "pcs_internal_temperature", 1, DType.S16, INVERTER_UNIT, gain=10, dynamic=True), # live + RegisterDef(31011, "phase_a_voltage", 2, DType.U32, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31013, "phase_b_voltage", 2, DType.U32, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31015, "phase_c_voltage", 2, DType.U32, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31017, "phase_a_current", 2, DType.S32, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31019, "phase_b_current", 2, DType.S32, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31021, "phase_c_current", 2, DType.S32, INVERTER_UNIT, gain=100, dynamic=True), + # PV1-3 only: this real installation has pv_string_count=3 (31025). + RegisterDef(31027, "pv1_voltage", 1, DType.S16, INVERTER_UNIT, gain=10, dynamic=True), + RegisterDef(31028, "pv1_current", 1, DType.S16, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31029, "pv2_voltage", 1, DType.S16, INVERTER_UNIT, gain=10, dynamic=True), + RegisterDef(31030, "pv2_current", 1, DType.S16, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31031, "pv3_voltage", 1, DType.S16, INVERTER_UNIT, gain=10, dynamic=True), + RegisterDef(31032, "pv3_current", 1, DType.S16, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31035, "pv_power", 2, DType.S32, INVERTER_UNIT, gain=1000, dynamic=True), + RegisterDef(31037, "insulation_resistance", 1, DType.U16, INVERTER_UNIT, gain=1000, dynamic=True), + RegisterDef(31509, "pv_daily_generation", 2, DType.U32, INVERTER_UNIT, gain=100, dynamic=True), + RegisterDef(31511, "pv_total_generation", 2, DType.U32, INVERTER_UNIT, gain=100, dynamic=True), +] diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-idle-disconnected.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-idle-disconnected.yaml new file mode 100644 index 0000000..ca13071 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-idle-disconnected.yaml @@ -0,0 +1,170 @@ +# Real register readings, captured live via tools/modbus-probe-go (record mode) +# at 2026-08-13T20:50:50+02:00 from 10.10.8.243:502. Read-only capture, no writes issued. +# +# Every sample here was captured via a solo (count=1) read per register, +# which is why 30281 (general_alarm7) is absent from every timeseries +# entry below -- a solo read to that address returns illegal-address on +# the real device. That's a firmware quirk about the read's starting +# address, not proof the register doesn't exist -- see +# 2026-08-13-live-eps-discharge.yaml's static block comment and +# registers.py's module docstring for the full explanation, now +# reproduced simulator-wide via Profile.non_anchor_addresses. + +static: + unit_247: + 30047: 8800 # ess_avail_max_charge_power (decoded: 8.8) + 30049: 14400 # ess_avail_max_discharge_power (decoded: 14.4) + 30064: 1185 # ess_avail_max_charge_capacity (decoded: 11.85) + 30066: 676 # ess_avail_max_discharge_capacity (decoded: 6.76) + 30068: 8800 # ess_rated_charge_power (decoded: 8.8) + 30070: 9600 # ess_rated_discharge_power (decoded: 9.6) + 30083: 1808 # ess_rated_energy_capacity (decoded: 18.08) + 30085: 1000 # ess_charge_cutoff_soc (decoded: 100) + 30086: 0 # ess_discharge_cutoff_soc (decoded: 0) + 30087: 1000 # ess_soh (decoded: 100) + unit_1: + 30500: "SigenStor EC 12.0 TP" # inverter_model_type + 30515: "CMU110A144L0174" # inverter_serial_number + 30525: "V100R001C21SPC116" # inverter_firmware_version + +timeseries: + - t: 0.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -156 # ess_power (decoded: -0.156) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 2.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -138 # ess_power (decoded: -0.138) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 4.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -146 # ess_power (decoded: -0.146) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 6.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -158 # ess_power (decoded: -0.158) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 8.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -157 # ess_power (decoded: -0.157) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 10.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -150 # ess_power (decoded: -0.15) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 12.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -151 # ess_power (decoded: -0.151) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 14.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -147 # ess_power (decoded: -0.147) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 16.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -165 # ess_power (decoded: -0.165) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) + - t: 18.0 + 30004: 0 # grid_sensor_status (decoded: 0) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0) + 30014: 363 # ess_soc (decoded: 36.3) + 30027: 0 # general_alarm1 (decoded: 0) + 30028: 0 # general_alarm2 (decoded: 0) + 30029: 0 # general_alarm3 (decoded: 0) + 30030: 0 # general_alarm4 (decoded: 0) + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -148 # ess_power (decoded: -0.148) + 30051: 1 # plant_running_state (decoded: 1) + 30072: 0 # general_alarm5 (decoded: 0) + 30280: 0 # general_alarm6 (decoded: 0) diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-live-eps-discharge.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-live-eps-discharge.yaml new file mode 100644 index 0000000..cc61259 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-13-live-eps-discharge.yaml @@ -0,0 +1,74 @@ +# Real register readings, captured live from a SigenStor EC 12.0 TP +# (serial CMU110A144L0174) at 10.10.8.243 on 2026-08-13, while it was +# actually running off-grid on battery (not a synthetic scenario). +# Device was powered off after capture (driver implementation incomplete). +# +# Purpose: the simulator's first scenario should replay *real* behavior, +# not just invented values -- this is ground truth to test the driver +# against before any synthetic ramp/edge-case scenarios are trusted. +# +# Static registers (did not change across the capture window): +static: + unit_247: + 30051: 1 # plant_running_state -- single real sample, meaning still TBD (R2) + 30027: 0 # general_alarm1 + 30028: 0 # general_alarm2 + 30029: 0 # general_alarm3 + 30030: 0 # general_alarm4 + 30072: 0 # general_alarm5 + 30280: 0 # general_alarm6 + # 30281 (general_alarm7): a solo (count=1) read returns + # exception_code=2 (illegal data address) on the real device -- but + # this is a firmware quirk about the read's *starting* address, not + # about the register not existing (it decodes real data, 0xFFFF at + # capture time, when read as part of a range starting at/before + # 30280). Reproduced profile-wide via Profile.non_anchor_addresses + # (see registers.py / this firmware's __init__.py), not as a + # per-scenario exception here -- a blanket "always illegal" entry in + # this scenario's `exceptions:` block would incorrectly reject valid + # range reads too, which real hardware does not. + 30047: 8812 # ess_avail_max_charge_power (raw, gain 1000 -> 8.812 kW) + 30049: 14422 # ess_avail_max_discharge_power (raw, gain 1000 -> 14.422 kW) + 30064: 1135 # ess_avail_max_charge_capacity (raw, gain 100 -> 11.35 kWh) + 30066: 708 # ess_avail_max_discharge_capacity (raw, gain 100 -> 7.08 kWh) + 30068: 8800 # ess_rated_charge_power (raw, gain 1000 -> 8.8 kW) + 30070: 9600 # ess_rated_discharge_power (raw, gain 1000 -> 9.6 kW) + 30083: 1808 # ess_rated_energy_capacity (raw, gain 100 -> 18.08 kWh) + 30085: 1000 # ess_charge_cutoff_soc (raw, gain 10 -> 100.0%) + 30086: 0 # ess_discharge_cutoff_soc (raw, gain 10 -> 0.0% -- no reserve floor configured) + 30087: 1000 # ess_soh (raw, gain 10 -> 100.0%) + unit_1: + 30500: "SigenStor EC 12.0 TP" # inverter_model_type + 30515: "CMU110A144L0174" # inverter_serial_number + +# Time-series samples, unit 247, as actually observed (elapsed seconds +# from first read). grid_sensor_status stayed 1 ("connected", i.e. the +# external CT/meter accessory is present -- NOT "grid power available", +# see docs/PRD.md's corrected mapping) throughout; on_off_grid_status +# stayed 1 (off-grid/EPS) throughout -- this device was genuinely +# islanded and discharging the whole capture window, not a transition. +timeseries: + - t: 0 + 30004: 1 # grid_sensor_status: connected (sensor present, NOT grid power) + 30009: 1 # on_off_grid_status: off-grid (auto/EPS) + 30014: 384 # ess_soc raw (gain 10 -> 38.4%) + 30037: [65535, 63519] # ess_power raw regs, S32 gain 1000 -> -2.017 kW (discharging) + - t: ~15 # approx, second probe run shortly after the first + 30004: 1 + 30009: 1 + 30014: 382 # -> 38.2% + 30037: null # not re-read at this sample point + - t: ~30 # approx, third probe run + 30004: 1 + 30009: 1 + 30014: 377 # -> 37.7% + 30037: [65535, 63541] # -> -1.946 kW + 30005: [0, 0] # grid_sensor_active_power -> 0 kW (off-grid, no import/export) + 30035: [0, 0] # pv_plant_power -> 0 kW (no solar at capture time) + +notes: | + Derived at the time: ~1.95 kW continuous load, 100% sourced from the + battery (grid=0, PV=0). At ~7.08 kWh available discharge capacity and + ~1.95 kW draw, that's roughly 3.5h runtime remaining at that rate -- + a real, human-sanity-checked number, useful as a reference point for + validating the driver's own battery.runtime estimate formula later. diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-14-idle-full-soc.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-14-idle-full-soc.yaml new file mode 100644 index 0000000..dc8e8d4 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/recorded/2026-08-14-idle-full-soc.yaml @@ -0,0 +1,101 @@ +# Real register readings, captured live from sigenergy-inverter-0.energy.mb:502 +# on 2026-08-14 across several read-only sessions during the same steady +# idle state (battery full, no load, no PV, not yet grid-tied/commissioned +# for export). Not a single atomic register dump -- unit 1's fields (which +# can't vary over this engine's timeseries, only unit 247's default-unit +# registers can) are frozen at one representative moment; unit 247's +# timeseries samples below carry the small real sensor jitter actually +# observed across multiple reads a few seconds apart, not invented noise. +# +# Notable real detail worth preserving, not smoothing away: the inverter's +# own lifetime PV total generation (31511-31512) reads only ~12.9 kWh -- +# consistent with a very recently commissioned installation, matching the +# "can't export to grid yet" state this capture was taken in. +static: + unit_247: + 30047: 0 # ess_avail_max_charge_power (decoded: 0) + 30049: 14401 # ess_avail_max_discharge_power (decoded: 14.401) + 30064: 0 # ess_avail_max_charge_capacity (decoded: 0) + 30066: 1927 # ess_avail_max_discharge_capacity (decoded: 19.27) + 30068: 8800 # ess_rated_charge_power (decoded: 8.8) + 30070: 9600 # ess_rated_discharge_power (decoded: 9.6) + 30083: 1808 # ess_rated_energy_capacity (decoded: 18.08) + 30085: 1000 # ess_charge_cutoff_soc (decoded: 100) + 30086: 0 # ess_discharge_cutoff_soc (decoded: 0) + 30087: 1000 # ess_soh (decoded: 100) + 30276: 5000 # grid_code_rated_frequency (decoded: 50.00 Hz -- configured, not live) + 30277: 23000 # grid_code_rated_voltage (decoded: 230.00 V -- configured, not live) + unit_1: + 30500: "SigenStor EC 12.0 TP" # inverter_model_type + 30515: "CMU110A144L0174" # inverter_serial_number + 30525: "V100R001C21SPC116" # inverter_firmware_version + 30540: 12000 # inverter_rated_active_power (decoded: 12.0 -- matches "EC 12.0" nameplate) + 31000: 2300 # rated_grid_voltage (decoded: 230.0) + 31001: 5000 # rated_grid_frequency (decoded: 50.00) + 31004: 2 # output_type (decoded: 2 = L1/L2/L3/N) + 31024: 2 # pack_count (2 battery packs) + 31025: 3 # pv_string_count (3 PV strings) + 31026: 3 # mppt_count + 30578: 1 # inverter_running_state (decoded: 1 = Running) + 30587: 0 # inverter_active_power (decoded: 0.0) + 30599: -3 # ess_charge_discharge_power (decoded: -0.003) + 30601: 1000 # ess_battery_soc (decoded: 100.0) + 30602: 1000 # ess_battery_soh (decoded: 100.0) + 30603: 380 # ess_average_cell_temperature_inv (decoded: 38.0) + 30604: 3404 # ess_average_cell_voltage (decoded: 3.404 V) + 30605: 0 # inverter_alarm1 + 30606: 0 # inverter_alarm2 + 30607: 0 # inverter_alarm3 + 30608: 0 # inverter_alarm4 + 30609: 0 # inverter_alarm5 + 31002: 5001 # grid_frequency (decoded: 50.01 -- live measurement, real ~50Hz jitter) + 31003: 546 # pcs_internal_temperature (decoded: 54.6) + 31011: 24041 # phase_a_voltage (decoded: 240.41) + 31013: 23854 # phase_b_voltage (decoded: 238.54) + 31015: 23508 # phase_c_voltage (decoded: 235.08) + 31017: 47 # phase_a_current (decoded: 0.47 -- negligible, matches ~zero measured load) + 31019: 47 # phase_b_current (decoded: 0.47) + 31021: 46 # phase_c_current (decoded: 0.46) + 31027: 5765 # pv1_voltage (decoded: 576.5 -- open-circuit-shaped, near-zero current) + 31028: 9 # pv1_current (decoded: 0.09) + 31029: 5144 # pv2_voltage (decoded: 514.4) + 31030: 13 # pv2_current (decoded: 0.13) + 31031: 0 # pv3_voltage (decoded: 0.0 -- string 3 reads no data at capture time) + 31032: 0 # pv3_current (decoded: 0.0) + 31035: 0 # pv_power (decoded: 0.0) + 31037: 668 # insulation_resistance (decoded: 0.668 MOhm) + 31509: 1289 # pv_daily_generation (decoded: 12.89 kWh) + 31511: 1290 # pv_total_generation (decoded: 12.90 kWh -- lifetime total, very new install) + +timeseries: + - t: 0.0 + 30004: 0 # grid_sensor_status (decoded: 0 -- no CT/meter accessory configured) + 30005: 0 # grid_active_power (decoded: 0) + 30009: 0 # on_off_grid_status (decoded: 0 = on grid; see sigennut/docs/PRD.md R11) + 30014: 1000 # ess_soc (decoded: 100) + 30027: 0 # general_alarm1 + 30028: 0 # general_alarm2 + 30029: 0 # general_alarm3 + 30030: 0 # general_alarm4 + 30035: 0 # pv_plant_power (decoded: 0) + 30037: -3 # ess_power (decoded: -0.003) + 30051: 1 # plant_running_state (decoded: 1 = Running) + 30072: 0 # general_alarm5 + 30272: 1289 # pv_total_daily_generation (decoded: 12.89 kWh) + 30274: 0 # pv_total_generation_previous_day (decoded: 0 -- very new install) + 30279: 65535 # current_control_command_value (Japan remote-output-control register, unpopulated sentinel here) + 30280: 0 # general_alarm6 + 30281: 65535 # general_alarm7 (0xFFFF sentinel; solo-read-illegal on the real device, see registers.py) + 30282: 0 # general_load_power (decoded: 0.0) + 30284: 0 # total_load_power (decoded: 0.0) + 30286: 379 # ess_average_cell_temperature (decoded: 37.9) + - t: 10.0 + 30035: 6 # pv_plant_power (decoded: 0.006 -- real jitter observed a few seconds later) + 30037: -4 # ess_power (decoded: -0.004) + 30272: 1290 # pv_total_daily_generation (decoded: 12.90 kWh) + 30286: 380 # ess_average_cell_temperature (decoded: 38.0) + - t: 20.0 + 30035: 0 # pv_plant_power (decoded: 0.0) + 30037: -3 # ess_power (decoded: -0.003) + 30272: 1290 + 30286: 379 diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-pv-power-negative.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-pv-power-negative.yaml new file mode 100644 index 0000000..0a16c02 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-pv-power-negative.yaml @@ -0,0 +1,39 @@ +# Synthetic scenario -- NOT captured from a real device. Deliberately +# wire-legal but semantically impossible: pv_plant_power (S32, gain +# 1000) can represent negative values on the wire, but PV generation is +# physically magnitude-only -- a real panel/inverter fault or a +# corrupted read could hand a driver a negative value with no +# protocol-level error. Chosen over ess_power/grid_active_power, which +# are legitimately bidirectional by design (charge/discharge, +# import/export) -- this is the clean "negative where it shouldn't be" +# case. Exercises whether a NUT driver clamps/rejects it rather than +# propagating a nonsensical negative generation figure downstream. +static: + unit_247: + 30047: 8800 # ess_avail_max_charge_power (gain 1000 -> 8.8 kW) + 30049: 9600 # ess_avail_max_discharge_power (gain 1000 -> 9.6 kW) + 30064: 1800 # ess_avail_max_charge_capacity (gain 100 -> 18.0 kWh) + 30066: 1800 # ess_avail_max_discharge_capacity (gain 100 -> 18.0 kWh) + 30068: 8800 # ess_rated_charge_power + 30070: 9600 # ess_rated_discharge_power + 30083: 1800 # ess_rated_energy_capacity (gain 100 -> 18.0 kWh) + 30085: 1000 # ess_charge_cutoff_soc (gain 10 -> 100.0%) + 30086: 100 # ess_discharge_cutoff_soc (gain 10 -> 10.0%) + 30087: 1000 # ess_soh (gain 10 -> 100.0%, normal) + 30004: 0 # grid_sensor_status + 30005: 0 # grid_active_power + 30009: 0 # on_off_grid_status + 30014: 550 # ess_soc (gain 10 -> 55.0%, normal) + 30027: 0 + 30028: 0 + 30029: 0 + 30030: 0 + 30035: -500 # pv_plant_power (gain 1000 -> -0.5 kW -- INVALID, PV can't be negative) + 30037: 0 # ess_power (idle) + 30051: 1 # plant_running_state + 30072: 0 + 30280: 0 + unit_1: + 30500: "SigenStor SYNTHETIC-BADDATA" + 30515: "SYNTH-BADDATA-0003" + 30525: "0.0.0-synthetic" diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soc-over-100.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soc-over-100.yaml new file mode 100644 index 0000000..e09e767 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soc-over-100.yaml @@ -0,0 +1,35 @@ +# Synthetic scenario -- NOT captured from a real device. Deliberately +# wire-legal but semantically impossible: ess_soc (U16, gain 10) can +# represent any value 0-6553.5%, so a real firmware bug or a corrupted +# read could plausibly hand a driver 101.0% with no protocol-level error +# at all. Exercises whether a NUT driver clamps/rejects battery.charge +# values outside 0-100 rather than trusting the wire value blindly. +static: + unit_247: + 30047: 8800 # ess_avail_max_charge_power (gain 1000 -> 8.8 kW) + 30049: 9600 # ess_avail_max_discharge_power (gain 1000 -> 9.6 kW) + 30064: 1800 # ess_avail_max_charge_capacity (gain 100 -> 18.0 kWh) + 30066: 1800 # ess_avail_max_discharge_capacity (gain 100 -> 18.0 kWh) + 30068: 8800 # ess_rated_charge_power + 30070: 9600 # ess_rated_discharge_power + 30083: 1800 # ess_rated_energy_capacity (gain 100 -> 18.0 kWh) + 30085: 1000 # ess_charge_cutoff_soc (gain 10 -> 100.0%) + 30086: 100 # ess_discharge_cutoff_soc (gain 10 -> 10.0%) + 30087: 1000 # ess_soh (gain 10 -> 100.0%, normal) + 30004: 0 # grid_sensor_status + 30005: 0 # grid_active_power + 30009: 0 # on_off_grid_status + 30014: 1010 # ess_soc (gain 10 -> 101.0% -- INVALID, out of range) + 30027: 0 + 30028: 0 + 30029: 0 + 30030: 0 + 30035: 0 # pv_plant_power + 30037: 0 # ess_power (idle) + 30051: 1 # plant_running_state + 30072: 0 + 30280: 0 + unit_1: + 30500: "SigenStor SYNTHETIC-BADDATA" + 30515: "SYNTH-BADDATA-0001" + 30525: "0.0.0-synthetic" diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soh-over-100.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soh-over-100.yaml new file mode 100644 index 0000000..7482c80 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/bad-data-soh-over-100.yaml @@ -0,0 +1,35 @@ +# Synthetic scenario -- NOT captured from a real device. Deliberately +# wire-legal but semantically impossible: ess_soh (U16, gain 10) can +# represent any value 0-6553.5%, so 150.0% state-of-health is a value +# a driver could receive with no protocol-level error. Exercises whether +# a NUT driver clamps/rejects battery.charge.warning-adjacent SoH data +# outside 0-100 rather than trusting the wire value blindly. +static: + unit_247: + 30047: 8800 # ess_avail_max_charge_power (gain 1000 -> 8.8 kW) + 30049: 9600 # ess_avail_max_discharge_power (gain 1000 -> 9.6 kW) + 30064: 1800 # ess_avail_max_charge_capacity (gain 100 -> 18.0 kWh) + 30066: 1800 # ess_avail_max_discharge_capacity (gain 100 -> 18.0 kWh) + 30068: 8800 # ess_rated_charge_power + 30070: 9600 # ess_rated_discharge_power + 30083: 1800 # ess_rated_energy_capacity (gain 100 -> 18.0 kWh) + 30085: 1000 # ess_charge_cutoff_soc (gain 10 -> 100.0%) + 30086: 100 # ess_discharge_cutoff_soc (gain 10 -> 10.0%) + 30087: 1500 # ess_soh (gain 10 -> 150.0% -- INVALID, out of range) + 30004: 0 # grid_sensor_status + 30005: 0 # grid_active_power + 30009: 0 # on_off_grid_status + 30014: 620 # ess_soc (gain 10 -> 62.0%, normal) + 30027: 0 + 30028: 0 + 30029: 0 + 30030: 0 + 30035: 0 # pv_plant_power + 30037: 0 # ess_power (idle) + 30051: 1 # plant_running_state + 30072: 0 + 30280: 0 + unit_1: + 30500: "SigenStor SYNTHETIC-BADDATA" + 30515: "SYNTH-BADDATA-0002" + 30525: "0.0.0-synthetic" diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/offline-window.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/offline-window.yaml new file mode 100644 index 0000000..b0b509d --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/offline-window.yaml @@ -0,0 +1,38 @@ +# Synthetic scenario -- NOT captured from a real device. The Modbus TCP +# listener is genuinely unreachable during the offline: window below +# (core/connectivity.py drops the real socket, not just a register-level +# exception), then comes back automatically -- exercises whether a NUT +# driver surfaces DATASTALE and recovers, rather than misreporting a +# stale last-known value as current. +static: + unit_247: + 30047: 8800 + 30049: 9600 + 30064: 1800 + 30066: 1800 + 30068: 8800 + 30070: 9600 + 30083: 1800 + 30085: 1000 + 30086: 100 + 30087: 1000 + 30004: 0 + 30005: 0 + 30009: 0 + 30014: 620 # ess_soc (gain 10 -> 62.0%), constant throughout + 30027: 0 + 30028: 0 + 30029: 0 + 30030: 0 + 30035: 0 + 30037: 0 + 30051: 1 + 30072: 0 + 30280: 0 + unit_1: + 30500: "SigenStor SYNTHETIC-OFFLINE" + 30515: "SYNTH-OFFLINE-0001" + 30525: "0.0.0-synthetic" + +offline: + - { start: 3, end: 8 } # unreachable for 5s starting at scenario t=3 diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/ramp-discharge-100-to-0.yaml b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/ramp-discharge-100-to-0.yaml new file mode 100644 index 0000000..f48f7d1 --- /dev/null +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/scenarios/synthetic/ramp-discharge-100-to-0.yaml @@ -0,0 +1,35 @@ +# Synthetic scenario -- NOT captured from a real device (contrast with +# scenarios/recorded/). Linear SoC ramp 100%->0% over 60s at a constant +# ~5kW discharge, off-grid throughout, discharge cutoff at 10%. Per +# docs/test-plan.md's scenario matrix: validates battery.charge tracking, +# LB triggering at ess_discharge_cutoff_soc, and battery.runtime shrinking +# plausibly as SoC falls -- once the driver exists to assert against. +static: + unit_247: + 30047: 8800 # ess_avail_max_charge_power (gain 1000 -> 8.8 kW) + 30049: 9600 # ess_avail_max_discharge_power (gain 1000 -> 9.6 kW) + 30064: 1800 # ess_avail_max_charge_capacity (gain 100 -> 18.0 kWh) + 30066: 1800 # ess_avail_max_discharge_capacity (gain 100 -> 18.0 kWh) + 30068: 8800 # ess_rated_charge_power + 30070: 9600 # ess_rated_discharge_power + 30083: 1800 # ess_rated_energy_capacity (gain 100 -> 18.0 kWh) + 30085: 1000 # ess_charge_cutoff_soc (gain 10 -> 100.0%) + 30086: 100 # ess_discharge_cutoff_soc (gain 10 -> 10.0%) -- LB should trip here + 30087: 980 # ess_soh (gain 10 -> 98.0%) + unit_1: + 30500: "SigenStor SYNTHETIC" # clearly not a real model string + 30515: "SYNTH-0001" + 30525: "0.0.0-synthetic" + +timeseries: + - { t: 0, 30004: 1, 30009: 1, 30051: 1, 30014: 1000, 30037: -5000, 30005: 0, 30035: 0, 30027: 0, 30028: 0, 30029: 0, 30030: 0, 30072: 0, 30280: 0 } + - { t: 6, 30014: 900, 30037: -5000 } + - { t: 12, 30014: 800, 30037: -5000 } + - { t: 18, 30014: 700, 30037: -5000 } + - { t: 24, 30014: 600, 30037: -5000 } + - { t: 30, 30014: 500, 30037: -5000 } + - { t: 36, 30014: 400, 30037: -5000 } + - { t: 42, 30014: 300, 30037: -5000 } + - { t: 48, 30014: 200, 30037: -5000 } + - { t: 54, 30014: 100, 30037: -5000 } + - { t: 60, 30014: 0, 30037: 0 } # empty, discharge stops diff --git a/invforge/profiles/sigenergy/firmwares/__init__.py b/invforge/profiles/sigenergy/firmwares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2eb2915 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["invforge"] + +[[tool.mypy.overrides]] +# pymodbus ships no type stubs and its own source is unannotated; without +# this, --strict flags every call into it as no-untyped-call. Treat it as +# an opaque Any boundary rather than chasing stub coverage we don't own. +module = "pymodbus.*" +follow_imports = "skip" +ignore_missing_imports = true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..119a6ce --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +-r requirements.txt +pytest>=8.0 +ruff +mypy +types-PyYAML +# httpx: integration tests' HTTP calls against the control API. Dev-only +# deliberately -- the Docker runtime image (requirements.txt) doesn't +# need it, only the test runner does. +httpx>=0.27 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e7d637a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Pinned to 3.6.9 deliberately, not "latest" -- pymodbus 3.15 (current at +# time of writing) refactored its server-side datastore into a different +# SimData-based simulator framework (ModbusSlaveContext/getValues/setValues +# no longer exist the way this package uses them). This is our own +# controlled test tooling, not something that needs the bleeding edge; +# revisit the pin deliberately, not via an incidental `pip install -U`. +pymodbus==3.6.9 +pyyaml>=6.0 +fastapi>=0.110 +uvicorn>=0.29 diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..50342cc --- /dev/null +++ b/scripts/integration-test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# One-command local equivalent of the CI "integration" job: build, bring +# up, wait for healthy, run the real integration suite against the +# mapped ports, tear down. Uses the repo's own .venv if present, falling +# back to whatever `python`/`pytest` is already on PATH. +set -euo pipefail +cd "$(dirname "$0")/.." + +docker compose up -d --build +trap 'docker compose down' EXIT + +scripts/wait-healthy.sh invforge + +PYTHON=python3 +[[ -x .venv/bin/python ]] && PYTHON=.venv/bin/python + +"$PYTHON" -m pip install -q -r requirements-dev.txt + +INVFORGE_MODBUS_PORT=5020 INVFORGE_CONTROL_PORT=8080 "$PYTHON" -m pytest tests/integration -q diff --git a/scripts/wait-healthy.sh b/scripts/wait-healthy.sh new file mode 100755 index 0000000..2ac2c75 --- /dev/null +++ b/scripts/wait-healthy.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Polls a docker-compose service's healthcheck until it reports +# "healthy", or fails loudly with the service's logs. Shared between +# .github/workflows/ci.yml and scripts/integration-test.sh so the two +# never drift apart on how "ready" is defined. +set -euo pipefail + +service="${1:?usage: wait-healthy.sh }" +cd "$(dirname "$0")/.." + +for _ in $(seq 1 30); do + container_id="$(docker compose ps -q "$service")" + if [[ -n "$container_id" ]]; then + status="$(docker inspect -f '{{.State.Health.Status}}' "$container_id" 2>/dev/null || echo "")" + if [[ "$status" == "healthy" ]]; then + exit 0 + fi + fi + sleep 1 +done + +echo "'$service' did not become healthy in time" >&2 +docker compose logs "$service" >&2 +exit 1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..64e2a87 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,65 @@ +"""Integration test fixtures -- these tests need a real InvForge +instance already running (via `docker compose up -d --build`, or +`scripts/integration-test.sh` locally / in CI) and talk to it over real +Modbus TCP + HTTP, exactly as a driver under test would. +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Iterator + +import httpx +import pytest +from pymodbus.client import ModbusTcpClient + +MODBUS_HOST = os.environ.get("INVFORGE_MODBUS_HOST", "127.0.0.1") +MODBUS_PORT = int(os.environ.get("INVFORGE_MODBUS_PORT", "5020")) +CONTROL_HOST = os.environ.get("INVFORGE_CONTROL_HOST", "127.0.0.1") +CONTROL_PORT = int(os.environ.get("INVFORGE_CONTROL_PORT", "8080")) +CONTROL_BASE_URL = f"http://{CONTROL_HOST}:{CONTROL_PORT}" + + +@pytest.fixture(scope="session", autouse=True) +def _require_running_instance() -> None: + deadline = time.monotonic() + 10.0 + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + resp = httpx.get(f"{CONTROL_BASE_URL}/health", timeout=1.0) + if resp.status_code == 200: + return + except httpx.HTTPError as e: + last_error = e + time.sleep(0.5) + pytest.fail( + f"no InvForge instance reachable at {CONTROL_BASE_URL} after 10s " + f"(last error: {last_error}) -- run `docker compose up -d --build` " + f"(or scripts/integration-test.sh) first" + ) + + +@pytest.fixture +def control_client() -> Iterator[httpx.Client]: + with httpx.Client(base_url=CONTROL_BASE_URL, timeout=5.0) as client: + yield client + + +def can_read_modbus(address: int = 30014, unit: int = 247) -> bool: + """A real protocol round-trip, not just a TCP handshake -- when a + published port is forwarded through Docker's host-side proxy, the + handshake alone can succeed even while nothing is actually listening + in the container (the proxy accepts, then drops the connection once + it can't reach a backend), so ModbusTcpClient.connect() alone is not + a reliable "is the emulator actually reachable" check here.""" + client = ModbusTcpClient(MODBUS_HOST, port=MODBUS_PORT, timeout=2) + try: + if not client.connect(): + return False + response = client.read_holding_registers(address=address, count=1, slave=unit) + return not response.isError() + except Exception: + return False + finally: + client.close() diff --git a/tests/integration/test_bad_data_scenarios.py b/tests/integration/test_bad_data_scenarios.py new file mode 100644 index 0000000..433e0cd --- /dev/null +++ b/tests/integration/test_bad_data_scenarios.py @@ -0,0 +1,44 @@ +"""Integration test: bad-data scenario fixtures round-trip their +deliberately out-of-spec values through a real Modbus TCP read -- proves +the wire encoding survives, not just that the YAML parses (see +invforge/core/registers.py's decode_numeric, reused here rather than +reimplemented).""" + +from __future__ import annotations + +import httpx +import pytest +from pymodbus.client import ModbusTcpClient + +from invforge.core.registers import decode_numeric +from invforge.profiles.sigenergy.firmwares.V100R001C21SPC116 import PROFILE + +from .conftest import MODBUS_HOST, MODBUS_PORT + + +@pytest.mark.parametrize( + ("scenario_name", "address", "expected_decoded"), + [ + ("bad-data-soc-over-100", 30014, 101.0), + ("bad-data-soh-over-100", 30087, 150.0), + ("bad-data-pv-power-negative", 30035, -0.5), + ], +) +def test_bad_data_round_trips_through_real_modbus( + control_client: httpx.Client, scenario_name: str, address: int, expected_decoded: float +) -> None: + resp = control_client.post("/scenario", json={"name": scenario_name}) + assert resp.status_code == 200 + + reg = PROFILE.find(247, address) + assert reg is not None + + client = ModbusTcpClient(MODBUS_HOST, port=MODBUS_PORT, timeout=3) + try: + assert client.connect() is True + r = client.read_holding_registers(address=address, count=reg.count, slave=247) + decoded = decode_numeric(reg, r.registers) + finally: + client.close() + + assert decoded == expected_decoded diff --git a/tests/integration/test_fault_injection.py b/tests/integration/test_fault_injection.py new file mode 100644 index 0000000..9cd5996 --- /dev/null +++ b/tests/integration/test_fault_injection.py @@ -0,0 +1,39 @@ +"""Integration test: on-demand connectivity fault injection via +POST /fault, including that it's sticky over scenario offline: windows +and gets cleared by loading a new scenario.""" + +from __future__ import annotations + +import time + +import httpx + +from .conftest import can_read_modbus + + +def test_forced_offline_and_online(control_client: httpx.Client) -> None: + control_client.post("/scenario", json={"name": "ramp-discharge-100-to-0"}) + + resp = control_client.post("/fault", json={"connectivity": "offline"}) + assert resp.status_code == 200 + assert can_read_modbus() is False + + resp = control_client.post("/fault", json={"connectivity": "online"}) + assert resp.status_code == 200 + assert can_read_modbus() is True + + resp = control_client.post("/fault", json={"connectivity": "auto"}) + assert resp.status_code == 200 + assert can_read_modbus() is True + + +def test_loading_a_scenario_clears_a_stale_forced_offline_override(control_client: httpx.Client) -> None: + control_client.post("/scenario", json={"name": "ramp-discharge-100-to-0"}) + control_client.post("/fault", json={"connectivity": "offline"}) + assert can_read_modbus() is False + + resp = control_client.post("/scenario", json={"name": "ramp-discharge-100-to-0"}) + assert resp.status_code == 200 + time.sleep(0.5) # give the connectivity loop one tick to notice the cleared override + + assert can_read_modbus() is True diff --git a/tests/integration/test_generator_playback.py b/tests/integration/test_generator_playback.py new file mode 100644 index 0000000..e302c0a --- /dev/null +++ b/tests/integration/test_generator_playback.py @@ -0,0 +1,50 @@ +"""Integration test: a parametric-generator scenario name (no YAML file +on disk for it), loaded via the control API, drives real Modbus reads +over the wire.""" + +from __future__ import annotations + +import time + +import httpx +from pymodbus.client import ModbusTcpClient + +from .conftest import MODBUS_HOST, MODBUS_PORT + + +def test_generated_ramp_drains_over_real_time(control_client: httpx.Client) -> None: + # A long-relative-to-jitter duration (30s), sampled well inside the + # window rather than near the wrap boundary (the engine loops a + # scenario back to t=0 once elapsed time passes its duration -- see + # core/server.py's elapsed_seconds() -- so timing precision near + # that edge isn't something this test needs to depend on). + resp = control_client.post("/scenario", json={"name": "linear-drain-100-to-0-30s", "speed": 1.0}) + assert resp.status_code == 200 + assert resp.json()["duration"] == 30.0 + + client = ModbusTcpClient(MODBUS_HOST, port=MODBUS_PORT, timeout=3) + try: + assert client.connect() is True + + def read_soc() -> float: + r = client.read_holding_registers(address=30014, count=1, slave=247) + return r.registers[0] / 10.0 + + soc_early = read_soc() + time.sleep(3.0) + soc_later = read_soc() + finally: + client.close() + + assert soc_early <= 100.0 + assert soc_later < soc_early + + +def test_invalid_generated_ramp_is_rejected(control_client: httpx.Client) -> None: + resp = control_client.post("/scenario", json={"name": "linear-drain-0-to-100-10s"}) + assert resp.status_code == 400 + + +def test_unknown_scenario_name_is_404(control_client: httpx.Client) -> None: + resp = control_client.post("/scenario", json={"name": "definitely-not-a-real-scenario"}) + assert resp.status_code == 404 diff --git a/tests/integration/test_health_and_state.py b/tests/integration/test_health_and_state.py new file mode 100644 index 0000000..25ae9d2 --- /dev/null +++ b/tests/integration/test_health_and_state.py @@ -0,0 +1,40 @@ +"""Baseline integration test -- validates the Docker/CI plumbing itself +(container up, both ports reachable) before anything scenario-specific +is trusted.""" + +from __future__ import annotations + +import httpx +from pymodbus.client import ModbusTcpClient + +from .conftest import MODBUS_HOST, MODBUS_PORT + + +def test_health_endpoint(control_client: httpx.Client) -> None: + resp = control_client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_scenarios_endpoint_lists_library_and_generators(control_client: httpx.Client) -> None: + resp = control_client.get("/scenarios") + assert resp.status_code == 200 + body = resp.json() + assert "ramp-discharge-100-to-0" in body["library"] + assert any("linear-" in pattern for pattern in body["generators"]) + + +def test_modbus_port_is_reachable() -> None: + client = ModbusTcpClient(MODBUS_HOST, port=MODBUS_PORT, timeout=3) + try: + assert client.connect() is True + finally: + client.close() + + +def test_state_endpoint_reports_connectivity(control_client: httpx.Client) -> None: + resp = control_client.get("/state") + assert resp.status_code == 200 + body = resp.json() + assert "connectivity" in body + assert body["connectivity"]["online"] is True diff --git a/tests/integration/test_offline_scenario.py b/tests/integration/test_offline_scenario.py new file mode 100644 index 0000000..48036d8 --- /dev/null +++ b/tests/integration/test_offline_scenario.py @@ -0,0 +1,24 @@ +"""Integration test: a scenario's offline: window makes the Modbus TCP +listener genuinely unreachable for real, not just returning a Modbus +exception -- and it recovers automatically afterward.""" + +from __future__ import annotations + +import time + +import httpx + +from .conftest import can_read_modbus + + +def test_offline_window_drops_and_recovers_the_connection(control_client: httpx.Client) -> None: + resp = control_client.post("/scenario", json={"name": "offline-window", "speed": 1.0}) + assert resp.status_code == 200 + + assert can_read_modbus() is True # before the window (offline: start=3, end=8) + + time.sleep(4.0) # inside the window + assert can_read_modbus() is False + + time.sleep(5.0) # past the window (t~9) + assert can_read_modbus() is True diff --git a/tests/integration/test_scenario_playback.py b/tests/integration/test_scenario_playback.py new file mode 100644 index 0000000..10fe942 --- /dev/null +++ b/tests/integration/test_scenario_playback.py @@ -0,0 +1,29 @@ +"""Integration test: a static YAML scenario, loaded via the control API, +actually drives real Modbus reads over the wire.""" + +from __future__ import annotations + +import time + +import httpx +from pymodbus.client import ModbusTcpClient + +from .conftest import MODBUS_HOST, MODBUS_PORT + + +def test_ramp_scenario_soc_decreases_over_real_modbus(control_client: httpx.Client) -> None: + resp = control_client.post("/scenario", json={"name": "ramp-discharge-100-to-0", "speed": 20.0}) + assert resp.status_code == 200 + + client = ModbusTcpClient(MODBUS_HOST, port=MODBUS_PORT, timeout=3) + try: + assert client.connect() is True + r0 = client.read_holding_registers(address=30014, count=1, slave=247) + soc0 = r0.registers[0] / 10.0 + time.sleep(1.5) # at 20x speed, ~30 scenario-seconds elapse + r1 = client.read_holding_registers(address=30014, count=1, slave=247) + soc1 = r1.registers[0] / 10.0 + finally: + client.close() + + assert soc1 < soc0 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_connectivity.py b/tests/unit/test_connectivity.py new file mode 100644 index 0000000..7e146bd --- /dev/null +++ b/tests/unit/test_connectivity.py @@ -0,0 +1,37 @@ +"""Unit tests for the time model shared by the register ticker and +core/connectivity.py's offline-window watcher (core/server.py's +elapsed_seconds()) -- no asyncio, no network.""" + +from __future__ import annotations + +import time + +from invforge.core.scenario import Scenario +from invforge.core.server import elapsed_seconds +from invforge.profiles.sigenergy.firmwares.V100R001C21SPC116 import PROFILE + + +def _scenario_with_duration(duration_s: float) -> Scenario: + return Scenario( + "test-elapsed", + {"timeseries": [{"t": 0, 30014: 1000}, {"t": duration_s, 30014: 0}]}, + PROFILE, + ) + + +def test_elapsed_seconds_before_duration_is_unwrapped(): + scenario = _scenario_with_duration(10.0) + start_time = time.monotonic() - 5.0 # "5 seconds into the scenario" + assert 4.9 < elapsed_seconds(scenario, start_time, speed=1.0) < 5.1 + + +def test_elapsed_seconds_wraps_at_duration(): + scenario = _scenario_with_duration(10.0) + start_time = time.monotonic() - 23.0 # 23s in on a 10s-duration scenario -> wraps to ~3s + assert 2.9 < elapsed_seconds(scenario, start_time, speed=1.0) < 3.1 + + +def test_elapsed_seconds_speed_multiplier(): + scenario = _scenario_with_duration(100.0) + start_time = time.monotonic() - 2.0 # 2 real seconds at 10x speed -> ~20s elapsed + assert 19.0 < elapsed_seconds(scenario, start_time, speed=10.0) < 21.0 diff --git a/tests/unit/test_generator.py b/tests/unit/test_generator.py new file mode 100644 index 0000000..c43d15a --- /dev/null +++ b/tests/unit/test_generator.py @@ -0,0 +1,48 @@ +"""Unit tests for the parametric ramp-scenario generator +(core/generator.py) against the Sigenergy profile's ramp_builder.""" + +from __future__ import annotations + +import pytest + +from invforge.core.generator import resolve_generated +from invforge.profiles.sigenergy.firmwares.V100R001C21SPC116 import PROFILE + + +def test_non_matching_name_returns_none(): + assert resolve_generated("2026-08-13-idle-disconnected", PROFILE) is None + assert resolve_generated("not-a-ramp-at-all", PROFILE) is None + + +def test_valid_drain_ramp_interpolates_between_endpoints(): + scenario = resolve_generated("linear-drain-100-to-0-60s", PROFILE) + assert scenario is not None + assert scenario.duration() == 60.0 + v0 = scenario.value_at(247, 30014, 0.0) + v_mid = scenario.value_at(247, 30014, 30.0) + v_end = scenario.value_at(247, 30014, 60.0) + assert v0 == 1000 # 100.0% raw + assert v_end == 0 + assert 0 < v_mid < 1000 + + +def test_valid_charge_ramp(): + scenario = resolve_generated("linear-charge-20-to-80-30s", PROFILE) + assert scenario is not None + assert scenario.value_at(247, 30014, 0.0) == 200 + assert scenario.value_at(247, 30014, 30.0) == 800 + + +def test_duration_must_be_positive(): + with pytest.raises(ValueError, match="duration must be > 0"): + resolve_generated("linear-drain-100-to-0-0s", PROFILE) + + +def test_drain_direction_must_actually_decrease(): + with pytest.raises(ValueError, match="direction 'drain'"): + resolve_generated("linear-drain-0-to-100-60s", PROFILE) + + +def test_charge_direction_must_actually_increase(): + with pytest.raises(ValueError, match="direction 'charge'"): + resolve_generated("linear-charge-100-to-0-60s", PROFILE) diff --git a/tests/unit/test_profile_registry.py b/tests/unit/test_profile_registry.py new file mode 100644 index 0000000..7edbe67 --- /dev/null +++ b/tests/unit/test_profile_registry.py @@ -0,0 +1,32 @@ +"""Unit tests for invforge.profiles' vendor/firmware resolution.""" + +from __future__ import annotations + +import pytest + +from invforge import profiles + + +def test_get_resolves_known_vendor_and_firmware(): + profile = profiles.get("sigenergy", "V100R001C21SPC116") + assert profile.name == "sigenergy" + assert profile.firmware == "V100R001C21SPC116" + + +def test_get_unknown_vendor_lists_available(): + with pytest.raises(KeyError, match="unknown vendor 'bogus'.*sigenergy"): + profiles.get("bogus", "whatever") + + +def test_get_unknown_firmware_lists_available(): + with pytest.raises(KeyError, match="unknown firmware 'bogus'.*V100R001C21SPC116"): + profiles.get("sigenergy", "bogus") + + +def test_default_firmware_resolves_when_exactly_one(): + assert profiles.default_firmware("sigenergy") == "V100R001C21SPC116" + + +def test_default_firmware_unknown_vendor(): + with pytest.raises(KeyError, match="unknown vendor 'bogus'"): + profiles.default_firmware("bogus") diff --git a/tests/unit/test_scenario_offline.py b/tests/unit/test_scenario_offline.py new file mode 100644 index 0000000..d18a675 --- /dev/null +++ b/tests/unit/test_scenario_offline.py @@ -0,0 +1,55 @@ +"""Unit tests for Scenario's offline: window parsing/validation and +is_offline_at()/duration() -- no server, no network.""" + +from __future__ import annotations + +import pytest + +from invforge.core.scenario import Scenario +from invforge.profiles.sigenergy.firmwares.V100R001C21SPC116 import PROFILE + + +def _scenario(offline_windows: list[dict[str, float]]) -> Scenario: + return Scenario("test-offline-inline", {"offline": offline_windows}, PROFILE) + + +def test_is_offline_at_respects_window_bounds(): + scenario = _scenario([{"start": 3, "end": 8}]) + assert scenario.is_offline_at(0) is False + assert scenario.is_offline_at(2.999) is False + assert scenario.is_offline_at(3) is True + assert scenario.is_offline_at(7.999) is True + assert scenario.is_offline_at(8) is False # end is exclusive + assert scenario.is_offline_at(100) is False + + +def test_no_offline_section_means_never_offline(): + scenario = Scenario("test-no-offline", {}, PROFILE) + assert scenario.is_offline_at(0) is False + assert scenario.is_offline_at(1000) is False + assert scenario.duration() == 0.0 + + +def test_duration_covers_the_last_offline_window_end(): + scenario = _scenario([{"start": 3, "end": 8}]) + assert scenario.duration() == 8.0 + + +def test_multiple_windows(): + scenario = _scenario([{"start": 3, "end": 5}, {"start": 10, "end": 12}]) + assert scenario.is_offline_at(4) is True + assert scenario.is_offline_at(7) is False + assert scenario.is_offline_at(11) is True + assert scenario.duration() == 12.0 + + +def test_rejects_negative_start(): + with pytest.raises(ValueError, match="must be >= 0"): + _scenario([{"start": -1, "end": 5}]) + + +def test_rejects_end_not_after_start(): + with pytest.raises(ValueError, match="must be > start"): + _scenario([{"start": 5, "end": 5}]) + with pytest.raises(ValueError, match="must be > start"): + _scenario([{"start": 5, "end": 3}]) diff --git a/tests/unit/test_sigenergy_profile.py b/tests/unit/test_sigenergy_profile.py new file mode 100644 index 0000000..1e8fe53 --- /dev/null +++ b/tests/unit/test_sigenergy_profile.py @@ -0,0 +1,86 @@ +"""Engine-level smoke tests for the Sigenergy profile -- exercises the +vendor-agnostic core (profile/scenario/server) against real fixtures, +without opening a network socket.""" + +from __future__ import annotations + +from invforge.core import server as sim_server +from invforge.core.registers import decode_numeric +from invforge.core.scenario import Scenario +from invforge.profiles.sigenergy.firmwares.V100R001C21SPC116 import PROFILE + + +def _load(name: str) -> Scenario: + path = PROFILE.scenarios_dir / ("recorded" if "202" in name else "synthetic") / f"{name}.yaml" + return Scenario.from_yaml(path, PROFILE) + + +def test_all_fixtures_load(): + for sub in ("recorded", "synthetic"): + for path in sorted((PROFILE.scenarios_dir / sub).glob("*.yaml")): + Scenario.from_yaml(path, PROFILE) # must not raise + + +def test_recorded_capture_decodes_expected_soc_and_identity(): + scenario = _load("2026-08-13-idle-disconnected") + blocks = sim_server.build_blocks(PROFILE) + sim_server.apply_exceptions(blocks, scenario) + sim_server.write_scenario_statics(blocks, PROFILE, scenario) + + soc_reg = PROFILE.find(247, 30014) + words = blocks[247].getValues(30014, 1) + assert decode_numeric(soc_reg, words) == 36.3 + + model_reg = PROFILE.find(1, 30500) + words = blocks[1].getValues(30500, model_reg.count) + raw = b"" + for w in words: + raw += bytes([(w >> 8) & 0xFF, w & 0xFF]) + assert raw.decode("utf-8").rstrip("\x00") == "SigenStor EC 12.0 TP" + + +def test_general_alarm_7_solo_read_fails_but_range_read_succeeds(): + """general_alarm7 (30281) is a real, in-range register (spec- + confirmed, see registers.py's module docstring) -- but the real + device rejects it as a read's *starting* address specifically, + reproduced here via Profile.non_anchor_addresses, not a per-scenario + exception. A solo read must still fail (matching real hardware); + unlike the old modeling, a range read starting before it must now + succeed (also matching real hardware, and previously broken -- + a blanket scenario-level `exceptions:` entry would have rejected + this too, which is what got fixed).""" + scenario = _load("2026-08-13-live-eps-discharge") + blocks = sim_server.build_blocks(PROFILE) + sim_server.apply_exceptions(blocks, scenario) + sim_server.write_scenario_statics(blocks, PROFILE, scenario) + + assert blocks[247].validate(30281, 1) is False + assert blocks[247].validate(30280, 2) is True + + +def test_exceptions_block_disables_an_in_range_register(): + """Regression test: a scenario's `exceptions:` block is keyed by + "unit_" strings, which must resolve to the block's int unit id + (247) to have any effect. 30014 (ess_soc) is a real, in-range + register, so this can only pass if the "unit_247" -> 247 key + conversion actually runs.""" + scenario = Scenario( + "test-exceptions-inline", + {"exceptions": {"unit_247": {30014: {"function_code": 3, "exception_code": 2}}}}, + PROFILE, + ) + blocks = sim_server.build_blocks(PROFILE) + sim_server.apply_exceptions(blocks, scenario) + + assert blocks[247].validate(30014, 1) is False + assert blocks[247].validate(30047, 1) is True # an unrelated in-range register stays valid + + +def test_ramp_scenario_interpolates_soc_down(): + scenario = _load("ramp-discharge-100-to-0") + v0 = scenario.value_at(247, 30014, 0.0) + v_mid = scenario.value_at(247, 30014, 15.0) + v_end = scenario.value_at(247, 30014, 60.0) + assert v0 == 1000 # 100.0% raw + assert 500 < v_mid < 1000 + assert v_end == 0 From 4c6db650dbc18b3b68124ea9a08d90911bec31f6 Mon Sep 17 00:00:00 2001 From: Miklos Bagi <68903245+miklosbagi@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:49:23 +0200 Subject: [PATCH 2/4] CI: add CodeQL analysis and unit-test coverage reporting CodeQL (security-and-quality query suite) runs on push/PR against main plus a weekly schedule. Unit test coverage is measured via pytest-cov and uploaded as a Cobertura report so the repo's code_coverage ruleset rule (75% minimum, 10-point max drop) has real data to enforce. Note: unit-only coverage is currently ~50% -- control_api.py, connectivity.py, and __main__.py are exercised by the integration suite instead, which runs the app in a separate Docker container that pytest-cov can't instrument. Closing that gap is tracked as follow-up work, not hidden. --- .github/workflows/ci.yml | 11 ++++++++++- .github/workflows/codeql.yml | 29 +++++++++++++++++++++++++++++ .gitignore | 2 ++ requirements-dev.txt | 1 + 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66985a7..5e394d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: [push, pull_request] jobs: unit: runs-on: ubuntu-latest + permissions: + contents: read + code-quality: write steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -14,7 +17,13 @@ jobs: - run: ruff format --check invforge tests - run: ruff check invforge tests - run: mypy - - run: python -m pytest tests/unit -q + - run: python -m pytest tests/unit -q --cov=invforge --cov-report=xml + - name: Upload coverage report + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/upload-code-coverage@v1 + with: + file: coverage.xml + language: Python integration: runs-on: ubuntu-latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6d23d18 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,29 @@ +name: CodeQL + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "22 6 * * 3" + +jobs: + analyze: + name: Analyze (python) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + + steps: + - uses: actions/checkout@v4 + + - uses: github/codeql-action/init@v3 + with: + languages: python + queries: security-and-quality + + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:python" diff --git a/.gitignore b/.gitignore index a5f38aa..4d595ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc .venv/ *.egg-info/ +.coverage +coverage.xml diff --git a/requirements-dev.txt b/requirements-dev.txt index 119a6ce..da9d485 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ -r requirements.txt pytest>=8.0 +pytest-cov>=5.0 ruff mypy types-PyYAML From 3a9386e2bedd2e292270886a01e72956232e8301 Mon Sep 17 00:00:00 2001 From: Miklos Bagi <68903245+miklosbagi@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:57:06 +0200 Subject: [PATCH 3/4] Fix CodeQL findings and stop coverage upload from hard-failing CI - __main__.py: parser.error() isn't statically guaranteed to exit, so add explicit returns rather than relying on argparse's default sys.exit(2) behavior (py/uninitialized-local-variable, 2 findings). - generator.py/profile.py/scenario.py: suppress py/unsafe-cyclic-import with an explanation -- not a real runtime cycle, profile.py's reciprocal import is TYPE_CHECKING-guarded and never executes; mypy already resolves it fine (4 findings, all false positives). - ci.yml: fail-on-error: false on the coverage upload step -- it 404s until "Code Quality" is turned on in repo Settings (no API for that toggle), which shouldn't hard-fail the whole unit job in the meantime. --- .github/workflows/ci.yml | 6 ++++++ invforge/__main__.py | 2 ++ invforge/core/generator.py | 9 +++++++-- invforge/core/profile.py | 6 +++++- invforge/core/scenario.py | 5 ++++- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e394d6..f9c1a31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: with: file: coverage.xml language: Python + # Soft-fail until "Code Quality" is enabled in this repo's + # Settings -> Code security page (no API for that toggle as of + # this writing, so it can't be flipped from here) -- without + # this, every CI run hard-fails on a 404 from the upload + # endpoint before that's turned on. + fail-on-error: false integration: runs-on: ubuntu-latest diff --git a/invforge/__main__.py b/invforge/__main__.py index 90a107e..3f601b7 100644 --- a/invforge/__main__.py +++ b/invforge/__main__.py @@ -49,6 +49,7 @@ def main() -> None: profile = profiles.get(args.vendor, firmware) except KeyError as e: parser.error(str(e)) + return blocks = sim_server.build_blocks(profile) context = sim_server.build_context(blocks) state = SimState() @@ -60,6 +61,7 @@ def main() -> None: scenario = resolve_scenario(args.scenario, profile) except (KeyError, ValueError) as e: parser.error(str(e)) + return sim_server.apply_exceptions(blocks, scenario) sim_server.write_scenario_statics(blocks, profile, scenario) state.set_scenario(scenario, speed=args.speed) diff --git a/invforge/core/generator.py b/invforge/core/generator.py index 7fee6cf..f62da63 100644 --- a/invforge/core/generator.py +++ b/invforge/core/generator.py @@ -12,8 +12,13 @@ from dataclasses import dataclass from typing import Literal -from .profile import Profile -from .scenario import Scenario +# Not a real runtime cycle: profile.py's reciprocal import of this module +# is guarded by `if TYPE_CHECKING:` (see profile.py), which is False at +# runtime, so Python never executes that import line. mypy resolves the +# annotation-only cycle fine too, since every module here uses +# `from __future__ import annotations`. +from .profile import Profile # codeql[py/unsafe-cyclic-import] +from .scenario import Scenario # codeql[py/unsafe-cyclic-import] _RAMP_RE = re.compile( r"^linear-(?Pdrain|charge)-(?P\d+(?:\.\d+)?)-to-" diff --git a/invforge/core/profile.py b/invforge/core/profile.py index fb3101b..0510fd1 100644 --- a/invforge/core/profile.py +++ b/invforge/core/profile.py @@ -13,7 +13,11 @@ from .registers import RegisterDef if TYPE_CHECKING: - from .generator import RampParams + # Not a real runtime cycle -- this only ever executes for a type + # checker (TYPE_CHECKING is False at runtime), and generator.py's + # reciprocal import of Profile is fine to resolve after the fact + # since every module here uses `from __future__ import annotations`. + from .generator import RampParams # codeql[py/unsafe-cyclic-import] @dataclass(frozen=True) diff --git a/invforge/core/scenario.py b/invforge/core/scenario.py index f5a17df..61512a9 100644 --- a/invforge/core/scenario.py +++ b/invforge/core/scenario.py @@ -35,7 +35,10 @@ import yaml -from .profile import Profile +# Not a real runtime cycle: profile.py never imports this module except +# under `if TYPE_CHECKING:` (see profile.py), which doesn't execute at +# runtime. +from .profile import Profile # codeql[py/unsafe-cyclic-import] from .registers import DType, RegisterDef, normalize_scenario_value From 8b1eeac3178d0309b562b21aac5e351f5a4e1c46 Mon Sep 17 00:00:00 2001 From: Miklos Bagi <68903245+miklosbagi@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:01:07 +0200 Subject: [PATCH 4/4] Eliminate the generator/profile cyclic import for real The prior TYPE_CHECKING-guarded import wasn't a real runtime cycle, but CodeQL's py/unsafe-cyclic-import flagged it anyway, and inline codeql[] suppression comments didn't take for this query (still 4 findings after adding them). Simpler to actually remove the cycle: RampParams moves to its own core/ramp_params.py with no dependency on either generator.py or profile.py, so profile.py can import it directly instead of only under TYPE_CHECKING. --- invforge/core/generator.py | 19 +++---------------- invforge/core/profile.py | 9 +-------- invforge/core/ramp_params.py | 18 ++++++++++++++++++ invforge/core/scenario.py | 5 +---- .../firmwares/V100R001C21SPC116/ramps.py | 2 +- 5 files changed, 24 insertions(+), 29 deletions(-) create mode 100644 invforge/core/ramp_params.py diff --git a/invforge/core/generator.py b/invforge/core/generator.py index f62da63..0257d2f 100644 --- a/invforge/core/generator.py +++ b/invforge/core/generator.py @@ -9,16 +9,11 @@ from __future__ import annotations import re -from dataclasses import dataclass from typing import Literal -# Not a real runtime cycle: profile.py's reciprocal import of this module -# is guarded by `if TYPE_CHECKING:` (see profile.py), which is False at -# runtime, so Python never executes that import line. mypy resolves the -# annotation-only cycle fine too, since every module here uses -# `from __future__ import annotations`. -from .profile import Profile # codeql[py/unsafe-cyclic-import] -from .scenario import Scenario # codeql[py/unsafe-cyclic-import] +from .profile import Profile +from .ramp_params import RampParams +from .scenario import Scenario _RAMP_RE = re.compile( r"^linear-(?Pdrain|charge)-(?P\d+(?:\.\d+)?)-to-" @@ -26,14 +21,6 @@ ) -@dataclass(frozen=True) -class RampParams: - direction: Literal["drain", "charge"] - start_pct: float - end_pct: float - duration_s: float - - def resolve_generated(name: str, profile: Profile) -> Scenario | None: """None if `name` doesn't match the generator's naming convention, or the profile has no ramp_builder -- caller falls back to the static diff --git a/invforge/core/profile.py b/invforge/core/profile.py index 0510fd1..28ce6aa 100644 --- a/invforge/core/profile.py +++ b/invforge/core/profile.py @@ -8,17 +8,10 @@ from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING +from .ramp_params import RampParams from .registers import RegisterDef -if TYPE_CHECKING: - # Not a real runtime cycle -- this only ever executes for a type - # checker (TYPE_CHECKING is False at runtime), and generator.py's - # reciprocal import of Profile is fine to resolve after the fact - # since every module here uses `from __future__ import annotations`. - from .generator import RampParams # codeql[py/unsafe-cyclic-import] - @dataclass(frozen=True) class Profile: diff --git a/invforge/core/ramp_params.py b/invforge/core/ramp_params.py new file mode 100644 index 0000000..f8eca80 --- /dev/null +++ b/invforge/core/ramp_params.py @@ -0,0 +1,18 @@ +"""RampParams lives in its own module, separate from generator.py, so +that profile.py (which needs the type for Profile.ramp_builder) doesn't +have to import back into generator.py (which imports profile.py) -- +avoids a cyclic import rather than working around one. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class RampParams: + direction: Literal["drain", "charge"] + start_pct: float + end_pct: float + duration_s: float diff --git a/invforge/core/scenario.py b/invforge/core/scenario.py index 61512a9..f5a17df 100644 --- a/invforge/core/scenario.py +++ b/invforge/core/scenario.py @@ -35,10 +35,7 @@ import yaml -# Not a real runtime cycle: profile.py never imports this module except -# under `if TYPE_CHECKING:` (see profile.py), which doesn't execute at -# runtime. -from .profile import Profile # codeql[py/unsafe-cyclic-import] +from .profile import Profile from .registers import DType, RegisterDef, normalize_scenario_value diff --git a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py index 7ccf753..c635c5c 100644 --- a/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py +++ b/invforge/profiles/sigenergy/firmwares/V100R001C21SPC116/ramps.py @@ -16,7 +16,7 @@ from __future__ import annotations -from invforge.core.generator import RampParams +from invforge.core.ramp_params import RampParams _RATED_CAPACITY_KWH = 18.0 # matches this firmware's other synthetic fixtures' 30083 (gain 100 -> raw 1800)