Per discussion: "test" and "eval" overlap in meaning; "simulations" is more honest about what's actually happening — scripted plant inputs driving a physics sim, then recorded for analysis. Rename scope: - eval/ → simulations/ (tracked as git renames) - Internal references in run.js and README.md updated - wiki/modes/mpc.md link updated Also fixes a log-write bug noticed during the rename: - run.js didn't mkdir simulations/logs/ before createWriteStream, so the stream opened into a potentially non-existent dir and the file never materialised. Added fs.mkdirSync(..., recursive:true). - end() wasn't awaited, so the process could exit before the stream flushed. Now awaits the 'finish' event. Confirmed: 1200 records actually land in simulations/logs/<scenario>.jsonl. - Added simulations/logs/.gitignore so future JSONL artefacts stay out of the repo but the dir remains tracked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
7.0 KiB
Markdown
150 lines
7.0 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 [simulations harness](../../simulations/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
|
||
- [simulations/README.md](../../simulations/README.md) — where MPC simulation scenarios will live
|