Add eval harness + Tier 2/3 mode template pages
### eval/ (scenario-based evaluation)
Complements the unit tests under test/basic. Scenarios fluctuate inputs
over simulated time, record every tick to JSONL, print a summary
table + event log, and check expectations. Complementary to unit
tests — these answer "how does the system respond to this input
profile" rather than "is this function correct".
- eval/run.js — driver; monkey-patches Date.now so the
volume integrator ticks at 1 s/iter
regardless of wall-clock
- eval/scenarios/ — one file per scenario
- levelbased-steady.js — constant inflow, demand converges
- levelbased-storm.js — inflow surge, demand saturates
- safety-dry-run-trip.js — manual mode, empty basin, safety trips
- eval/formatters/table.js — ASCII summary of sampled ticks
- eval/logs/ — per-scenario JSONL output (one line per tick)
- eval/README.md — usage + scenario file shape + how to pipe
into InfluxDB/Grafana
All three starter scenarios PASS with their expectations.
### wiki/modes/ (tier template pages)
The levelbased page templated Tier-1 modes (static transfer function).
Added worked examples for the other two tiers so all mode pages share
a common skeleton and new modes have something concrete to imitate:
- flowbased.md — Tier 2 (PID on measured outflow)
- powerbased.md — Tier 2 (levelbased curve clipped by grid power budget)
- mpc.md — Tier 3 (optimisation + forecast; block diagram +
scenario time-series instead of a fixed curve)
- modes/README.md — updated with the three-tier classification table
and diagram-type-per-tier guidance
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Control modes
|
||||
|
||||
Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it sets the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC.
|
||||
Each page describes one `pumpingStation` control mode and how it uses the shared [basin model](../functional-description.md#basin-model) — specifically, how it uses the three control thresholds (`minLevel`, `startLevel`, `maxLevel`) and computes the demand it sends to the MGC.
|
||||
|
||||
The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-independent and are enforced by the safety layer outside any mode. They never appear in a mode's policy.
|
||||
|
||||
@@ -9,21 +9,30 @@ The two **safety** thresholds (`dryRunLevel` and `overflowLevel`) are mode-indep
|
||||
Every mode page follows the same structure:
|
||||
|
||||
1. **At a glance** — one sentence + small fact table (inputs, output, status)
|
||||
2. **Diagram** — reference to `../diagrams/modes/<mode>.drawio.svg`
|
||||
2. **Diagram** — one or more, per tier (see below)
|
||||
3. **Inputs** — what signals the mode reads
|
||||
4. **Threshold policy** — how it sets/adjusts `minLevel`, `startLevel`, `maxLevel`
|
||||
5. **Demand formula** — how it turns inputs into a 0-100 % demand for the MGC
|
||||
4. **Threshold policy** — how it uses / adjusts `minLevel`, `startLevel`, `maxLevel`
|
||||
5. **Demand formula** — pseudocode for Tier 1/2, objective function for Tier 3
|
||||
6. **Edge cases** — cold start, sensor dropout, interaction with safety layer
|
||||
7. **Related** — links to other modes + functional description
|
||||
|
||||
The three **tiers** classify modes by how dynamic the decision surface is:
|
||||
|
||||
| Tier | Curve | Example modes | Diagram type |
|
||||
|---|---|---|---|
|
||||
| **1** — static | Memoryless `demand = f(x)`; single curve | `levelbased`, `manual` | Single-curve transfer function |
|
||||
| **2** — parameterised | Shape fixed, curve moves with θ(t) | `flowbased`, `pressureBased`, `percentageBased`, `powerBased` | Transfer function + parameter overlay / family |
|
||||
| **3** — horizon-based | Optimisation, no fixed curve | `hybrid-optimal`, `mpc`, weather-aware | Block diagram of signal flow + scenario time-series |
|
||||
|
||||
## Implementation status
|
||||
|
||||
| Mode | Status | Page |
|
||||
|---|---|---|
|
||||
| `levelbased` | ✅ implemented | [levelbased.md](levelbased.md) |
|
||||
| `flowbased` | 🚧 placeholder in code | — |
|
||||
| `pressureBased` | 🚧 placeholder in code | — |
|
||||
| `percentageBased` | 🚧 placeholder in code | — |
|
||||
| `powerBased` | 🚧 placeholder in code | — |
|
||||
| `hybrid` | 🚧 placeholder in code | — |
|
||||
| `manual` | ✅ implemented (Qd topic) | — |
|
||||
| Mode | Tier | Status | Page |
|
||||
|---|---|---|---|
|
||||
| `levelbased` | 1 | ✅ implemented | [levelbased.md](levelbased.md) |
|
||||
| `manual` | 1 | ✅ implemented (via `Qd` topic) | — |
|
||||
| `flowbased` | 2 | 🚧 code placeholder, template | [flowbased.md](flowbased.md) |
|
||||
| `pressureBased` | 2 | 🚧 code placeholder | — |
|
||||
| `percentageBased` | 2 | 🚧 code placeholder | — |
|
||||
| `powerBased` | 2 | 🚧 code placeholder, template | [powerbased.md](powerbased.md) |
|
||||
| `hybrid` | 3 | 🚧 code placeholder | — |
|
||||
| `mpc` | 3 | 🚧 not in code yet, template | [mpc.md](mpc.md) |
|
||||
|
||||
83
wiki/modes/flowbased.md
Normal file
83
wiki/modes/flowbased.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Flow-based mode
|
||||
mode: flowbased
|
||||
tier: 2
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Flow-based mode — *Tier 2 template*
|
||||
|
||||
> **Status — not yet implemented.** The `flowbased` entry is a placeholder in `_controlLogic`. This page reserves the shape and documents the intended design so all Tier-2 modes share the same layout.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 2 — parameterised transfer function |
|
||||
| Signal driving demand | measured outflow (actual pumps) |
|
||||
| Secondary inputs | integrator + derivative state (for PID) |
|
||||
| Output | demand 0–100 % via PID correction |
|
||||
| Thresholds adjusted at runtime? | No (but the demand can move independently of level) |
|
||||
| Use when | The station has a flow sensor on the outlet and you want to hold a target outflow rate regardless of basin level |
|
||||
|
||||
## Diagram
|
||||
|
||||
**Primary plot.** Demand vs *outflow-error* (not level!) is the meaningful transfer function for flow-based control. The curve is a classic PID surface — proportional slope times error, plus integral + derivative terms.
|
||||
|
||||
**Secondary plot.** Level still enters as gates (STOP below `minLevel`, don't overfill above `maxLevel`) — same thresholds as levelbased, but the mode doesn't *use* level to pick demand.
|
||||
|
||||
```
|
||||
Placeholder image — replace with:
|
||||
diagrams/modes/flowbased.drawio.svg (demand vs outflow-error, showing Kp slope)
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| measured outflow | sum of `flow.measured.*` at outflow positions | error = (flowSetpoint − measuredOutflow) |
|
||||
| `config.control.flowBased.flowSetpoint` | editor, static | target outflow in m³/h |
|
||||
| `config.control.flowBased.flowDeadband` | editor, static | zone around setpoint where PID output holds |
|
||||
| `config.control.flowBased.pid.{kp, ki, kd, ...}` | editor / schema | PID gains + rate limits |
|
||||
| current level | fallback → threshold gates | only used for `minLevel`/`maxLevel` bounds |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
The **control** thresholds (`minLevel`, `startLevel`, `maxLevel`) are still enforced but for different reasons than levelbased:
|
||||
|
||||
| Threshold | Role in flowbased |
|
||||
|---|---|
|
||||
| `minLevel` | If level drops below, force demand=0 regardless of PID output (prevents pump undercut) |
|
||||
| `startLevel` | unused — demand is driven by error, not level |
|
||||
| `maxLevel` | If level climbs above, force demand=100 regardless of PID output (prevents spill) |
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
error = flowSetpoint − measuredOutflow
|
||||
|
||||
if level < minLevel:
|
||||
demand = 0 # pump-undercut guard
|
||||
elif level > maxLevel:
|
||||
demand = 100 # anti-spill guard
|
||||
else:
|
||||
# normal PID branch
|
||||
P = Kp × error
|
||||
I += Ki × error × dt # with anti-windup clamp
|
||||
D = Kd × d(error)/dt # with low-pass filter
|
||||
demand = clamp(P + I + D, 0, 100) # with rate limits Δup/Δdown
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cold start, no prior outflow measurement.** PID state starts at 0; first error is `flowSetpoint`. Integral term will build up — rate-limit the demand ramp to avoid over-shoot.
|
||||
- **Sensor dropout on the outflow meter.** Fall back to predicted outflow (sum of pump curve predictions). Log a warning — PID on predicted-only is unreliable.
|
||||
- **Setpoint step change.** PID with derivative filter + rate limits handles this gracefully; without filter, the D-kick would saturate output.
|
||||
- **Safety layer interaction.** Same as levelbased — `dryRunLevel` and `overflowLevel` override the PID output. See [functional description § Safety](../functional-description.md#safety-controller).
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model + shared safety layer
|
||||
- [modes/README.md](README.md) — mode index + page template
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 reference implementation
|
||||
149
wiki/modes/mpc.md
Normal file
149
wiki/modes/mpc.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: MPC (Model-Predictive Control)
|
||||
mode: mpc
|
||||
tier: 3
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# MPC mode — *Tier 3 template*
|
||||
|
||||
> **Status — not yet implemented.** Not even in the schema today. This page reserves the shape for when the time comes.
|
||||
|
||||
## Why this is Tier 3
|
||||
|
||||
The levelbased/flowbased/powerBased modes are all **memoryless or near-memoryless transfer functions**. You give them the current state; they give you a demand. You can draw them as 2D plots.
|
||||
|
||||
MPC is different. At each tick the controller solves an optimisation over a prediction horizon:
|
||||
|
||||
```
|
||||
minimise Σ cost(state(t+k), command(t+k)) for k = 0 .. N
|
||||
subject to forecast, physical limits, power budget, spill penalty, ...
|
||||
```
|
||||
|
||||
The *command* that's emitted at time `t` is merely the first step of that plan; next tick the forecast shifts and the optimiser re-runs. There's no fixed `demand = f(level)` curve — the curve is remade every tick.
|
||||
|
||||
That's why Tier-3 modes get **block diagrams + scenario time-series**, not transfer functions.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 3 — optimisation-based |
|
||||
| Signal driving demand | full state (level, flow, power) + **forecasts** (inflow, grid price, weather) |
|
||||
| Secondary inputs | cost weights, horizon length, solver config |
|
||||
| Output | demand + planned trajectory over horizon |
|
||||
| Thresholds adjusted at runtime? | Effectively yes — the optimiser treats them as soft constraints |
|
||||
| Use when | Available forecasts beat reactive control, or multi-objective optimisation is needed |
|
||||
|
||||
## Diagram 1 — signal flow (block diagram)
|
||||
|
||||
```
|
||||
Placeholder image — replace with:
|
||||
diagrams/modes/mpc-block.drawio.svg
|
||||
|
||||
Blocks:
|
||||
|
||||
[sensors] [inflow forecast] [grid price] [weather API]
|
||||
│ │ │ │
|
||||
└─────────────┴──────────────────┴──────────────┘
|
||||
│
|
||||
┌─────▼──────┐
|
||||
│ state + │
|
||||
│ forecast │
|
||||
│ bundle │
|
||||
└─────┬──────┘
|
||||
│
|
||||
┌─────▼───────────────────┐
|
||||
│ MPC solver │
|
||||
│ • horizon N │
|
||||
│ • cost weights w │
|
||||
│ • constraints C │
|
||||
│ • linearised model │
|
||||
└─────┬───────────────────┘
|
||||
│
|
||||
┌─────▼───────┐
|
||||
│ command[0] │ ── the step we act on now
|
||||
│ command[1] │
|
||||
│ ... │
|
||||
│ command[N] │ ── re-planned next tick
|
||||
└─────┬───────┘
|
||||
│
|
||||
┌─────────▼─────────┐
|
||||
│ safety layer clip │ ← dryRun / overflow always apply
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
demand → MGC
|
||||
```
|
||||
|
||||
## Diagram 2 — scenario time-series
|
||||
|
||||
A much more useful way to evaluate MPC is to plot *what it did* over a simulated scenario: level, planned vs actual demand, the cost function breakdown, the active constraints. The [eval harness](../../eval/README.md) is built for exactly this — MPC will need a dedicated scenario like `mpc-storm-with-forecast.js`.
|
||||
|
||||
```
|
||||
Placeholder — replace with:
|
||||
diagrams/modes/mpc-scenario.drawio.svg
|
||||
|
||||
Stacked time-series showing:
|
||||
1. basin level over time (with forecast shadow and horizon)
|
||||
2. demand over time (with the re-planning edges visible)
|
||||
3. cost breakdown: energy vs spill-penalty vs ramp-penalty
|
||||
4. active constraints over time (colored bands)
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current state | `measurements` container | initial condition for optimiser |
|
||||
| inflow forecast | external — sewer model / weather API | drives the cost integral |
|
||||
| grid-price forecast | external — market feed / schedule | weights energy cost |
|
||||
| cost weights `w` | config | trades off spill vs energy vs ramp |
|
||||
| horizon `N` | config | 15–60 minutes typical |
|
||||
| model parameters | config / learned | basin dynamics, pump curves |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
Levels appear in the optimiser as **soft constraints** (penalties in the cost function):
|
||||
|
||||
| Threshold | Role in MPC |
|
||||
|---|---|
|
||||
| `dryRunLevel`, `overflowLevel` | hard constraints — if the optimiser's plan crosses them, safety layer clips |
|
||||
| `minLevel`, `maxLevel` | soft constraints — penalty weight `w_level` applied to excursions |
|
||||
| `startLevel` | advisory only — optimiser doesn't inherently care, but may be used in cost weights for rule-of-thumb alignment with human expectations |
|
||||
|
||||
So unlike Tier-1/2 where thresholds directly gate the action, here they shape the objective.
|
||||
|
||||
## Demand formula
|
||||
|
||||
Not a formula — an optimisation problem:
|
||||
|
||||
```text
|
||||
state, forecast, constraints = gather_inputs()
|
||||
plan = mpc_solver.solve(
|
||||
state0 = state,
|
||||
forecast = forecast,
|
||||
horizon = N,
|
||||
model = basin_dynamics + pump_curves,
|
||||
cost = w_energy × Σ power(k)
|
||||
+ w_spill × Σ max(0, level(k) − overflowLevel)²
|
||||
+ w_undercut × Σ max(0, minLevel − level(k))²
|
||||
+ w_ramp × Σ (command(k) − command(k-1))²,
|
||||
constraints = pump_limits + power_budget + rate_limits,
|
||||
)
|
||||
demand = plan.command[0]
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Solver timeout.** Fall back to the previous plan's step, or to a levelbased curve as a safe default. Log.
|
||||
- **Bad forecast (persistent bias).** Optimiser can chase a wrong prediction for many ticks. Adaptive forecast bias correction, or a watchdog comparing forecast-vs-realised, is essential.
|
||||
- **Infeasibility.** If constraints can't be satisfied (e.g. power budget and maxLevel simultaneously during a severe storm), relax soft constraints in priority order (ramp first, then maxLevel, then energy) — never relax dryRun/overflow.
|
||||
- **Safety takeover.** The safety layer still overrides. MPC should *anticipate* safety trips in its cost function (big penalty for trajectories that invoke them), not hit them.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md) — basin model + safety layer
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 — the "default" MPC falls back to
|
||||
- [modes/powerbased.md](powerbased.md) — Tier 2 — MPC generalises the clip idea into full optimisation
|
||||
- [eval/README.md](../../eval/README.md) — where MPC evaluation scenarios will live
|
||||
83
wiki/modes/powerbased.md
Normal file
83
wiki/modes/powerbased.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Power-based mode
|
||||
mode: powerBased
|
||||
tier: 2
|
||||
status: placeholder
|
||||
updated: 2026-04-22
|
||||
---
|
||||
|
||||
# Power-based mode — *Tier 2 template*
|
||||
|
||||
> **Status — not yet implemented.** Placeholder. This page documents the intended shape of a grid-aware / netcongestion-aware station.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Tier | 2 — parameterised transfer function |
|
||||
| Signal driving demand | basin level (primary), **max-power budget** (clip) |
|
||||
| Secondary inputs | measured pump power, live grid-price / peak-hours signal |
|
||||
| Output | demand 0–100 % clipped so `Σ pump power ≤ maxPowerKW(t)` |
|
||||
| Thresholds adjusted at runtime? | `maxPowerKW(t)` yes — level thresholds no |
|
||||
| Use when | Grid has peak-hour tariffs or net-congestion caps |
|
||||
|
||||
## Diagram — the levelbased curve with a moving clip ceiling
|
||||
|
||||
```
|
||||
demand % ← dashed line: levelbased curve
|
||||
100 ┤ ╱ ─────── ← solid: clip at powerBudget(t)
|
||||
│ ╱ clip lowers
|
||||
│ ╱ during grid peak
|
||||
│ ╱ ─────────
|
||||
│ ╱ ╱
|
||||
│ ╱ ╱
|
||||
│ ╱ ╱
|
||||
0 ┼────────●───────●─────────────────────► level
|
||||
startLevel maxLevel
|
||||
|
||||
↑ the family of curves:
|
||||
clip=100% (grid idle),
|
||||
clip=70% (shoulder),
|
||||
clip=40% (peak).
|
||||
```
|
||||
|
||||
The *shape* stays levelbased; the *ceiling* drops when the grid is strained. That's the Tier-2 signature: same input axis, parameter shifts the curve.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Signal | Where from | Role |
|
||||
|---|---|---|
|
||||
| current level | as in levelbased | primary input |
|
||||
| `config.control.powerBased.maxPowerKW` | editor, static | hard cap on station power |
|
||||
| `config.control.powerBased.powerControlMode` | `limit` / `optimize` | whether to just clip or to schedule |
|
||||
| live grid signal (future) | external topic or forecast | modulates the cap over time |
|
||||
| measured pump power | `power.measured.*` from children | real-time feedback against the cap |
|
||||
|
||||
## Threshold policy
|
||||
|
||||
Level thresholds (`minLevel`, `startLevel`, `maxLevel`) are **identical to levelbased** — they define the shape of the underlying curve. What's new is a runtime-varying ceiling `demandCap(t)` derived from the power budget.
|
||||
|
||||
`demandCap(t) = 100 × (maxPowerKW(t) / nominalStationPowerAtFull)` — where `maxPowerKW(t)` may come from config (static `limit` mode) or an external grid-price feed (dynamic).
|
||||
|
||||
## Demand formula
|
||||
|
||||
```text
|
||||
rawDemand = levelbasedDemand(level) # the underlying Tier-1 curve
|
||||
demandCap = min(100, 100 × maxPowerKW(t) / nominalStationPower)
|
||||
demand = min(rawDemand, demandCap)
|
||||
```
|
||||
|
||||
When `demandCap < rawDemand`, the mode sacrifices drainage rate to stay within power budget. Level may rise — the overfill safety layer still applies as the last line of defence.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Peak hour with rising level.** demandCap drops faster than level rises → demand gets clipped; level approaches `overflowLevel`. If overfill safety trips, it overrides the clip (safety wins).
|
||||
- **Power signal dropout.** Fall back to static `maxPowerKW` from config; log warning.
|
||||
- **Grid exit from peak while basin is nearly full.** demandCap jumps back to 100; PID is memoryless so demand rises in one tick to match rawDemand.
|
||||
- **Measured vs predicted pump power.** Cap is enforced on predicted (decisions are made before the pump responds). Reconcile against measured for logging/diagnostics.
|
||||
|
||||
## Related
|
||||
|
||||
- [Functional description](../functional-description.md)
|
||||
- [modes/levelbased.md](levelbased.md) — Tier 1 reference (the curve that powerBased clips)
|
||||
- [modes/flowbased.md](flowbased.md) — other Tier-2 example with different control variable
|
||||
Reference in New Issue
Block a user