From 5c6ed694dadb06b35b05ce57eb7d9bdcad03b4ab Mon Sep 17 00:00:00 2001
From: Dan Martin <93075337+Mingqwqqaq@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:44:37 +0800
Subject: [PATCH 1/2] feat(WastewaterTreatment): add BSM1 aeration control
benchmark
---
TASK_DETAILS.md | 5 +
TASK_DETAILS_zh-CN.md | 5 +
.../BSM1AerationControl/README.md | 53 +++
.../BSM1AerationControl/README_zh-CN.md | 44 +++
.../BSM1AerationControl/Task.md | 92 +++++
.../BSM1AerationControl/Task_zh-CN.md | 58 +++
.../baseline/result_log.txt | 24 ++
.../BSM1AerationControl/baseline/solution.py | 16 +
.../frontier_eval/agent_files.txt | 7 +
.../frontier_eval/artifact_files.txt | 2 +
.../frontier_eval/candidate_destination.txt | 1 +
.../frontier_eval/constraints.txt | 9 +
.../frontier_eval/copy_files.txt | 12 +
.../frontier_eval/eval_command.txt | 1 +
.../frontier_eval/initial_program.txt | 1 +
.../frontier_eval/readonly_files.txt | 15 +
.../references/BSD-3-Clause-bsm2-python.txt | 28 ++
.../references/config.json | 63 ++++
.../references/design_notes.md | 41 +++
.../BSM1AerationControl/scripts/init.py | 20 +
.../verification/bsm1_model.py | 341 ++++++++++++++++++
.../verification/evaluator.py | 309 ++++++++++++++++
.../verification/policy_runtime.py | 251 +++++++++++++
.../verification/policy_worker.py | 98 +++++
.../verification/requirements.txt | 2 +
.../verification/test_bsm1_model.py | 50 +++
.../verification/test_evaluator.py | 81 +++++
.../verification/test_policy_runtime.py | 46 +++
benchmarks/WastewaterTreatment/README.md | 11 +
.../WastewaterTreatment/README_zh-CN.md | 9 +
.../conf/task/bsm1_aeration_control.yaml | 6 +
31 files changed, 1701 insertions(+)
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/README.md
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/README_zh-CN.md
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/Task.md
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/Task_zh-CN.md
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/solution.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/agent_files.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/artifact_files.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/candidate_destination.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/constraints.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/copy_files.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/eval_command.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/initial_program.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/readonly_files.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/references/BSD-3-Clause-bsm2-python.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/references/config.json
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/scripts/init.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/bsm1_model.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/evaluator.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_runtime.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/requirements.txt
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_evaluator.py
create mode 100644 benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py
create mode 100644 benchmarks/WastewaterTreatment/README.md
create mode 100644 benchmarks/WastewaterTreatment/README_zh-CN.md
create mode 100644 frontier_eval/conf/task/bsm1_aeration_control.yaml
diff --git a/TASK_DETAILS.md b/TASK_DETAILS.md
index 7475c4cc..2ff811fb 100644
--- a/TASK_DETAILS.md
+++ b/TASK_DETAILS.md
@@ -351,5 +351,10 @@ We welcome new engineering problem ideas — even without complete verification
DiffSimThermalControl |
Process optimization in additive manufacturing via differentiable simulation |
+
+ | WastewaterTreatment |
+ BSM1AerationControl |
+ Feedback control of activated-sludge aeration and internal recycle across dry, rain, and storm operation |
+
diff --git a/TASK_DETAILS_zh-CN.md b/TASK_DETAILS_zh-CN.md
index e2a070a2..e3a4fa6c 100644
--- a/TASK_DETAILS_zh-CN.md
+++ b/TASK_DETAILS_zh-CN.md
@@ -351,5 +351,10 @@ Frontier-Eng 目前已覆盖以下领域的任务。每个任务均配有可运
DiffSimThermalControl |
基于可微仿真的增材制造工艺优化 |
+
+ | WastewaterTreatment |
+ BSM1AerationControl |
+ 在旱天、降雨和暴雨工况下反馈控制活性污泥曝气与内回流 |
+
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/README.md b/benchmarks/WastewaterTreatment/BSM1AerationControl/README.md
new file mode 100644
index 00000000..aeca69ee
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/README.md
@@ -0,0 +1,53 @@
+# BSM1 Aeration Control
+
+Design a deterministic feedback controller for an activated-sludge plant derived from the
+IWA Benchmark Simulation Model No. 1 (BSM1). The controller sets oxygen-transfer coefficients
+in the three aerobic reactors and the internal recycle flow. It must balance effluent quality,
+energy, compliance, and actuator smoothness across dry, rain, and storm scenarios.
+
+Edit only the EVOLVE-BLOCK in `scripts/init.py`, preserving:
+
+```python
+def reset_controller(scenario: dict) -> None: ...
+def control(observation: dict) -> dict: ...
+```
+
+## Setup
+
+The task is offline and CPU-only:
+
+```bash
+python -m pip install -r verification/requirements.txt
+```
+
+No raw IWA influent files are redistributed. The evaluator generates deterministic trajectories
+from published BSM1 averages and weather-event descriptions. A complete baseline run takes about
+45 seconds on a laptop.
+
+## Direct evaluation
+
+```bash
+python verification/evaluator.py scripts/init.py --metrics-out metrics.json --artifacts-out artifacts.json
+```
+
+## Regression tests
+
+```bash
+python -m unittest discover -s verification -p "test_*.py" -v
+```
+
+## Unified evaluation
+
+From the repository root:
+
+```bash
+python -m frontier_eval task=unified task.benchmark=WastewaterTreatment/BSM1AerationControl algorithm=openevolve algorithm.iterations=0
+```
+
+The ranking metric is `combined_score` (higher is better). `metrics.json` contains the aggregate
+score and validity fields; `artifacts.json` contains scenario-level engineering metrics and daily
+effluent samples. Candidate code runs in a separate, bounded JSON-lines worker process. This is
+process isolation, not an operating-system security sandbox.
+
+See `Task.md` for the exact interface and scoring model, and
+`references/design_notes.md` for provenance, validation, and modelling limitations.
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/README_zh-CN.md b/benchmarks/WastewaterTreatment/BSM1AerationControl/README_zh-CN.md
new file mode 100644
index 00000000..baa70329
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/README_zh-CN.md
@@ -0,0 +1,44 @@
+# BSM1 曝气控制
+
+为基于 IWA 一号基准仿真模型(BSM1)的活性污泥处理厂设计确定性反馈控制器。控制器设置三个好氧反应池的氧传质系数与内回流量,并在旱天、降雨和暴雨场景下权衡出水质量、能耗、达标情况和执行器平滑性。
+
+只修改 `scripts/init.py` 中的 EVOLVE-BLOCK,并保持以下接口:
+
+```python
+def reset_controller(scenario: dict) -> None: ...
+def control(observation: dict) -> dict: ...
+```
+
+## 环境安装
+
+本任务离线运行且只需要 CPU:
+
+```bash
+python -m pip install -r verification/requirements.txt
+```
+
+仓库不再分发 IWA 原始进水文件。评测器依据已发表的 BSM1 平均值与天气事件说明生成确定性轨迹。在普通笔记本上完整基准评测约需 45 秒。
+
+## 直接评测
+
+```bash
+python verification/evaluator.py scripts/init.py --metrics-out metrics.json --artifacts-out artifacts.json
+```
+
+## 回归测试
+
+```bash
+python -m unittest discover -s verification -p "test_*.py" -v
+```
+
+## 统一评测
+
+在仓库根目录运行:
+
+```bash
+python -m frontier_eval task=unified task.benchmark=WastewaterTreatment/BSM1AerationControl algorithm=openevolve algorithm.iterations=0
+```
+
+排名指标为 `combined_score`,越高越好。`metrics.json` 给出聚合分数与有效性;`artifacts.json` 给出逐场景工程指标和每日出水样本。候选代码运行在带时限的独立 JSON-lines 工作进程中;这是进程隔离,不是操作系统级安全沙箱。
+
+完整接口和评分模型见 `Task_zh-CN.md`,来源、验证和模型限制见 `references/design_notes.md`。
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/Task.md b/benchmarks/WastewaterTreatment/BSM1AerationControl/Task.md
new file mode 100644
index 00000000..0e2e2ebf
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/Task.md
@@ -0,0 +1,92 @@
+# Task: BSM1 Aeration Control
+
+## Engineering setting
+
+Activated-sludge plants must remove carbon and nitrogen while limiting blower and pumping energy.
+Influent flow and composition vary diurnally and become more difficult during rain and storm
+events. This task exposes feedback control rather than a one-shot parameter fit: every 15 minutes,
+the policy observes noisy process measurements and selects aeration and internal recycle settings.
+
+## Process model and scenarios
+
+The evaluator implements the standard BSM1 layout: five completely mixed ASM1 reactors (two
+anoxic, three aerobic) followed by a ten-layer Takacs secondary settler. Fixed return-activated
+sludge and waste-sludge flows are 18,446 and 385 m3/day. The plant is simulated for 14 days and
+the final seven days are scored.
+
+Three deterministic scenarios share the same diurnal base load:
+
+- `dry`: diurnal and weekend load variation;
+- `rain`: a sustained dilution-water event from day 8.35 to day 10.44;
+- `storm`: two shorter hydraulic pulses centred near days 8.87 and 11.18.
+
+The rain and storm series are deterministic IWA-derived engineering trajectories, not byte-for-byte
+copies of the official BSM1 dynamic influent files. Sensor noise uses fixed, scenario-specific seeds.
+
+## Observation
+
+`control(observation)` receives only current or past information:
+
+- `scenario_id`, `weather`, `time_day`, and `step_minutes`;
+- influent flow and ammonium;
+- dissolved oxygen in reactors 3, 4, and 5;
+- nitrate in reactor 2;
+- effluent ammonium and total nitrogen;
+- `previous_action`.
+
+The dissolved-oxygen, nitrate, and ammonium measurements contain deterministic sensor noise. The
+policy does not receive future influent, process state arrays, random seeds, or evaluator internals.
+
+## Action interface and hard constraints
+
+Return exactly these four finite numeric fields:
+
+```python
+{
+ "kla3_per_day": float, # [0, 360]
+ "kla4_per_day": float, # [0, 360]
+ "kla5_per_day": float, # [0, 360]
+ "internal_recycle_m3_per_day": float, # [0, 92230]
+}
+```
+
+At one 15-minute step, each KLa may change by at most 120/day and internal recycle by at most
+30,000 m3/day. Missing/extra fields, booleans, non-finite values, range errors, slew errors,
+exceptions, protocol errors, or timeouts make the complete candidate invalid with score zero.
+`reset_controller(scenario)` must reset all candidate-owned state before each scenario.
+
+## Metrics and score
+
+For every scored step, the evaluator computes the official BSM1 effluent quality index (EQI),
+aeration energy, pumping energy, mixing energy, actuator switching, and normalized exceedance of
+five standard limits: NH4-N 4, total nitrogen 18, COD 100, TSS 30, and BOD5 10 g/m3.
+
+Each objective is mapped to a dimensionless utility:
+
+```text
+Uq = exp(-EQI / 6000)
+Ua = exp(-aeration_energy / 5000)
+Up = exp(-pumping_energy / 1200)
+Um = exp(-mixing_energy / 600)
+Us = exp(-4 * switching_index)
+
+base = 100 * (0.45 Uq + 0.20 Ua + 0.10 Up + 0.10 Um + 0.15 Us)
+scenario_score = base * exp(-6 * violation_index)
+```
+
+The aggregate rewards typical and worst-case behavior:
+
+```text
+combined_score = 0.75 * mean(scenario_scores) + 0.25 * min(scenario_scores)
+```
+
+The score is absolute and contains no frozen candidate baseline. A fixed published BSM1 operating
+point is supplied only as editable starter code and as a reproducible comparison in the result log.
+
+## Candidate process boundary
+
+The evaluator copies the candidate to a temporary directory and imports it in a persistent worker.
+Communication is JSON-only; import, each call, cumulative response time, and response size are
+bounded. Candidate stdout is discarded and credentials are removed from its environment. This
+prevents ordinary Python-level mutation of the parent evaluator; it is not a sandbox against
+hostile native code or unrestricted filesystem/network access.
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/Task_zh-CN.md b/benchmarks/WastewaterTreatment/BSM1AerationControl/Task_zh-CN.md
new file mode 100644
index 00000000..a0720228
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/Task_zh-CN.md
@@ -0,0 +1,58 @@
+# 任务:BSM1 曝气控制
+
+## 工程背景
+
+活性污泥处理厂既要去除有机物和氮,又要控制鼓风与泵送能耗。进水流量和组分具有日周期变化,降雨与暴雨会进一步增加水力冲击。本任务是反馈控制而非一次性参数拟合:策略每 15 分钟读取带噪过程测量,并设置曝气与内回流。
+
+## 工艺模型与场景
+
+评测器实现标准 BSM1 布局:五个完全混合 ASM1 反应池(前两个缺氧、后三个好氧)以及十层 Takacs 二沉池。固定污泥回流和排泥流量分别为 18,446 与 385 m3/day。每个场景仿真 14 天,最后 7 天计分。
+
+三个确定性场景共享同一日周期基础负荷:
+
+- `dry`:日周期和周末负荷变化;
+- `rain`:第 8.35 至 10.44 天的持续稀释水事件;
+- `storm`:中心约在第 8.87 与 11.18 天的两次短时水力脉冲。
+
+降雨与暴雨序列是依据 IWA 资料构造的确定性工程轨迹,并非官方 BSM1 动态进水文件的逐字节副本。传感器噪声使用固定且按场景区分的种子。
+
+## 观测
+
+`control(observation)` 只接收当前或历史信息:场景、天气、时间、步长;进水流量与氨氮;3—5 号池溶解氧;2 号池硝酸盐;出水氨氮与总氮;以及 `previous_action`。溶解氧、硝酸盐和氨氮测量含确定性噪声。策略不能获得未来进水、完整过程状态、随机种子或评测器内部信息。
+
+## 动作接口与硬约束
+
+必须恰好返回四个有限数值字段:
+
+```python
+{
+ "kla3_per_day": float, # [0, 360]
+ "kla4_per_day": float, # [0, 360]
+ "kla5_per_day": float, # [0, 360]
+ "internal_recycle_m3_per_day": float, # [0, 92230]
+}
+```
+
+相邻 15 分钟内,每个 KLa 的最大变化为 120/day,内回流最大变化为 30,000 m3/day。字段缺失或多余、布尔值、非有限值、越界、变化率超限、异常、协议错误或超时都会使整个候选无效并得零分。`reset_controller(scenario)` 必须在每个场景前重置策略自身状态。
+
+## 指标与评分
+
+计分期的每一步都会计算 BSM1 出水质量指数(EQI)、曝气/泵送/搅拌能耗、执行器切换,以及五项标准限值的归一化超标量:NH4-N 4、总氮 18、COD 100、TSS 30、BOD5 10 g/m3。
+
+```text
+Uq = exp(-EQI / 6000)
+Ua = exp(-曝气能耗 / 5000)
+Up = exp(-泵送能耗 / 1200)
+Um = exp(-搅拌能耗 / 600)
+Us = exp(-4 * switching_index)
+
+base = 100 * (0.45 Uq + 0.20 Ua + 0.10 Up + 0.10 Um + 0.15 Us)
+场景分 = base * exp(-6 * violation_index)
+combined_score = 0.75 * 场景均分 + 0.25 * 最差场景分
+```
+
+该分数是绝对工程分,不依赖冻结的候选基线。公开的 BSM1 固定工况仅作为可编辑起始程序和结果日志中的可复现实验对照。
+
+## 候选进程边界
+
+评测器把候选复制到临时目录并在持久工作进程中导入。通信仅使用 JSON;导入、单次调用、累计响应时间和响应大小均受限。候选标准输出会被丢弃,环境中的凭据会被移除。该边界可阻止普通 Python 代码修改父评测器,但不能替代针对恶意原生代码、文件系统或网络访问的操作系统沙箱。
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt
new file mode 100644
index 00000000..954cc0da
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt
@@ -0,0 +1,24 @@
+Direct command:
+python verification/evaluator.py scripts/init.py --metrics-out metrics.json --artifacts-out artifacts.json
+
+Environment:
+Windows 11, Python 3.11, CPU-only
+
+Result:
+=== BSM1 Aeration Control ===
+scenario=dry score=53.457 eqi=5488.86 ae=3341.39 violations=0.011330
+scenario=rain score=44.609 eqi=7535.45 ae=3341.39 violations=0.025575
+scenario=storm score=48.513 eqi=6780.93 ae=3341.39 violations=0.017011
+---
+completed_scenarios: 3/3
+diagnostic_score: 47.7973
+combined_score: 47.7973
+
+Regression:
+python -m unittest discover -s verification -p "test_*.py" -v
+Ran 9 tests in 3.574s
+OK
+
+Unified evaluation:
+python -m frontier_eval task=unified task.benchmark=WastewaterTreatment/BSM1AerationControl algorithm=openevolve algorithm.iterations=0
+combined_score=47.7973, valid=1.0000, completed_scenarios=3.0000, benchmark_returncode=0.0000
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/solution.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/solution.py
new file mode 100644
index 00000000..5911e71a
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/solution.py
@@ -0,0 +1,16 @@
+"""Reference fixed-operating-point policy for BSM1AerationControl."""
+
+from __future__ import annotations
+
+
+def reset_controller(scenario: dict) -> None:
+ pass
+
+
+def control(observation: dict) -> dict:
+ return {
+ "kla3_per_day": 240.0,
+ "kla4_per_day": 240.0,
+ "kla5_per_day": 84.0,
+ "internal_recycle_m3_per_day": 55338.0,
+ }
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/agent_files.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/agent_files.txt
new file mode 100644
index 00000000..e8a78865
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/agent_files.txt
@@ -0,0 +1,7 @@
+README.md
+README_zh-CN.md
+Task.md
+Task_zh-CN.md
+scripts/init.py
+references/design_notes.md
+frontier_eval/constraints.txt
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/artifact_files.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/artifact_files.txt
new file mode 100644
index 00000000..76dc893a
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/artifact_files.txt
@@ -0,0 +1,2 @@
+metrics.json
+artifacts.json
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/candidate_destination.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/candidate_destination.txt
new file mode 100644
index 00000000..b9411b3d
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/candidate_destination.txt
@@ -0,0 +1 @@
+scripts/init.py
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/constraints.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/constraints.txt
new file mode 100644
index 00000000..1b10a473
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/constraints.txt
@@ -0,0 +1,9 @@
+BSM1 aeration-control constraints:
+1) Edit only `scripts/init.py` inside the EVOLVE-BLOCK markers.
+2) Keep `reset_controller(scenario) -> None` and `control(observation) -> dict` working.
+3) Return exactly the four finite numeric fields documented in Task.md.
+4) Keep each KLa in [0, 360]/day and internal recycle in [0, 92230] m3/day.
+5) Respect per-step slew limits: 120/day for each KLa and 30000 m3/day for recycle.
+6) Reset all candidate-owned state before each dry, rain, or storm scenario.
+7) Keep the controller deterministic, self-contained, and within the 0.10-second call limit.
+8) Do not read, write, import, or modify evaluator, model, result, or environment-secret files.
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/copy_files.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/copy_files.txt
new file mode 100644
index 00000000..68c8d26c
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/copy_files.txt
@@ -0,0 +1,12 @@
+README.md
+README_zh-CN.md
+Task.md
+Task_zh-CN.md
+scripts/init.py
+verification/evaluator.py
+verification/bsm1_model.py
+verification/policy_runtime.py
+verification/policy_worker.py
+verification/requirements.txt
+references/config.json
+references/BSD-3-Clause-bsm2-python.txt
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/eval_command.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/eval_command.txt
new file mode 100644
index 00000000..527d23b5
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/eval_command.txt
@@ -0,0 +1 @@
+{python} verification/evaluator.py {candidate} --metrics-out metrics.json --artifacts-out artifacts.json
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/initial_program.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/initial_program.txt
new file mode 100644
index 00000000..b9411b3d
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/initial_program.txt
@@ -0,0 +1 @@
+scripts/init.py
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/readonly_files.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/readonly_files.txt
new file mode 100644
index 00000000..ce96ab94
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/frontier_eval/readonly_files.txt
@@ -0,0 +1,15 @@
+README.md
+README_zh-CN.md
+Task.md
+Task_zh-CN.md
+verification/evaluator.py
+verification/bsm1_model.py
+verification/policy_runtime.py
+verification/policy_worker.py
+verification/test_bsm1_model.py
+verification/test_evaluator.py
+verification/test_policy_runtime.py
+verification/requirements.txt
+references/config.json
+references/design_notes.md
+references/BSD-3-Clause-bsm2-python.txt
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/references/BSD-3-Clause-bsm2-python.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/BSD-3-Clause-bsm2-python.txt
new file mode 100644
index 00000000..eb25b478
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/BSD-3-Clause-bsm2-python.txt
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2025, FAU-EVT
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/references/config.json b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/config.json
new file mode 100644
index 00000000..fe7018fa
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/config.json
@@ -0,0 +1,63 @@
+{
+ "benchmark": "BSM1AerationControl",
+ "source": {
+ "iwa_page": "https://iwa-mia.org/benchmarking/",
+ "iwa_report": "https://iwa-mia.org/wp-content/uploads/2019/04/BSM_TG_Tech_Report_no_1_BSM1_General_Description.pdf",
+ "validation_implementation": "https://github.com/fau-evt/bsm2-python",
+ "validation_license": "BSD-3-Clause"
+ },
+ "simulation": {
+ "days": 14.0,
+ "evaluation_start_day": 7.0,
+ "step_minutes": 15.0,
+ "sensor_noise_seed": 1729,
+ "sensor_noise_std": {
+ "dissolved_oxygen": 0.025,
+ "nitrate": 0.025,
+ "ammonium": 0.04
+ }
+ },
+ "scenarios": [
+ {"scenario_id": "dry", "weather": "dry"},
+ {"scenario_id": "rain", "weather": "rain"},
+ {"scenario_id": "storm", "weather": "storm"}
+ ],
+ "action": {
+ "kla_min_per_day": 0.0,
+ "kla_max_per_day": 360.0,
+ "qintr_min_m3_per_day": 0.0,
+ "qintr_max_m3_per_day": 92230.0,
+ "kla_max_change_per_step": 120.0,
+ "qintr_max_change_per_step": 30000.0
+ },
+ "reference_action": {
+ "kla3_per_day": 240.0,
+ "kla4_per_day": 240.0,
+ "kla5_per_day": 84.0,
+ "internal_recycle_m3_per_day": 55338.0
+ },
+ "limits": {
+ "ammonium_gN_per_m3": 4.0,
+ "total_nitrogen_gN_per_m3": 18.0,
+ "cod_gCOD_per_m3": 100.0,
+ "tss_gSS_per_m3": 30.0,
+ "bod5_gBOD_per_m3": 10.0
+ },
+ "scoring": {
+ "eqi_scale": 6000.0,
+ "aeration_energy_scale": 5000.0,
+ "pumping_energy_scale": 1200.0,
+ "mixing_energy_scale": 600.0,
+ "switching_exponent": 4.0,
+ "violation_exponent": 6.0,
+ "objective_weights": {
+ "quality": 0.45,
+ "aeration": 0.20,
+ "pumping": 0.10,
+ "mixing": 0.10,
+ "smoothness": 0.15
+ },
+ "mean_weight": 0.75,
+ "worst_case_weight": 0.25
+ }
+}
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md
new file mode 100644
index 00000000..10855221
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md
@@ -0,0 +1,41 @@
+# Model provenance and design notes
+
+## Sources
+
+The process layout, ASM1 kinetics, Takacs settler, standard operating point, effluent limits,
+effluent quality index, and energy equations follow the IWA Benchmark Simulation Model No. 1:
+
+- [IWA benchmark overview](https://iwa-mia.org/benchmarking/)
+- [BSM1 general description and simulation protocol](https://iwa-mia.org/wp-content/uploads/2019/04/BSM_TG_Tech_Report_no_1_BSM1_General_Description.pdf)
+
+The numerical implementation was cross-checked against the open-source
+[`fau-evt/bsm2-python`](https://github.com/fau-evt/bsm2-python) implementation. Initial
+steady-state arrays and equation structure were adapted from that BSD-3-Clause project; its
+license is retained in `BSD-3-Clause-bsm2-python.txt`.
+
+## Data boundary
+
+The official IWA site publishes dynamic influent files but does not state a redistribution license
+for those data. This repository therefore does not copy them. `influent_at()` builds reproducible
+engineering trajectories from the published average BSM1 influent, a deterministic diurnal/weekend
+profile, and the dry/rain/storm timing described by IWA. Rain and storm add water while conserving
+the instantaneous mass flow of all 13 ASM1 components. Results should be described as BSM1-derived,
+not as an exact replay of the official influent files.
+
+## Validation evidence
+
+- The fixed operating point yields 3341.3867 kWh/day aeration energy, matching the BSM1 reference
+ equation and published operating point.
+- Unit tests check weather-event component mass conservation, positive settler hydraulics after a
+ recycle-flow change, action validation, deterministic results, worker isolation, and timeouts.
+- A 96-step dry-weather regression demonstrates optimization headroom: lowering internal recycle
+ reduces pumping energy and improves score relative to the starter policy.
+- The full direct evaluation covers all three 14-day scenarios; its recorded output is in
+ `baseline/result_log.txt`.
+
+## Deliberate limitations
+
+This compact CPU benchmark assumes ideal completely mixed reactors, a fixed temperature of 15 C,
+fixed return/waste sludge flows, no explicit blower or pump dynamics, and deterministic hydraulic
+events. Candidate actions cannot change exogenous influent or sensor-noise sequences. The model is
+appropriate for controller-search benchmarking, not plant commissioning or regulatory prediction.
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/scripts/init.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/scripts/init.py
new file mode 100644
index 00000000..e71ecf02
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/scripts/init.py
@@ -0,0 +1,20 @@
+"""Editable baseline controller for the BSM1 aeration benchmark."""
+
+from __future__ import annotations
+
+
+def reset_controller(scenario: dict) -> None:
+ """Reset any candidate-owned state before a weather scenario."""
+
+
+def control(observation: dict) -> dict:
+ """Return aeration coefficients and the internal recycle flow."""
+
+ # EVOLVE-BLOCK-START
+ return {
+ "kla3_per_day": 240.0,
+ "kla4_per_day": 240.0,
+ "kla5_per_day": 84.0,
+ "internal_recycle_m3_per_day": 55338.0,
+ }
+ # EVOLVE-BLOCK-END
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/bsm1_model.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/bsm1_model.py
new file mode 100644
index 00000000..bb4ea899
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/bsm1_model.py
@@ -0,0 +1,341 @@
+"""Independent BSM1 plant model used by the aeration-control benchmark.
+
+The equations follow the IWA Benchmark Simulation Model No. 1: five ASM1
+reactors and a ten-layer Takacs secondary settler. The implementation was
+cross-checked against ``fau-evt/bsm2-python`` and is adapted from its
+BSD-3-Clause implementation; the retained license is in ``references/``.
+
+Units are days, m3, g/m3, and kWh/day unless stated otherwise.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from functools import lru_cache
+from pathlib import Path
+from typing import Any, Mapping
+
+import numpy as np
+from scipy.integrate import odeint
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CONFIG_PATH = ROOT / "references" / "config.json"
+
+SI, SS, XI, XS, XBH, XBA, XP, SO, SNO, SNH, SND, XND, SALK = range(13)
+TSS, Q, TEMP = 13, 14, 15
+N_COMPONENTS = 16
+
+ASM1_PARAMETERS = np.array(
+ [
+ 4.0, 10.0, 0.2, 0.5, 0.3, 0.5, 1.0, 0.4, 0.05, 0.8,
+ 0.05, 3.0, 0.1, 0.8, 0.67, 0.24, 0.08, 0.08, 0.06,
+ 0.75, 0.75, 0.75, 0.75, 0.75,
+ ],
+ dtype=float,
+)
+VOLUMES = np.array([1000.0, 1000.0, 1333.0, 1333.0, 1333.0])
+OXYGEN_SATURATION = 8.0
+RETURN_FLOW = 18446.0
+WASTE_FLOW = 385.0
+SETTLER_AREA = 1500.0
+SETTLER_HEIGHT = 4.0
+SETTLER_LAYERS = 10
+SETTLER_FEED_LAYER = 5
+SETTLER_PARAMETERS = np.array([250.0, 474.0, 0.000576, 0.00286, 0.00228, 3000.0, 3000.0])
+
+_REACTOR_INITIAL = np.array(
+ [
+ [30.0, 2.696169337, 1149.16340146, 79.78436831, 2553.45921988, 151.75632721, 449.22675116, 0.018822826, 8.310656768, 7.108011983, 1.228022799, 5.127002559, 4.659811087],
+ [30.0, 1.402154895, 1149.16340144, 73.67182578, 2555.28394130, 151.67460865, 449.89826272, 0.000279954, 6.570406004, 7.536848342, 0.896501365, 4.847841192, 4.814745881],
+ [30.0, 1.113091672, 1149.16340141, 62.63753037, 2558.68270986, 152.32053441, 450.79460391, 2.0, 9.544077377, 4.687308245, 0.822255969, 4.244269898, 4.398802205],
+ [30.0, 0.967558516, 1149.16340138, 54.01878920, 2560.36433241, 152.85355533, 451.69155922, 2.0, 12.068200287, 2.322898686, 0.751729972, 3.767047773, 4.049621314],
+ [30.0, 0.857137129, 1149.16340135, 47.36389426, 2560.70622587, 153.17106513, 452.58865148, 2.0, 13.711131912, 0.866431079, 0.690377137, 3.394128545, 3.828235655],
+ ],
+ dtype=float,
+)
+_SETTLER_TSS_INITIAL = np.array(
+ [12.50109702, 18.11774069, 29.54712298, 68.99884217, 356.2589969,
+ 356.25899691, 356.2589969, 356.25899691, 356.2589969, 6398.69776357],
+ dtype=float,
+)
+
+
+@lru_cache(maxsize=1)
+def load_config() -> dict[str, Any]:
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
+
+
+def _tss(components: np.ndarray) -> float:
+ return float(0.75 * np.sum(components[[XI, XS, XBH, XBA, XP]]))
+
+
+def _stream(components: np.ndarray, flow: float, temperature: float = 15.0) -> np.ndarray:
+ result = np.zeros(N_COMPONENTS, dtype=float)
+ result[:13] = components
+ result[TSS] = _tss(components)
+ result[Q] = float(flow)
+ result[TEMP] = float(temperature)
+ return result
+
+
+def combine_streams(*streams: np.ndarray) -> np.ndarray:
+ total_flow = float(sum(max(0.0, stream[Q]) for stream in streams))
+ if total_flow <= 0.0:
+ raise ValueError("cannot combine zero-flow streams")
+ result = np.zeros(N_COMPONENTS, dtype=float)
+ for stream in streams:
+ result[:14] += stream[:14] * stream[Q]
+ result[TEMP] += stream[TEMP] * stream[Q]
+ result[:14] /= total_flow
+ result[TEMP] /= total_flow
+ result[Q] = total_flow
+ return result
+
+
+def _asm1_rhs(y: np.ndarray, influent: np.ndarray, kla: float, volume: float) -> np.ndarray:
+ y = np.maximum(np.asarray(y, dtype=float), 1e-12)
+ p = ASM1_PARAMETERS
+ mu_h, k_s, k_oh, k_no, b_h, mu_a, k_nh, k_oa, b_a = p[:9]
+ ny_g, k_a, k_h, k_x, ny_h, y_h, y_a, f_p, i_xb, i_xp = p[9:19]
+ temp = float(influent[TEMP])
+ mu_h *= math.exp((math.log(mu_h / 3.0) / 5.0) * (temp - 15.0))
+ b_h *= math.exp((math.log(b_h / 0.2) / 5.0) * (temp - 15.0))
+ mu_a *= math.exp((math.log(mu_a / 0.3) / 5.0) * (temp - 15.0))
+ b_a *= math.exp((math.log(b_a / 0.03) / 5.0) * (temp - 15.0))
+ k_h *= math.exp((math.log(k_h / 2.5) / 5.0) * (temp - 15.0))
+ k_a *= math.exp((math.log(k_a / 0.04) / 5.0) * (temp - 15.0))
+ kla_temp = kla * 1.024 ** (temp - 15.0)
+
+ proc1 = mu_h * y[SS] / (k_s + y[SS]) * y[SO] / (k_oh + y[SO]) * y[XBH]
+ proc2 = mu_h * y[SS] / (k_s + y[SS]) * k_oh / (k_oh + y[SO]) * y[SNO] / (k_no + y[SNO]) * ny_g * y[XBH]
+ proc3 = mu_a * y[SNH] / (k_nh + y[SNH]) * y[SO] / (k_oa + y[SO]) * y[XBA]
+ proc4 = b_h * y[XBH]
+ proc5 = b_a * y[XBA]
+ proc6 = k_a * y[SND] * y[XBH]
+ ratio_xs = y[XS] / max(y[XBH], 1e-12)
+ proc7 = k_h * ratio_xs / (k_x + ratio_xs) * (
+ y[SO] / (k_oh + y[SO]) + ny_h * k_oh / (k_oh + y[SO]) * y[SNO] / (k_no + y[SNO])
+ ) * y[XBH]
+ proc8 = proc7 * y[XND] / max(y[XS], 1e-12)
+
+ reaction = np.zeros(13, dtype=float)
+ reaction[SS] = (-proc1 - proc2) / y_h + proc7
+ reaction[XS] = (1.0 - f_p) * (proc4 + proc5) - proc7
+ reaction[XBH] = proc1 + proc2 - proc4
+ reaction[XBA] = proc3 - proc5
+ reaction[XP] = f_p * (proc4 + proc5)
+ reaction[SO] = -(1.0 - y_h) / y_h * proc1 - (4.57 - y_a) / y_a * proc3
+ reaction[SNO] = -(1.0 - y_h) / (2.86 * y_h) * proc2 + proc3 / y_a
+ reaction[SNH] = -i_xb * (proc1 + proc2) - (i_xb + 1.0 / y_a) * proc3 + proc6
+ reaction[SND] = -proc6 + proc8
+ reaction[XND] = (i_xb - f_p * i_xp) * (proc4 + proc5) - proc8
+ reaction[SALK] = (
+ -i_xb / 14.0 * proc1
+ + ((1.0 - y_h) / (14.0 * 2.86 * y_h) - i_xb / 14.0) * proc2
+ - (i_xb / 14.0 + 1.0 / (7.0 * y_a)) * proc3
+ + proc6 / 14.0
+ )
+ derivative = influent[Q] / volume * (influent[:13] - y) + reaction
+ derivative[SO] += kla_temp * (OXYGEN_SATURATION - y[SO])
+ return derivative
+
+
+def _integrate_reactor(state: np.ndarray, influent: np.ndarray, kla: float, volume: float, dt: float) -> np.ndarray:
+ result = odeint(
+ lambda y, _: _asm1_rhs(y, influent, kla, volume),
+ state,
+ np.array([0.0, dt]),
+ rtol=1e-4,
+ atol=1e-6,
+ mxstep=500,
+ )[-1]
+ if not np.all(np.isfinite(result)):
+ raise FloatingPointError("ASM1 integration produced non-finite state")
+ return np.maximum(result, 0.0)
+
+
+def _settler_rhs(state: np.ndarray, feed: np.ndarray) -> np.ndarray:
+ layers = SETTLER_LAYERS
+ feed_layer = SETTLER_FEED_LAYER
+ height = SETTLER_HEIGHT / layers
+ state = np.maximum(np.asarray(state, dtype=float), 1e-8)
+ q_under = RETURN_FLOW + WASTE_FLOW
+ q_effluent = feed[Q] - q_under
+ if q_effluent <= 0.0:
+ raise ValueError("settler effluent flow is non-positive")
+ v_in = feed[Q] / SETTLER_AREA
+ v_up = q_effluent / SETTLER_AREA
+ v_down = q_under / SETTLER_AREA
+
+ tss = state[7 * layers : 8 * layers]
+ v0_max, v0, r_h, r_p, f_ns, x_threshold, _ = SETTLER_PARAMETERS
+ velocity = v0 * (
+ np.exp(-r_h * (tss - f_ns * feed[TSS]))
+ - np.exp(-r_p * (tss - f_ns * feed[TSS]))
+ )
+ velocity = np.clip(velocity, 0.0, v0_max)
+ raw_flux = velocity * tss
+ flux = np.zeros(layers + 1, dtype=float)
+ for index in range(layers - 1):
+ if index < feed_layer - 1 and tss[index + 1] <= x_threshold:
+ flux[index + 1] = raw_flux[index]
+ else:
+ flux[index + 1] = min(raw_flux[index], raw_flux[index + 1])
+
+ derivative = np.zeros_like(state)
+ soluble_indices = [SI, SS, SO, SNO, SNH, SND, SALK]
+ for block, component in enumerate(soluble_indices):
+ values = state[block * layers : (block + 1) * layers]
+ out = derivative[block * layers : (block + 1) * layers]
+ for index in range(feed_layer - 1):
+ out[index] = v_up * (values[index + 1] - values[index]) / height
+ f = feed_layer - 1
+ out[f] = (v_in * feed[component] - (v_up + v_down) * values[f]) / height
+ for index in range(feed_layer, layers):
+ out[index] = v_down * (values[index - 1] - values[index]) / height
+
+ out_tss = derivative[7 * layers : 8 * layers]
+ for index in range(feed_layer - 1):
+ out_tss[index] = (v_up * (tss[index + 1] - tss[index]) - flux[index + 1] + flux[index]) / height
+ f = feed_layer - 1
+ out_tss[f] = (v_in * feed[TSS] - (v_up + v_down) * tss[f] - flux[f + 1] + flux[f]) / height
+ for index in range(feed_layer, layers):
+ out_tss[index] = (v_down * (tss[index - 1] - tss[index]) - flux[index + 1] + flux[index]) / height
+ return derivative
+
+
+def _settler_outputs(state: np.ndarray, feed: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ layers = SETTLER_LAYERS
+ state = np.maximum(state, 0.0)
+ soluble_indices = [SI, SS, SO, SNO, SNH, SND, SALK]
+ effluent = np.zeros(13, dtype=float)
+ return_sludge = np.zeros(13, dtype=float)
+ for block, component in enumerate(soluble_indices):
+ values = state[block * layers : (block + 1) * layers]
+ effluent[component] = values[0]
+ return_sludge[component] = values[-1]
+ top_tss = state[7 * layers]
+ bottom_tss = state[8 * layers - 1]
+ particulate = [XI, XS, XBH, XBA, XP, XND]
+ if feed[TSS] > 1e-12:
+ for component in particulate:
+ effluent[component] = top_tss / feed[TSS] * feed[component]
+ return_sludge[component] = bottom_tss / feed[TSS] * feed[component]
+ return _stream(effluent, feed[Q] - RETURN_FLOW - WASTE_FLOW), _stream(return_sludge, RETURN_FLOW)
+
+
+def influent_at(time_day: float, weather: str) -> np.ndarray:
+ """Generate a deterministic BSM1 influent at a 15-minute timestamp.
+
+ The dry profile uses the published BSM1 dynamic-load averages. Rain and
+ storm add dilution water over the event windows described by the IWA
+ report; pollutant mass flow is conserved during the dilution event.
+ """
+
+ phase = 2.0 * math.pi * (time_day % 1.0)
+ weekend = 0.82 if int(time_day) % 7 in (5, 6) else 1.0
+ flow = 18446.0 * weekend * (1.0 + 0.31 * math.sin(phase - 1.0) + 0.08 * math.sin(2.0 * phase - 0.4))
+ flow = max(9000.0, flow)
+ concentration_scale = 1.0 + 0.12 * math.sin(phase + 0.55) + 0.04 * math.sin(2.0 * phase)
+ components = np.array(
+ [30.0, 69.50, 51.20, 202.32, 28.17, 0.0, 0.0, 0.0, 0.0, 31.56, 6.95, 10.59, 7.0],
+ dtype=float,
+ )
+ variable = [SS, XI, XS, XBH, SNH, SND, XND]
+ components[variable] *= concentration_scale
+
+ extra_water = 0.0
+ if weather == "rain" and 8.35 <= time_day <= 10.44:
+ edge = min((time_day - 8.35) / 0.08, (10.44 - time_day) / 0.08, 1.0)
+ extra_water = 20000.0 * max(0.0, edge)
+ elif weather == "storm":
+ extra_water = 42000.0 * math.exp(-0.5 * ((time_day - 8.87) / 0.075) ** 2)
+ extra_water += 45000.0 * math.exp(-0.5 * ((time_day - 11.18) / 0.16) ** 2)
+ if extra_water > 0.0:
+ dilution = flow / (flow + extra_water)
+ # Added water dilutes every soluble and particulate concentration,
+ # including alkalinity. The corresponding component mass flow is
+ # conserved while the hydraulic load increases.
+ components[:13] *= dilution
+ flow += extra_water
+ return _stream(components, flow)
+
+
+def advanced_quantities(effluent: np.ndarray) -> dict[str, float]:
+ p = ASM1_PARAMETERS
+ kjeldahl = (
+ effluent[SNH] + effluent[SND] + effluent[XND]
+ + p[17] * (effluent[XBH] + effluent[XBA])
+ + p[18] * (effluent[XP] + effluent[XI])
+ )
+ cod = float(np.sum(effluent[[SI, SS, XI, XS, XBH, XBA, XP]]))
+ bod5 = 0.25 * (effluent[SS] + effluent[XS] + (1.0 - p[16]) * (effluent[XBH] + effluent[XBA]))
+ return {
+ "total_nitrogen": float(kjeldahl + effluent[SNO]),
+ "cod": cod,
+ "bod5": float(bod5),
+ "tss": float(effluent[TSS]),
+ }
+
+
+def effluent_quality_index(effluent: np.ndarray) -> float:
+ advanced = advanced_quantities(effluent)
+ kjeldahl = advanced["total_nitrogen"] - effluent[SNO]
+ weighted = (
+ 2.0 * effluent[TSS]
+ + advanced["cod"]
+ + 30.0 * kjeldahl
+ + 10.0 * effluent[SNO]
+ + 2.0 * advanced["bod5"]
+ )
+ return float(weighted * effluent[Q] / 1000.0)
+
+
+class BSM1Plant:
+ """Stateful five-reactor BSM1 plant with a ten-layer settler."""
+
+ def __init__(self) -> None:
+ self.reactors = _REACTOR_INITIAL.copy()
+ soluble_initial = np.array([30.0, 0.857137129, 2.0, 13.711131912, 0.866431079, 0.690377137, 3.828235655])
+ self.settler = np.concatenate([np.full(SETTLER_LAYERS, value) for value in soluble_initial] + [_SETTLER_TSS_INITIAL.copy()])
+ return_components = np.array([30.0, 0.857137129, 2247.12680338, 92.61753044, 5007.32236075, 299.51772354, 885.01259999, 2.0, 13.711131912, 0.866431079, 0.690377137, 6.637034575, 3.828235655])
+ self.return_sludge = _stream(return_components, RETURN_FLOW)
+ self.internal_recycle = _stream(self.reactors[-1], 55338.0)
+ effluent_components = np.array([30.0, 0.857137129, 4.390198009, 0.180946308, 9.782775332, 0.585165960, 1.729043750, 2.0, 13.711131912, 0.866431079, 0.690377137, 0.012966734, 3.828235655])
+ self.effluent = _stream(effluent_components, 18061.0)
+
+ def step(self, influent: np.ndarray, action: Mapping[str, float], dt_days: float) -> np.ndarray:
+ qintr = float(action["internal_recycle_m3_per_day"])
+ self.internal_recycle[Q] = qintr
+ inlet = combine_streams(influent, self.return_sludge, self.internal_recycle)
+ klas = [0.0, 0.0, float(action["kla3_per_day"]), float(action["kla4_per_day"]), float(action["kla5_per_day"])]
+ stream = inlet
+ for index, (kla, volume) in enumerate(zip(klas, VOLUMES)):
+ self.reactors[index] = _integrate_reactor(self.reactors[index], stream, kla, float(volume), dt_days)
+ stream = _stream(self.reactors[index], inlet[Q], influent[TEMP])
+
+ settler_feed = _stream(self.reactors[-1], max(stream[Q] - qintr, 1.0), influent[TEMP])
+ self.internal_recycle = _stream(self.reactors[-1], qintr, influent[TEMP])
+ self.settler = odeint(
+ lambda y, _: _settler_rhs(y, settler_feed),
+ self.settler,
+ np.array([0.0, dt_days]),
+ rtol=1e-4,
+ atol=1e-6,
+ mxstep=1000,
+ )[-1]
+ if not np.all(np.isfinite(self.settler)):
+ raise FloatingPointError("settler integration produced non-finite state")
+ self.settler = np.maximum(self.settler, 0.0)
+ self.effluent, self.return_sludge = _settler_outputs(self.settler, settler_feed)
+ return self.effluent.copy()
+
+
+def action_energy(action: Mapping[str, float]) -> dict[str, float]:
+ klas = np.array([0.0, 0.0, action["kla3_per_day"], action["kla4_per_day"], action["kla5_per_day"]], dtype=float)
+ aeration = float(np.sum(OXYGEN_SATURATION * VOLUMES * klas) / (1.8 * 1000.0))
+ pumping = float(0.004 * action["internal_recycle_m3_per_day"] + 0.008 * RETURN_FLOW + 0.05 * WASTE_FLOW)
+ mixing = float(24.0 * 0.005 * np.sum(VOLUMES[klas < 20.0]))
+ return {"aeration": aeration, "pumping": pumping, "mixing": mixing}
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/evaluator.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/evaluator.py
new file mode 100644
index 00000000..c2cdd3ac
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/evaluator.py
@@ -0,0 +1,309 @@
+"""Evaluator for the BSM1AerationControl benchmark."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+from pathlib import Path
+from typing import Any, Callable, Mapping
+
+import numpy as np
+
+try:
+ from .bsm1_model import (
+ BSM1Plant, SNH, SNO, SO, action_energy, advanced_quantities,
+ effluent_quality_index, influent_at, load_config,
+ )
+ from .policy_runtime import PolicyRuntime
+except ImportError:
+ from bsm1_model import (
+ BSM1Plant, SNH, SNO, SO, action_energy, advanced_quantities,
+ effluent_quality_index, influent_at, load_config,
+ )
+ from policy_runtime import PolicyRuntime
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_CANDIDATE = ROOT / "scripts" / "init.py"
+ACTION_KEYS = (
+ "kla3_per_day", "kla4_per_day", "kla5_per_day", "internal_recycle_m3_per_day",
+)
+
+
+def validate_action(value: Any, previous: Mapping[str, float], config: Mapping[str, Any]) -> dict[str, float]:
+ if not isinstance(value, dict):
+ raise TypeError("control must return a JSON object")
+ if set(value) != set(ACTION_KEYS):
+ missing = sorted(set(ACTION_KEYS) - set(value))
+ extra = sorted(set(value) - set(ACTION_KEYS))
+ raise ValueError(f"action keys mismatch; missing={missing}, extra={extra}")
+ action: dict[str, float] = {}
+ for key in ACTION_KEYS:
+ raw = value[key]
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
+ raise TypeError(f"action[{key!r}] must be a finite number")
+ number = float(raw)
+ if not math.isfinite(number):
+ raise ValueError(f"action[{key!r}] must be finite")
+ action[key] = number
+
+ limits = config["action"]
+ for key in ACTION_KEYS[:3]:
+ if not limits["kla_min_per_day"] <= action[key] <= limits["kla_max_per_day"]:
+ raise ValueError(f"action[{key!r}] is outside the KLa range")
+ if abs(action[key] - previous[key]) > limits["kla_max_change_per_step"] + 1e-9:
+ raise ValueError(f"action[{key!r}] exceeds the per-step KLa slew limit")
+ recycle_key = "internal_recycle_m3_per_day"
+ if not limits["qintr_min_m3_per_day"] <= action[recycle_key] <= limits["qintr_max_m3_per_day"]:
+ raise ValueError("internal recycle flow is outside its range")
+ if abs(action[recycle_key] - previous[recycle_key]) > limits["qintr_max_change_per_step"] + 1e-9:
+ raise ValueError("internal recycle flow exceeds its per-step slew limit")
+ return action
+
+
+def _observation(
+ plant: BSM1Plant,
+ scenario: Mapping[str, str],
+ time_day: float,
+ influent: np.ndarray,
+ previous: Mapping[str, float],
+ noise: Mapping[str, float],
+ step_minutes: float,
+) -> dict[str, Any]:
+ advanced = advanced_quantities(plant.effluent)
+ return {
+ "scenario_id": scenario["scenario_id"],
+ "weather": scenario["weather"],
+ "time_day": float(time_day),
+ "step_minutes": float(step_minutes),
+ "influent_flow_m3_per_day": float(influent[14]),
+ "influent_ammonium_gN_per_m3": float(influent[SNH]),
+ "reactor3_do_gO2_per_m3": max(0.0, float(plant.reactors[2, SO]) + noise["do3"]),
+ "reactor4_do_gO2_per_m3": max(0.0, float(plant.reactors[3, SO]) + noise["do4"]),
+ "reactor5_do_gO2_per_m3": max(0.0, float(plant.reactors[4, SO]) + noise["do5"]),
+ "reactor2_nitrate_gN_per_m3": max(0.0, float(plant.reactors[1, SNO]) + noise["nitrate"]),
+ "effluent_ammonium_gN_per_m3": max(0.0, float(plant.effluent[SNH]) + noise["ammonium"]),
+ "effluent_total_nitrogen_gN_per_m3": float(advanced["total_nitrogen"]),
+ "previous_action": dict(previous),
+ }
+
+
+def _score(metrics: Mapping[str, float], config: Mapping[str, Any]) -> float:
+ scoring = config["scoring"]
+ quality = math.exp(
+ -metrics["eqi_kg_pollution_units_per_day"] / scoring["eqi_scale"]
+ )
+ aeration = math.exp(
+ -metrics["aeration_energy_kwh_per_day"] / scoring["aeration_energy_scale"]
+ )
+ pumping = math.exp(
+ -metrics["pumping_energy_kwh_per_day"] / scoring["pumping_energy_scale"]
+ )
+ mixing = math.exp(
+ -metrics["mixing_energy_kwh_per_day"] / scoring["mixing_energy_scale"]
+ )
+ smoothness = math.exp(-scoring["switching_exponent"] * metrics["switching_index"])
+ weights = scoring["objective_weights"]
+ base = 100.0 * (
+ weights["quality"] * quality
+ + weights["aeration"] * aeration
+ + weights["pumping"] * pumping
+ + weights["mixing"] * mixing
+ + weights["smoothness"] * smoothness
+ )
+ compliance = math.exp(
+ -scoring["violation_exponent"] * metrics["violation_index"]
+ )
+ return float(np.clip(base * compliance, 0.0, 100.0))
+
+
+def simulate_scenario(
+ scenario: Mapping[str, str],
+ action_provider: Callable[[dict[str, Any]], Any],
+ reset_provider: Callable[[dict[str, Any]], None],
+ *,
+ max_steps: int | None = None,
+) -> dict[str, Any]:
+ config = load_config()
+ simulation = config["simulation"]
+ step_minutes = float(simulation["step_minutes"])
+ dt_days = step_minutes / (24.0 * 60.0)
+ total_steps = int(round(float(simulation["days"]) / dt_days))
+ if max_steps is not None:
+ total_steps = min(total_steps, int(max_steps))
+ evaluation_start = float(simulation["evaluation_start_day"])
+ if max_steps is not None and total_steps * dt_days <= evaluation_start:
+ evaluation_start = 0.0
+
+ descriptor = {
+ "scenario_id": scenario["scenario_id"], "weather": scenario["weather"],
+ "duration_days": total_steps * dt_days, "step_minutes": step_minutes,
+ }
+ reset_provider(descriptor)
+ plant = BSM1Plant()
+ previous = {key: float(config["reference_action"][key]) for key in ACTION_KEYS}
+ seed_offset = {"dry": 0, "rain": 1000, "storm": 2000}[scenario["weather"]]
+ rng = np.random.default_rng(int(simulation["sensor_noise_seed"]) + seed_offset)
+ std = simulation["sensor_noise_std"]
+
+ eqi_values: list[float] = []
+ aeration_values: list[float] = []
+ pumping_values: list[float] = []
+ mixing_values: list[float] = []
+ switching_values: list[float] = []
+ violation_values: list[float] = []
+ violation_excess: dict[str, list[float]] = {
+ name: [] for name in ("ammonium", "total_nitrogen", "cod", "tss", "bod5")
+ }
+ maximum_quantities = {name: 0.0 for name in violation_excess}
+ effluent_rows: list[dict[str, float]] = []
+
+ for index in range(total_steps):
+ time_day = index * dt_days
+ influent = influent_at(time_day, scenario["weather"])
+ noise = {
+ "do3": rng.normal(0.0, std["dissolved_oxygen"]),
+ "do4": rng.normal(0.0, std["dissolved_oxygen"]),
+ "do5": rng.normal(0.0, std["dissolved_oxygen"]),
+ "nitrate": rng.normal(0.0, std["nitrate"]),
+ "ammonium": rng.normal(0.0, std["ammonium"]),
+ }
+ observation = _observation(plant, scenario, time_day, influent, previous, noise, step_minutes)
+ action = validate_action(action_provider(observation), previous, config)
+ effluent = plant.step(influent, action, dt_days)
+ if time_day >= evaluation_start:
+ advanced = advanced_quantities(effluent)
+ energy = action_energy(action)
+ limits = config["limits"]
+ quantities = {
+ "ammonium": float(effluent[SNH]),
+ "total_nitrogen": advanced["total_nitrogen"],
+ "cod": advanced["cod"], "tss": advanced["tss"], "bod5": advanced["bod5"],
+ }
+ limit_keys = {
+ "ammonium": "ammonium_gN_per_m3",
+ "total_nitrogen": "total_nitrogen_gN_per_m3",
+ "cod": "cod_gCOD_per_m3",
+ "tss": "tss_gSS_per_m3",
+ "bod5": "bod5_gBOD_per_m3",
+ }
+ normalized_excess = [
+ max(0.0, quantities[name] / limits[limit_key] - 1.0)
+ for name, limit_key in limit_keys.items()
+ ]
+ for name, excess in zip(limit_keys, normalized_excess):
+ violation_excess[name].append(float(excess))
+ maximum_quantities[name] = max(
+ maximum_quantities[name], float(quantities[name])
+ )
+ switching = sum(
+ abs(action[key] - previous[key]) / (360.0 if key.startswith("kla") else 92230.0)
+ for key in ACTION_KEYS
+ )
+ eqi_values.append(effluent_quality_index(effluent))
+ aeration_values.append(energy["aeration"])
+ pumping_values.append(energy["pumping"])
+ mixing_values.append(energy["mixing"])
+ switching_values.append(float(switching))
+ violation_values.append(float(np.mean(normalized_excess)))
+ samples_per_day = max(1, int(round(24.0 * 60.0 / step_minutes)))
+ if index % samples_per_day == 0 or index == total_steps - 1:
+ effluent_rows.append({"time_day": time_day, **quantities})
+ previous = action
+
+ if not eqi_values:
+ raise ValueError("scenario has no evaluation samples")
+ metrics = {
+ "eqi_kg_pollution_units_per_day": float(np.mean(eqi_values)),
+ "aeration_energy_kwh_per_day": float(np.mean(aeration_values)),
+ "pumping_energy_kwh_per_day": float(np.mean(pumping_values)),
+ "mixing_energy_kwh_per_day": float(np.mean(mixing_values)),
+ "switching_index": float(np.mean(switching_values)),
+ "violation_index": float(np.mean(violation_values)),
+ "max_ammonium_gN_per_m3": maximum_quantities["ammonium"],
+ "max_total_nitrogen_gN_per_m3": maximum_quantities["total_nitrogen"],
+ "max_cod_gCOD_per_m3": maximum_quantities["cod"],
+ "max_tss_gSS_per_m3": maximum_quantities["tss"],
+ "max_bod5_gBOD_per_m3": maximum_quantities["bod5"],
+ }
+ for name, values in violation_excess.items():
+ metrics[f"{name}_violation_fraction"] = float(np.mean(np.asarray(values) > 0.0))
+ metrics[f"{name}_mean_normalized_excess"] = float(np.mean(values))
+ return {
+ "scenario_id": scenario["scenario_id"], "weather": scenario["weather"],
+ "score": _score(metrics, config), "metrics": metrics, "effluent_samples": effluent_rows,
+ }
+
+
+def evaluate(candidate_path: Path, *, max_steps: int | None = None) -> dict[str, Any]:
+ config = load_config()
+ rows: list[dict[str, Any]] = []
+ try:
+ with PolicyRuntime(candidate_path) as runtime:
+ for scenario in config["scenarios"]:
+ rows.append(simulate_scenario(
+ scenario, runtime.control, runtime.reset, max_steps=max_steps,
+ ))
+ except Exception as exc:
+ return {
+ "combined_score": 0.0, "diagnostic_score": 0.0, "valid": 0.0,
+ "completed_scenarios": float(len(rows)), "error": f"{type(exc).__name__}: {exc}",
+ "rows": rows,
+ }
+ scores = np.asarray([row["score"] for row in rows], dtype=float)
+ scoring = config["scoring"]
+ diagnostic = float(
+ scoring["mean_weight"] * np.mean(scores)
+ + scoring["worst_case_weight"] * np.min(scores)
+ )
+ return {
+ "combined_score": diagnostic, "diagnostic_score": diagnostic, "valid": 1.0,
+ "completed_scenarios": float(len(rows)), "rows": rows,
+ }
+
+
+def _write_json(path: str | None, payload: Mapping[str, Any]) -> None:
+ if not path:
+ return
+ output = Path(path).expanduser().resolve()
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Evaluate a BSM1 aeration controller")
+ parser.add_argument("candidate", nargs="?", default=str(DEFAULT_CANDIDATE))
+ parser.add_argument("--metrics-out", default=None)
+ parser.add_argument("--artifacts-out", default=None)
+ args = parser.parse_args()
+ candidate = Path(args.candidate).expanduser().resolve()
+ result = evaluate(candidate)
+ print("=== BSM1 Aeration Control ===")
+ for row in result["rows"]:
+ metrics = row["metrics"]
+ print(
+ f"scenario={row['scenario_id']} score={row['score']:.3f} "
+ f"eqi={metrics['eqi_kg_pollution_units_per_day']:.2f} "
+ f"ae={metrics['aeration_energy_kwh_per_day']:.2f} "
+ f"violations={metrics['violation_index']:.6f}"
+ )
+ if "error" in result:
+ print(f"error: {result['error']}")
+ print("---")
+ print(f"completed_scenarios: {result['completed_scenarios']:.0f}/{len(load_config()['scenarios'])}")
+ print(f"diagnostic_score: {result['diagnostic_score']:.4f}")
+ print(f"combined_score: {result['combined_score']:.4f}")
+ metrics_out = {
+ "combined_score": float(result["combined_score"]),
+ "diagnostic_score": float(result["diagnostic_score"]),
+ "valid": float(result["valid"]),
+ "completed_scenarios": float(result["completed_scenarios"]),
+ "num_scenarios": float(len(load_config()["scenarios"])),
+ }
+ _write_json(args.metrics_out, metrics_out)
+ _write_json(args.artifacts_out, {"candidate_path": candidate.name, "rows": result["rows"]})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_runtime.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_runtime.py
new file mode 100644
index 00000000..9227f7b8
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_runtime.py
@@ -0,0 +1,251 @@
+"""Bounded parent-side runtime for BSM1 candidate controllers."""
+
+from __future__ import annotations
+
+import json
+import os
+import queue
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from collections import deque
+from pathlib import Path
+from typing import Any
+
+
+MAX_RESPONSE_BYTES = 16 * 1024
+_EOF = object()
+
+
+class PolicyRuntimeError(RuntimeError):
+ pass
+
+
+class PolicyTimeoutError(PolicyRuntimeError):
+ pass
+
+
+class PolicyProtocolError(PolicyRuntimeError):
+ pass
+
+
+class PolicyCandidateError(PolicyRuntimeError):
+ pass
+
+
+def _clean_environment() -> dict[str, str]:
+ allowed = {
+ "COMSPEC", "LANG", "LC_ALL", "NUMBER_OF_PROCESSORS", "PATH", "PATHEXT",
+ "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "TMP", "WINDIR",
+ }
+ env = {key: value for key, value in os.environ.items() if key.upper() in allowed}
+ env["PYTHONUTF8"] = "1"
+ env["PYTHONIOENCODING"] = "utf-8"
+ return env
+
+
+class PolicyRuntime:
+ def __init__(
+ self,
+ candidate_path: str | Path,
+ *,
+ startup_timeout_s: float = 3.0,
+ call_timeout_s: float = 0.10,
+ candidate_time_budget_s: float = 12.0,
+ ) -> None:
+ if min(startup_timeout_s, call_timeout_s, candidate_time_budget_s) <= 0:
+ raise ValueError("policy timeouts must be positive")
+ source = Path(candidate_path).expanduser().resolve()
+ if not source.is_file():
+ raise FileNotFoundError(source)
+ self.call_timeout_s = float(call_timeout_s)
+ self._candidate_time_remaining = float(candidate_time_budget_s)
+ self._next_id = 1
+ self._closed = False
+ self._responses: queue.Queue[Any] = queue.Queue()
+ self._stderr_chunks: deque[bytes] = deque()
+ self._stderr_size = 0
+ self._tempdir = Path(tempfile.mkdtemp(prefix="bsm1_policy_"))
+ self._process: subprocess.Popen[bytes] | None = None
+ try:
+ copied_candidate = self._tempdir / "candidate.py"
+ shutil.copy2(source, copied_candidate)
+ worker = Path(__file__).with_name("policy_worker.py").resolve()
+ kwargs: dict[str, Any] = {
+ "cwd": str(self._tempdir), "env": _clean_environment(), "stdin": subprocess.PIPE,
+ "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "bufsize": 0,
+ }
+ if os.name == "nt":
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
+ else:
+ kwargs["start_new_session"] = True
+ self._process = subprocess.Popen(
+ [sys.executable, "-I", "-u", str(worker), str(copied_candidate)], **kwargs
+ )
+ assert self._process.stdout is not None and self._process.stderr is not None
+ threading.Thread(target=self._read_stdout, args=(self._process.stdout,), daemon=True).start()
+ threading.Thread(target=self._drain_stderr, args=(self._process.stderr,), daemon=True).start()
+ ready = self._receive(startup_timeout_s, "candidate import", charge_budget=False)
+ if not isinstance(ready, dict) or not ready.get("ok") or not ready.get("ready"):
+ self._raise_response_error(ready, "candidate import")
+ except BaseException:
+ self.close(force=True)
+ raise
+
+ @property
+ def stderr_tail(self) -> str:
+ return b"".join(self._stderr_chunks).decode("utf-8", errors="replace")
+
+ def _read_stdout(self, stream: Any) -> None:
+ try:
+ while True:
+ line = stream.readline(MAX_RESPONSE_BYTES + 1)
+ if not line:
+ break
+ self._responses.put(line)
+ if len(line) > MAX_RESPONSE_BYTES or not line.endswith(b"\n"):
+ break
+ finally:
+ self._responses.put(_EOF)
+
+ def _drain_stderr(self, stream: Any) -> None:
+ while True:
+ chunk = stream.read(4096)
+ if not chunk:
+ return
+ self._stderr_chunks.append(chunk)
+ self._stderr_size += len(chunk)
+ while self._stderr_size > 16 * 1024 and self._stderr_chunks:
+ self._stderr_size -= len(self._stderr_chunks.popleft())
+
+ def _receive(self, timeout_s: float, operation: str, *, charge_budget: bool = True) -> dict[str, Any]:
+ if charge_budget and self._candidate_time_remaining <= 0.0:
+ self.close(force=True)
+ raise PolicyTimeoutError("candidate cumulative runtime budget exceeded")
+ timeout = min(timeout_s, self._candidate_time_remaining) if charge_budget else timeout_s
+ started = time.monotonic()
+ try:
+ item = self._responses.get(timeout=timeout)
+ except queue.Empty as exc:
+ self.close(force=True)
+ raise PolicyTimeoutError(f"{operation} timed out after {timeout:.3f}s") from exc
+ finally:
+ if charge_budget:
+ self._candidate_time_remaining -= time.monotonic() - started
+ if item is _EOF:
+ code = None if self._process is None else self._process.poll()
+ detail = self.stderr_tail[-1000:]
+ raise PolicyCandidateError(
+ f"candidate worker exited unexpectedly (code={code})" + (f": {detail}" if detail else "")
+ )
+ if not isinstance(item, bytes) or len(item) > MAX_RESPONSE_BYTES or not item.endswith(b"\n"):
+ self.close(force=True)
+ raise PolicyProtocolError("candidate response exceeds 16 KiB or lacks newline")
+ try:
+ response = json.loads(item.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ self.close(force=True)
+ raise PolicyProtocolError("candidate returned invalid UTF-8 JSON") from exc
+ if not isinstance(response, dict):
+ raise PolicyProtocolError("candidate response must be a JSON object")
+ return response
+
+ @staticmethod
+ def _raise_response_error(response: Any, operation: str) -> None:
+ if isinstance(response, dict) and isinstance(response.get("error"), dict):
+ error = response["error"]
+ raise PolicyCandidateError(
+ f"{operation} failed: {error.get('type', 'CandidateError')}: {error.get('message', '')}"
+ )
+ raise PolicyProtocolError(f"malformed response during {operation}")
+
+ def _rpc(self, operation: str, **payload: Any) -> dict[str, Any]:
+ if self._closed or self._process is None or self._process.poll() is not None:
+ raise PolicyCandidateError("candidate worker is not running")
+ request_id = self._next_id
+ self._next_id += 1
+ encoded = (json.dumps(
+ {"id": request_id, "op": operation, **payload}, ensure_ascii=True,
+ allow_nan=False, separators=(",", ":"),
+ ) + "\n").encode("utf-8")
+ try:
+ assert self._process.stdin is not None
+ self._process.stdin.write(encoded)
+ self._process.stdin.flush()
+ except (BrokenPipeError, OSError) as exc:
+ raise PolicyCandidateError("candidate worker closed its input") from exc
+ response = self._receive(self.call_timeout_s, operation)
+ if response.get("id") != request_id:
+ self.close(force=True)
+ raise PolicyProtocolError("candidate response id does not match request")
+ if not response.get("ok"):
+ self._raise_response_error(response, operation)
+ return response
+
+ def reset(self, scenario: dict[str, Any]) -> None:
+ self._rpc("reset", scenario=scenario)
+
+ def control(self, observation: dict[str, Any]) -> Any:
+ return self._rpc("control", observation=observation).get("action")
+
+ def close(self, *, force: bool = False) -> None:
+ if self._closed:
+ return
+ process = self._process
+ if process is not None and process.poll() is None and not force:
+ try:
+ self._rpc("shutdown")
+ process.wait(timeout=0.25)
+ except Exception:
+ force = True
+ if process is not None and process.poll() is None:
+ self._kill_process_tree(process)
+ self._closed = True
+ if process is not None:
+ for stream in (process.stdin, process.stdout, process.stderr):
+ try:
+ if stream is not None:
+ stream.close()
+ except OSError:
+ pass
+ shutil.rmtree(self._tempdir, ignore_errors=True)
+
+ @staticmethod
+ def _kill_process_tree(process: subprocess.Popen[bytes]) -> None:
+ if process.poll() is not None:
+ return
+ if os.name == "nt":
+ try:
+ subprocess.run(
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ timeout=2.0, check=False,
+ )
+ except Exception:
+ process.kill()
+ else:
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ process.kill()
+ try:
+ process.wait(timeout=2.0)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=2.0)
+
+ def __enter__(self) -> "PolicyRuntime":
+ return self
+
+ def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
+ self.close()
+
+ def __del__(self) -> None:
+ try:
+ self.close(force=True)
+ except Exception:
+ pass
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py
new file mode 100644
index 00000000..2abaec4f
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py
@@ -0,0 +1,98 @@
+"""JSON-lines worker for an untrusted BSM1 controller module.
+
+This provides process and protocol isolation, not an operating-system sandbox.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any
+
+
+_json_loads = json.loads
+_json_dumps = json.dumps
+
+
+def _protocol_streams() -> tuple[Any, Any]:
+ protocol_in = os.fdopen(os.dup(0), "r", encoding="utf-8", newline="\n")
+ protocol_out = os.fdopen(os.dup(1), "w", encoding="utf-8", newline="\n", buffering=1)
+ devnull = os.open(os.devnull, os.O_WRONLY)
+ try:
+ os.dup2(devnull, 1)
+ finally:
+ os.close(devnull)
+ return protocol_in, protocol_out
+
+
+def _load_candidate(path: Path) -> Any:
+ spec = importlib.util.spec_from_file_location("bsm1_candidate", path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"cannot load candidate from {path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ if not callable(getattr(module, "control", None)):
+ raise AttributeError("candidate must define callable control(observation)")
+ if not callable(getattr(module, "reset_controller", None)):
+ raise AttributeError("candidate must define callable reset_controller(scenario)")
+ return module
+
+
+def _error(request_id: Any, exc: BaseException) -> dict[str, Any]:
+ try:
+ message = str(exc)[:500]
+ except Exception:
+ message = "failed to format candidate exception"
+ return {"id": request_id, "ok": False, "error": {"type": type(exc).__name__, "message": message}}
+
+
+def _send(stream: Any, payload: dict[str, Any]) -> None:
+ stream.write(_json_dumps(payload, ensure_ascii=True, allow_nan=False, separators=(",", ":")) + "\n")
+ stream.flush()
+
+
+def main() -> int:
+ protocol_in, protocol_out = _protocol_streams()
+ if len(sys.argv) != 2:
+ _send(protocol_out, _error(None, ValueError("expected candidate path")))
+ return 2
+ try:
+ candidate = _load_candidate(Path(sys.argv[1]).resolve())
+ except BaseException as exc:
+ _send(protocol_out, _error(None, exc))
+ return 1
+ _send(protocol_out, {"id": None, "ok": True, "ready": True})
+
+ for line in protocol_in:
+ request_id: Any = None
+ try:
+ request = _json_loads(line)
+ if not isinstance(request, dict):
+ raise TypeError("request must be a JSON object")
+ request_id = request.get("id")
+ operation = request.get("op")
+ if operation == "reset":
+ candidate.reset_controller(request.get("scenario"))
+ response = {"id": request_id, "ok": True}
+ elif operation == "control":
+ action = candidate.control(request.get("observation"))
+ response = {"id": request_id, "ok": True, "action": action}
+ elif operation == "shutdown":
+ _send(protocol_out, {"id": request_id, "ok": True})
+ return 0
+ else:
+ raise ValueError(f"unknown operation: {operation!r}")
+ _send(protocol_out, response)
+ except BaseException as exc:
+ try:
+ _send(protocol_out, _error(request_id, exc))
+ except BaseException:
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/requirements.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/requirements.txt
new file mode 100644
index 00000000..0ffddefd
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/requirements.txt
@@ -0,0 +1,2 @@
+numpy>=1.24
+scipy>=1.10
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py
new file mode 100644
index 00000000..864f2bba
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import unittest
+
+import numpy as np
+
+try:
+ from verification.bsm1_model import (
+ Q,
+ BSM1Plant,
+ action_energy,
+ influent_at,
+ )
+except ModuleNotFoundError:
+ from bsm1_model import Q, BSM1Plant, action_energy, influent_at
+
+
+BASELINE_ACTION = {
+ "kla3_per_day": 240.0,
+ "kla4_per_day": 240.0,
+ "kla5_per_day": 84.0,
+ "internal_recycle_m3_per_day": 55338.0,
+}
+
+
+class BSM1ModelTests(unittest.TestCase):
+ def test_reference_energy_matches_published_bsm1_operating_point(self) -> None:
+ energy = action_energy(BASELINE_ACTION)
+ self.assertAlmostEqual(energy["aeration"], 3341.3866666666663, places=9)
+ self.assertAlmostEqual(energy["pumping"], 388.17, places=9)
+ self.assertAlmostEqual(energy["mixing"], 240.0, places=9)
+
+ def test_rain_adds_water_without_changing_component_mass_flow(self) -> None:
+ time_day = 9.0
+ dry = influent_at(time_day, "dry")
+ rain = influent_at(time_day, "rain")
+ self.assertAlmostEqual(rain[Q] - dry[Q], 20000.0, places=9)
+ np.testing.assert_allclose(rain[:13] * rain[Q], dry[:13] * dry[Q], rtol=1e-12)
+
+ def test_recycle_change_keeps_the_settler_hydraulically_valid(self) -> None:
+ plant = BSM1Plant()
+ action = dict(BASELINE_ACTION, internal_recycle_m3_per_day=30000.0)
+ effluent = plant.step(influent_at(0.0, "dry"), action, 15.0 / 1440.0)
+ self.assertTrue(np.all(np.isfinite(effluent)))
+ self.assertGreater(effluent[Q], 0.0)
+ self.assertAlmostEqual(effluent[Q], influent_at(0.0, "dry")[Q] - 385.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_evaluator.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_evaluator.py
new file mode 100644
index 00000000..2f745453
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_evaluator.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+from typing import Any
+
+try:
+ from verification.bsm1_model import load_config
+ from verification.evaluator import evaluate, simulate_scenario, validate_action
+except ModuleNotFoundError:
+ from bsm1_model import load_config
+ from evaluator import evaluate, simulate_scenario, validate_action
+
+
+BASELINE_ACTION = {
+ "kla3_per_day": 240.0,
+ "kla4_per_day": 240.0,
+ "kla5_per_day": 84.0,
+ "internal_recycle_m3_per_day": 55338.0,
+}
+
+
+class EvaluatorTests(unittest.TestCase):
+ def _candidate(self, source: str) -> Path:
+ directory = tempfile.TemporaryDirectory(prefix="bsm1_candidate_test_")
+ self.addCleanup(directory.cleanup)
+ path = Path(directory.name) / "candidate.py"
+ path.write_text(source, encoding="utf-8")
+ return path
+
+ def test_action_schema_type_range_and_slew_are_enforced(self) -> None:
+ config = load_config()
+ self.assertEqual(validate_action(dict(BASELINE_ACTION), BASELINE_ACTION, config), BASELINE_ACTION)
+ invalid_actions: list[dict[str, Any]] = [
+ {key: value for key, value in BASELINE_ACTION.items() if key != "kla5_per_day"},
+ dict(BASELINE_ACTION, kla3_per_day=True),
+ dict(BASELINE_ACTION, kla3_per_day=361.0),
+ dict(BASELINE_ACTION, internal_recycle_m3_per_day=1000.0),
+ ]
+ for action in invalid_actions:
+ with self.subTest(action=action), self.assertRaises((TypeError, ValueError)):
+ validate_action(action, BASELINE_ACTION, config)
+
+ def test_lower_recycle_exposes_measurable_optimization_headroom(self) -> None:
+ scenario = {"scenario_id": "dry", "weather": "dry"}
+ lower_recycle = dict(BASELINE_ACTION, internal_recycle_m3_per_day=30000.0)
+ reset = lambda _: None
+ baseline = simulate_scenario(
+ scenario, lambda _: BASELINE_ACTION, reset, max_steps=96
+ )
+ improved = simulate_scenario(
+ scenario, lambda _: lower_recycle, reset, max_steps=96
+ )
+ self.assertGreater(improved["score"], baseline["score"] + 0.5)
+ self.assertLess(
+ improved["metrics"]["pumping_energy_kwh_per_day"],
+ baseline["metrics"]["pumping_energy_kwh_per_day"],
+ )
+
+ def test_short_baseline_evaluation_is_valid_and_deterministic(self) -> None:
+ source = (
+ "def reset_controller(scenario): pass\n"
+ f"def control(observation): return {BASELINE_ACTION!r}\n"
+ )
+ candidate = self._candidate(source)
+ first = evaluate(candidate, max_steps=2)
+ second = evaluate(candidate, max_steps=2)
+ self.assertEqual(first, second)
+ self.assertEqual(first["valid"], 1.0)
+ self.assertEqual(first["completed_scenarios"], 3.0)
+
+ def test_malformed_candidate_returns_zero_instead_of_crashing(self) -> None:
+ result = evaluate(self._candidate("def broken(:\n"), max_steps=2)
+ self.assertEqual(result["valid"], 0.0)
+ self.assertEqual(result["combined_score"], 0.0)
+ self.assertIn("candidate import failed", result["error"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py
new file mode 100644
index 00000000..a3661a8e
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+try:
+ from verification.policy_runtime import PolicyRuntime, PolicyTimeoutError
+except ModuleNotFoundError:
+ from policy_runtime import PolicyRuntime, PolicyTimeoutError
+
+
+class PolicyRuntimeTests(unittest.TestCase):
+ def _candidate(self, source: str) -> Path:
+ directory = tempfile.TemporaryDirectory(prefix="bsm1_runtime_test_")
+ self.addCleanup(directory.cleanup)
+ path = Path(directory.name) / "candidate.py"
+ path.write_text(source, encoding="utf-8")
+ return path
+
+ def test_stdout_is_isolated_from_the_json_protocol(self) -> None:
+ candidate = self._candidate(
+ "print('during import')\n"
+ "def reset_controller(scenario): print('during reset')\n"
+ "def control(observation):\n"
+ " print('during control')\n"
+ " return {'ok': 1}\n"
+ )
+ with PolicyRuntime(candidate) as runtime:
+ runtime.reset({"scenario_id": "dry"})
+ self.assertEqual(runtime.control({"step": 0}), {"ok": 1})
+
+ def test_slow_control_is_terminated(self) -> None:
+ candidate = self._candidate(
+ "import time\n"
+ "def reset_controller(scenario): pass\n"
+ "def control(observation): time.sleep(0.2); return {}\n"
+ )
+ with PolicyRuntime(candidate, call_timeout_s=0.03) as runtime:
+ runtime.reset({})
+ with self.assertRaises(PolicyTimeoutError):
+ runtime.control({})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/benchmarks/WastewaterTreatment/README.md b/benchmarks/WastewaterTreatment/README.md
new file mode 100644
index 00000000..d500038a
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/README.md
@@ -0,0 +1,11 @@
+# Wastewater Treatment
+
+This domain collects control and optimization tasks for biological wastewater-treatment
+plants. The benchmarks emphasize process dynamics, effluent compliance, energy use, and
+robust operation under changing hydraulic loads.
+
+## Tasks
+
+- `BSM1AerationControl`
+ - Unified benchmark: `task=unified task.benchmark=WastewaterTreatment/BSM1AerationControl`
+ - Description: optimize zone aeration and internal recycle in an IWA BSM1-derived activated-sludge plant across dry, rain, and storm operation.
diff --git a/benchmarks/WastewaterTreatment/README_zh-CN.md b/benchmarks/WastewaterTreatment/README_zh-CN.md
new file mode 100644
index 00000000..a6880660
--- /dev/null
+++ b/benchmarks/WastewaterTreatment/README_zh-CN.md
@@ -0,0 +1,9 @@
+# 污水处理
+
+本领域收集生物污水处理厂的控制与优化任务,重点关注过程动态、出水达标、能源消耗以及水力负荷变化下的稳健运行。
+
+## 任务列表
+
+- `BSM1AerationControl`
+ - 统一任务:`task=unified task.benchmark=WastewaterTreatment/BSM1AerationControl`
+ - 简介:在基于 IWA BSM1 的活性污泥厂中,针对旱天、降雨和暴雨工况优化分区曝气与内回流。
diff --git a/frontier_eval/conf/task/bsm1_aeration_control.yaml b/frontier_eval/conf/task/bsm1_aeration_control.yaml
new file mode 100644
index 00000000..0751974e
--- /dev/null
+++ b/frontier_eval/conf/task/bsm1_aeration_control.yaml
@@ -0,0 +1,6 @@
+name: unified
+benchmark: WastewaterTreatment/BSM1AerationControl
+
+runtime:
+ env:
+ PYTHONUTF8: "1"
From 2d5d01676091a1798fa144ed9da1a00e96dcec92 Mon Sep 17 00:00:00 2001
From: Dan Martin <93075337+Mingqwqqaq@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:26:21 +0800
Subject: [PATCH 2/2] fix(WastewaterTreatment): address BSM1 review findings
---
.../baseline/result_log.txt | 4 +-
.../references/design_notes.md | 7 +-
.../verification/policy_worker.py | 13 ++-
.../verification/test_bsm1_model.py | 101 +++++++++++++++++-
.../verification/test_policy_runtime.py | 15 +++
5 files changed, 133 insertions(+), 7 deletions(-)
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt b/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt
index 954cc0da..05dc79cf 100644
--- a/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/baseline/result_log.txt
@@ -2,7 +2,7 @@ Direct command:
python verification/evaluator.py scripts/init.py --metrics-out metrics.json --artifacts-out artifacts.json
Environment:
-Windows 11, Python 3.11, CPU-only
+Windows 11, Python 3.12.13, CPU-only
Result:
=== BSM1 Aeration Control ===
@@ -16,7 +16,7 @@ combined_score: 47.7973
Regression:
python -m unittest discover -s verification -p "test_*.py" -v
-Ran 9 tests in 3.574s
+Ran 12 tests in 3.266s
OK
Unified evaluation:
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md
index 10855221..0237bd6c 100644
--- a/benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/references/design_notes.md
@@ -26,8 +26,11 @@ not as an exact replay of the official influent files.
- The fixed operating point yields 3341.3867 kWh/day aeration energy, matching the BSM1 reference
equation and published operating point.
-- Unit tests check weather-event component mass conservation, positive settler hydraulics after a
- recycle-flow change, action validation, deterministic results, worker isolation, and timeouts.
+- Unit tests compare ASM1 and Takacs derivatives with independent numerical oracles generated by
+ [`bsm2-python` commit `73caa8b`](https://github.com/fau-evt/bsm2-python/commit/73caa8bde193c0dd8a8803dfcd67b546501c950e),
+ and also check weather-event component mass conservation,
+ positive settler hydraulics after a recycle-flow change, action validation, deterministic
+ results, worker isolation, standard dataclass imports, and timeouts.
- A 96-step dry-weather regression demonstrates optimization headroom: lowering internal recycle
reduces pumping energy and improves score relative to the starter policy.
- The full direct evaluation covers all three 14-day scenarios; its recorded output is in
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py
index 2abaec4f..be3509b5 100644
--- a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/policy_worker.py
@@ -29,11 +29,20 @@ def _protocol_streams() -> tuple[Any, Any]:
def _load_candidate(path: Path) -> Any:
- spec = importlib.util.spec_from_file_location("bsm1_candidate", path)
+ module_name = "bsm1_candidate"
+ spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise ImportError(f"cannot load candidate from {path}")
module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
+ # exec_module() does not register manually created modules. Standard
+ # library features such as dataclasses with postponed annotations resolve
+ # types through sys.modules while the module is executing.
+ sys.modules[module_name] = module
+ try:
+ spec.loader.exec_module(module)
+ except BaseException:
+ sys.modules.pop(module_name, None)
+ raise
if not callable(getattr(module, "control", None)):
raise AttributeError("candidate must define callable control(observation)")
if not callable(getattr(module, "reset_controller", None)):
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py
index 864f2bba..720ccc7c 100644
--- a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_bsm1_model.py
@@ -8,11 +8,22 @@
from verification.bsm1_model import (
Q,
BSM1Plant,
+ _asm1_rhs,
+ _settler_rhs,
+ _stream,
action_energy,
influent_at,
)
except ModuleNotFoundError:
- from bsm1_model import Q, BSM1Plant, action_energy, influent_at
+ from bsm1_model import (
+ Q,
+ BSM1Plant,
+ _asm1_rhs,
+ _settler_rhs,
+ _stream,
+ action_energy,
+ influent_at,
+ )
BASELINE_ACTION = {
@@ -24,6 +35,94 @@
class BSM1ModelTests(unittest.TestCase):
+ # These derivative oracles were generated independently with the
+ # BSD-3-Clause bsm2-python equations at commit 73caa8b. Unlike invariant
+ # checks, they detect stoichiometric, component-mapping, and flux-sign
+ # regressions in the adapted implementation.
+ def test_asm1_derivative_matches_bsm2_python_oracle(self) -> None:
+ state = np.array(
+ [30.0, 2.5, 1100.0, 75.0, 2500.0, 150.0, 440.0,
+ 1.7, 9.5, 4.2, 1.0, 4.5, 4.3]
+ )
+ feed = _stream(
+ np.array(
+ [30.0, 20.0, 100.0, 80.0, 300.0, 10.0, 50.0,
+ 0.5, 5.0, 20.0, 4.0, 8.0, 6.0]
+ ),
+ 75000.0,
+ 15.0,
+ )
+ expected = np.array(
+ [
+ 0.0,
+ -237.99652553781368,
+ -56264.066016504126,
+ -708.8242043304347,
+ -122581.47155209856,
+ -7835.430780772116,
+ -21882.385746436612,
+ -699.6350170215012,
+ -76.41590901456709,
+ 649.7643483239231,
+ 145.0148701142897,
+ 152.66555899298714,
+ 65.93603488544677,
+ ]
+ )
+ actual = _asm1_rhs(state, feed, kla=180.0, volume=1333.0)
+ np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-9)
+
+ def test_settler_derivative_matches_bsm2_python_oracle(self) -> None:
+ components = np.array(
+ [30.0, 0.9, 1100.0, 50.0, 2500.0, 150.0, 450.0,
+ 2.0, 12.0, 1.0, 0.7, 3.5, 4.0]
+ )
+ feed = _stream(components, 36892.0, 15.0)
+ soluble_indices = [0, 1, 7, 8, 9, 10, 12]
+ state = np.concatenate(
+ [
+ *(feed[index] * np.linspace(0.95, 1.05, 10)
+ for index in soluble_indices),
+ np.array(
+ [15.0, 20.0, 30.0, 70.0, 350.0,
+ 500.0, 800.0, 1500.0, 3000.0, 6500.0]
+ ),
+ ]
+ )
+ expected = np.array(
+ [
+ 10.033888888888924, 10.033888888888924, 10.033888888888782,
+ 10.033888888889066, 10.247777777777287, -10.461666666666503,
+ -10.461666666666645, -10.461666666666503, -10.461666666666929,
+ -10.461666666666645, 0.3010166666666647, 0.30101666666666915,
+ 0.30101666666666915, 0.3010166666666647, 0.3074333333333268,
+ -0.3138500000000022, -0.31384999999999774, -0.31384999999999774,
+ -0.3138500000000066, -0.31384999999999774, 0.6689259259259295,
+ 0.6689259259259295, 0.6689259259259206, 0.6689259259259295,
+ 0.6831851851851756, -0.6974444444444394, -0.6974444444444394,
+ -0.6974444444444483, -0.6974444444444572, -0.6974444444444394,
+ 4.013555555555541, 4.013555555555612, 4.013555555555541,
+ 4.013555555555612, 4.099111111111, -4.184666666666672,
+ -4.184666666666601, -4.184666666666672, -4.184666666666743,
+ -4.184666666666672, 0.33446296296296474, 0.33446296296296474,
+ 0.3344629629629603, 0.33446296296296474, 0.3415925925925878,
+ -0.3487222222222197, -0.3487222222222197, -0.34872222222222415,
+ -0.3487222222222286, -0.3487222222222197, 0.23412407407407354,
+ 0.23412407407407354, 0.23412407407407354, 0.23412407407407798,
+ 0.23911481481481367, -0.2441055555555538, -0.2441055555555538,
+ -0.2441055555555538, -0.24410555555556268, -0.2441055555555538,
+ 1.337851851851859, 1.337851851851859, 1.3378518518518412,
+ 1.337851851851859, 1.3663703703703511, -1.3948888888888789,
+ -1.3948888888888789, -1.3948888888888966, -1.3948888888889144,
+ -1.3948888888888789, -159.27776512990226, -63.52879722181932,
+ 103.10310727314942, -476.22393432199857, 327.35724044926883,
+ -121215.44270969188, -208086.76214135683, -155444.6724199591,
+ 403397.7099670557, 73152.46245290338,
+ ]
+ )
+ actual = _settler_rhs(state, feed)
+ np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-9)
+
def test_reference_energy_matches_published_bsm1_operating_point(self) -> None:
energy = action_energy(BASELINE_ACTION)
self.assertAlmostEqual(energy["aeration"], 3341.3866666666663, places=9)
diff --git a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py
index a3661a8e..9f6cdee7 100644
--- a/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py
+++ b/benchmarks/WastewaterTreatment/BSM1AerationControl/verification/test_policy_runtime.py
@@ -30,6 +30,21 @@ def test_stdout_is_isolated_from_the_json_protocol(self) -> None:
runtime.reset({"scenario_id": "dry"})
self.assertEqual(runtime.control({"step": 0}), {"ok": 1})
+ def test_candidate_module_supports_postponed_dataclass_annotations(self) -> None:
+ candidate = self._candidate(
+ "from __future__ import annotations\n"
+ "from dataclasses import dataclass\n"
+ "@dataclass\n"
+ "class State:\n"
+ " previous: State | None = None\n"
+ "state = State()\n"
+ "def reset_controller(scenario): state.previous = None\n"
+ "def control(observation): return {'loaded': state.previous is None}\n"
+ )
+ with PolicyRuntime(candidate) as runtime:
+ runtime.reset({"scenario_id": "dry"})
+ self.assertEqual(runtime.control({"step": 0}), {"loaded": True})
+
def test_slow_control_is_terminated(self) -> None:
candidate = self._candidate(
"import time\n"