From ffa147b0670dcd5157064a3077aa5162697104cc Mon Sep 17 00:00:00 2001 From: Joel Sansana Date: Thu, 27 Aug 2026 20:12:38 +0200 Subject: [PATCH 1/2] chore(audit): clean up codebase audit findings (1.1.x branch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-PR pass over the high-confidence, low-risk findings from the codebase audit: silent-correctness drops, dead code, doc rot, a no-op test, and missing CI. All changes preserve the byte-identical trajectory contract — verified by hashing legacy and Layer 2.5 sv against clean HEAD (identical hashes). Workstream A — silent-correctness drops - A1 Remove unused `import logging` + `logger` from live_simulator.py. - A2 Fix plots.py:358 `include_plotlyjs` comment (was `inline`, code is `cdn`). - A3 Document live_sp* semantics in Settings: LiveSimulator-only; batch reads sp*. Prevents accidental "make batch honor live_sp*" fingerprint-breaking refactors. - A4 Move `import copy` to module top in config.py. - A5 SKIPPED. Promoting `_pfaults` from class-level default to a dataclass field(init=False, repr=False) is technically a behavior change in dataclass field ordering. Per AGENTS.md "Numba kernels are sacred", a riskier change deserves its own discussion. Tracked in Progress.md "Decisions" + "Open Questions". Workstream B — dead code removal - B1 Remove unused Parameters.vmolo_local / .cpmolo_local (and their finalize() writes). - B2 Remove self-assignment `self.cpmolm = self.cpmolm` in finalize(). - B3 DEFERRED. ProcessFaults.fouling_mode is documented public API; removal needs Joel's call (see Open Questions). - B4 Drop the `factor_for_window` thin pass-through helper in fouling_modes.py and the corresponding test in test_layer28b_fouling_modes.py. LiveSimulator calls `self._fouling_stepper.step(...)` directly. - B5 `_PESOS_W_OUT = _PESOS_W.copy()` — explicit copy in split_nn.py removes an aliasing hazard. - B6 Remove unreachable `i == 0` from lab-cycle latch in both run_with and LiveSimulator.step. last_lab_sample_t is initialised to -inf which already forces a sample at t=0. Workstream C — test fix - C5 Rewrite test_stuck_does_not_update_from_dropout to actually exercise the path: dropout first (NaN published), then stuck (must latch onto the cached finite value, not the NaN). Workstream D — documentation fixes - D1 Fix the false "test_smoke.py fingerprint regression" claim in AGENTS.md, ADMIN.md, and README.md. test_smoke.py checks shapes, physical ranges, and determinism only — no SHA pins. The pinning lives in test_live_simulator.py + per-Layer tests. - D2 Resolve AGENTS.md vs ADMIN.md contradiction on the dashboard's bdsim-version pin (the dashboard does NOT pin today). - D3 README.md "matplotlib" → "Plotly". - D4 Date the Performance table ("captured 2026-07-13 on the bdsim-dashboard reference host"). - D5 Expand AGENTS.md profile fingerprint table: 9 rows now including Layer 2.4 pump-only / valve-only / both and the Layer 2.6b row. - D6 Resolve the Layer 2.1 "✅ shipped but pending review" contradiction in AGENTS.md by collapsing both lines into a single 🟡 row. - D7 NOTES.md smoke-test count "12" → "14" (two occurrences). - D8 Tighten config.py:695 StepResult.spectra type from `object | None` to `SpectrumSample | None` (TYPE_CHECKING import — no runtime cost, no ruff F821). - D9 Remove USER.md stale IndexError warning for sim.t after done — fixed in v0.4.1 (test_sim_t_after_done_returns_settings_tf). - D10 USER.md ProcessFaults table expanded to ~30 rows by Layer. - D11 docs/30-engine/Config-surface.md same expansion (mirrors USER.md). - D12 docs/30-engine/Fingerprints-and-tests.md table expanded to match AGENTS.md. - D13 New docs/30-engine/Fouling-modes.md — five-mode stepper walkthrough with three-layer priority diagram, mode table, determinism notes, ProcessFaults knobs, and test pointers. Linked from docs/Home.md "Engine" map. - D14 .gitignore: PDF paths moved under docs/ (Fernandes2019_BDSIM.pdf and manual.pdf). - D15 docs/README.md + ADMIN.md: stop claiming PDFs are gitignored at the repo root (they're under docs/ now). Workstream E — CI - E3 New .github/workflows/ci.yml: pytest matrix on Python 3.10 / 3.11 / 3.12 via uv sync --extra test, plus a ruff job. Cancels in-flight runs on the same branch. Verification - `ruff check` clean on all changed files (remaining 22 issues are pre-existing bdsim/ode.py E702/F841, out of scope per AGENTS.md). - Non-fingerprint tests pass: test_smoke.py 14/14, test_live_simulator non-fp 15/15 + byte-identical contract 2/2, test_layer28b_* 36/36. - Fingerprint regression: legacy sv=6f61eb53... and Layer 2.5 sv=1938fec8... match clean HEAD — this PR introduces no fingerprint drift. Pre-existing (out of scope, tracked in Progress.md "Discovered during work") - The 1.1.1 pinned hashes (c8807b23... / 696531c4... / 8865a8c3... / bb763a9b...) are retargeted to numpy==2.2.6 / scipy==1.15.3 (CHANGELOG 1.1.1). This box resolves to numpy==2.4.6 / scipy==1.18.0, so the pinned tests fail with the pre-1.1.1 hash. Documented in Progress.md. - Layer 2.1 + Layer 2.4 + Layer 2.5 combination crashes with IndexError: index 27 is out of bounds for axis 1 with size 27 (sv width off-by-one). Same bug, same fix needed, in bdsim/simulation.py:441 and bdsim/live_simulator.py:849. Separate audit item. Progress.md captures the full task log, decisions, and open questions. --- .github/workflows/ci.yml | 61 ++++++++++ .gitignore | 4 +- ADMIN.md | 4 +- AGENTS.md | 25 ++-- NOTES.md | 4 +- Progress.md | 139 +++++++++++++++++++++++ README.md | 11 +- USER.md | 97 +++++++++++----- bdsim/config.py | 36 +++--- bdsim/fouling_modes.py | 19 ---- bdsim/live_simulator.py | 10 +- bdsim/plots.py | 5 +- bdsim/simulation.py | 2 +- bdsim/split_nn.py | 2 +- docs/30-engine/Config-surface.md | 76 +++++++++++-- docs/30-engine/Fingerprints-and-tests.md | 25 ++-- docs/30-engine/Fouling-modes.md | 119 +++++++++++++++++++ docs/Home.md | 1 + docs/README.md | 2 +- tests/test_layer28b_fouling_modes.py | 17 --- tests/test_live_simulator.py | 43 +++++-- 21 files changed, 559 insertions(+), 143 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Progress.md create mode 100644 docs/30-engine/Fouling-modes.md 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..3afc1d1 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -89,7 +89,7 @@ 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`): @@ -145,7 +145,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..d314b64 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 @@ -65,12 +65,17 @@ NOTES.md # historical: upstream-faithful bugs we found and fixed - **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…` | + | Profile | Construction | `sv` width | Pins (full SHA-256[:16]) | + |--------|--------------|-------------|----------------------------| + | Runtime default | `ProcessFaults()` | 22 | Layer 2.5 path; **not** the legacy hash (no pinned family) | + | Legacy fingerprint | `fouling_dynamic=False`, `quality_state=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` | 28 | (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` | 23 | batch `sv=7ddd7aaa…` `pv=e0dba881…` `uv=86c2704f…` | + | Layer 2.4 valve-only | `pump_wear=False`, `valve_wear=True`, `fouling_dynamic=True` | 23 | batch `sv=9425d007…` `pv=02296ffa…` `uv=aa146ea3…` | + | Layer 2.4 both | `pump_wear=True`, `valve_wear=True`, `fouling_dynamic=True` | 24 | batch `sv=c092fe08…` `pv=2d26f03f…` `uv=4ef50b9f…` | + | Layer 2.6 active | `fouling_dynamic=True`, `ambient_t_amplitude_k` etc. > 0 | 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/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..b59b788 --- /dev/null +++ b/Progress.md @@ -0,0 +1,139 @@ +# Audit Cleanup — Progress Log + +**Branch:** `chore/audit-cleanup-1.1.x` +**Status:** Ready for review +**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). + +### 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..09a4b29 100644 --- a/README.md +++ b/README.md @@ -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..621230e 100644 --- a/USER.md +++ b/USER.md @@ -219,36 +219,72 @@ 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 (default ON; turn off for legacy fingerprint) | +| **2.1** | `quality_state` | `bool` | `False` | master switch — adds 6 sv slots, latched QA channels | +| **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` | `False` | enables pump_health sv slot | +| **2.4** | `valve_wear` | `bool` | `False` | 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` | `False` | 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). @@ -259,7 +295,6 @@ class ProcessFaults: - **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/config.py b/bdsim/config.py index a94ea95..d8ad4b9 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``. @@ -633,11 +634,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 +655,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 +697,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 +740,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/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('
') parts.append(f"

{name}

") - # include_plotlyjs='inline' avoids the CDN dependency for offline viewing + # include_plotlyjs='cdn' keeps the index page small; the per-figure + # standalone HTML files (write_html above) also use the CDN, so the + # page renders correctly whenever plotly.js is reachable. For fully + # offline viewing, set include_plotlyjs='inline' on both call sites. parts.append(fig.to_html(include_plotlyjs="cdn", full_html=False)) parts.append("
") parts.append("") diff --git a/bdsim/simulation.py b/bdsim/simulation.py index 9060852..e5146c8 100644 --- a/bdsim/simulation.py +++ b/bdsim/simulation.py @@ -702,7 +702,7 @@ def run_with( # Lab-cycle latching: at each lab boundary, sample the true # quality with analytical noise and hold the value until the # next cycle. This is the time-lag structure. - if (t[i] - last_lab_sample_t) >= lab_period_s or i == 0: + if (t[i] - last_lab_sample_t) >= lab_period_s: quality_latched[i, 0] = sv[i, 22] + pfaults.lab_noise_fame * np.random.randn() quality_latched[i, 1] = sv[i, 23] + pfaults.lab_noise_water * np.random.randn() quality_latched[i, 2] = sv[i, 24] + pfaults.lab_noise_iv * np.random.randn() diff --git a/bdsim/split_nn.py b/bdsim/split_nn.py index 4e40867..ba6fa70 100644 --- a/bdsim/split_nn.py +++ b/bdsim/split_nn.py @@ -29,7 +29,7 @@ [ 0.005798439102391745, 0.007933451534695593, 2.26649586627043, -0.2986375856202046, 0.7311754343879735], [-0.001364318678976015, 0.0007368268046611492, 0.3912844089747987, 0.002646628766137311, 0.1563496596243398], ]) -_PESOS_W_OUT = _PESOS_W # PyTorch nn.Linear weight is (out, in) +_PESOS_W_OUT = _PESOS_W.copy() # PyTorch nn.Linear weight is (out, in) _PESOS_W_HIDDEN = np.array([ [-0.2803986419064017, -0.1333039855614737, -0.4777087005027253], diff --git a/docs/30-engine/Config-surface.md b/docs/30-engine/Config-surface.md index 99420cd..6562d0e 100644 --- a/docs/30-engine/Config-surface.md +++ b/docs/30-engine/Config-surface.md @@ -20,16 +20,72 @@ All typed knobs live in `bdsim/config.py`. Public re-exports: `bdsim/__init__.py | `PIDController` | Gains and saturation | | `Results` / `StepResult` | Batch trajectories / live sample | -## ProcessFaults Layer flags (selected) - -| Flag | Layer | Default (code today) | Effect | -|------|-------|----------------------|--------| -| `quality_state` | 2.1 | `False` | QA states + latching; widens `sv` | -| `fouling_dynamic` | 2.5 | **`True`** | Continuous α at `sv[21]` | -| `pump_wear` / `valve_wear` | 2.4 | `False` | Wear states | -| `spectrum_enabled` | 2.8a | `False` | NIR/IR samples | -| disturbance amplitudes | 2.6 | `0.0` | Tamb / Tcw / Pcw tracks | -| `fouling_mode*` | 2.8b | off | Windowed ARMAX fouling | +## ProcessFaults field table + +Defaults shown are exactly what `ProcessFaults()` produces. + +| 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 (default ON; turn off for legacy fingerprint) | +| **2.1** | `quality_state` | `bool` | `False` | master switch — adds 6 sv slots, latched QA channels | +| **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` | `False` | enables pump_health sv slot | +| **2.4** | `valve_wear` | `bool` | `False` | 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` | `False` | 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) | > [!note] > **Exception:** Layer 2.5 is **default-on** (`fouling_dynamic=True`). Bare `ProcessFaults()` ≠ legacy fingerprint profile. Named profiles: [[Byte-identical-contract#Canonical profiles]]. diff --git a/docs/30-engine/Fingerprints-and-tests.md b/docs/30-engine/Fingerprints-and-tests.md index 336df1d..9d361cf 100644 --- a/docs/30-engine/Fingerprints-and-tests.md +++ b/docs/30-engine/Fingerprints-and-tests.md @@ -11,14 +11,23 @@ Truncated SHA-256 (16 hex) over a trajectory array — a **regression ID**, not ## Where pins live -Primarily `tests/test_smoke.py`, `tests/test_live_simulator.py`, and Layer-specific tests. Profile names match [[Byte-identical-contract#Canonical profiles]]: - -| Canonical profile | How tests build it | Example pin family | -|-------------------|--------------------|--------------------| -| Legacy fingerprint (batch) | `fouling_dynamic=False` etc. | `sv=c8807b23…` | -| Legacy fingerprint (live) | matching legacy knobs | `sv=23c3c885…` | -| Layer 2.5 fingerprint (batch) | `fouling_dynamic=True`, extras off | `sv=696531c4…` | -| Layer 2.6 batch / live | disturbance amplitudes on | `8865a8c3…` / `bb763a9b…` | +`tests/test_live_simulator.py` and the per-Layer test files pin SHA-256 +fingerprints over the full trajectory. `tests/test_smoke.py` checks +shapes, physical ranges, and determinism only — no SHA pins. Profile +names match [[Byte-identical-contract#Canonical profiles]]: + +| Profile | Construction | `sv` width | Pin family (full SHA-256[:16]) | +|---------|--------------|-------------|--------------------------------| +| Runtime default | `ProcessFaults()` | 22 | Layer 2.5 path; **no pinned family** | +| Legacy fingerprint (batch) | `fouling_dynamic=False`, all masters off | 21 | `sv=c8807b23…` `pv=77def506…` `uv=17e62051…` | +| Legacy fingerprint (live) | matching legacy knobs | 21 | `sv=23c3c885…` | +| Layer 2.5 fingerprint (batch) | `fouling_dynamic=True`, all masters off | 22 | `sv=696531c4…` `pv=3c96ca4f…` `uv=0d9a9673…` | +| Layer 2.5 + Layer 2.1 (batch) | `fouling_dynamic=True`, `quality_state=True` | 28 | (Layer 2.1 review pending — see Open Questions in `Progress.md`) | +| Layer 2.4 pump-only (batch) | `pump_wear=True`, `valve_wear=False`, `fouling_dynamic=True` | 23 | `sv=7ddd7aaa…` `pv=e0dba881…` `uv=86c2704f…` | +| Layer 2.4 valve-only (batch) | `pump_wear=False`, `valve_wear=True`, `fouling_dynamic=True` | 23 | `sv=9425d007…` `pv=02296ffa…` `uv=aa146ea3…` | +| Layer 2.4 both (batch) | `pump_wear=True`, `valve_wear=True`, `fouling_dynamic=True` | 24 | `sv=c092fe08…` `pv=2d26f03f…` `uv=4ef50b9f…` | +| Layer 2.6 active (batch) | `fouling_dynamic=True`, amplitudes > 0 | 22 | `sv=8865a8c3…` | +| Layer 2.6 active (live) | matching knobs | 22 | `sv=bb763a9b…` | (Exact strings are in the tests — always trust the test file over this note if they diverge.) Bare `ProcessFaults()` is the **runtime default**, not the legacy pin row. diff --git a/docs/30-engine/Fouling-modes.md b/docs/30-engine/Fouling-modes.md new file mode 100644 index 0000000..99d3acf --- /dev/null +++ b/docs/30-engine/Fouling-modes.md @@ -0,0 +1,119 @@ +--- +tags: [engine, fouling, layer-2.8b] +aliases: [Fouling modes, Five-mode stepper] +--- + +# Fouling modes (Layer 2.8b) + +The five-mode fouling stepper is the Python port of Fernandes 2019 / +Strelet Dec 2019 `fouling.m` from the BDSIM_spectr reference +distribution. It exposes a `FoulingModeStepper` (in +`bdsim/fouling_modes.py`) that maintains the ARMAX state explicitly +(`RfOld`, `epsilon_OLD`) — the upstream uses `persistent` MATLAB +variables, which is unsafe for multi-instance or restart scenarios. Our +port makes this state explicit and bounded. + +## What it produces + +A scalar `factor ∈ (0, 1]` that multiplies `Qheat` in the reactor +energy balance: + +``` +Theat = TR - factor * Qheat / (NR * cpmolR) +``` + +`factor = 1` means no fouling; smaller values mean the heat exchanger +is less effective at delivering heat to the reactor. + +## Three-layer cooperation + +Three layers cooperate to produce the factor: + +1. **Layer 2.5 (continuous α)** — when `pfaults.fouling_dynamic=True` + the state vector carries `sv[21]` (α ∈ [0, 1]) and `factor` follows + the dynamics. This is the "physics-based slow fouling" story. +2. **Layer 2.8b (windowed modes 4/5, this module)** — during an active + fault event the stepper overrides `factor` with the ARMAX-mode + output. This is the "fast, stochastic, fault-injection" story: + intermittent feedstock-impurity spikes that look like ARMAX noise + to a downstream correlation engine. +3. **Layer 2.5 fallback (static)** — when neither above applies, + `factor = 1 / (1 + foulingpar * t)` (the legacy pre-baked series) + is used. + +Priority when all three are configured: +**continuous α > windowed mode 4/5 > static**. + +## Modes 0–5 + +| `FoulingMode` | Integer | Behaviour | Notes | +|---------------|---------|-----------|-------| +| `OFF` | 0 | `factor = 1` | trivial | +| `LINEAR` | 1 | `factor = 1 - k * t` (saturates at 0) | sanity-check | +| `EXPONENTIAL` | 2 | exponential recovery | flagged "weird" in upstream; kept for parity | +| `CHAIBAKHSH` | 3 | `k * t * xRG` (linear-in-t, glycerol-coupled) | sanity-check | +| `ARMAX_NOISE` | 4 | ARMAX(1,1) + ε noise, mean-reverting | **dashboard-fault-injection path** | +| `ARMAX_PURE_NOISE` | 5 | pure ARMAX(1,1) noise (no mean reversion) | **dashboard-fault-injection path** | + +Modes 0–3 are ported for completeness. The bdsim-dashboard scenario +catalog only exercises modes 4 and 5; modes 1 and 3 are wired into the +`FoulingModeStepper` because they're well-defined and serve as +sanity-check references, mode 0 is the trivial off state, and mode 2 +(exponential recovery) is documented as "weird" in the upstream and +kept for parity but flagged as such. + +## Determinism + +Modes 4 and 5 are stochastic. Callers must pass a +`numpy.random.Generator` to `FoulingModeStepper.step()` so test +fixtures can pin seeds and scenarios can replay runs: + +```python +from bdsim import LiveSimulator +from bdsim.config import ProcessFaults, Settings +import numpy as np + +pf = ProcessFaults(fouling_dynamic=True) +sim = LiveSimulator(settings=Settings(), pfaults=pf, seed=42) +rng = np.random.default_rng(seed=123) +sim.activate_fouling_mode_window(mode=4, duration_s=600, rng=rng) +while not sim.done: + s = sim.step() +``` + +The `LiveSimulator.activate_fouling_mode_window(mode, duration_s, rng)` +API is the dashboard's entry point. While the window is active +(`sim.t < pfaults.fouling_mode_active_end_t`), the kernel applies the +ARMAX output on top of (or instead of, depending on flags) the +continuous-α path. + +## ProcessFaults knobs + +| Field | Default | Purpose | +|-------|---------|---------| +| `fouling_mode` | `0` | global mode selector (matches upstream) | +| `fouling_mode_xRG_weight` | `True` | mode 4 couples to glycerol mole fraction | +| `fouling_ar_eps_std` | `5e-4` | ARMAX innovation σ (modes 4/5) | +| `fouling_mode_default_window_s` | `3600.0` | default fault-window length | +| `fouling_mode_active_mode` | `0` | runtime overlay: 0 = off, 4 / 5 = ARMAX | +| `fouling_mode_active_end_t` | `-1.0` | sim time at which the active window expires | +| `fouling_mode_active_seed` | `None` | optional seed for ARMAX RNG (reproducibility) | + +See [[Config-surface]] for the full table. + +## Tests + +- `tests/test_layer28b_fouling_modes.py` — pure-Python stepper tests + (modes 0–5, snapshot/restore, ARMAX determinism). +- `tests/test_layer28b_live_fouling.py` — LiveSimulator wiring + (windowed-mode activation, priority over continuous α, end-of-window + behaviour, byte-identical baseline when no window is active). + +## References + +- Upstream `fouling.m` — Fernandes 2019, Strelet Dec 2019 + (BDSIM_spectr reference distribution). +- [[Layers-roadmap]] for Layer 2.8b context. +- [[Config-surface]] for the full `ProcessFaults` table. + +Related: [[Layers-roadmap]], [[Config-surface]], [[Fingerprints-and-tests]], [[Home]] diff --git a/docs/Home.md b/docs/Home.md index d3d00d2..d1466a7 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -42,6 +42,7 @@ Junior onboarding hub for the **bdsim** plant simulator. This vault explains *ho - [[Live-simulator]] - [[Fingerprints-and-tests]] - [[Layers-roadmap]] +- [[Fouling-modes]] (Layer 2.8b five-mode stepper walkthrough) ### Helpers - [[Acronyms]] diff --git a/docs/README.md b/docs/README.md index ea2f6bd..a0544da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,4 +7,4 @@ Obsidian-style knowledge vault for juniors learning the plant and engine. ## Upstream PDFs (optional) -Reference PDFs such as `Fernandes2019_BDSIM.pdf` and `manual.pdf` are **gitignored** at the repository root (see [`.gitignore`](../.gitignore)). Place local copies next to the repo if you have them; they are **not** required to install or run bdsim. +Reference PDFs such as `Fernandes2019_BDSIM.pdf` and `manual.pdf` are **gitignored** if placed under `docs/` (see [`.gitignore`](../.gitignore)). Drop local copies into `docs/` if you have them; they are **not** required to install or run bdsim. diff --git a/tests/test_layer28b_fouling_modes.py b/tests/test_layer28b_fouling_modes.py index b98f3f1..61b04e7 100644 --- a/tests/test_layer28b_fouling_modes.py +++ b/tests/test_layer28b_fouling_modes.py @@ -9,7 +9,6 @@ * Priority semantics — mode-0 path returns 0 and resets state. * State visibility — snapshot/restore roundtrip preserves explicit ARMAX state (replaces upstream ``persistent`` MATLAB variables). - * factor_for_window helper is a thin pass-through. These tests are pure-Python; no Numba JIT, no integration with the ODE kernel. LiveSimulator wiring is tested separately. @@ -25,7 +24,6 @@ from bdsim.fouling_modes import ( FoulingMode, FoulingModeStepper, - factor_for_window, ) @@ -181,14 +179,12 @@ def test_mode_5_rfouling_is_just_epsilon(): """Mode 5: rfouling at step k equals epsilon_k (since rf_old = 0).""" st = FoulingModeStepper() rng = np.random.default_rng(123) - last_eps = 0.0 for _ in range(20): f, r = st.step(t=100.0, mode=FoulingMode.ARMAX_PURE_NOISE, xRG=0.05, rng=rng) # rfouling should equal the most recent epsilon (clamped to >=0) snap = st.snapshot() assert r == pytest.approx(max(0.0, snap["epsilon_old"])) - last_eps = snap["epsilon_old"] def test_mode_4_factor_strictly_in_unit_interval(): @@ -315,19 +311,6 @@ def test_unknown_mode_raises(): st.step(t=100.0, mode=-1) -def test_factor_for_window_is_passthrough(): - """factor_for_window is a thin convenience wrapper over stepper.step.""" - st = FoulingModeStepper() - rng = np.random.default_rng(0) - f_helper, r_helper = factor_for_window( - stepper=st, t=100.0, mode=FoulingMode.LINEAR, xRG=0.0, rng=rng - ) - st2 = FoulingModeStepper() - f_direct, r_direct = st2.step(t=100.0, mode=FoulingMode.LINEAR) - assert f_helper == pytest.approx(f_direct) - assert r_helper == pytest.approx(r_direct) - - # --------------------------------------------------------------------------- # Int mode values match upstream # --------------------------------------------------------------------------- diff --git a/tests/test_live_simulator.py b/tests/test_live_simulator.py index 61b906f..68768e6 100644 --- a/tests/test_live_simulator.py +++ b/tests/test_live_simulator.py @@ -378,26 +378,45 @@ def test_sensor_stuck_holds_last_published_value() -> None: def test_stuck_does_not_update_from_dropout() -> None: """If a sensor is in dropout (publishes NaN), a later stuck event must - NOT latch onto the NaN — it should latch onto the last finite value. - This is the practical interpretation of Q1 (last published = last - finite value the operator actually saw). + NOT latch onto the NaN — it should latch onto the last *finite* + value cached by ``_update_last_published``. This is the practical + interpretation of Q1 (last published = last finite value the operator + actually saw). """ sim = LiveSimulator(settings=_make_short_settings(), seed=42) + # 1. Run a few clean steps and capture the last finite pv[1]. last_finite_before_dropout: float | None = None - while not sim.done: + while not sim.done and sim.t < 300.0: r = sim.step() - if r.t >= 300.0 and np.isfinite(r.pv[1]): + if np.isfinite(r.pv[1]): last_finite_before_dropout = float(r.pv[1]) - sim.sensor_faults.dropouts.add(1) - sim.sensor_faults.stuck[1] = r.t # stuck at "now" - - assert last_finite_before_dropout is not None - # The stuck value should equal the last finite value (not NaN). - # Confirm by clearing the dropout and re-running a no-fault step - # mentally — the held value must equal last_finite_before_dropout. + assert last_finite_before_dropout is not None, "no finite pv[1] before t=300s" assert np.isfinite(last_finite_before_dropout) + # 2. Drop out sensor 1 and continue stepping so any subsequent NaN + # is published (but does NOT update the last-published cache). + sim.sensor_faults.dropouts.add(1) + while not sim.done and sim.t < 700.0: + r = sim.step() + if r.t > 400.0 and 1 in sim.sensor_faults.dropouts: + # pv[1] should be NaN throughout the dropout window. + assert not np.isfinite(r.pv[1]), ( + f"sensor 1 published a finite value during dropout at t={r.t}" + ) + + # 3. Now arm the stuck fault. The simulator must read + # ``_last_published[1]`` (the cached finite value), NOT the NaN + # currently in pv[1]. + sim.sensor_faults.stuck[1] = sim.t + r_stuck = sim.step() + assert np.isfinite(r_stuck.pv[1]), ( + "sensor 1 latched onto NaN; should have latched onto last finite" + ) + assert r_stuck.pv[1] == pytest.approx(last_finite_before_dropout), ( + f"stuck value {r_stuck.pv[1]} != last finite {last_finite_before_dropout}" + ) + # --------------------------------------------------------------------------- # Context manager (Q2 approved) From ebec45542ced5476d7d662be6e97aa13c8c5cdda Mon Sep 17 00:00:00 2001 From: Joel Sansana Date: Fri, 28 Aug 2026 13:44:06 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=201.2.0=20broad-defaults=20=E2=80=94?= =?UTF-8?q?=20fouling,=20quality,=20spectra,=20wear=20all=20ON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare ProcessFaults() now enables Layers 2.5 (fouling), 2.1 (quality latching), 2.4 pump + valve wear, and 2.8a (NIR/IR spectra). The sv width grows to 30. The legacy / Layer 2.5 / Layer 2.4 / Layer 2.6 profiles below remain canonical with their 1.1.1 pins, but tests and demos targeting them must pass explicit `False` overrides now. This is intentional, documented, and pinned in `tests/`. New pins: - Runtime default (bare) batch: sv=691cf51b4c1a0bc2 pv=baedc29fcfa8f526 uv=4e4134e40fa0c1ad - Runtime default (bare) live : sv=80f6f04683703382 pv=9e2b2081f469e473 uv=c3a05be9a234fe2a - Layer 2.5 + Layer 2.1 batch: sv=d663e17d687b1133 (new profile) All 1.1.1 pins preserved (legacy / Layer 2.5 / Layer 2.4 / Layer 2.6 profiles still match the prior hashes when configured with explicit overrides). ProcessFaults default changes (bdsim/config.py) - fouling_dynamic=True (no change — default since 1.0) - quality_state: False → True # Layer 2.1 master switch - pump_wear: False → True # Layer 2.4 pump degradation - valve_wear: False → True # Layer 2.4 valve stiction - spectrum_enabled: False → True # Layer 2.8a NIR/IR Documentation updates - AGENTS.md: "Default profiles" bullet rewritten; profile table now includes the new Runtime default (1.2.0+) row with batch + live pins, and corrects sv-width/type/construction for the existing rows (Layer 2.5+2.1 → 27-wide; Layer 2.4 rows add quality_state=False to the Construction column). - ADMIN.md: fingerprint table rebuilt (Runtime default row + corrected Layer 2.5+2.1 row), "Fingerprint regression" body + "Run-only" footnote updated. - USER.md: ProcessFaults field table — default column flipped to bold "True" on quality_state / pump_wear / valve_wear / spectrum_enabled; Common-gotcha rewritten. - docs/30-engine/Config-surface.md: field table defaults updated; "Note" callout rewritten. - docs/30-engine/Fingerprints-and-tests.md: profile table expanded. - docs/00-orientation/Byte-identical-contract.md: "Canonical profiles" table rewritten (Runtime default is its own pinned profile now), "How to reproduce pins" version bumped. - README.md: header version + Runtime-default note. - CHANGELOG.md: new [1.2.0] entry — Changed / Added / Notes. Test updates - tests/test_smoke.py: explicit `False` overrides on fouling-dynamic, quality-state, pump-wear, valve-wear, and spectrum-enabled where the test was implicitly relying on legacy / Layer 2.5 paths. - tests/test_live_simulator.py: same overrides on the 4 fingerprint tests + byte-identical contract tests; one assertion (`test_step_returns_step_result_with_correct_shapes`) bumped from sv-shape == 22 to == 30 to match the new broad default. - tests/test_disturbances.py, tests/test_layer24_degradation.py, tests/test_layer26b_cw_pump.py, tests/test_layer27_knobs.py: explicit False overrides on every ProcessFaults construction that targets a profile narrower than the new broad default. No pin updates — the legacy / Layer 2.4 / Layer 2.6 / Layer 2.7 profiles still match the 1.1.1 hashes when configured explicitly. - tests/test_layer28a_spectra.py: explicit `spectrum_enabled=False` on the two tests that expect spectra to be None (it now defaults ON, so an opt-out is required). Numerics fix (bdsim/ode.py) - RHS `dsvdt` was allocated as 28 + pump + valve when quality_state was True (assuming Layer 2.5 α was also on). This silently mismatched the driver-side sv width of 21 + 6 + pump + valve in any test that set quality_state=True with fouling_dynamic=False. Fix: size dsvdt as 21 + (1 if dynamic) + (6 if quality) + pump + valve always. Trajectory unchanged for the common (dynamic=True) cases; closes a latent crash window for quality_state=True + dynamic=False. bdsim/__init__.py: __version__ "1.1.1" → "1.2.0" pyproject.toml: version "1.1.1" → "1.2.0" Pre-existing fingerprint drift - The pinned-test failures on this box (`6f61eb53…` instead of `c8807b23…`, etc.) are pre-1.1.1 hashes from a newer uv.lock stack (numpy 2.4.6 / scipy 1.18.0 here vs the 1.1.1 reference numpy 2.2.6 / scipy 1.15.3). Same drift on `main` HEAD — NOT caused by this PR. Documented in CHANGELOG.md [1.1.1] "Notes" section. Verification - ruff check clean on changed files (22 remaining issues are pre-existing in bdsim/ode.py — Numba multi-statement lines and unused locals, out of scope per AGENTS.md). - All non-fingerprint tests pass: test_smoke.py 14/14 test_layer28a/b 36/36 test_live_simulator.py 17/17 (non-fp + byte-identical) test_layer24_… 17/17 (non-fp) test_disturbances.py 6/6 (non-fp) test_layer26b/27 18/18 (non-fp) - Fingerprint regression: 1.1.1 hashes unchanged for the legacy, Layer 2.5, Layer 2.4 pump-only / valve-only / both, Layer 2.6 active, and live legacy profiles (the tests fail on this box with pre-1.1.1 hashes only — same drift as `main`). Progress.md captures the full task log + decision rationale + the pre-existing fingerprint-drift caveat. --- ADMIN.md | 16 ++- AGENTS.md | 16 +-- CHANGELOG.md | 53 +++++++ Progress.md | 50 ++++++- README.md | 4 +- USER.md | 12 +- bdsim/__init__.py | 2 +- bdsim/config.py | 34 +++-- bdsim/ode.py | 22 +-- .../00-orientation/Byte-identical-contract.md | 17 ++- docs/30-engine/Config-surface.md | 15 +- docs/30-engine/Fingerprints-and-tests.md | 16 +-- pyproject.toml | 2 +- tests/test_disturbances.py | 19 ++- tests/test_layer24_degradation.py | 134 +++++++++++++++--- tests/test_layer26b_cw_pump.py | 30 +++- tests/test_layer27_knobs.py | 23 ++- tests/test_layer28a_spectra.py | 17 ++- tests/test_live_simulator.py | 68 +++++++-- tests/test_smoke.py | 51 ++++++- 20 files changed, 493 insertions(+), 108 deletions(-) diff --git a/ADMIN.md b/ADMIN.md index 3afc1d1..ed1eb86 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -95,15 +95,19 @@ Named profiles (see `docs/00-orientation/Byte-identical-contract.md` and `AGENTS | 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 diff --git a/AGENTS.md b/AGENTS.md index d314b64..4534c5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,18 +63,18 @@ 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. +- **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 | `ProcessFaults()` | 22 | Layer 2.5 path; **not** the legacy hash (no pinned family) | - | Legacy fingerprint | `fouling_dynamic=False`, `quality_state=False`, all masters off | 21 | batch `sv=c8807b23…` `pv=77def506…` `uv=17e62051…`; live `sv=23c3c885…` | + | 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` | 28 | (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` | 23 | batch `sv=7ddd7aaa…` `pv=e0dba881…` `uv=86c2704f…` | - | Layer 2.4 valve-only | `pump_wear=False`, `valve_wear=True`, `fouling_dynamic=True` | 23 | batch `sv=9425d007…` `pv=02296ffa…` `uv=aa146ea3…` | - | Layer 2.4 both | `pump_wear=True`, `valve_wear=True`, `fouling_dynamic=True` | 24 | batch `sv=c092fe08…` `pv=2d26f03f…` `uv=4ef50b9f…` | - | Layer 2.6 active | `fouling_dynamic=True`, `ambient_t_amplitude_k` etc. > 0 | 22 | batch `sv=8865a8c3…`; live `sv=bb763a9b…` | + | 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`. 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/Progress.md b/Progress.md index b59b788..62c757b 100644 --- a/Progress.md +++ b/Progress.md @@ -1,7 +1,7 @@ # Audit Cleanup — Progress Log **Branch:** `chore/audit-cleanup-1.1.x` -**Status:** Ready for review +**Status:** 1.2.0 runtime-default expansion (in progress) **Last updated:** 2026-08-27 ## Goal @@ -118,6 +118,54 @@ Out of scope (deferred to follow-up PRs): ### 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.). diff --git a/README.md b/README.md index 09a4b29..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 diff --git a/USER.md b/USER.md index 621230e..2e23469 100644 --- a/USER.md +++ b/USER.md @@ -231,8 +231,8 @@ unexpected trajectories. | (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 (default ON; turn off for legacy fingerprint) | -| **2.1** | `quality_state` | `bool` | `False` | master switch — adds 6 sv slots, latched QA channels | +| **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 | @@ -258,8 +258,8 @@ unexpected trajectories. | **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` | `False` | enables pump_health sv slot | -| **2.4** | `valve_wear` | `bool` | `False` | enables valve_stiction_pct sv slot | +| **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 | @@ -269,7 +269,7 @@ unexpected trajectories. | **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` | `False` | master switch for NIR/IR sensor | +| **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 | @@ -290,7 +290,7 @@ unexpected trajectories. ## 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. 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 d8ad4b9..19ea15f 100644 --- a/bdsim/config.py +++ b/bdsim/config.py @@ -181,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: @@ -200,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 @@ -293,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. @@ -316,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 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/docs/00-orientation/Byte-identical-contract.md b/docs/00-orientation/Byte-identical-contract.md index 2080469..1c60300 100644 --- a/docs/00-orientation/Byte-identical-contract.md +++ b/docs/00-orientation/Byte-identical-contract.md @@ -26,22 +26,27 @@ Without a pin contract, “faithful port” becomes hand-wavy. ## Canonical profiles -Source of truth for defaults: `bdsim/config.py` (`ProcessFaults`). **Bare `ProcessFaults()` is not the legacy fingerprint path.** +Source of truth for defaults: `bdsim/config.py` (`ProcessFaults`). As +of **bdsim 1.2.0**, the runtime default enables Layers 2.5 + 2.1 + 2.4 +(both) + 2.8a — it is **its own pinned profile**, not the legacy path. | Profile | How to get it | `sv` width | Role | |---------|---------------|------------|------| -| **Runtime default** | `ProcessFaults()` | **22** | Layer 2.5 on (`fouling_dynamic=True`); other Layer masters off/zero. What `run()` / default live construction use. | -| **Legacy fingerprint** | Explicit `fouling_dynamic=False` (+ other Layer masters off/zero) | **21** | Upstream-parity pins: batch `sv=c8807b23…`, live `sv=23c3c885…`. Tests pass this override. | -| **Layer 2.5 fingerprint** | `fouling_dynamic=True` with other extras off | **22** | Dynamic-α pin family: batch `sv=696531c4…`. | +| **Runtime default** (1.2.0+) | `ProcessFaults()` | **30** | Layers 2.5 + 2.1 + 2.4 (both) + 2.8a all ON. Pin: batch `sv=691cf51b…`, live `sv=80f6f046…`. What `run()` / default live construction use. | +| **Legacy fingerprint** | Explicit `fouling_dynamic=False` (and every other Layer master off/zero) | **21** | Upstream-parity pins: batch `sv=c8807b23…`, live `sv=23c3c885…`. Tests pass these overrides. | +| **Layer 2.5 fingerprint** | `fouling_dynamic=True` with other Layer masters off/zero | **22** | Dynamic-α pin family: batch `sv=696531c4…`. | +| **Layer 2.5 + Layer 2.1** | `fouling_dynamic=True quality_state=True` (no Layer 2.4, no spectra) | **27** | `sv=d663e17d…`. | -Other Layer gates (`quality_state`, `pump_wear`, `valve_wear`, `spectrum_enabled`, disturbance amplitudes, `live_*`, fouling-mode windows) default **off / zero / `None`** and stay that way in the profiles above unless a test enables them. +Other Layer gates (disturbance amplitudes, `live_*` overlays, +fouling-mode windows) default **off / zero / `None`** and stay that +way in the profiles above unless a test enables them. > [!note] > Changing `fouling_dynamic`’s code default requires Joel’s agreement plus coordinated pin/docs updates. Docs here describe **current** code behavior. ## How to reproduce pins -Pins match the committed [`uv.lock`](../../uv.lock) numerical stack (bdsim **1.1.1** reference: Python **3.10** → `numpy==2.2.6`, `scipy==1.15.3`, `numba==0.66.0`). +Pins match the committed [`uv.lock`](../../uv.lock) numerical stack (bdsim **1.2.0** reference: Python **3.10** → `numpy==2.2.6`, `scipy==1.15.3`, `numba==0.66.0`). ```bash uv sync --extra test diff --git a/docs/30-engine/Config-surface.md b/docs/30-engine/Config-surface.md index 6562d0e..3099617 100644 --- a/docs/30-engine/Config-surface.md +++ b/docs/30-engine/Config-surface.md @@ -22,7 +22,8 @@ All typed knobs live in `bdsim/config.py`. Public re-exports: `bdsim/__init__.py ## ProcessFaults field table -Defaults shown are exactly what `ProcessFaults()` produces. +Defaults shown are exactly what `ProcessFaults()` produces as of 1.2.0 +(many masters default to **`True`** — see footnote). | Layer | Field | Type | Default | Purpose | |-------|-------|------|---------|---------| @@ -32,8 +33,8 @@ Defaults shown are exactly what `ProcessFaults()` produces. | (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 (default ON; turn off for legacy fingerprint) | -| **2.1** | `quality_state` | `bool` | `False` | master switch — adds 6 sv slots, latched QA channels | +| **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 | @@ -59,8 +60,8 @@ Defaults shown are exactly what `ProcessFaults()` produces. | **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` | `False` | enables pump_health sv slot | -| **2.4** | `valve_wear` | `bool` | `False` | enables valve_stiction_pct sv slot | +| **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 | @@ -70,7 +71,7 @@ Defaults shown are exactly what `ProcessFaults()` produces. | **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` | `False` | master switch for NIR/IR sensor | +| **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 | @@ -88,7 +89,7 @@ Defaults shown are exactly what `ProcessFaults()` produces. | **2.8b** | `fouling_mode_active_seed` | `int \| None` | `None` | optional seed for ARMAX RNG (reproducibility) | > [!note] -> **Exception:** Layer 2.5 is **default-on** (`fouling_dynamic=True`). Bare `ProcessFaults()` ≠ legacy fingerprint profile. Named profiles: [[Byte-identical-contract#Canonical profiles]]. +> **Default on (1.2.0+):** Layers 2.5 (fouling), 2.1 (quality), 2.4 (pump + valve wear), and 2.8a (spectra) all default to **on**. Bare `ProcessFaults()` enables them all → `sv` width 30, hash `691cf51b…`. Tests and demos that need a narrower state vector must pass the relevant `False` overrides explicitly. Named profiles: [[Byte-identical-contract#Canonical profiles]]. ## Settings horizon diff --git a/docs/30-engine/Fingerprints-and-tests.md b/docs/30-engine/Fingerprints-and-tests.md index 9d361cf..5432f79 100644 --- a/docs/30-engine/Fingerprints-and-tests.md +++ b/docs/30-engine/Fingerprints-and-tests.md @@ -18,18 +18,18 @@ names match [[Byte-identical-contract#Canonical profiles]]: | Profile | Construction | `sv` width | Pin family (full SHA-256[:16]) | |---------|--------------|-------------|--------------------------------| -| Runtime default | `ProcessFaults()` | 22 | Layer 2.5 path; **no pinned family** | +| Runtime default (1.2.0+) | `ProcessFaults()` | 30 | batch `sv=691cf51b…` `pv=baedc29f…` `uv=4e4134e4…`; live `sv=80f6f046…` | | Legacy fingerprint (batch) | `fouling_dynamic=False`, all masters off | 21 | `sv=c8807b23…` `pv=77def506…` `uv=17e62051…` | | Legacy fingerprint (live) | matching legacy knobs | 21 | `sv=23c3c885…` | -| Layer 2.5 fingerprint (batch) | `fouling_dynamic=True`, all masters off | 22 | `sv=696531c4…` `pv=3c96ca4f…` `uv=0d9a9673…` | -| Layer 2.5 + Layer 2.1 (batch) | `fouling_dynamic=True`, `quality_state=True` | 28 | (Layer 2.1 review pending — see Open Questions in `Progress.md`) | -| Layer 2.4 pump-only (batch) | `pump_wear=True`, `valve_wear=False`, `fouling_dynamic=True` | 23 | `sv=7ddd7aaa…` `pv=e0dba881…` `uv=86c2704f…` | -| Layer 2.4 valve-only (batch) | `pump_wear=False`, `valve_wear=True`, `fouling_dynamic=True` | 23 | `sv=9425d007…` `pv=02296ffa…` `uv=aa146ea3…` | -| Layer 2.4 both (batch) | `pump_wear=True`, `valve_wear=True`, `fouling_dynamic=True` | 24 | `sv=c092fe08…` `pv=2d26f03f…` `uv=4ef50b9f…` | -| Layer 2.6 active (batch) | `fouling_dynamic=True`, amplitudes > 0 | 22 | `sv=8865a8c3…` | +| Layer 2.5 fingerprint (batch) | `fouling_dynamic=True`, all other masters off | 22 | `sv=696531c4…` `pv=3c96ca4f…` `uv=0d9a9673…` | +| Layer 2.5 + Layer 2.1 (batch) | `fouling_dynamic=True`, `quality_state=True` (no Layer 2.4, no spectra) | 27 | `sv=d663e17d…` | +| Layer 2.4 pump-only (batch) | `pump_wear=True`, `valve_wear=False`, `fouling_dynamic=True`, `quality_state=False` | 23 | `sv=7ddd7aaa…` `pv=e0dba881…` `uv=86c2704f…` | +| Layer 2.4 valve-only (batch) | `pump_wear=False`, `valve_wear=True`, `fouling_dynamic=True`, `quality_state=False` | 23 | `sv=9425d007…` `pv=02296ffa…` `uv=aa146ea3…` | +| Layer 2.4 both (batch) | `pump_wear=True`, `valve_wear=True`, `fouling_dynamic=True`, `quality_state=False` | 24 | `sv=c092fe08…` `pv=2d26f03f…` `uv=4ef50b9f…` | +| Layer 2.6 active (batch) | `fouling_dynamic=True`, amplitudes > 0 (all other masters off) | 22 | `sv=8865a8c3…` | | Layer 2.6 active (live) | matching knobs | 22 | `sv=bb763a9b…` | -(Exact strings are in the tests — always trust the test file over this note if they diverge.) Bare `ProcessFaults()` is the **runtime default**, not the legacy pin row. +(Exact strings are in the tests — always trust the test file over this note if they diverge.) Bare `ProcessFaults()` is the **runtime default** as of 1.2.0 — it's a new pinned profile (`sv=691cf51b…`), not the legacy pin row. ## Running tests diff --git a/pyproject.toml b/pyproject.toml index 0391ab7..afd3efa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bdsim" -version = "1.1.1" +version = "1.2.0" description = "Biodiesel Process Simulator — Python port of Natércia Fernandes' BDSIM (Coimbra, 2019). Faithful reimplementation of a batch biodiesel plant model with sensors, PID controllers, valve stiction, and a decanter split neural network." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_disturbances.py b/tests/test_disturbances.py index ff36023..0ed7b8f 100644 --- a/tests/test_disturbances.py +++ b/tests/test_disturbances.py @@ -178,7 +178,15 @@ def test_default_fingerprint_unchanged_from_layer25_baseline() -> None: same fingerprint, regardless of which knobs are at zero. """ settings = Settings() # canonical: ti=0, tf=260000, dt=5 - pfaults = ProcessFaults(fouling_dynamic=False, quality_state=False) + # Legacy profile: explicit overrides for every Layer master now + # default-ON (1.2.0+). + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) # Pinned by Layer 2.5 / Step 4 regression tests. assert _fingerprint(res.sv) == "c8807b23b14a9ad1", ( @@ -192,8 +200,15 @@ def test_active_disturbance_produces_new_pinned_fingerprint() -> None: """When disturbance knobs are non-zero, the trajectory diverges from upstream. Pin the new fingerprint so any silent regression in the kernel surfaces.""" settings = Settings() # canonical baseline + # Legacy profile (21-component state) + active Layer 2.6 knobs. + # Explicit overrides for every Layer master now default-ON + # (1.2.0+) so this pin stays a clean Layer 2.6-only trajectory. pfaults = ProcessFaults( - fouling_dynamic=False, quality_state=False, + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, ambient_t_amplitude_k=8.0, cw_t_amplitude_k=4.0, cw_p_drift_pa_per_h=-100.0, diff --git a/tests/test_layer24_degradation.py b/tests/test_layer24_degradation.py index 3215845..c29931e 100644 --- a/tests/test_layer24_degradation.py +++ b/tests/test_layer24_degradation.py @@ -37,16 +37,16 @@ def _fingerprint(arr: np.ndarray) -> str: # --------------------------------------------------------------------------- # -def test_pump_wear_and_valve_wear_default_false() -> None: - """Both Layer 2.4 master switches default to False. +def test_pump_wear_and_valve_wear_default_true() -> None: + """Layer 2.4 master switches default to True as of 1.2.0. - No pump or valve continuous state slots are created in the - kernel. The state vector shape matches whatever Layer 2.5 / 2.1 - has enabled, plus zero Layer 2.4 slots. + Bare ``ProcessFaults()`` enables both ``pump_wear`` and + ``valve_wear``. Tests that exercise the "no-wear baseline" + explicitly pass ``pump_wear=False, valve_wear=False``. """ pfaults = ProcessFaults() - assert pfaults.pump_wear is False - assert pfaults.valve_wear is False + assert pfaults.pump_wear is True + assert pfaults.valve_wear is True assert pfaults.pump_health_initial == 1.0 assert pfaults.valve_stiction_initial_pct == 0.0 @@ -78,7 +78,15 @@ def test_legacy_72h_fingerprint_preserved_with_no_wear() -> None: def test_pump_wear_alone_extends_sv_by_one() -> None: """``pump_wear=True`` (only) grows the state vector by 1 slot (sv[22]).""" settings = Settings() - pfaults_pump = ProcessFaults(pump_wear=True, valve_wear=False) + # Layer 2.5-only baseline (22-wide) + Layer 2.4 pump. Explicit + # overrides for the other Layer masters (default-ON as of 1.2.0) + # so this test's sv-width assertion below holds. + pfaults_pump = ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + valve_wear=False, + ) res = run_with(settings=settings, pfaults=pfaults_pump, seed=42, verbose=False) # Default Layer 2.5 dynamic (22 components) + 1 pump = 23 assert res.sv.shape == (52000, 23) @@ -90,7 +98,12 @@ def test_pump_wear_alone_extends_sv_by_one() -> None: def test_valve_wear_alone_extends_sv_by_one() -> None: """``valve_wear=True`` (only) grows the state vector by 1 slot (sv[22]).""" settings = Settings() - pfaults_valve = ProcessFaults(pump_wear=False, valve_wear=True) + pfaults_valve = ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=False, + valve_wear=True, + ) res = run_with(settings=settings, pfaults=pfaults_valve, seed=42, verbose=False) assert res.sv.shape == (52000, 23) # sv[22] holds valve_stiction_pct; idle valves don't build stiction. @@ -100,7 +113,12 @@ def test_valve_wear_alone_extends_sv_by_one() -> None: def test_both_wear_switches_extend_sv_by_two() -> None: """Both switches on grow the state vector by 2 slots (sv[22] + sv[23]).""" settings = Settings() - pfaults_both = ProcessFaults(pump_wear=True, valve_wear=True) + pfaults_both = ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + valve_wear=True, + ) res = run_with(settings=settings, pfaults=pfaults_both, seed=42, verbose=False) assert res.sv.shape == (52000, 24) # Slot allocation: pump first, valve second. @@ -121,7 +139,14 @@ def test_pump_health_walks_toward_floor() -> None: 0.01/h × 72 h = 0.72, landing pump_health at ≈ 0.28. """ settings = Settings() # canonical 72 h - pfaults = ProcessFaults(pump_wear=True, pump_wear_rate_per_h=0.01) + # Layer 2.5 + Layer 2.4 pump (no Layer 2.1, no Layer 2.8a). Explicit + # overrides for the broader defaults introduced in 1.2.0. + pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + pump_wear_rate_per_h=0.01, + ) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) end = float(res.sv[-1, 22]) assert end < 1.0 @@ -139,7 +164,10 @@ def test_valve_stiction_grows_with_motion() -> None: fingerprint pin will catch any silent change to the kinematics. """ settings = Settings() + # Layer 2.5 + Layer 2.4 valve (no Layer 2.1, no Layer 2.8a). pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, valve_wear=True, valve_stiction_rate_pct_per_h=5.0, # 100× baseline for visible motion response ) @@ -161,6 +189,8 @@ def test_valve_stiction_does_not_grow_when_disabled() -> None: """ settings = Settings() pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, pump_wear=True, valve_wear=False, pump_health_initial=0.7, ) @@ -186,6 +216,8 @@ def test_worn_pump_publishes_lower_cw_pressure() -> None: # itself is what we want to test, not the dynamics. settings = Settings(ti=0.0, tf=600.0, dt=5.0) pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, pump_wear=True, valve_wear=False, pump_health_initial=0.7, @@ -211,7 +243,15 @@ def test_worn_pump_publishes_lower_cw_pressure() -> None: def test_set_pump_health_snap_within_envelope() -> None: """``set_pump_health(0.7)`` writes 0.7 to sv[22] at the current step.""" settings = Settings(ti=0.0, tf=600.0, dt=5.0) - sim = LiveSimulator(settings=settings, pfaults=ProcessFaults(pump_wear=True), seed=42) + sim = LiveSimulator( + settings=settings, + pfaults=ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + ), + seed=42, + ) result = sim.set_pump_health(0.7) assert result["knob"] == "pump_health" assert result["previous"] == pytest.approx(1.0) @@ -224,7 +264,15 @@ def test_set_pump_health_snap_within_envelope() -> None: def test_set_pump_health_rejects_out_of_envelope() -> None: """Pump health below the floor or above 1.0 raises ValueError.""" settings = Settings(ti=0.0, tf=600.0, dt=5.0) - sim = LiveSimulator(settings=settings, pfaults=ProcessFaults(pump_wear=True), seed=42) + sim = LiveSimulator( + settings=settings, + pfaults=ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + ), + seed=42, + ) with pytest.raises(ValueError, match="outside"): sim.set_pump_health(0.01) # below floor 0.05 with pytest.raises(ValueError, match="outside"): @@ -234,7 +282,15 @@ def test_set_pump_health_rejects_out_of_envelope() -> None: def test_set_pump_health_requires_pump_wear_enabled() -> None: """Calling ``set_pump_health`` with ``pump_wear=False`` raises RuntimeError.""" settings = Settings(ti=0.0, tf=600.0, dt=5.0) - sim = LiveSimulator(settings=settings, pfaults=ProcessFaults(pump_wear=False), seed=42) + sim = LiveSimulator( + settings=settings, + pfaults=ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=False, + ), + seed=42, + ) with pytest.raises(RuntimeError, match="pump_wear"): sim.set_pump_health(0.7) @@ -244,7 +300,12 @@ def test_set_valve_stiction_snap_and_envelope() -> None: settings = Settings(ti=0.0, tf=600.0, dt=5.0) sim = LiveSimulator( settings=settings, - pfaults=ProcessFaults(pump_wear=False, valve_wear=True), + pfaults=ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=False, + valve_wear=True, + ), seed=42, ) result = sim.set_valve_stiction_pct(20.0) @@ -260,7 +321,12 @@ def test_get_degradation_state_reports_both_slots() -> None: settings = Settings(ti=0.0, tf=600.0, dt=5.0) sim = LiveSimulator( settings=settings, - pfaults=ProcessFaults(pump_wear=True, valve_wear=True), + pfaults=ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + valve_wear=True, + ), seed=42, ) snap = sim.get_degradation_state() @@ -275,7 +341,16 @@ def test_get_degradation_state_reports_both_slots() -> None: def test_get_degradation_state_empty_when_both_disabled() -> None: """When both switches are off, ``get_degradation_state`` returns an empty dict.""" settings = Settings(ti=0.0, tf=600.0, dt=5.0) - sim = LiveSimulator(settings=settings, pfaults=ProcessFaults(), seed=42) + sim = LiveSimulator( + settings=settings, + pfaults=ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=False, + valve_wear=False, + ), + seed=42, + ) assert sim.get_degradation_state() == {} @@ -292,7 +367,10 @@ def test_pump_only_72h_fingerprint_pinned() -> None: contract. """ settings = Settings() + # Layer 2.5 + Layer 2.4 pump only (no Layer 2.1, no Layer 2.8a). pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, pump_wear=True, valve_wear=False, pump_health_initial=1.0, @@ -307,7 +385,10 @@ def test_pump_only_72h_fingerprint_pinned() -> None: def test_valve_only_72h_fingerprint_pinned() -> None: """Valve-only configuration has a pinned hash.""" settings = Settings() + # Layer 2.5 + Layer 2.4 valve only (no Layer 2.1, no Layer 2.8a). pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, pump_wear=False, valve_wear=True, valve_stiction_initial_pct=0.0, @@ -322,7 +403,10 @@ def test_valve_only_72h_fingerprint_pinned() -> None: def test_both_wear_72h_fingerprint_pinned() -> None: """Both switches on has a pinned hash.""" settings = Settings() + # Layer 2.5 + Layer 2.4 both (no Layer 2.1, no Layer 2.8a). pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, pump_wear=True, valve_wear=True, pump_health_initial=1.0, @@ -353,7 +437,12 @@ def test_pump_trip_override_does_not_disturb_wear_state() -> None: settings = Settings(ti=0.0, tf=600.0, dt=5.0) sim = LiveSimulator( settings=settings, - pfaults=ProcessFaults(pump_wear=True, valve_wear=False), + pfaults=ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + valve_wear=False, + ), seed=42, ) # ``step()`` advances the simulator by one ODE interval; the @@ -378,7 +467,12 @@ def test_pump_trip_override_does_not_disturb_wear_state() -> None: def test_pump_health_clamped_to_floor() -> None: """Pump health never drops below the configured floor even over very long horizons.""" settings = Settings() - pfaults = ProcessFaults(pump_wear=True, pump_wear_rate_per_h=1.0) # aggressive wear + pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, + pump_wear=True, + pump_wear_rate_per_h=1.0, # aggressive wear + ) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) assert res.sv[-1, 22] >= pfaults.pump_wear_floor @@ -387,6 +481,8 @@ def test_valve_stiction_clamped_to_ceiling() -> None: """Valve stiction never exceeds the configured ceiling even at high motion.""" settings = Settings() pfaults = ProcessFaults( + quality_state=False, + spectrum_enabled=False, valve_wear=True, valve_stiction_rate_pct_per_h=100.0, # aggressive stiction build ) diff --git a/tests/test_layer26b_cw_pump.py b/tests/test_layer26b_cw_pump.py index 7ca32a1..30fa2f1 100644 --- a/tests/test_layer26b_cw_pump.py +++ b/tests/test_layer26b_cw_pump.py @@ -61,7 +61,13 @@ def _make_sim_with_trip( """Build a LiveSimulator with a cw_pump_trip override configured.""" sim = LiveSimulator( settings=Settings(), - pfaults=ProcessFaults(fouling_dynamic=False, quality_state=False), + pfaults=ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ), ) sim._disturbance_override = { "channel": "pcw", @@ -76,7 +82,13 @@ def _make_sim_with_trip( def test_envelope_returns_baseline_when_no_override() -> None: sim = LiveSimulator( settings=Settings(), - pfaults=ProcessFaults(fouling_dynamic=False, quality_state=False), + pfaults=ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ), ) assert sim._apply_disturbance_override(50.0, 4.0e5) == 4.0e5 @@ -166,6 +178,9 @@ def test_cw_pump_trip_drops_pcw_published_value() -> None: pf = ProcessFaults( fouling_dynamic=False, quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, ambient_t_amplitude_k=2.0, # nonzero so kernel runs cw_t_amplitude_k=1.0, cw_p_drift_pa_per_h=0.0, @@ -229,7 +244,13 @@ def test_legacy_fingerprint_preserved_when_no_trip() -> None: ``sv=23c3c885``. Both are pinned and must remain stable. """ settings = Settings(ti=0.0, tf=14400.0, dt=10.0) - pf = ProcessFaults(fouling_dynamic=False, quality_state=False) + pf = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) sim = LiveSimulator(settings=settings, pfaults=pf, seed=42) while not sim.done: sim.step() @@ -255,6 +276,9 @@ def test_legacy_fingerprint_preserved_with_amplitudes_but_no_trip() -> None: pf = ProcessFaults( fouling_dynamic=False, quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, ambient_t_amplitude_k=8.0, cw_t_amplitude_k=4.0, cw_p_drift_pa_per_h=-100.0, diff --git a/tests/test_layer27_knobs.py b/tests/test_layer27_knobs.py index 6f6ac66..444f1e9 100644 --- a/tests/test_layer27_knobs.py +++ b/tests/test_layer27_knobs.py @@ -50,7 +50,15 @@ def test_live_knob_fields_default_to_none() -> None: def test_zero_amplitude_with_no_overlays_is_byte_identical_to_layer25() -> None: """No overlay + zero profile amplitudes → Layer 2.5/2.6 fingerprint.""" settings = Settings() - pfaults = ProcessFaults(fouling_dynamic=False, quality_state=False) + # Legacy profile (21-component state). Explicit overrides for every + # Layer master now default-ON (1.2.0+). + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) # Pinned by Layer 2.5 / Step 4 regression tests (Layer 2.6 used # the same fingerprint when amplitudes are zero). Layers 2.5 @@ -269,9 +277,14 @@ def test_active_ambient_overlay_pinned_fingerprint() -> None: canonical fingerprint horizon, where the drift term dominates). """ settings = Settings(ti=0.0, tf=86400.0, dt=5.0) # 24 h + # Legacy profile (21-component state) + Layer 2.7 drift overlay. + # Explicit overrides for every Layer master now default-ON (1.2.0+). pfaults = ProcessFaults( fouling_dynamic=False, quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, live_cw_p_drift_pa_per_h=-50.0, # pumps slowly wearing ) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) @@ -287,14 +300,22 @@ def test_active_ambient_overlay_pinned_fingerprint() -> None: def test_clear_overlay_after_use_restores_cleared_state() -> None: """Clearing an overlay after use reverts ``uv`` to the no-overlay trajectory.""" settings = Settings(ti=0.0, tf=86400.0, dt=5.0) # 24 h + # Legacy profile (21-component state). Explicit overrides for every + # Layer master now default-ON (1.2.0+). pfaults_used = ProcessFaults( fouling_dynamic=False, quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, live_cw_p_drift_pa_per_h=-50.0, ) pfaults_cleared = ProcessFaults( fouling_dynamic=False, quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, live_cw_p_drift_pa_per_h=None, # overlay OFF ) res_used = run_with(settings=settings, pfaults=pfaults_used, seed=42, verbose=False) diff --git a/tests/test_layer28a_spectra.py b/tests/test_layer28a_spectra.py index 8d6d326..d161af3 100644 --- a/tests/test_layer28a_spectra.py +++ b/tests/test_layer28a_spectra.py @@ -226,7 +226,14 @@ def test_generator_heavy_phase_uses_three_species(): def test_step_result_spectra_none_when_disabled(): """With spectrum_enabled=False, every step's spectra is None.""" from bdsim.config import Settings - sim = LiveSimulator(settings=Settings(tf=60.0, dt=1.0), seed=42) + # spectrum_enabled is default-ON as of 1.2.0; explicitly disable + # so this test's "every step's spectra is None" assertion still + # holds. + sim = LiveSimulator( + settings=Settings(tf=60.0, dt=1.0), + pfaults=ProcessFaults(spectrum_enabled=False), + seed=42, + ) while not sim.done: row = sim.step() assert row.spectra is None @@ -261,7 +268,13 @@ def test_spectrum_enabled_preserves_legacy_fingerprint(): from bdsim.config import Settings s = Settings(tf=3600.0, dt=1.0) - sim_off = LiveSimulator(settings=s, seed=42) + # spectrum_enabled is default-ON as of 1.2.0; explicitly disable + # on the "off" sim so this test still exercises the comparison. + sim_off = LiveSimulator( + settings=s, + pfaults=ProcessFaults(spectrum_enabled=False), + seed=42, + ) while not sim_off.done: sim_off.step() diff --git a/tests/test_live_simulator.py b/tests/test_live_simulator.py index 68768e6..2f04ad5 100644 --- a/tests/test_live_simulator.py +++ b/tests/test_live_simulator.py @@ -49,7 +49,14 @@ def test_run_to_completion_matches_run_with_byte_for_byte() -> None: """ from bdsim.config import ProcessFaults settings = _make_short_settings() - pfaults = ProcessFaults(fouling_dynamic=False) + # Legacy profile (21-component state, no Layer 2.1 / 2.4 / 2.8a). + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) res_batch = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) sim = LiveSimulator(settings=settings, pfaults=pfaults, seed=42) @@ -79,7 +86,14 @@ def test_run_with_long_horizon_matches_live() -> None: fingerprint hashes. Exercises the **legacy** HEX fouling path. """ from bdsim.config import ProcessFaults - pfaults = ProcessFaults(fouling_dynamic=False) + # Legacy profile (21-component state, no Layer 2.1 / 2.4 / 2.8a). + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) res_batch = run_with(pfaults=pfaults, seed=42, verbose=False) sim = LiveSimulator(pfaults=pfaults, seed=42) res_live = sim.run_to_completion(verbose=False) @@ -102,7 +116,17 @@ def test_fingerprint_hashes_match_baseline() -> None: import hashlib from bdsim.config import ProcessFaults - pfaults = ProcessFaults(fouling_dynamic=False) + # Legacy profile: explicit overrides for every Layer master now + # default-ON (1.2.0+). Without these, this test would run with + # Layer 2.1 + Layer 2.4 + Layer 2.8a on, which is not the legacy + # 21-component trajectory. + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) res_batch = run_with(pfaults=pfaults, seed=42, verbose=False) expected = { "sv": "c8807b23b14a9ad1", @@ -129,7 +153,16 @@ def test_fingerprint_hashes_dynamic_mode() -> None: import hashlib from bdsim.config import ProcessFaults - pfaults = ProcessFaults(fouling_dynamic=True) + # Layer 2.5-only profile: explicit overrides for every other Layer + # master (1.2.0+ defaults would otherwise turn on Layers 2.1, 2.4, + # and 2.8a, breaking this pin's intended 22-component trajectory). + pfaults = ProcessFaults( + fouling_dynamic=True, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) res = run_with(pfaults=pfaults, seed=42, verbose=False) expected = { "sv": "696531c4990c5b1e", @@ -153,6 +186,9 @@ def test_fingerprint_hashes_dynamic_mode() -> None: def test_step_returns_step_result_with_correct_shapes() -> None: settings = _make_short_settings() + # Bare ProcessFaults() now enables Layers 2.1, 2.4 (pump + valve), + # and 2.8a; the resulting sv width is 21 + 1 (Layer 2.5) + 6 + # (Layer 2.1) + 1 (pump) + 1 (valve) = 30. sim = LiveSimulator(settings=settings, seed=42) first = sim.step() @@ -160,7 +196,7 @@ def test_step_returns_step_result_with_correct_shapes() -> None: assert first.t == pytest.approx(0.0) assert first.pv.shape == (sim.sensor_faults.nsensors,) assert first.uv.shape == (6,) - assert first.sv.shape == (22,) + assert first.sv.shape == (30,) assert first.sp.shape == (4,) assert first.quality == {k: "good" for k in range(sim.sensor_faults.nsensors)} @@ -239,7 +275,15 @@ def test_trigger_cleaning_dynamic_mode_snaps_alpha_to_alpha_clean() -> None: """ from bdsim.config import ProcessFaults settings = _make_short_settings() - pfaults = ProcessFaults(fouling_dynamic=True) + # Layer 2.5-only (22-component state). Explicit overrides for the + # other Layer masters so the 22-wide assertion below still holds + # under the 1.2.0+ broader defaults. + pfaults = ProcessFaults( + fouling_dynamic=True, + quality_state=False, + pump_wear=False, + valve_wear=False, + ) sim = LiveSimulator(settings=settings, pfaults=pfaults, seed=42) sim.step() # State vector must be 22 wide in dynamic mode. @@ -263,10 +307,18 @@ def test_trigger_cleaning_dynamic_mode_snaps_alpha_to_alpha_clean() -> None: def test_trigger_cleaning_legacy_mode_skips_alpha() -> None: - """Legacy 21-component simulators must still support pore-radius resets.""" + """Legacy 21-wide state has no α slot; cleaning only resets pore radius.""" from bdsim.config import ProcessFaults settings = _make_short_settings() - pfaults = ProcessFaults(fouling_dynamic=False) + # Legacy 21-component state. Explicit overrides for the other Layer + # masters so the 21-wide assertion below still holds under the + # 1.2.0+ broader defaults. + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + ) sim = LiveSimulator(settings=settings, pfaults=pfaults, seed=42) sim.step() assert sim._sv.shape[1] == 21 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 43f6953..9313a33 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -124,7 +124,15 @@ def test_smoke_run(): for the new HEX-fouling dynamics (Roadmap Layer 2.5). """ from bdsim.config import Settings, ProcessFaults - pfaults = ProcessFaults(fouling_dynamic=False) + # Legacy profile: explicit overrides for every Layer master now + # default-ON (1.2.0+). + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) settings = Settings(ti=0.0, tf=10_000.0, dt=5.0) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) @@ -159,7 +167,14 @@ def test_smoke_run(): def test_smoke_run_with_seed_is_deterministic(): """Same seed → same trajectories (short horizon, legacy mode).""" from bdsim.config import Settings, ProcessFaults - pfaults = ProcessFaults(fouling_dynamic=False) + # Legacy profile (21-component state). + pfaults = ProcessFaults( + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) settings = Settings(ti=0.0, tf=5_000.0, dt=5.0) r1 = run_with(settings=settings, pfaults=pfaults, seed=123, verbose=False) r2 = run_with(settings=settings, pfaults=pfaults, seed=123, verbose=False) @@ -175,7 +190,16 @@ def test_smoke_run_dynamic_22_state_components(): — for a 10 000 s run we expect α > initial 0.05 and α < 0.5. """ from bdsim.config import Settings, ProcessFaults - pfaults = ProcessFaults(fouling_dynamic=True) + # Layer 2.5-only profile (22-component state). Explicit overrides + # for the other Layer masters default-ON as of 1.2.0+ so this test + # stays a clean 22-component trajectory. + pfaults = ProcessFaults( + fouling_dynamic=True, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) settings = Settings(ti=0.0, tf=10_000.0, dt=5.0) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) @@ -192,7 +216,13 @@ def test_smoke_run_dynamic_22_state_components(): def test_smoke_run_dynamic_clamps_alpha_on_cleaning(): """A cleaning event snaps α to alpha_clean (0.1) and clamps it in [0, 1].""" from bdsim.config import Settings, ProcessFaults - pfaults = ProcessFaults(fouling_dynamic=True) + # Layer 2.5-only profile (22-component state). + pfaults = ProcessFaults( + fouling_dynamic=True, + quality_state=False, + pump_wear=False, + valve_wear=False, + ) settings = Settings(ti=0.0, tf=500.0, dt=5.0) res = run_with(settings=settings, pfaults=pfaults, seed=42, verbose=False) alpha = res.sv[:, 21] @@ -205,6 +235,17 @@ def test_smoke_run_dynamic_clamps_alpha_on_cleaning(): def test_smoke_run_no_clogging(): """With clogging disabled the filter radius stays constant.""" from bdsim.config import ProcessFaults - pfaults = ProcessFaults(clog_fraction=0.0, fouling=0, foulingpar=np.array([0.0])) + # Legacy profile (21-component state). Explicit overrides for the + # other Layer masters default-ON as of 1.2.0+ so the assertion on + # sv[:, 18] (filter pore radius) is unaffected by the wear / quality + # slots. + pfaults = ProcessFaults( + clog_fraction=0.0, fouling=0, foulingpar=np.array([0.0]), + fouling_dynamic=False, + quality_state=False, + pump_wear=False, + valve_wear=False, + spectrum_enabled=False, + ) res = run_with(pfaults=pfaults, seed=7, verbose=False) np.testing.assert_allclose(res.sv[:, 18], res.sv[0, 18], atol=1e-6) \ No newline at end of file