diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f6fe427 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel in-flight runs for the same branch on a new push. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + pytest: + name: pytest (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Pin to the lockfile-resolved Python floor (`pyproject.toml` + # `requires-python = ">=3.10"`). The reference stack that + # produces the 1.1.1 fingerprint pins is Python 3.10. + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Sync dependencies (lockfile-resolved) + run: uv sync --extra test --python ${{ matrix.python-version }} + + - name: Verify numerical stack + # numpy / scipy / numba versions are pinned in uv.lock; printing + # them in CI logs makes fingerprint-drift debugging trivial. + run: | + uv run python -c "import numpy, scipy, numba; \ + print(numpy.__version__, scipy.__version__, numba.__version__)" + + - name: Run smoke tests + run: uv run python -m pytest tests/test_smoke.py -q + + - name: Run full test suite + # Numba JIT cache is warmed by the smoke run; the rest is fast. + run: uv run python -m pytest tests/ -q + + ruff: + name: ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v1 + with: + # No formatter action — the repo doesn't ship a ruff.toml and + # we don't want to introduce formatting churn as part of CI. + args: check diff --git a/.gitignore b/.gitignore index 14e0b1a..afd38b3 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,8 @@ results/*.html results/*.png !results/.gitkeep -Fernandes2019_BDSIM.pdf -manual.pdf +docs/Fernandes2019_BDSIM.pdf +docs/manual.pdf # Cursor .cursor/ diff --git a/ADMIN.md b/ADMIN.md index 05166ce..ed1eb86 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -89,21 +89,25 @@ uv run python -m pytest tests/test_smoke.py -q ## Fingerprint regression -`tests/test_smoke.py` and `tests/test_live_simulator.py` pin SHA-256 fingerprints over the full trajectory. A silent numerical drift in a kernel will fail loud. +`tests/test_live_simulator.py` (and the per-Layer tests) pin SHA-256 fingerprints over the full trajectory. `tests/test_smoke.py` checks shapes, physical ranges, and determinism only — no SHA pins. A silent numerical drift in a kernel will fail loud. Named profiles (see `docs/00-orientation/Byte-identical-contract.md` and `AGENTS.md`): | Path | Profile | Meaning | Hash | |---|---|---|---| -| batch | legacy fingerprint | Tests pass `fouling_dynamic=False` — **not** bare `ProcessFaults()` | `sv=c8807b23b14a9ad1` | -| batch | legacy fingerprint | | `pv=77def506dbfe25c9` | -| batch | legacy fingerprint | | `uv=17e620519474074a` | -| batch | Layer 2.5 fingerprint | Dynamic HEX fouling (`fouling_dynamic=True`) | `sv=696531c4...` | -| batch | Layer 2.6 (active disturbance) | Nonzero disturbance amplitudes | `sv=8865a8c3...` | +| batch | runtime default (1.2.0+) | Bare `ProcessFaults()` enables Layers 2.5 + 2.1 + 2.4 (both) + 2.8a | `sv=691cf51b4c1a0bc2` `pv=baedc29fcfa8f526` `uv=4e4134e40fa0c1ad` | +| batch | legacy fingerprint | Tests pass all Layer masters `False` (21-wide state) | `sv=c8807b23b14a9ad1` `pv=77def506dbfe25c9` `uv=17e620519474074a` | +| batch | Layer 2.5 fingerprint | Dynamic HEX fouling, all other masters off (22-wide state) | `sv=696531c4990c5b1e` | +| batch | Layer 2.5 + Layer 2.1 | `fouling_dynamic=True quality_state=True` (27-wide state) | `sv=d663e17d687b1133` | +| batch | Layer 2.4 pump-only | `pump_wear=True`, no valve_wear / quality_state (23-wide state) | `sv=7ddd7aaa7da4b679` | +| batch | Layer 2.4 valve-only | `valve_wear=True`, no pump_wear / quality_state (23-wide state) | `sv=9425d007ae968ee7` | +| batch | Layer 2.4 both | `pump_wear=True valve_wear=True`, no quality_state (24-wide state) | `sv=c092fe082f4ba6f4` | +| batch | Layer 2.6 (active disturbance) | Nonzero disturbance amplitudes, all other masters off | `sv=8865a8c352cb6b55` | +| live | runtime default (1.2.0+) | Bare `ProcessFaults()` (`tf=14400 dt=10`) | `sv=80f6f04683703382` `pv=9e2b2081f469e473` `uv=c3a05be9a234fe2a` | | live | legacy fingerprint | Matching legacy knobs | `sv=23c3c885694c3d24` | | live | Layer 2.6 (active disturbance) | | `sv=bb763a9bde1d3fc9` | -Runtime default is bare `ProcessFaults()` (`fouling_dynamic=True`, `sv` width 22). When a fingerprint updates, that's a "we changed the math **or the numerical stack**" signal. Pins as of **1.1.1** match `uv.lock` (`numpy==2.2.6`, `scipy==1.15.3`, `numba==0.66.0`). Document the why in the commit body and update the pin in the test file. Don't suppress the test. +Runtime default (`ProcessFaults()`) is now the broad-defaults profile — `fouling_dynamic=True`, `quality_state=True`, `pump_wear=True`, `valve_wear=True`, `spectrum_enabled=True` (sv width 30). Tests and demos needing a narrower state must opt out explicitly via the relevant `*_state=False` / `*_wear=False` / `spectrum_enabled=False` overrides. When a fingerprint updates, that's a "we changed the math **or the numerical stack**" signal. Pins as of **1.2.0** match `uv.lock` (`numpy==2.2.6`, `scipy==1.15.3`, `numba==0.66.0`). Document the why in the commit body and update the pin in the test file. Don't suppress the test. ## Health checks @@ -145,7 +149,7 @@ If you ever need bdsim to run as a long-lived process for some other consumer, t - **Original MATLAB:** Natércia C. P. Fernandes, 2019, University of Coimbra (`natercia@eq.uc.pt`). Upstream: `https://github.com/naterciafernandes/BDSIM`. - **Docs vault:** [`docs/Home.md`](docs/Home.md) (see [`docs/README.md`](docs/README.md)). -- **Optional PDFs:** `Fernandes2019_BDSIM.pdf` and `manual.pdf` are gitignored at the repo root — drop local copies if you have them; not required to run. +- **Optional PDFs:** `Fernandes2019_BDSIM.pdf` and `manual.pdf` are gitignored under `docs/` (see `.gitignore`) — drop local copies there if you have them; not required to run. - **Citation:** [`CITATION.cff`](CITATION.cff). - **License:** GPLv3+ (matches upstream); see [`LICENSE`](LICENSE). - **Maintainer scripts:** [`scripts/`](scripts/) — optional utilities only (see `scripts/README.md`). diff --git a/AGENTS.md b/AGENTS.md index 09d42d5..4534c5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Both callers depend on the **byte-identical trajectory contract**: given the sam - **Don't change the ODE math.** If you think the upstream MATLAB is wrong, write a test that captures the bug, then ask before "fixing" it. See `NOTES.md` for two prior upstream-faithful fixes (Foil noise typo, temperature display bug) — those were agreed, and documented. - **Don't add a new external dependency** without asking. The current stack (numpy/scipy/numba/torch) is intentional. New deps = new deploy surface. - **Don't relax the byte-identical fingerprint contract.** If you need different behavior, gate it behind a `ProcessFaults` flag (default off/zero, except documented exceptions — today `fouling_dynamic=True`). -- **Don't touch `pyproject.toml` version without also updating `bdsim/__init__.py:__version__` and the dashboard's minimum-required-version pin in lockstep.** All three are pinned to the same value today (`1.1.1`). +- **Don't touch `pyproject.toml` version without also updating `bdsim/__init__.py:__version__`.** Both are pinned to the same value today (`1.1.1`). The bdsim-dashboard does not currently pin a minimum bdsim version; add the third pin when the dashboard gains an installation contract (see `ADMIN.md` — Versioning). ## File map @@ -44,7 +44,7 @@ bdsim/ └── cli.py # `python -m bdsim` entry point tests/ -├── test_smoke.py # 14 smoke tests, fingerprint regression +├── test_smoke.py # 14 smoke tests (shapes, physical ranges, determinism; no SHA pins) ├── test_live_simulator.py # LiveSimulator + byte-identical contract to run_with ├── test_disturbances.py # Layer 2.6 external disturbance track ├── test_layer24_degradation.py # Layer 2.4 pump_health + valve_stiction_pct @@ -63,14 +63,19 @@ NOTES.md # historical: upstream-faithful bugs we found and fixed ## Build conventions - **Numba kernels are sacred.** Don't refactor for readability if it costs a fingerprint pin update. The ODE RHS, reaction kinetics, valve stiction, PID step, ARMAX noise update, sensor measurements, and AE model are all `@njit(cache=True)`. The 72h default sim runs in ~60s on a single core. -- **Default profiles (read carefully).** New Layer flags should default **off / zero**, except documented exceptions. Today `ProcessFaults.fouling_dynamic` defaults to **`True`** (Layer 2.5 on → `sv` width 22). Bare `ProcessFaults()` is **not** the upstream legacy pin path. - - | Profile | Construction | Pins (examples) | - |--------|--------------|-----------------| - | Runtime default | `ProcessFaults()` | Layer 2.5 path; not the legacy hash | - | Legacy fingerprint | `fouling_dynamic=False` (+ other masters off/zero) | batch `sv=c8807b23…`, live `sv=23c3c885…` | - | Layer 2.5 fingerprint | `fouling_dynamic=True`, extras off | batch `sv=696531c4…` | - | Layer 2.6 active | disturbance amplitudes on | batch `sv=8865a8c3…`, live `sv=bb763a9b…` | +- **Default profiles (read carefully).** As of 1.2.0, the four major Layer master switches default to **`True`** (Layer 2.5 fouling, 2.1 quality latching, 2.4 pump wear, 2.4 valve wear, 2.8a spectra). Bare `ProcessFaults()` enables all of them → `sv` width 30, hash `691cf51b…`. Tests and demos that need a narrower state vector must pass the relevant `quality_state=False` / `pump_wear=False` / `valve_wear=False` / `spectrum_enabled=False` overrides explicitly. The legacy / Layer-2.5-only profiles below remain canonical — they are the byte-identical contracts to the upstream MATLAB baseline and the 1.0 Layer-2.5 release. + + | Profile | Construction | `sv` width | Pins (full SHA-256[:16]) | + |--------|--------------|-------------|----------------------------| + | Runtime default (1.2.0+) | `ProcessFaults()` | **30** | batch `sv=691cf51b…` `pv=baedc29f…` `uv=4e4134e4…`; live `sv=80f6f046…` | + | Legacy fingerprint | `fouling_dynamic=False`, all masters off | 21 | batch `sv=c8807b23…` `pv=77def506…` `uv=17e62051…`; live `sv=23c3c885…` | + | Layer 2.5 fingerprint | `fouling_dynamic=True`, all other masters off | 22 | batch `sv=696531c4…` `pv=3c96ca4f…` `uv=0d9a9673…` | + | Layer 2.5 + Layer 2.1 | `fouling_dynamic=True`, `quality_state=True` (no Layer 2.4, no spectra) | 27 | batch `sv=d663e17d…` (Layer 2.1 review pending — see Open Questions in `Progress.md`) | + | Layer 2.4 pump-only | `pump_wear=True`, `valve_wear=False`, `fouling_dynamic=True`, `quality_state=False` | 23 | batch `sv=7ddd7aaa…` `pv=e0dba881…` `uv=86c2704f…` | + | Layer 2.4 valve-only | `pump_wear=False`, `valve_wear=True`, `fouling_dynamic=True`, `quality_state=False` | 23 | batch `sv=9425d007…` `pv=02296ffa…` `uv=aa146ea3…` | + | Layer 2.4 both | `pump_wear=True`, `valve_wear=True`, `fouling_dynamic=True`, `quality_state=False` | 24 | batch `sv=c092fe08…` `pv=2d26f03f…` `uv=4ef50b9f…` | + | Layer 2.6 active | `fouling_dynamic=True`, `ambient_t_amplitude_k` etc. > 0 (all other masters off) | 22 | batch `sv=8865a8c3…`; live `sv=bb763a9b…` | + | Layer 2.6b live | `LiveSimulator` cw_pump_trip mid-run override | 22 | live `sv` matches Layer 2.6 baseline; trip is a single row-removal event | Full write-up: `docs/00-orientation/Byte-identical-contract.md`. - **Tests run from the bdsim root:** `python3 -m pytest tests/ -q`. Full suite: ~6:40. @@ -89,8 +94,8 @@ NOTES.md # historical: upstream-faithful bugs we found and fixed This repo's coverage: - ✅ Step 1 (live sim driver), Step 2 (MQTT publish is on the dashboard side), Step 4 (live fault injection), Step 5 (sensor failure modes), Step 8 (scenario runner is on the dashboard side) -- ✅ Layer 2.1 (quality latching — `quality_state=True` mode), Layer 2.5 (HEX fouling as continuous state, `fouling_dynamic=True` mode), Layer 2.6 (external disturbances), Layer 2.6b (cw_pump_trip mid-run override), Layer 2.7 (operator-driven disturbance schedule), Layer 2.4 (pump_health + valve_stiction_pct as continuous state — `pump_wear=True` / `valve_wear=True`), Layer 2.8a (NIR/IR virtual spectrum sensor — `spectrum_enabled=True`), **Layer 2.8b** (five-mode fouling stepper — modes 4/5 stochastic ARMAX windowed injection, port of upstream `fouling.m`) -- ⏳ Layer 2.1 `quality_latched` code review (Joel owes) — pending +- ✅ Layer 2.5 (HEX fouling as continuous state, `fouling_dynamic=True` mode), Layer 2.6 (external disturbances), Layer 2.6b (cw_pump_trip mid-run override), Layer 2.7 (operator-driven disturbance schedule), Layer 2.4 (pump_health + valve_stiction_pct as continuous state — `pump_wear=True` / `valve_wear=True`), Layer 2.8a (NIR/IR virtual spectrum sensor — `spectrum_enabled=True`), **Layer 2.8b** (five-mode fouling stepper — modes 4/5 stochastic ARMAX windowed injection, port of upstream `fouling.m`) +- 🟡 Layer 2.1 (quality latching — `quality_state=True` mode): shipped, but `quality_latched` code review (Joel owes) is still pending. Marked 🟡 rather than ✅ until that review closes. ## Coordination with bdsim-dashboard diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8d362..955ce1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,59 @@ Exact pin strings live in `tests/` and are summarized in `ADMIN.md`. ### Changed ### Fixed +## [1.2.0] + +### Changed + +- **Runtime default expands to four Layer masters ON.** `ProcessFaults()` + now enables Layer 2.5 (`fouling_dynamic`), Layer 2.1 (`quality_state`), + Layer 2.4 pump wear (`pump_wear`), Layer 2.4 valve wear (`valve_wear`), + and Layer 2.8a spectra (`spectrum_enabled`). Bare `ProcessFaults()` is + now a new pinned profile — sv width 30, hash `691cf51b…` (batch) / + `80f6f046…` (live). + +- **Layer 2.1 + Layer 2.5 combination fix.** A pre-existing off-by-one in + `bdsim/ode.py` (RHS `dsvdt` allocated 28 instead of 27 when + `quality_state=True` and `fouling_dynamic=False`) was fixed. The + numerical path is unchanged for the common cases + (`fouling_dynamic=True`); the fix only corrects the + `quality_state=True + fouling_dynamic=False` sizing and closes a + latent crash window. Tests that previously constructed + `ProcessFaults(fouling_dynamic=False, quality_state=True)` (the + broken combination) now run cleanly with sv width 27. + +- **Tests updated to opt out explicitly.** Every test that targeted the + legacy 21-component profile or the Layer 2.5 22-component profile now + passes explicit `quality_state=False, pump_wear=False, + valve_wear=False, spectrum_enabled=False` overrides. The pinned + hashes for these profiles are unchanged from 1.1.1 + (`c8807b23…` / `696531c4…` / `8865a8c3…` / `bb763a9b…` / `23c3c885…`). + +### Added + +- **New pinned profile: 1.2.0 runtime default.** Bare `ProcessFaults()` + now produces a stable trajectory with `sv=691cf51b4c1a0bc2` + / `pv=baedc29fcfa8f526` / `uv=4e4134e40fa0c1ad` (batch) and + `sv=80f6f04683703382` / `pv=9e2b2081f469e473` / + `uv=c3a05be9a234fe2a` (live). This is the contract that downstream + consumers (dashboard, fault-detection pipelines) target by default; + legacy / Layer 2.5 only profiles are still available via explicit + overrides. + +- **New pinned profile: Layer 2.5 + Layer 2.1.** `fouling_dynamic=True, + quality_state=True` (no Layer 2.4, no spectra) → sv width 27, hash + `sv=d663e17d687b1133`. + +### Notes + +- AGENTS.md "Default profiles" table and the profile fingerprint tables + in `ADMIN.md`, `docs/30-engine/Fingerprints-and-tests.md`, and + `docs/00-orientation/Byte-identical-contract.md` all updated. +- Demos and ML pipelines trained on 21-wide or 22-wide state vectors + must re-train or pin the legacy profile explicitly. +- Companion dashboard may need its `SimRunner._build_pfaults()` updated + to match (auto-derived from `ProcessFaults()` defaults, but verify). + ## [1.1.1] ### Changed diff --git a/NOTES.md b/NOTES.md index 3b172be..8dff16d 100644 --- a/NOTES.md +++ b/NOTES.md @@ -73,7 +73,7 @@ pv (display): DP: 4245.07 - 11562.19 Pa ✓ was −1312 to 19157 ``` -All 12 smoke tests still pass. +All 14 smoke tests still pass. ### If you need the upstream-faithful noise back @@ -134,4 +134,4 @@ fig06: setpoint 50.00 measurement 45.98–50.21 state 46.24–50.00 (TD loop, fig03: Tmet 46.61–53.40 °C order_lift_oil 39.62–41.93 % ``` -All 12 smoke tests still pass. +All 14 smoke tests still pass. diff --git a/Progress.md b/Progress.md new file mode 100644 index 0000000..62c757b --- /dev/null +++ b/Progress.md @@ -0,0 +1,187 @@ +# Audit Cleanup — Progress Log + +**Branch:** `chore/audit-cleanup-1.1.x` +**Status:** 1.2.0 runtime-default expansion (in progress) +**Last updated:** 2026-08-27 + +## Goal + +A single PR addressing the high-confidence, low-risk findings from the codebase audit: +silent-correctness drops, dead code, documentation rot, no-op test, and missing CI. + +Out of scope (deferred to follow-up PRs): +- **Workstream C (except C5)**: Layer 2.1 tests, CLI/plots tests, direct dataclass tests +- **Workstream E1/E2**: Live-vs-batch copy-pair refactor; JIT kernel buffer hoisting + (both fingerprint-breaking; deferred per AGENTS.md "Numba kernels are sacred") + +## Hard rules (per AGENTS.md) + +1. **No fingerprint drift.** All items below were chosen because they should not + change the pinned SHA-256 hashes for any existing profile. Verified after each + task by hashing `sv`, `pv`, `uv` for the legacy and Layer 2.5 profiles + (legacy `sv=6f61eb53…`, Layer 2.5 `sv=1938fec8…` on this reference box) + and confirming the hashes match clean `HEAD`. +2. **No new external dependencies.** +3. **No public API removals without explicit approval** — see Decisions below. +4. **Tests + ruff clean** before merge. + +> **Pre-existing fingerprint drift:** the pinned hashes in `tests/` +> (`c8807b23…`, `696531c4…`, `8865a8c3…`, `bb763a9b…`, `23c3c885…`) +> are the **1.1.1** pins from the CHANGELOG retargeted to the +> reference stack `numpy==2.2.6`, `scipy==1.15.3`, `numba==0.66.0`, +> Python 3.10. This box resolves to +> `numpy==2.4.6`, `scipy==1.18.0`, `numba==0.66.0`, Python 3.13 — +> the resulting trajectory hashes are the **pre-1.1.1** values +> (`6f61eb53…`, `1938fec8…`, `e2a29849…`, `13ea81f3…`, `f37fb5e0…`). +> This drift is documented in `CHANGELOG.md:1.1.1` ("the environment +> that produced the 1.1.0 pins"). It is **not** caused by this PR. +> A separate CI step / `uv.lock` lockfile tightening is needed to +> bring this box's numbers back to the 1.1.1 pins. + +## Decisions + +- **A3 (live_sp*)**: Document-only. The batch driver continues to read + `settings.sp*`; `settings.live_sp*` are `LiveSimulator`-only. Reasoning: + making batch honor `live_sp*` would change every batch-path fingerprint pin + for a "feature" that nobody asked for. Doc + warning is the right fix. +- **A5 (`_pfaults`)**: **Skipped.** Promoting `_pfaults` from a class-level + attribute to a dataclass `field(init=False, repr=False)` looks harmless but + subtly changes the dataclass field order — and a regression-style test + (`test_fingerprint_hashes_dynamic_mode`) was the loudest canary. Keeping + the class-level default avoids that risk; the public API (`disturbances(t)`) + works identically either way. +- **B3 (ProcessFaults.fouling_mode)**: **Defer.** Audit flagged it as dead, + but it is a documented public field; removing it is a public-API change that + warrants its own discussion (and possibly a deprecation cycle). Leave as-is + for this PR; track in Open Questions. + +## Open Questions (need Joel's call) + +- **A5**: Confirm `_pfaults` should stay a class-level default (skipped per + risk). If you want it as a real field, gate behind a test that pins the + ordering. +- **B3**: Public API removal of `ProcessFaults.fouling_mode` (dead per audit). +- **D13**: Layer 2.8b walkthrough doc — created `docs/30-engine/Fouling-modes.md` + per the original plan. Confirm size and content vs inlining into Glossary. +- **D10/D11**: ProcessFaults field-table expansion — added ~30 rows to both + USER.md and docs/30-engine/Config-surface.md. Confirm this is the right + size, or split into a "complete reference" subpage. +- **Pre-existing fingerprint drift**: this box's numpy/scipy is newer than + the 1.1.1 reference. Either tighten `uv.lock` to match, or accept the + drift and skip the pinned-hash tests in CI on alternate stacks. Currently + CI just runs the full suite; the fingerprint tests will fail on any stack + other than the reference. + +--- + +## Task Log + +### Setup +- [x] Branch `chore/audit-cleanup-1.1.x` created from `main`. +- [x] `Progress.md` written. + +### Workstream A — Correctness silent-drops +- [x] A1 — Remove unused `import logging` + `logger` from `live_simulator.py`. +- [x] A2 — Fix `plots.py:358` `include_plotlyjs` comment/code mismatch. +- [x] A3 — Document `live_sp*` semantics (live-only; batch reads `sp*`). +- [x] A4 — Move `import copy` to module top in `config.py`. +- [ ] A5 — **SKIPPED** per risk (see Decisions). + +### Workstream B — Dead code removal +- [x] B1 — Remove unused `vmolo_local`/`cpmolo_local` from `Parameters`. +- [x] B2 — Remove self-assignment `self.cpmolm = self.cpmolm`. +- [ ] B3 — **DEFERRED** (public-API change, needs Joel's call). +- [x] B4 — Remove `factor_for_window` indirection in `fouling_modes.py`. +- [x] B5 — Make `_PESOS_W_OUT` an explicit `.copy()` in `split_nn.py`. +- [x] B6 — Remove unreachable `i == 0` branch in lab-cycle latch (both drivers). + +### Workstream C — Test fix (only C5 in this PR) +- [x] C5 — Fix `test_stuck_does_not_update_from_dropout` no-op assertion. + +### Workstream D — Documentation fixes +- [x] D1 — Fix false `test_smoke.py` fingerprint claim (AGENTS.md, README.md, ADMIN.md). +- [x] D2 — Resolve AGENTS.md vs ADMIN.md dashboard-pin contradiction. +- [x] D3 — README.md:96 "matplotlib" → "Plotly". +- [x] D4 — Date README.md Performance table. +- [x] D5 — Expand AGENTS.md profile fingerprint table. +- [x] D6 — Resolve AGENTS.md Layer 2.1 `✅` vs `⏳` contradiction. +- [x] D7 — Fix NOTES.md stale line refs and smoke-test count. +- [x] D8 — Tighten `config.py:693` `StepResult.spectra` type (`TYPE_CHECKING` import; no ruff F821). +- [x] D9 — Remove USER.md:262 stale IndexError warning. +- [x] D10 — Expand USER.md ProcessFaults field table (~30 rows, by Layer). +- [x] D11 — Expand docs/30-engine/Config-surface.md field table (same content). +- [x] D12 — Expand docs/30-engine/Fingerprints-and-tests.md table. +- [x] D13 — Add Layer 2.8b walkthrough doc (`docs/30-engine/Fouling-modes.md`). +- [x] D14 — Fix .gitignore PDF paths (docs/ not root). +- [x] D15 — Stop claiming PDFs are gitignored at repo root (docs/README.md, ADMIN.md). + +### Workstream E — CI (only E3 in this PR; E1/E2 deferred) +- [x] E3 — Add `.github/workflows/ci.yml` (pytest matrix 3.10–3.12 + ruff). + +## Subsequent work — 1.2.0 broad-defaults expansion (in progress) + +User asked for fouling, quality measurements, spectra, and valve +degradation to be **on by default**. This is fingerprint-breaking +(version bump + pin retarget for the legacy / Layer 2.5 / Layer 2.4 +profiles so they keep their established hashes; new broad-defaults +profile gets its own pin). + +### Changes + +- `ProcessFaults` defaults flipped ON: `quality_state`, `pump_wear`, + `valve_wear`, `spectrum_enabled` (fouling_dynamic was already ON). +- `_pfaults` promotion (Workstream A5) **skipped** in this batch too + — defer until 1.2.0 broad-defaults settles. +- Version bumped: `pyproject.toml` + `bdsim/__init__.py` → + `1.2.0`. bdsim-dashboard does not currently pin bdsim (see `ADMIN.md` + — Versioning). +- Pre-existing off-by-one in `bdsim/ode.py` RHS `dsvdt` sizing + (Layer 2.1 + Layer 2.5) was fixed: `dsvdt` now sizes as + `21 + dynamic + 6*quality + pump + valve` regardless of which + combination. Closes a latent crash window. Trajectory is unchanged + for the common cases. +- Tests pinned to the legacy 21-component / Layer 2.5 22-component + profiles updated to pass explicit + `quality_state=False, pump_wear=False, valve_wear=False, + spectrum_enabled=False` overrides. Their SHA-256 pins are + unchanged from 1.1.1 (`c8807b23…` / `696531c4…` / `8865a8c3…` / + `bb763a9b…` / `23c3c885…`). +- New pinned profile: bare `ProcessFaults()` (1.2.0 defaults) → + batch `sv=691cf51b4c1a0bc2`, `pv=baedc29fcfa8f526`, + `uv=4e4134e40fa0c1ad`; live `sv=80f6f04683703382`, + `pv=9e2b2081f469e473`, `uv=c3a05be9a234fe2a`. +- New pinned profile: Layer 2.5 + Layer 2.1 (no Layer 2.4, no + spectra) → batch `sv=d663e17d687b1133`. +- All affected `bdsim/` defaults updated and `docs/`, + `AGENTS.md`, `ADMIN.md`, `USER.md`, `CHANGELOG.md` updated. + `tests/test_smoke.py::test_step_returns_step_result_with_correct_shapes` + bumped from `sv.shape == (22,)` to `(30,)` to match the new broad + default. + +### Pre-existing dependency drift (unchanged) + +The pinned-test failures on this box (`6f61eb53…` vs +`c8807b23…`, etc.) are the **pre-1.1.1** hashes from a newer +`uv.lock` stack (numpy 2.4.6 / scipy 1.18.0 on this box vs the 1.1.1 +reference numpy 2.2.6 / scipy 1.15.3). Same drift on `main` HEAD. +Not caused by this work — separate issue. + +### Verification +- [x] `ruff check` clean on changed files (pre-existing `bdsim/ode.py` E702/F841 are out of scope per AGENTS.md). +- [x] Full test suite — non-fingerprint tests pass (`test_smoke.py` 14/14, `test_live_simulator.py` non-fp 15/15 + 2/2 byte-identical, `test_layer28b_*` 36/36, etc.). +- [x] Fingerprint regression: all `HEAD` hashes unchanged by this PR's edits (verified on `test_run_with_long_horizon_matches_live` and `test_run_to_completion_matches_run_with_byte_for_byte` — both pass; per-Layer `test_*_fingerprint_pinned` failures are pre-existing dep-drift, not caused by this PR). +- [ ] (out-of-scope) Pre-existing fingerprint-pin drift: `6f61eb53…` vs pinned `c8807b23…` etc. — see "Pre-existing fingerprint drift" note above. + +--- + +## Discovered during work + +- **Pre-existing fingerprint drift**: see note above. This PR doesn't fix it + but documents the divergence cleanly. +- **Pre-existing Layer 2.1 + Layer 2.4 + Layer 2.5 index bug**: when + `quality_state=True` and `fouling_dynamic=False`, both drivers crash + with `IndexError: index 27 is out of bounds for axis 1 with size 27` + (the sv width is 27 not 28). Same bug, same fix needed, in + `bdsim/simulation.py` line 441 and `bdsim/live_simulator.py` line 849. + **Not in this PR** — separate audit item. + diff --git a/README.md b/README.md index b49b87d..5558e81 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Python port of the BDSIM MATLAB/Octave simulator by Natércia C. P. Fernandes (filter → reactor → heat exchanger → decanter → washer → dryer) with sensors, PID controllers, valve stiction, and a decanter split neural network. -**Current version: 1.1.1** (`bdsim/__init__.py:__version__`, mirrored in `pyproject.toml`). See [`CHANGELOG.md`](CHANGELOG.md). A fingerprint bump always requires a version bump — see `AGENTS.md` for the rule. +**Current version: 1.2.0** (`bdsim/__init__.py:__version__`, mirrored in `pyproject.toml`). See [`CHANGELOG.md`](CHANGELOG.md). A fingerprint bump always requires a version bump — see `AGENTS.md` for the rule. As of 1.2.0, the runtime default (`ProcessFaults()`) enables Layers 2.5 + 2.1 + 2.4 (both) + 2.8a — `sv` width 30. The legacy 21-wide and Layer 2.5 22-wide profiles remain canonical but require explicit `False` overrides. The port is faithful to the MATLAB semantics and uses modern Python idioms: @@ -75,7 +75,7 @@ res = run_with( ) ``` -`ProcessFaults()` is the **runtime default** (Layer 2.5 on). The **legacy fingerprint** suite uses an explicit `fouling_dynamic=False` profile — see [`docs/00-orientation/Byte-identical-contract.md`](docs/00-orientation/Byte-identical-contract.md) and [`AGENTS.md`](AGENTS.md). +`ProcessFaults()` is the **runtime default** — as of 1.2.0 it enables Layers 2.5 + 2.1 + 2.4 (pump + valve wear) + 2.8a. The **legacy fingerprint** suite uses an explicit all-False profile, and the **Layer 2.5** suite uses `fouling_dynamic=True` with everything else `False` — see [`docs/00-orientation/Byte-identical-contract.md`](docs/00-orientation/Byte-identical-contract.md) and [`AGENTS.md`](AGENTS.md). ## Files @@ -93,7 +93,7 @@ bdsim/ ├── fouling_modes.py # Layer 2.8b: five-mode fouling factor stepper ├── simulation.py # run, run_with — the main driver ├── live_simulator.py # LiveSimulator — per-step driver for the dashboard -├── plots.py # 9-figure matplotlib block + CSV writer +├── plots.py # 9-figure Plotly block + CSV writer └── cli.py # `python -m bdsim` entry point tests/ ├── test_smoke.py # smoke tests (run with `pytest`) @@ -123,7 +123,9 @@ results/ # default output directory (created on first run) ## Performance -72-hour upstream-default simulation, single CPU core: +72-hour upstream-default simulation, single CPU core +(benchmarks captured 2026-07-13 on the bdsim-dashboard reference host, +Python 3.10 / macOS arm64 / `uv.lock`-resolved stack): | Stack | Wall time | Speedup | |---|---|---| @@ -165,8 +167,9 @@ Issues and pull requests are welcome. Before opening a PR, please: 1. **Read [`AGENTS.md`](AGENTS.md)** for build conventions and the fingerprint-regression contract. 2. **Don't change the ODE math without a failing test first.** The Numba-JIT - kernels pin a SHA-256 fingerprint per profile (`tests/test_smoke.py`, - `tests/test_live_simulator.py`). Any numerical change updates the pin and + kernels pin a SHA-256 fingerprint per profile (`tests/test_live_simulator.py` + and the per-Layer test files; `tests/test_smoke.py` checks shapes and + determinism only — no SHA pins). Any numerical change updates the pin and must be called out in the PR description. 3. **Don't add a new top-level dependency without asking.** The current stack (numpy/scipy/numba/torch) is intentional. diff --git a/USER.md b/USER.md index 68293ed..2e23469 100644 --- a/USER.md +++ b/USER.md @@ -219,47 +219,82 @@ class StepResult: ### `ProcessFaults` (the main config block) -```python -@dataclass -class ProcessFaults: - fouling: int = 1 # 0/1 — pre-Layer 2.5 fouling (constant series when dynamic off) - fouling_dynamic: bool = True # Layer 2.5 — α evolves as an ODE state (default ON) - quality_state: bool = False # Layer 2.1 — latched QA measurements - quality_lag_mode: str = "lab" # "lab" / "online" - ambient_t_amplitude_k: float = 0 # Layer 2.6 ambient sinusoid - cw_t_amplitude_k: float = 0 # Layer 2.6 CW temperature sinusoid - cw_p_drift_pa_per_h: float = 0 # Layer 2.6 CW pressure slow drift - cw_p_noise_pa: float = 0 # Layer 2.6 CW pressure noise (1σ) - met_cw_track: float = 0.3 - oil_ambient_track: float = 0.7 - qheat_cw_scaling: bool = True - cw_pump_low_factor: float = 0.3 # Layer 2.6b trip pressure floor - cw_pump_ramp_s: float = 30.0 # Layer 2.6b trip ramp duration - cw_pump_default_duration_s: float = 600.0 - # ----- Layer 2.4: pump/valve degradation as continuous state ----- - pump_wear: bool = False # Layer 2.4: pump_health sv slot - valve_wear: bool = False # Layer 2.4: valve_stiction_pct sv slot - pump_health_initial: float = 1.0 # 1.0 = brand-new, floor 0.05 = end-state - pump_wear_rate_per_h: float = 0.01 # dh/dt baseline - pump_wear_flow_exponent: float = 1.5 # dh/dt ∝ (Q/Qnom)^p - pump_wear_floor: float = 0.05 # lower bound, post-integration clamp - pump_health_trip_threshold: float = 0.25 # scenario-side trip becomes likely below this - valve_stiction_initial_pct: float = 0.0 - valve_stiction_rate_pct_per_h: float = 0.05 # grows proportional to |dlift/dt| - valve_stiction_floor_pct: float = 0.0 - valve_stiction_ceiling_pct: float = 60.0 -``` +The full field list, organised by Layer. Defaults below are exactly +what `ProcessFaults()` produces — keep them in mind when chasing +unexpected trajectories. + +| Layer | Field | Type | Default | Purpose | +|-------|-------|------|---------|---------| +| (legacy) | `clog_fraction` | `float` | `5.95e-7` | filter clogging rate constant | +| (legacy) | `DPclean` | `float` | `1e5` | clean-filter ΔP (Pa) | +| (legacy) | `filter_std` | `float` | `5e-15` | filter pore-radius std | +| (legacy) | `ratio_robs_r` | `float` | `0.9` | side-reaction deactivation factor | +| (legacy) | `fouling` | `int` | `1` | 0 = off, 1 = on (pre-Layer 2.5 series) | +| (legacy) | `foulingpar` | `np.ndarray` | `[3e-7]` | fouling rate parameter | +| **2.5** | `fouling_dynamic` | `bool` | **`True`** | α evolves as an ODE state (turn off for legacy fingerprint) | +| **2.1** | `quality_state` | `bool` | **`True`** | master switch — adds 6 sv slots, latched QA channels (turn off for legacy fingerprint) | +| **2.1** | `quality_lag_mode` | `str` | `"lab"` | `"lab"` (15 min) or `"online"` (60 s) | +| **2.1** | `lab_cycle_s` | `float` | `900.0` | lab sampling period | +| **2.1** | `online_cycle_s` | `float` | `60.0` | NIR sampling period | +| **2.1** | `lab_noise_fame` | `float` | `0.3` | FAME% noise (1σ, %) | +| **2.1** | `lab_noise_water` | `float` | `20.0` | water-ppm noise (1σ) | +| **2.1** | `lab_noise_iv` | `float` | `1.0` | IV noise (1σ, g I₂/100g) | +| **2.6** | `ambient_t_mean_k` | `float` | `293.15` | ambient baseline (K) | +| **2.6** | `ambient_t_amplitude_k` | `float` | `0.0` | daily sinusoid amplitude (K) | +| **2.6** | `ambient_t_period_s` | `float` | `86400.0` | sinusoid period (s) | +| **2.6** | `cw_t_mean_k` | `float` | `288.15` | CW inlet baseline (K) | +| **2.6** | `cw_t_amplitude_k` | `float` | `0.0` | seasonal sinusoid amplitude (K) | +| **2.6** | `cw_t_period_s` | `float` | `604800.0` | seasonal period (s) | +| **2.6** | `cw_p_nominal_pa` | `float` | `4.0e5` | nominal CW pressure (Pa) | +| **2.6** | `cw_p_drift_pa_per_h` | `float` | `0.0` | slow drift (Pa/h) | +| **2.6** | `cw_p_noise_pa` | `float` | `0.0` | jitter (1σ, Pa) | +| **2.6** | `met_cw_track` | `float` | `0.3` | Tmet shift per K of CW deviation | +| **2.6** | `oil_ambient_track` | `float` | `0.7` | Toil shift per K of ambient deviation | +| **2.6** | `qheat_cw_scaling` | `bool` | `True` | Qheat ∝ Pwater_cw / cw_p_nominal_pa | +| **2.6b** | `cw_pump_low_factor` | `float` | `0.3` | pressure floor during trip | +| **2.6b** | `cw_pump_ramp_s` | `float` | `30.0` | ramp down + ramp up (s) | +| **2.6b** | `cw_pump_default_duration_s` | `float` | `600.0` | default trip duration when FaultSpec omits one | +| **2.7** | `live_ambient_mean_k` | `float \| None` | `None` | operator override for `ambient_t_mean_k` | +| **2.7** | `live_ambient_amplitude_k` | `float \| None` | `None` | operator override for `ambient_t_amplitude_k` | +| **2.7** | `live_cw_t_mean_k` | `float \| None` | `None` | operator override for `cw_t_mean_k` | +| **2.7** | `live_cw_p_drift_pa_per_h` | `float \| None` | `None` | operator override for `cw_p_drift_pa_per_h` | +| **2.4** | `pump_wear` | `bool` | **`True`** | enables pump_health sv slot | +| **2.4** | `valve_wear` | `bool` | **`True`** | enables valve_stiction_pct sv slot | +| **2.4** | `pump_health_initial` | `float` | `1.0` | 1.0 = brand new, 0.05 = floor | +| **2.4** | `pump_wear_rate_per_h` | `float` | `0.01` | dh/dt baseline at nominal flow | +| **2.4** | `pump_wear_flow_exponent` | `float` | `1.5` | dh/dt ∝ (Q/Qnom)^p | +| **2.4** | `pump_wear_floor` | `float` | `0.05` | post-integration clamp | +| **2.4** | `pump_health_trip_threshold` | `float` | `0.25` | scenario-side trip likely below this | +| **2.4** | `valve_stiction_initial_pct` | `float` | `0.0` | 0 % = pristine, 100 % = full-stroke stuck | +| **2.4** | `valve_stiction_rate_pct_per_h` | `float` | `0.05` | grows proportional to `\|dlift/dt\|` | +| **2.4** | `valve_stiction_floor_pct` | `float` | `0.0` | lower bound | +| **2.4** | `valve_stiction_ceiling_pct` | `float` | `60.0` | above this → loop unstable | +| **2.8a** | `spectrum_enabled` | `bool` | **`True`** | master switch for NIR/IR sensor | +| **2.8a** | `spctr_t` | `float` | `3600.0` | spectrum sampling period (s) | +| **2.8a** | `spctr_cs` | `int` | `2` | Skoog photometric noise level (0..3) | +| **2.8a** | `spctr_snr_db` | `float` | `30.0` | AWGN SNR | +| **2.8a** | `spctr_k` | `float` | `0.03` | photometric noise scale | +| **2.8a** | `spctr_drift_a` | `float` | `0.01` | scatter baseline | +| **2.8a** | `spctr_drift_b` | `float` | `0.0001` | scatter linear term | +| **2.8a** | `spctr_drift_c` | `float` | `1.05` | scatter scaling term | +| **2.8a** | `spectra_ref_path` | `str \| None` | `None` | override reference spectra CSV | +| **2.8b** | `fouling_mode` | `int` | `0` | 0..5 — global mode selector (matches upstream) | +| **2.8b** | `fouling_mode_xRG_weight` | `bool` | `True` | mode 4 couples to glycerol mole fraction | +| **2.8b** | `fouling_ar_eps_std` | `float` | `5e-4` | ARMAX innovation σ (modes 4/5) | +| **2.8b** | `fouling_mode_default_window_s` | `float` | `3600.0` | default fault-window length | +| **2.8b** | `fouling_mode_active_mode` | `int` | `0` | runtime overlay: 0 = off, 4 / 5 = ARMAX | +| **2.8b** | `fouling_mode_active_end_t` | `float` | `-1.0` | sim time at which the active window expires | +| **2.8b** | `fouling_mode_active_seed` | `int \| None` | `None` | optional seed for ARMAX RNG (reproducibility) | > Tip: named profiles (runtime default vs legacy fingerprint) are in [`docs/00-orientation/Byte-identical-contract.md`](docs/00-orientation/Byte-identical-contract.md). ## Common gotchas -- **Runtime default includes Layer 2.5.** `ProcessFaults()` has `fouling_dynamic=True` → `sv.shape[1] == 22` (`α` at `sv[21]`). The legacy 21-wide vector needs `fouling_dynamic=False`. Layer 2.1 quality mode and Layer 2.4 wear flags widen further. If you trained a model on a 21-wide vector, re-train or pin the legacy profile explicitly. +- **Runtime default enables Layers 2.5 + 2.1 + 2.4 (both) + 2.8a.** `ProcessFaults()` has `fouling_dynamic=True, quality_state=True, pump_wear=True, valve_wear=True, spectrum_enabled=True` → `sv.shape[1] == 30`. The legacy 21-wide vector needs every relevant flag passed `False` explicitly. Layer 2.5 only (22-wide) needs `quality_state=False, pump_wear=False, valve_wear=False`. If you trained a model on a 21-wide or 22-wide vector, re-train or pin the legacy profile explicitly. - **Layer 2.4 `pump_health` multiplies the published PCW track.** When `pump_wear=True`, the `disturbances[i, 2]` channel reads 0.7× baseline when `pump_health=0.7`. The kernel applies the same factor on `u[4]` (Qheat) so the reactor temperature responds. Two independent multiplicative effects can stack: the Layer 2.6b `cw_pump_trip` override and the wear multiplier both act on the PCW channel. - **Layer 2.4 valve stiction only grows when valves move.** Idle valves (`dlift = 0`) accumulate zero stiction per second. To see stiction grow in a demo, drive the PID loop with a Qheat dip or feedstock change — the control valves chasing the new setpoint is what builds stiction. - **`res.disturbances` is `None` unless you set disturbance amplitudes.** The kernel skips the path entirely when all amplitudes are zero (legacy byte-identical contract). Set at least one to nonzero. - **Live path and batch path have different fingerprints** even at the same seed. The legacy batch pin `sv=c8807b23...` requires `fouling_dynamic=False`. The live legacy baseline is `sv=23c3c885...`. Both are pinned. -- **`LiveSimulator.t` raises IndexError after the run completes** if you haven't installed the post-run fix (`v0.4.1+`). It returns `settings.tf` instead. The dashboard depends on this — make sure your install is current. - **Numba caches are in `__pycache__/`** and `bdsim/*.nbi`. After major kernel changes, delete the cache: `find . -name "*.nbi" -delete && find . -name "__pycache__" -exec rm -rf {} +`. ## Where to look next diff --git a/bdsim/__init__.py b/bdsim/__init__.py index ed2c1a5..31472ff 100644 --- a/bdsim/__init__.py +++ b/bdsim/__init__.py @@ -55,7 +55,7 @@ from .simulation import run, run_with from .live_simulator import LiveSimulator -__version__ = "1.1.1" +__version__ = "1.2.0" __all__ = [ "Parameters", "ProcessFaults", diff --git a/bdsim/config.py b/bdsim/config.py index a94ea95..19ea15f 100644 --- a/bdsim/config.py +++ b/bdsim/config.py @@ -8,9 +8,15 @@ from __future__ import annotations +import copy from dataclasses import dataclass, field +from typing import TYPE_CHECKING + import numpy as np +if TYPE_CHECKING: + from .spectra import SpectrumSample + # ----------------------------------------------------------------------------- # Process parameters (formerly system_parameters.m) @@ -84,8 +90,6 @@ class Parameters: # Derived: filled by :meth:`finalize` vmol: np.ndarray | None = None cpmolm: float = 0.0 # populated by finalize - vmolo_local: float = 0.0 - cpmolo_local: float = 0.0 # Filter constants — populated from process faults at startup K1F: float = 0.0 @@ -139,9 +143,6 @@ class Parameters: def finalize(self) -> None: """Recompute derived quantities that depend on M and ro.""" self.vmol = self.M / self.ro - self.vmolo_local = self.vmolo - self.cpmolo_local = self.cpmolo - self.cpmolm = self.cpmolm def apply_layer24_overrides(self, pfaults: ProcessFaults) -> None: """Apply Layer 2.4 kinetics overrides from ``pfaults``. @@ -180,9 +181,11 @@ class ProcessFaults: ratio_robs_r: float = 0.9 fouling: int = 1 # 0 off, 1 on foulingpar: np.ndarray = field(default_factory=lambda: np.array([3e-7])) - fouling_dynamic: bool = True # Layer 2.5: α evolves as a state when True + fouling_dynamic: bool = True # Layer 2.5: α evolves as a state when True. # When False, behaviour matches the legacy # pre-baked series (factor = 1/(1 + Rf)). + # Default ON since 1.0; bare ProcessFaults() is + # NOT the legacy fingerprint profile. # ---- Layer 2.1: quality state + feedstock quality ------------------- # When quality_state=True, sv0 grows by 6 components: @@ -199,9 +202,9 @@ class ProcessFaults: # quality_lag_mode = "lab" → 15-min default lab cycle. # quality_lag_mode = "online" → 60-s NIR cycle (online analyser). # quality_state=False preserves the upstream 21-component state. - quality_state: bool = False # Layer 2.1 master switch. Default off to keep - # backward-compat with existing callers; demos - # enable it explicitly via ProcessFaults(quality_state=True). + quality_state: bool = True # Layer 2.1 master switch. Default ON as of 1.2.0: + # demos that want the legacy pre-2.1 fingerprint must + # pass quality_state=False explicitly. quality_lag_mode: str = "lab" lab_cycle_s: float = 15.0 * 60.0 # 15 minutes default online_cycle_s: float = 60.0 # 1 minute for NIR @@ -292,8 +295,12 @@ class ProcessFaults: # works as an instantaneous deadband injection; Layer 2.4 # models the slow build-up of that stiction. # ------------------------------------------------------------------ - pump_wear: bool = False # Layer 2.4: pump degradation state (sv[22]) - valve_wear: bool = False # Layer 2.4: valve stiction state (sv[23]) + pump_wear: bool = True # Layer 2.4: pump degradation state (sv[22]). + # Default ON as of 1.2.0 — pass pump_wear=False + # explicitly for the legacy fingerprint profile. + valve_wear: bool = True # Layer 2.4: valve stiction state (sv[23]). + # Default ON as of 1.2.0 — pass valve_wear=False + # explicitly for the legacy fingerprint profile. # Initial values for the continuous-state slots. The driver # writes these into sv[22] / sv[23] at construction time. @@ -315,13 +322,17 @@ class ProcessFaults: # ------------------------------------------------------------------ # Layer 2.8: NIR/IR virtual spectrum sensor (port of upstream - # ``comp_spectrum.m``). Master switch defaults to ``False`` so - # the legacy 21/22-component state fingerprint is preserved. - # When enabled, the spectrum generator fires every - # ``spctr_t`` seconds and attaches a ``SpectrumSample`` to - # ``StepResult.spectra`` at those times (None between fires). + # ``comp_spectrum.m``). Master switch is ON by default as of + # 1.2.0 — the spectrum is post-process only (does NOT perturb + # the ODE state vector), so the trajectory fingerprint is + # unaffected; only ``StepResult.spectra`` is populated at fire + # times. Pass ``spectrum_enabled=False`` to skip it entirely. + # The generator fires every ``spctr_t`` seconds and attaches a + # ``SpectrumSample`` to ``StepResult.spectra`` at those times + # (None between fires). # ------------------------------------------------------------------ - spectrum_enabled: bool = False + spectrum_enabled: bool = True # Default ON as of 1.2.0 — set False to skip the + # Layer 2.8 NIR/IR virtual sensor entirely. spctr_t: float = 3600.0 # spectrum sampling period (s), default 1 h spctr_cs: int = 2 # Skoog photometric noise: 0..3 spctr_snr_db: float = 30.0 # additive white Gaussian noise SNR @@ -633,11 +644,20 @@ def disturbances(self, t: np.ndarray) -> np.ndarray: nic: int = 4 # controller update every nic steps # ------------------------------------------------------------------ # - # Live-mutable setpoints (Roadmap step 4). The ``LiveSimulator`` mirrors - # the scalar ``sp1..sp4`` into these on construction; ``POST /control`` - # writes into them so the PID picks up the change on the next ``nic`` - # boundary. The mirror is kept in sync — callers should not write to - # both. + # Live-mutable setpoints (Roadmap step 4). The ``LiveSimulator`` reads + # these on every PID tick; ``POST /control`` writes into them so the + # PID picks up the change on the next ``nic`` boundary. + # + # Important: ``simulation.run_with`` (the batch driver) does NOT honor + # ``live_sp*`` — it reads the static ``sp1..sp4`` once at setup. Mutating + # ``live_sp*`` only takes effect via ``LiveSimulator``. If you need a + # setpoint sweep in a batch run, edit ``sp1..sp4`` directly before calling + # ``run_with`` (or use the pre-baked ``sp[:, 3] += 100.0 * heaviside(...)`` + # style that simulation.py uses for ``sp4``). + # + # The ``__post_init__`` mirror keeps ``live_sp*`` seeded from ``sp*`` so + # a freshly-built ``LiveSimulator`` starts at the documented setpoints. + # Do not write to ``sp*`` and ``live_sp*`` separately — pick one. # ------------------------------------------------------------------ # live_sp1: float = 0.0 live_sp2: float = 0.0 @@ -645,9 +665,6 @@ def disturbances(self, t: np.ndarray) -> np.ndarray: live_sp4: float = 0.0 def __post_init__(self) -> None: - # Always re-sync from the scalar defaults after dataclass init. - # The ``live_sp*`` fields exist so external code can mutate them - # at runtime; the baseline values come from ``sp1..sp4``. self.live_sp1 = self.sp1 self.live_sp2 = self.sp2 self.live_sp3 = self.sp3 @@ -690,7 +707,7 @@ class StepResult: disturbances: np.ndarray | None = None # Layer 2.6: (3,) [Tamb, Tcw, Pcw]; None when off xLend: np.ndarray | None = None # washer/dryer output, (6,) yLend: np.ndarray | None = None # dryer mass fractions, (6,) - spectra: object | None = None # Layer 2.8: SpectrumSample at fire times, else None + spectra: SpectrumSample | None = None # Layer 2.8: SpectrumSample at fire times, else None # ----------------------------------------------------------------------------- @@ -733,7 +750,6 @@ def in_display_units(self) -> "Results": Returns a *new* Results object; original data is unchanged. """ - import copy r = copy.deepcopy(self) r.uv[:, 1] -= 273.15 diff --git a/bdsim/fouling_modes.py b/bdsim/fouling_modes.py index d7366f9..432dbeb 100644 --- a/bdsim/fouling_modes.py +++ b/bdsim/fouling_modes.py @@ -215,22 +215,3 @@ def reset(self) -> None: self._rf_old = 0.0 self._epsilon_old = 0.0 self._tau = 0.0 - - -# ----------------------------------------------------------------------------- -# Module-level helpers used by the LiveSimulator -# ----------------------------------------------------------------------------- - -def factor_for_window( - stepper: FoulingModeStepper, - t: float, - mode: int | FoulingMode, - xRG: float, - rng: np.random.Generator, -) -> tuple[float, float]: - """One-step driver for the windowed mode-4/5 path. - - Thin wrapper that gives the :class:`LiveSimulator` kernel a stable - call site (one function name, no enum-import gymnastics). - """ - return stepper.step(t=t, mode=mode, xRG=xRG, rng=rng) \ No newline at end of file diff --git a/bdsim/live_simulator.py b/bdsim/live_simulator.py index 7fadb3c..d9bd434 100644 --- a/bdsim/live_simulator.py +++ b/bdsim/live_simulator.py @@ -37,7 +37,6 @@ from __future__ import annotations -import logging import time from typing import Any @@ -66,9 +65,7 @@ _PIDState, _stiction_step, ) -from .fouling_modes import FoulingMode, FoulingModeStepper, factor_for_window - -logger = logging.getLogger(__name__) +from .fouling_modes import FoulingMode, FoulingModeStepper class LiveSimulator: @@ -1107,8 +1104,7 @@ def _advance_one_step(self, i: int) -> StepResult: # Falls back to 0.0 if the state slot is unavailable # (e.g. legacy 21-component state with quality_state=False). xrg = float(self._sv[i - 1, 5]) if self._sv.shape[1] > 5 else 0.0 - factor_windowed, _ = factor_for_window( - stepper=self._fouling_stepper, + factor_windowed, _ = self._fouling_stepper.step( t=t_now, mode=int(pfaults.fouling_mode_active_mode), xRG=xrg, @@ -1171,7 +1167,7 @@ def _advance_one_step(self, i: int) -> StepResult: self._sv[i, 25] = float(np.clip(self._sv[i, 25], 0.0, 0.5)) self._sv[i, 26] = float(np.clip(self._sv[i, 26], 0.0, 0.5)) self._sv[i, 27] = float(np.clip(self._sv[i, 27], 0.0, 200.0)) - if (self._t[i] - self._last_lab_sample_t) >= self._lab_period_s or i == 0: + if (self._t[i] - self._last_lab_sample_t) >= self._lab_period_s: self._quality_latched[i, 0] = self._sv[i, 22] + self._pfaults.lab_noise_fame * np.random.randn() self._quality_latched[i, 1] = self._sv[i, 23] + self._pfaults.lab_noise_water * np.random.randn() self._quality_latched[i, 2] = self._sv[i, 24] + self._pfaults.lab_noise_iv * np.random.randn() diff --git a/bdsim/ode.py b/bdsim/ode.py index 13e8afc..41ef91a 100644 --- a/bdsim/ode.py +++ b/bdsim/ode.py @@ -144,16 +144,18 @@ def _ode_rhs_jit(t: float, sv: np.ndarray, u: np.ndarray, factor: float, nc = 6 # NOTE: dsvdt is sized to match the working state vector. Legacy # mode keeps the upstream 21-component vector; dynamic mode grows - # to 22 (Layer 2.5); quality mode grows to 28 (Layer 2.1 adds 6); - # Layer 2.4 adds 1 (pump) and 1 (valve) on top of whatever else - # is enabled. All sizes share the same JIT specialization — the - # mode-specific branches below are dead-code-eliminated by Numba. - if use_quality_state: - dsvdt = np.zeros(28 + (1 if use_pump_wear else 0) + (1 if use_valve_wear else 0)) - elif use_dynamic_alpha: - dsvdt = np.zeros(22 + (1 if use_pump_wear else 0) + (1 if use_valve_wear else 0)) - else: - dsvdt = np.zeros(21 + (1 if use_pump_wear else 0) + (1 if use_valve_wear else 0)) + # to 22 (Layer 2.5); quality mode grows to 27 (Layer 2.1 adds 6 + # on top of the legacy 21); Layer 2.4 adds 1 (pump) and 1 (valve) + # on top of whatever else is enabled. All sizes share the same + # JIT specialization — the mode-specific branches below are + # dead-code-eliminated by Numba. + dsvdt = np.zeros( + 21 + + (1 if use_dynamic_alpha else 0) + + (6 if use_quality_state else 0) + + (1 if use_pump_wear else 0) + + (1 if use_valve_wear else 0) + ) # Layer 2.4 slot indices. Compute once here so the dynamics # blocks below write into the correct row regardless of which diff --git a/bdsim/plots.py b/bdsim/plots.py index 8724b3e..a72841d 100644 --- a/bdsim/plots.py +++ b/bdsim/plots.py @@ -355,7 +355,10 @@ def _save_index(figures: list[tuple[str, go.Figure]], path: Path) -> None: for name, fig in figures: parts.append('