### 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>
150 lines
6.9 KiB
Markdown
150 lines
6.9 KiB
Markdown
---
|
||
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
|