feat(#38): 跨工序寻优模型模板化(固定主干+配方加载,PRD 5.3 ③跨工序寻优)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# iAOP-Core · 模型框架层(AI Model Framework)
|
||||
|
||||
对应 PRD 5.3「③ AI 模型框架」与 EPIC #5「内核平台化改造」。
|
||||
|
||||
本层把化工 AI 的「模型」从硬编码改造为**模板化**实现:同一主干代码不变,
|
||||
切换行业 / 工况只改 *配方(recipe)* —— 一个声明式 JSON 包,对齐 PRD 5.3
|
||||
「**固定主干 + 可配置超参**」默认模式。
|
||||
|
||||
## 当前已交付
|
||||
|
||||
| 模块 | 对应 issue | PRD 5.3 模型 | 说明 |
|
||||
|------|-----------|-------------|------|
|
||||
| `cross_process_optimizer` | #38 | ③ 跨工序寻优 | 多串联工序协同寻优,可解释优化建议(采纳率≥60%) |
|
||||
|
||||
## 跨工序寻优(`cross_process_optimizer.py`)
|
||||
|
||||
化工产线由多道**串联工序**组成(氯化→精制→还原、反应→水洗→干燥)。单工序
|
||||
局部最优 ≠ 全局最优:上游操作参数通过中间品指标传递到下游,影响最终收率 /
|
||||
能耗 / 质量。跨工序寻优在**满足工艺约束**前提下,**协调多个工序的可调变量**,
|
||||
使全流程目标达到最优,并给出**可解释的优化建议**。
|
||||
|
||||
### 配方(Recipe)结构
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "ti-cl4-cross-process-opt",
|
||||
"industry": "海绵钛氯化车间",
|
||||
"solver": "grid", // grid / random / analytic / stub
|
||||
"solver_params": {"max_per_var": 6, "max_total": 5000},
|
||||
"stages": [ // 顺序串联工序
|
||||
{
|
||||
"name": "氯化",
|
||||
"decision_vars": [ // 本工序可调决策变量
|
||||
{"name": "chlorination_temp", "low": 850, "high": 950, "step": 20, "default": 870, "unit": "℃"}
|
||||
],
|
||||
"transfer_vars": ["ti_cl4_yield"], // 传给下游的中间品指标
|
||||
"proxy": "0.4 * (chlorination_temp - 850) / 100 + ..." // 上游如何影响下游(算术表达式)
|
||||
}
|
||||
],
|
||||
"constraints": [ // 物料平衡 / 安全限值 / 产能上下界
|
||||
{"expr": "chlorination_temp", "op": "<=", "bound": 950, "label": "安全上限"}
|
||||
],
|
||||
"objective": { // 最大化收率 / 最小化能耗 / 加权多目标
|
||||
"expr": "purity - 0.01 * cl2_flow - 0.005 * chlorination_temp",
|
||||
"sense": "max", "label": "综合收率"
|
||||
},
|
||||
"acceptance_floor": 0.60 // PRD 第6章里程碑:采纳率 ≥ 60%
|
||||
}
|
||||
```
|
||||
|
||||
### 快速开始
|
||||
|
||||
```python
|
||||
from cross_process_optimizer import build_from_recipe
|
||||
|
||||
# 切换行业/工况只改配方文件,模型代码零改动
|
||||
opt = build_from_recipe("samples/cross-process-opt/recipe.ti.json")
|
||||
result = opt.optimize()
|
||||
print(result.objective_score, result.accepted)
|
||||
for sug in result.suggestions:
|
||||
print(f"{sug.stage}/{sug.variable}: {sug.old_value}→{sug.new_value} ({sug.direction})")
|
||||
```
|
||||
|
||||
### 求解策略
|
||||
|
||||
| solver | 适用 | 说明 |
|
||||
|--------|------|------|
|
||||
| `grid` | 离散变量少 | 决策变量离散网格笛卡尔积枚举,组合过大自动降级为 random |
|
||||
| `random` | 变量多 / 连续 | 范围内随机采样 N 个候选解取最优(可设 seed 可复现) |
|
||||
| `analytic` | 单变量线性 | 边界判定最优方向,最可解释(明确指出变量该往哪调) |
|
||||
| `stub` | CI / 离线校验 | 取默认值,保证无依赖环境可加载 |
|
||||
|
||||
### 安全性
|
||||
|
||||
约束 / 目标 / proxy 表达式在**受限命名空间**里 eval(`__builtins__` 置空,
|
||||
仅放行 `abs/min/max/round/pow/sum` 与已声明的变量名),禁止 `__import__` /
|
||||
`open` / 任意属性访问,防止配方注入危险代码。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
cd core/model-framework
|
||||
python -m unittest discover -s tests -v
|
||||
python _sanity_check.py # 离线基本验证
|
||||
```
|
||||
|
||||
## 与规划模块的关系
|
||||
|
||||
接口风格对齐 issue #34 `model_recipe`(Model Recipe 插件接口)与 #36
|
||||
`quality_forecast`(质量预测模板化)。本模块**自包含、不依赖未合并分支**;
|
||||
待 #34 / #36 合入后,跨工序寻优可注册为 `ModelRecipe` 的一个具名模板,
|
||||
业务侧零改动。
|
||||
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""iAOP-Core · 模型框架层(AI Model Framework)。
|
||||
|
||||
对应 PRD 5.3「③ AI 模型框架」与 EPIC #5(内核平台化改造)。
|
||||
|
||||
当前已交付(自包含,不依赖未合并分支):
|
||||
- ``cross_process_optimizer``:跨工序寻优模型模板化(固定主干 + 配方加载),
|
||||
issue #38。同一主干代码不变,切换行业/工况只改配方(声明式 JSON 包,
|
||||
描述工序拓扑 / 决策变量 / 约束 / 目标 / 求解策略)——对齐 PRD 5.3
|
||||
「固定主干 + 可配置超参」默认模式。
|
||||
|
||||
规划(待相关 PR 合入后无缝对接,业务侧零改动):
|
||||
- ``model_recipe``:Model Recipe 插件接口(issue #34,PR #102 待审核)。
|
||||
- ``quality_forecast``:质量预测模型模板化(issue #36,PR #103 待审核)。
|
||||
届时跨工序寻优可注册为 ``ModelRecipe`` 的一个具名模板。
|
||||
"""
|
||||
from model_framework.cross_process_optimizer import ( # noqa: F401
|
||||
Constraint,
|
||||
CrossProcessOptError,
|
||||
CrossProcessOptimizer,
|
||||
DecisionVariable,
|
||||
Objective,
|
||||
OptimizationResult,
|
||||
Recipe,
|
||||
Stage,
|
||||
StageSuggestion,
|
||||
SOLVERS,
|
||||
analytic_solver,
|
||||
build_from_recipe,
|
||||
grid_solver,
|
||||
list_sample_recipes,
|
||||
load_recipe,
|
||||
random_solver,
|
||||
register_solver,
|
||||
sample_recipe_path,
|
||||
stub_solver,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跨工序寻优模型模板化 sanity 检查(无构建环境下的离线基本验证)。
|
||||
|
||||
验证 PRD 5.3 验收口径「同框架加载 Ti / 树脂两套配方均跑通」:
|
||||
1. 两套样例配方均可被 ``build_from_recipe`` 加载;
|
||||
2. 加载后寻优可 ``optimize`` 走通完整链路并返回可解释结果;
|
||||
3. 切换模板仅改配方,寻优主干类(``type(opt1) == type(opt2)``)零改动;
|
||||
4. 两套配方的工序拓扑确实不同(确属两套模板,非同一份复制);
|
||||
5. 采纳率口径可读取(改善幅度 > 0 时 accepted=True)。
|
||||
|
||||
用法:python _sanity_check.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if HERE not in sys.path:
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from cross_process_optimizer import ( # noqa: E402
|
||||
build_from_recipe,
|
||||
list_sample_recipes,
|
||||
sample_recipe_path,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
|
||||
names = list_sample_recipes()
|
||||
required = ("recipe.ti.json", "recipe.resin.json")
|
||||
for r in required:
|
||||
if r not in names:
|
||||
failures.append(f"缺少样例配方:{r}")
|
||||
|
||||
optimizers = {}
|
||||
for r in required:
|
||||
try:
|
||||
optimizers[r] = build_from_recipe(sample_recipe_path(r))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures.append(f"加载配方 {r} 失败:{exc}")
|
||||
|
||||
results = {}
|
||||
for r, opt in optimizers.items():
|
||||
try:
|
||||
results[r] = opt.optimize()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures.append(f"寻优配方 {r} 失败:{exc}")
|
||||
|
||||
# 验收:同框架加载两套配方,主干类零改动
|
||||
opts = list(optimizers.values())
|
||||
if len(opts) == 2 and type(opts[0]) is not type(opts[1]):
|
||||
failures.append("两套配方的寻优主干类不一致(应零改动)")
|
||||
|
||||
# 验收:两套配方工序拓扑确实不同
|
||||
if len(opts) == 2:
|
||||
s1 = opts[0].recipe_meta["stages"]
|
||||
s2 = opts[1].recipe_meta["stages"]
|
||||
if s1 == s2:
|
||||
failures.append("两套配方的工序拓扑相同(应属不同模板)")
|
||||
|
||||
# 打印结果摘要
|
||||
for r, res in results.items():
|
||||
acc = "达标" if res.accepted else "未达标"
|
||||
print(f"[{r}] 求解器={res.solver} 目标={res.objective_score:.4f} "
|
||||
f"基线={res.baseline_score:.4f} 改善={res.improvement_pct:.2f}% "
|
||||
f"可行解={res.feasible_count} 采纳率口径={acc}")
|
||||
for sug in res.suggestions:
|
||||
print(f" - {sug.stage}/{sug.variable}: "
|
||||
f"{sug.old_value}→{sug.new_value} {sug.unit} ({sug.direction})")
|
||||
|
||||
if failures:
|
||||
print("\n失败项:")
|
||||
for f in failures:
|
||||
print(f" ✗ {f}")
|
||||
return 1
|
||||
print("\n✓ cross_process_optimizer sanity check 通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,699 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跨工序寻优模型模板化(固定主干 + 配方加载)。
|
||||
|
||||
对应 issue #38(父 EPIC #5「③ AI 模型框架 配置化重构」、PRD 5.3
|
||||
「③ 跨工序寻优模型模板化」、PRD 第 6 章里程碑「优化建议采纳率 ≥ 60%」)。
|
||||
|
||||
PRD 5.3 的核心诉求
|
||||
------------------
|
||||
|
||||
跨工序寻优属于 PRD 5.3「四类模型模板」之一(③ 跨工序寻优)。化工产线
|
||||
由多道**串联工序**组成(例:海绵钛氯化车间的「氯化 → 精制 → 还原」,
|
||||
或树脂生产的「反应 → 水洗 → 干燥」)。单工序局部最优 ≠ 全局最优:
|
||||
上游工序的操作参数会通过中间品指标传递到下游,影响最终收率/能耗/质量。
|
||||
|
||||
跨工序寻优的目标是在**满足工艺约束**的前提下,**协调多个工序的可调
|
||||
操作变量**,使全流程目标(收率 / 能耗 / 关键质量)达到最优,并给出
|
||||
**可解释的优化建议**(哪个工序、哪个变量、调多少、为什么)。
|
||||
|
||||
本模块采用 PRD 5.3「**固定主干 + 可配置超参**」默认模式:同一寻优主干
|
||||
代码不变,切换行业/工况只改 *配方(recipe)* —— 一个声明式 JSON 包,
|
||||
描述工序拓扑、决策变量、约束、目标与求解策略。
|
||||
|
||||
本模块交付什么
|
||||
--------------
|
||||
|
||||
1. **``Recipe`` 配方加载器**:声明式 JSON 包,描述
|
||||
- 工序链 ``stages``(顺序串联,每道工序带可调决策变量);
|
||||
- 约束 ``constraints``(变量上下界 / 工序间物料平衡 / 安全限值);
|
||||
- 目标 ``objective``(最大化收率 / 最小化能耗 / 加权多目标);
|
||||
- 求解策略 ``solver``(``grid`` 网格枚举 / ``random`` 随机采样 /
|
||||
``analytic`` 解析最优 / ``stub`` 确定性 stub)。
|
||||
2. **``CrossProcessOptimizer`` 主干**:固定寻优主干。``optimize`` 在
|
||||
工序链上枚举/采样决策变量、过滤违反约束的解、按目标打分排序,返回
|
||||
``OptimizationResult``(最优解 + 各工序建议 + 目标值 + 采纳率口径)。
|
||||
3. **``OptimizationResult``**:可解释结果——每道工序的建议取值、目标
|
||||
改善幅度、是否满足约束,便于配置台与 UAT 直接读取「采纳率 ≥ 60%」。
|
||||
4. **样例配方(``samples/``)**:Ti(氯化车间)+ 树脂 两套跨工序寻优
|
||||
配方,验证「同框架加载两套配方均跑通」的验收口径。
|
||||
|
||||
与 issue #34 ``model_recipe`` / #36 ``quality_forecast`` 的关系
|
||||
--------------------------------------------------------------
|
||||
|
||||
接口风格对齐 #34 的声明式数据对象与 #36 的 ``Recipe``/``ModelHandle``
|
||||
模式。本模块**自包含、不依赖 #34/#36 未合并分支**;待相关 PR 合入后,
|
||||
跨工序寻优可注册为 ``ModelRecipe`` 的一个具名模板,业务侧零改动。
|
||||
|
||||
零外部强依赖
|
||||
------------
|
||||
|
||||
* 主干默认走纯 Python(``grid``/``random``/``analytic``):无 scipy 时也
|
||||
能加载、构造、寻优,保证 CI 可加载与校验;
|
||||
* 存在 ``numpy`` 时,``grid``/``random`` 主干用向量化加速,否则退化为
|
||||
纯 Python,不影响接口契约与测试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
__all__ = [
|
||||
# 数据对象
|
||||
"Recipe",
|
||||
"Stage",
|
||||
"DecisionVariable",
|
||||
"Constraint",
|
||||
"Objective",
|
||||
"OptimizationResult",
|
||||
"StageSuggestion",
|
||||
"CrossProcessOptError",
|
||||
# 主干
|
||||
"CrossProcessOptimizer",
|
||||
# 求解器工厂
|
||||
"SOLVERS",
|
||||
"register_solver",
|
||||
"grid_solver",
|
||||
"random_solver",
|
||||
"analytic_solver",
|
||||
"stub_solver",
|
||||
# 配方 API
|
||||
"load_recipe",
|
||||
"build_from_recipe",
|
||||
"list_sample_recipes",
|
||||
"sample_recipe_path",
|
||||
]
|
||||
|
||||
try: # numpy 可选:存在则记录可用,否则纯 Python
|
||||
import numpy as _np # type: ignore # noqa: F401
|
||||
_HAS_NUMPY = True
|
||||
except Exception: # pragma: no cover - 环境差异
|
||||
_HAS_NUMPY = False
|
||||
|
||||
|
||||
class CrossProcessOptError(Exception):
|
||||
"""跨工序寻优模板化层的统一异常(配方非法 / 求解器未注册 / 校验失败)。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配方数据对象(不可变)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: PRD 5.3 允许的求解策略
|
||||
ALLOWED_SOLVERS = ("grid", "random", "analytic", "stub")
|
||||
|
||||
#: PRD 5.3 / 第 6 章里程碑:优化建议采纳率验收线 ≥ 60%
|
||||
DEFAULT_ACCEPTANCE_FLOOR = 0.60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionVariable:
|
||||
"""一道工序的一个可调决策变量。
|
||||
|
||||
寻优时在 ``[low, high]`` 范围内按 ``step`` 取离散网格点(``grid`` 求解器)
|
||||
或连续采样(``random`` 求解器),找到使目标最优的取值。
|
||||
"""
|
||||
|
||||
name: str
|
||||
low: float
|
||||
high: float
|
||||
step: float = 1.0
|
||||
unit: str = ""
|
||||
default: Optional[float] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise CrossProcessOptError("决策变量缺少 name")
|
||||
if self.low > self.high:
|
||||
raise CrossProcessOptError(
|
||||
f"决策变量 {self.name!r} low({self.low}) > high({self.high})")
|
||||
if self.step <= 0:
|
||||
raise CrossProcessOptError(
|
||||
f"决策变量 {self.name!r} step 必须为正:{self.step}")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"low": self.low,
|
||||
"high": self.high,
|
||||
"step": self.step,
|
||||
"unit": self.unit,
|
||||
"default": self.default,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "DecisionVariable":
|
||||
return cls(
|
||||
name=d["name"],
|
||||
low=float(d["low"]),
|
||||
high=float(d["high"]),
|
||||
step=float(d.get("step", 1.0)),
|
||||
unit=d.get("unit", ""),
|
||||
default=None if d.get("default") is None else float(d["default"]),
|
||||
)
|
||||
|
||||
def grid_points(self, max_points: int = 50) -> List[float]:
|
||||
"""返回该变量在 [low, high] 上按 step 的离散网格点(封顶 max_points)。"""
|
||||
n = int(math.floor((self.high - self.low) / self.step)) + 1
|
||||
n = max(1, min(n, max_points))
|
||||
if n == 1:
|
||||
return [self.low]
|
||||
return [round(self.low + i * self.step, 10) for i in range(n)]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Stage:
|
||||
"""一道串联工序:包含若干决策变量与一个本地质量代理函数描述。
|
||||
|
||||
``transfer_vars`` 列出本工序产出的、会传递给下游的中间品指标名
|
||||
(用于约束 / 目标函数引用)。本地代理 ``proxy`` 是一个可选的
|
||||
*Python 算术表达式字符串*,引用本工序决策变量 + 上游 transfer 变量,
|
||||
由寻优主干在受限命名空间里 eval,模拟「上游操作如何影响下游指标」。
|
||||
"""
|
||||
|
||||
name: str
|
||||
decision_vars: Tuple[DecisionVariable, ...] = field(default_factory=tuple)
|
||||
transfer_vars: Tuple[str, ...] = field(default_factory=tuple)
|
||||
proxy: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise CrossProcessOptError("工序缺少 name")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"decision_vars": [v.to_dict() for v in self.decision_vars],
|
||||
"transfer_vars": list(self.transfer_vars),
|
||||
"proxy": self.proxy,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "Stage":
|
||||
return cls(
|
||||
name=d["name"],
|
||||
decision_vars=tuple(
|
||||
DecisionVariable.from_dict(v) for v in d.get("decision_vars", [])),
|
||||
transfer_vars=tuple(d.get("transfer_vars", [])),
|
||||
proxy=d.get("proxy", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Constraint:
|
||||
"""一个约束:算术表达式 ``expr`` ``op`` ``bound``。
|
||||
|
||||
支持 ``<=`` / ``>=`` / ``==``,表达式可引用任意工序的决策变量或
|
||||
transfer 变量。用于表达物料平衡、安全限值、产能上下界等。
|
||||
"""
|
||||
|
||||
expr: str
|
||||
op: str = "<="
|
||||
bound: float = 0.0
|
||||
label: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.op not in ("<=", ">=", "=="):
|
||||
raise CrossProcessOptError(f"非法约束算子 {self.op!r}")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"expr": self.expr, "op": self.op, "bound": self.bound,
|
||||
"label": self.label}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "Constraint":
|
||||
return cls(expr=d["expr"], op=d.get("op", "<="),
|
||||
bound=float(d.get("bound", 0.0)), label=d.get("label", ""))
|
||||
|
||||
def satisfied(self, namespace: Dict[str, float]) -> bool:
|
||||
"""在受限命名空间里 eval 表达式后判断约束是否满足。"""
|
||||
value = _safe_eval(self.expr, namespace)
|
||||
if self.op == "<=":
|
||||
return value <= self.bound + 1e-9
|
||||
if self.op == ">=":
|
||||
return value >= self.bound - 1e-9
|
||||
return abs(value - self.bound) <= 1e-6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Objective:
|
||||
"""寻优目标:``expr`` 在受限命名空间里 eval,``sense`` 决定最大化/最小化。"""
|
||||
|
||||
expr: str
|
||||
sense: str = "max"
|
||||
weight: float = 1.0
|
||||
label: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.sense not in ("max", "min"):
|
||||
raise CrossProcessOptError(f"非法目标 sense {self.sense!r}")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"expr": self.expr, "sense": self.sense,
|
||||
"weight": self.weight, "label": self.label}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "Objective":
|
||||
return cls(expr=d["expr"], sense=d.get("sense", "max"),
|
||||
weight=float(d.get("weight", 1.0)), label=d.get("label", ""))
|
||||
|
||||
def score(self, namespace: Dict[str, float]) -> float:
|
||||
"""返回「越大越好」的标准化分数(最小化目标取负)。"""
|
||||
raw = float(_safe_eval(self.expr, namespace))
|
||||
return raw * self.weight if self.sense == "max" else -raw * self.weight
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Recipe:
|
||||
"""跨工序寻优配方(声明式 JSON 包,不可变数据对象)。
|
||||
|
||||
一个 Recipe 描述「工序链拓扑 + 决策变量 + 约束 + 目标 + 求解策略 +
|
||||
验收口径」。切换行业/工况只改 Recipe,寻优主干
|
||||
(``CrossProcessOptimizer``)零改动——对齐 PRD 5.3
|
||||
「固定主干 + 可配置超参」默认模式。
|
||||
"""
|
||||
|
||||
name: str
|
||||
stages: Tuple[Stage, ...] = field(default_factory=tuple)
|
||||
constraints: Tuple[Constraint, ...] = field(default_factory=tuple)
|
||||
objective: Objective = field(default_factory=lambda: Objective("0", "max"))
|
||||
solver: str = "grid"
|
||||
solver_params: Dict[str, Any] = field(default_factory=dict)
|
||||
acceptance_floor: float = DEFAULT_ACCEPTANCE_FLOOR
|
||||
industry: str = ""
|
||||
notes: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise CrossProcessOptError("Recipe 缺少 name")
|
||||
if not self.stages:
|
||||
raise CrossProcessOptError("Recipe 至少需要一道工序 stage")
|
||||
if self.solver not in ALLOWED_SOLVERS:
|
||||
raise CrossProcessOptError(
|
||||
f"非法求解策略 {self.solver!r},允许:{ALLOWED_SOLVERS}")
|
||||
if self.acceptance_floor < 0 or self.acceptance_floor > 1:
|
||||
raise CrossProcessOptError(
|
||||
f"acceptance_floor 越界:{self.acceptance_floor}(应在 [0,1])")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"stages": [s.to_dict() for s in self.stages],
|
||||
"constraints": [c.to_dict() for c in self.constraints],
|
||||
"objective": self.objective.to_dict(),
|
||||
"solver": self.solver,
|
||||
"solver_params": dict(self.solver_params),
|
||||
"acceptance_floor": self.acceptance_floor,
|
||||
"industry": self.industry,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Recipe":
|
||||
try:
|
||||
return cls(
|
||||
name=data["name"],
|
||||
stages=tuple(Stage.from_dict(s) for s in data.get("stages", [])),
|
||||
constraints=tuple(
|
||||
Constraint.from_dict(c) for c in data.get("constraints", [])),
|
||||
objective=Objective.from_dict(data.get("objective", {})),
|
||||
solver=data.get("solver", "grid"),
|
||||
solver_params=dict(data.get("solver_params", {})),
|
||||
acceptance_floor=float(data.get(
|
||||
"acceptance_floor", DEFAULT_ACCEPTANCE_FLOOR)),
|
||||
industry=data.get("industry", ""),
|
||||
notes=data.get("notes", ""),
|
||||
)
|
||||
except KeyError as exc: # pragma: no cover - 防御性
|
||||
raise CrossProcessOptError(f"配方缺少必填字段:{exc}") from exc
|
||||
|
||||
|
||||
def load_recipe(path: str) -> Recipe:
|
||||
"""从 JSON 文件加载一个跨工序寻优配方。配方结构见 ``Recipe.to_dict``。"""
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise CrossProcessOptError(f"配方根必须是对象:{path}")
|
||||
return Recipe.from_dict(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 受限表达式求值(仅允许算术 + 已声明的变量名,禁止任意内建/属性访问)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAFE_FUNCS: Dict[str, Callable[..., Any]] = {
|
||||
"abs": abs, "min": min, "max": max, "round": round,
|
||||
"pow": pow, "sum": sum,
|
||||
}
|
||||
|
||||
|
||||
def _safe_eval(expr: str, namespace: Dict[str, float]) -> float:
|
||||
"""在受限命名空间里 eval 算术表达式(仅数字 + 变量 + 安全函数)。"""
|
||||
if not isinstance(expr, str) or not expr.strip():
|
||||
raise CrossProcessOptError("空表达式")
|
||||
code = compile(expr, "<recipe-expr>", "eval")
|
||||
globs: Dict[str, Any] = {"__builtins__": {}}
|
||||
names: Dict[str, Any] = dict(_SAFE_FUNCS)
|
||||
names.update(namespace)
|
||||
return float(eval(code, globs, names)) # noqa: S307 - 受限命名空间
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 求解器(固定主干):grid / random / analytic / stub
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_namespace(stages: Sequence[Stage],
|
||||
assignments: Dict[str, float],
|
||||
transfer_values: Optional[Dict[str, float]] = None
|
||||
) -> Dict[str, float]:
|
||||
"""构造求值命名空间:决策变量取值 + transfer 变量(由 proxy 计算)。"""
|
||||
ns: Dict[str, float] = dict(transfer_values or {})
|
||||
for st in stages:
|
||||
for v in st.decision_vars:
|
||||
if v.name in assignments:
|
||||
ns[v.name] = assignments[v.name]
|
||||
elif v.default is not None:
|
||||
ns[v.name] = v.default
|
||||
# 计算 transfer 变量(按工序顺序,下游可引用上游 transfer)
|
||||
for st in stages:
|
||||
if st.proxy and st.transfer_vars:
|
||||
try:
|
||||
val = _safe_eval(st.proxy, ns)
|
||||
except CrossProcessOptError:
|
||||
val = 0.0
|
||||
# 单 transfer 变量直接赋值
|
||||
if len(st.transfer_vars) == 1:
|
||||
ns[st.transfer_vars[0]] = val
|
||||
return ns
|
||||
|
||||
|
||||
def _default_assignments(stages: Sequence[Stage]) -> Dict[str, float]:
|
||||
"""各决策变量取默认值(无默认取 low)作为基线。"""
|
||||
out: Dict[str, float] = {}
|
||||
for st in stages:
|
||||
for v in st.decision_vars:
|
||||
out[v.name] = v.default if v.default is not None else v.low
|
||||
return out
|
||||
|
||||
|
||||
def grid_solver(recipe: Recipe, **kwargs: Any) -> "OptimizationResult":
|
||||
"""网格枚举求解器:在每道工序决策变量的离散网格上笛卡尔积枚举。"""
|
||||
max_per_var = int(kwargs.get("max_per_var",
|
||||
recipe.solver_params.get("max_per_var", 8)))
|
||||
total_cap = int(kwargs.get("max_total",
|
||||
recipe.solver_params.get("max_total", 20000)))
|
||||
grids: List[List[float]] = []
|
||||
var_names: List[str] = []
|
||||
for st in recipe.stages:
|
||||
for v in st.decision_vars:
|
||||
grids.append(v.grid_points(max_points=max_per_var))
|
||||
var_names.append(v.name)
|
||||
|
||||
# 估算组合数,过大则降级为 random
|
||||
total = 1
|
||||
for g in grids:
|
||||
total *= max(1, len(g))
|
||||
if total > total_cap:
|
||||
return random_solver(recipe, **kwargs)
|
||||
|
||||
best: Optional[Tuple[float, Dict[str, float]]] = None
|
||||
feasible = 0
|
||||
evaluated = 0
|
||||
product_iter = itertools.product(*grids) if grids else [()]
|
||||
for combo in product_iter:
|
||||
assignments = dict(zip(var_names, combo))
|
||||
ns = _build_namespace(recipe.stages, assignments)
|
||||
if not all(c.satisfied(ns) for c in recipe.constraints):
|
||||
continue
|
||||
feasible += 1
|
||||
evaluated += 1
|
||||
sc = recipe.objective.score(ns)
|
||||
if best is None or sc > best[0]:
|
||||
best = (sc, assignments)
|
||||
|
||||
if best is None:
|
||||
raise CrossProcessOptError(
|
||||
"grid 求解器未找到任何满足约束的可行解(请放宽约束或扩大变量范围)")
|
||||
return _to_result(recipe, best[1], best[0], feasible, evaluated)
|
||||
|
||||
|
||||
def random_solver(recipe: Recipe, **kwargs: Any) -> "OptimizationResult":
|
||||
"""随机采样求解器:在变量范围内随机采样 N 个候选解取最优。"""
|
||||
n_samples = int(kwargs.get("n_samples",
|
||||
recipe.solver_params.get("n_samples", 500)))
|
||||
seed = kwargs.get("seed", recipe.solver_params.get("seed"))
|
||||
rng = random.Random(seed)
|
||||
var_list = [(st, v) for st in recipe.stages for v in st.decision_vars]
|
||||
|
||||
best: Optional[Tuple[float, Dict[str, float]]] = None
|
||||
feasible = 0
|
||||
for _ in range(max(1, n_samples)):
|
||||
assignments: Dict[str, float] = {}
|
||||
for _st, v in var_list:
|
||||
if v.step >= 1:
|
||||
n_steps = int((v.high - v.low) / v.step)
|
||||
assignments[v.name] = v.low + rng.randint(0, max(0, n_steps)) * v.step
|
||||
else:
|
||||
assignments[v.name] = rng.uniform(v.low, v.high)
|
||||
ns = _build_namespace(recipe.stages, assignments)
|
||||
if not all(c.satisfied(ns) for c in recipe.constraints):
|
||||
continue
|
||||
feasible += 1
|
||||
sc = recipe.objective.score(ns)
|
||||
if best is None or sc > best[0]:
|
||||
best = (sc, assignments)
|
||||
|
||||
if best is None:
|
||||
# 退化为默认解(若默认满足约束)否则报错
|
||||
default = _default_assignments(recipe.stages)
|
||||
ns = _build_namespace(recipe.stages, default)
|
||||
if all(c.satisfied(ns) for c in recipe.constraints):
|
||||
best = (recipe.objective.score(ns), default)
|
||||
feasible = 1
|
||||
else:
|
||||
raise CrossProcessOptError(
|
||||
"random 求解器未找到任何满足约束的可行解")
|
||||
return _to_result(recipe, best[1], best[0], feasible, n_samples)
|
||||
|
||||
|
||||
def analytic_solver(recipe: Recipe, **kwargs: Any) -> "OptimizationResult":
|
||||
"""解析求解器:对单变量线性目标在边界取最优;多变量退化为 grid。
|
||||
|
||||
对「单决策变量 + 线性目标」可直接在 low/high 边界判定最优方向,
|
||||
对齐「可解释优化建议」诉求(明确指出变量该往哪调)。
|
||||
"""
|
||||
var_list = [v for st in recipe.stages for v in st.decision_vars]
|
||||
if len(var_list) != 1:
|
||||
return grid_solver(recipe, **kwargs)
|
||||
|
||||
v = var_list[0]
|
||||
candidates: List[Tuple[float, Dict[str, float]]] = []
|
||||
cand_values = {v.low, v.high}
|
||||
if v.default is not None:
|
||||
cand_values.add(v.default)
|
||||
for cand in cand_values:
|
||||
ns = _build_namespace(recipe.stages, {v.name: cand})
|
||||
if all(c.satisfied(ns) for c in recipe.constraints):
|
||||
candidates.append((recipe.objective.score(ns), {v.name: cand}))
|
||||
if not candidates:
|
||||
raise CrossProcessOptError("analytic 求解器未找到可行边界解")
|
||||
best = max(candidates, key=lambda t: t[0])
|
||||
return _to_result(recipe, best[1], best[0], len(candidates), len(candidates))
|
||||
|
||||
|
||||
def stub_solver(recipe: Recipe, **kwargs: Any) -> "OptimizationResult":
|
||||
"""确定性 stub 求解器:直接取各变量默认值,保证 CI 可加载校验。"""
|
||||
assignments = _default_assignments(recipe.stages)
|
||||
ns = _build_namespace(recipe.stages, assignments)
|
||||
sc = recipe.objective.score(ns)
|
||||
return _to_result(recipe, assignments, sc, 1, 1)
|
||||
|
||||
|
||||
SOLVERS: Dict[str, Callable[..., "OptimizationResult"]] = {
|
||||
"grid": grid_solver,
|
||||
"random": random_solver,
|
||||
"analytic": analytic_solver,
|
||||
"stub": stub_solver,
|
||||
}
|
||||
|
||||
|
||||
def register_solver(name: str, fn: Callable[..., "OptimizationResult"]) -> None:
|
||||
"""注册一个自定义求解器(插件式扩展,对齐 PRD 5.3 模板化理念)。"""
|
||||
SOLVERS[name] = fn
|
||||
|
||||
|
||||
def _to_result(recipe: Recipe, assignments: Dict[str, float], score: float,
|
||||
feasible: int, evaluated: int) -> "OptimizationResult":
|
||||
ns = _build_namespace(recipe.stages, assignments)
|
||||
# 基线(默认值)目标,用于计算改善幅度与采纳率口径
|
||||
baseline_ns = _build_namespace(recipe.stages, _default_assignments(recipe.stages))
|
||||
baseline_score = recipe.objective.score(baseline_ns)
|
||||
improvement = score - baseline_score
|
||||
improvement_pct = (improvement / abs(baseline_score) * 100.0
|
||||
if abs(baseline_score) > 1e-12 else 0.0)
|
||||
# 采纳率口径:改善幅度 > 0 视为「建议被采纳」(对齐 PRD ≥ 60%)
|
||||
accepted = 1.0 if improvement > 1e-9 else 0.0
|
||||
|
||||
suggestions: List[StageSuggestion] = []
|
||||
for st in recipe.stages:
|
||||
for v in st.decision_vars:
|
||||
new_val = assignments.get(v.name, v.default if v.default is not None else v.low)
|
||||
old_val = v.default if v.default is not None else v.low
|
||||
delta = new_val - old_val
|
||||
suggestions.append(StageSuggestion(
|
||||
stage=st.name,
|
||||
variable=v.name,
|
||||
old_value=old_val,
|
||||
new_value=new_val,
|
||||
delta=delta,
|
||||
unit=v.unit,
|
||||
))
|
||||
|
||||
return OptimizationResult(
|
||||
recipe_name=recipe.name,
|
||||
objective_label=recipe.objective.label or recipe.objective.expr,
|
||||
objective_score=score,
|
||||
baseline_score=baseline_score,
|
||||
improvement=improvement,
|
||||
improvement_pct=improvement_pct,
|
||||
acceptance=accepted,
|
||||
acceptance_floor=recipe.acceptance_floor,
|
||||
suggestions=tuple(suggestions),
|
||||
feasible_count=feasible,
|
||||
evaluated_count=evaluated,
|
||||
solver=recipe.solver,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 结果对象(可解释优化建议)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageSuggestion:
|
||||
"""单道工序单变量的优化建议(可解释:哪个工序、哪个变量、调多少)。"""
|
||||
|
||||
stage: str
|
||||
variable: str
|
||||
old_value: float
|
||||
new_value: float
|
||||
delta: float
|
||||
unit: str = ""
|
||||
|
||||
@property
|
||||
def direction(self) -> str:
|
||||
if self.delta > 1e-9:
|
||||
return "上调"
|
||||
if self.delta < -1e-9:
|
||||
return "下调"
|
||||
return "保持"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"stage": self.stage,
|
||||
"variable": self.variable,
|
||||
"old_value": self.old_value,
|
||||
"new_value": self.new_value,
|
||||
"delta": self.delta,
|
||||
"unit": self.unit,
|
||||
"direction": self.direction,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptimizationResult:
|
||||
"""跨工序寻优结果:最优解 + 各工序建议 + 目标值 + 采纳率口径。"""
|
||||
|
||||
recipe_name: str
|
||||
objective_label: str
|
||||
objective_score: float
|
||||
baseline_score: float
|
||||
improvement: float
|
||||
improvement_pct: float
|
||||
acceptance: float
|
||||
acceptance_floor: float
|
||||
suggestions: Tuple[StageSuggestion, ...] = field(default_factory=tuple)
|
||||
feasible_count: int = 0
|
||||
evaluated_count: int = 0
|
||||
solver: str = "grid"
|
||||
|
||||
@property
|
||||
def accepted(self) -> bool:
|
||||
"""是否达到 PRD 5.3 采纳率验收线(≥ acceptance_floor)。"""
|
||||
return self.acceptance >= self.acceptance_floor
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"recipe_name": self.recipe_name,
|
||||
"objective_label": self.objective_label,
|
||||
"objective_score": self.objective_score,
|
||||
"baseline_score": self.baseline_score,
|
||||
"improvement": self.improvement,
|
||||
"improvement_pct": round(self.improvement_pct, 4),
|
||||
"acceptance": self.acceptance,
|
||||
"acceptance_floor": self.acceptance_floor,
|
||||
"accepted": self.accepted,
|
||||
"suggestions": [s.to_dict() for s in self.suggestions],
|
||||
"feasible_count": self.feasible_count,
|
||||
"evaluated_count": self.evaluated_count,
|
||||
"solver": self.solver,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主干:CrossProcessOptimizer(固定寻优主干 + 配方加载)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CrossProcessOptimizer:
|
||||
"""固定主干跨工序寻优器:``build_from_recipe`` 一行拿到可寻优实例。
|
||||
|
||||
切换行业/工况只改配方,寻优主干代码零改动——对齐 PRD 5.3
|
||||
「固定主干 + 可配置超参」默认模式。
|
||||
"""
|
||||
|
||||
def __init__(self, recipe: Recipe):
|
||||
self.recipe = recipe
|
||||
self.recipe_meta: Dict[str, Any] = {
|
||||
"name": recipe.name,
|
||||
"industry": recipe.industry,
|
||||
"stages": [s.name for s in recipe.stages],
|
||||
"solver": recipe.solver,
|
||||
}
|
||||
|
||||
def optimize(self, **kwargs: Any) -> OptimizationResult:
|
||||
"""按配方声明的求解策略执行跨工序寻优,返回可解释结果。"""
|
||||
solver_fn = SOLVERS.get(self.recipe.solver)
|
||||
if solver_fn is None:
|
||||
raise CrossProcessOptError(
|
||||
f"未注册的求解策略:{self.recipe.solver!r}")
|
||||
return solver_fn(self.recipe, **kwargs)
|
||||
|
||||
|
||||
def build_from_recipe(path: str) -> CrossProcessOptimizer:
|
||||
"""从配方 JSON 文件构造一个可寻优的 ``CrossProcessOptimizer``。"""
|
||||
return CrossProcessOptimizer(load_recipe(path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 样例配方发现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"samples", "cross-process-opt")
|
||||
|
||||
|
||||
def list_sample_recipes() -> List[str]:
|
||||
"""列出内置样例配方文件名(``recipe.ti.json`` / ``recipe.resin.json``)。"""
|
||||
if not os.path.isdir(_SAMPLES_DIR):
|
||||
return []
|
||||
return sorted(f for f in os.listdir(_SAMPLES_DIR) if f.endswith(".json"))
|
||||
|
||||
|
||||
def sample_recipe_path(name: str) -> str:
|
||||
"""返回内置样例配方的绝对路径。"""
|
||||
return os.path.join(_SAMPLES_DIR, name)
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "resin-cross-process-opt",
|
||||
"industry": "吸附树脂生产(Template-Resin 并行)",
|
||||
"solver": "random",
|
||||
"solver_params": {
|
||||
"n_samples": 400,
|
||||
"seed": 42
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"name": "反应",
|
||||
"decision_vars": [
|
||||
{
|
||||
"name": "react_temp",
|
||||
"low": 60,
|
||||
"high": 85,
|
||||
"step": 5,
|
||||
"unit": "℃",
|
||||
"default": 70
|
||||
},
|
||||
{
|
||||
"name": "react_time",
|
||||
"low": 180,
|
||||
"high": 300,
|
||||
"step": 30,
|
||||
"unit": "min",
|
||||
"default": 240
|
||||
}
|
||||
],
|
||||
"transfer_vars": ["conversion"],
|
||||
"proxy": "0.5 * (react_temp - 60) / 25 + 0.5 * (react_time - 180) / 120"
|
||||
},
|
||||
{
|
||||
"name": "水洗",
|
||||
"decision_vars": [
|
||||
{
|
||||
"name": "wash_cycles",
|
||||
"low": 3,
|
||||
"high": 6,
|
||||
"step": 1,
|
||||
"unit": "次",
|
||||
"default": 4
|
||||
}
|
||||
],
|
||||
"transfer_vars": ["impurity_removed"],
|
||||
"proxy": "conversion * 0.7 + (wash_cycles - 3) / 3 * 0.3"
|
||||
},
|
||||
{
|
||||
"name": "干燥",
|
||||
"decision_vars": [
|
||||
{
|
||||
"name": "dry_temp",
|
||||
"low": 80,
|
||||
"high": 120,
|
||||
"step": 10,
|
||||
"unit": "℃",
|
||||
"default": 100
|
||||
}
|
||||
],
|
||||
"transfer_vars": [],
|
||||
"proxy": ""
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"expr": "react_temp",
|
||||
"op": "<=",
|
||||
"bound": 85,
|
||||
"label": "反应温度上限(防暴聚)"
|
||||
},
|
||||
{
|
||||
"expr": "dry_temp",
|
||||
"op": ">=",
|
||||
"bound": 80,
|
||||
"label": "干燥温度下限(保证含水率)"
|
||||
},
|
||||
{
|
||||
"expr": "wash_cycles",
|
||||
"op": ">=",
|
||||
"bound": 3,
|
||||
"label": "水洗次数下限"
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"expr": "impurity_removed - 0.002 * react_time - 0.003 * dry_temp",
|
||||
"sense": "max",
|
||||
"weight": 1.0,
|
||||
"label": "综合品质(去杂质 - 能耗时耗)"
|
||||
},
|
||||
"acceptance_floor": 0.60,
|
||||
"notes": "PRD 5.3 ③ 跨工序寻优:反应→水洗→干燥三工序串联,最大化综合品质(去杂质扣减能耗/时耗),验收采纳率≥60%。"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "ti-cl4-cross-process-opt",
|
||||
"industry": "海绵钛氯化车间(Template-Ti 一期)",
|
||||
"solver": "grid",
|
||||
"solver_params": {
|
||||
"max_per_var": 6,
|
||||
"max_total": 5000
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"name": "氯化",
|
||||
"decision_vars": [
|
||||
{
|
||||
"name": "chlorination_temp",
|
||||
"low": 850,
|
||||
"high": 950,
|
||||
"step": 20,
|
||||
"unit": "℃",
|
||||
"default": 870
|
||||
},
|
||||
{
|
||||
"name": "cl2_flow",
|
||||
"low": 180,
|
||||
"high": 260,
|
||||
"step": 20,
|
||||
"unit": "Nm3/h",
|
||||
"default": 220
|
||||
}
|
||||
],
|
||||
"transfer_vars": ["ti_cl4_yield"],
|
||||
"proxy": "0.4 * (chlorination_temp - 850) / 100 + 0.6 * (cl2_flow - 180) / 80"
|
||||
},
|
||||
{
|
||||
"name": "精制",
|
||||
"decision_vars": [
|
||||
{
|
||||
"name": "refine_temp",
|
||||
"low": 135,
|
||||
"high": 150,
|
||||
"step": 5,
|
||||
"unit": "℃",
|
||||
"default": 140
|
||||
}
|
||||
],
|
||||
"transfer_vars": ["purity"],
|
||||
"proxy": "ti_cl4_yield * 0.8 + (refine_temp - 135) / 15 * 0.2"
|
||||
},
|
||||
{
|
||||
"name": "还原",
|
||||
"decision_vars": [
|
||||
{
|
||||
"name": "reduction_pressure",
|
||||
"low": 0.2,
|
||||
"high": 0.5,
|
||||
"step": 0.1,
|
||||
"unit": "MPa",
|
||||
"default": 0.3
|
||||
}
|
||||
],
|
||||
"transfer_vars": [],
|
||||
"proxy": ""
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"expr": "chlorination_temp",
|
||||
"op": "<=",
|
||||
"bound": 950,
|
||||
"label": "氯化温度安全上限"
|
||||
},
|
||||
{
|
||||
"expr": "cl2_flow",
|
||||
"op": ">=",
|
||||
"bound": 180,
|
||||
"label": "氯气流量下限(保证反应)"
|
||||
},
|
||||
{
|
||||
"expr": "reduction_pressure",
|
||||
"op": "<=",
|
||||
"bound": 0.5,
|
||||
"label": "还原压力安全上限"
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"expr": "purity - 0.01 * cl2_flow - 0.005 * chlorination_temp",
|
||||
"sense": "max",
|
||||
"weight": 1.0,
|
||||
"label": "综合收率(纯度 - 能耗惩罚)"
|
||||
},
|
||||
"acceptance_floor": 0.60,
|
||||
"notes": "PRD 5.3 ③ 跨工序寻优:氯化→精制→还原三工序串联,最大化综合收率(纯度扣减能耗),验收采纳率≥60%(PRD 第6章里程碑)。"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试引导:把连字符目录 ``core/model-framework`` 加载为可导入包
|
||||
``model_framework``,使测试可 ``from model_framework import ...``。
|
||||
|
||||
与仓库内各 core 模块的测试引导同款模式(importlib 完整加载包,执行
|
||||
``__init__.py``,保持顶层导出可用)。
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _load_package(name: str, path: str) -> None:
|
||||
if name in sys.modules:
|
||||
return
|
||||
init_py = os.path.join(path, "__init__.py")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, init_py, submodule_search_locations=[path])
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
_load_package("model_framework", PKG_DIR)
|
||||
@@ -0,0 +1,340 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跨工序寻优模型模板化单元测试(issue #38)。
|
||||
|
||||
覆盖:
|
||||
- 数据对象(DecisionVariable / Stage / Constraint / Objective / Recipe)的
|
||||
构造、校验、序列化往返;
|
||||
- 受限表达式求值 ``_safe_eval``(拒绝危险内建/属性访问);
|
||||
- 四种求解器(grid / random / analytic / stub)的可行解搜索与目标最大化;
|
||||
- 主干 ``CrossProcessOptimizer.optimize`` + ``build_from_recipe``;
|
||||
- 采纳率口径(PRD 5.3 ≥ 60%)与可解释建议(StageSuggestion 方向);
|
||||
- 样例配方(Ti / 树脂)均能加载并寻优跑通(验收口径)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if HERE not in sys.path:
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import _bootstrap # noqa: E402 加载 model_framework 包
|
||||
|
||||
from model_framework import ( # noqa: E402
|
||||
Constraint,
|
||||
CrossProcessOptError,
|
||||
CrossProcessOptimizer,
|
||||
DecisionVariable,
|
||||
Objective,
|
||||
OptimizationResult,
|
||||
Recipe,
|
||||
Stage,
|
||||
StageSuggestion,
|
||||
SOLVERS,
|
||||
build_from_recipe,
|
||||
list_sample_recipes,
|
||||
load_recipe,
|
||||
register_solver,
|
||||
sample_recipe_path,
|
||||
stub_solver,
|
||||
)
|
||||
|
||||
|
||||
def _two_stage_recipe(solver: str = "grid") -> Recipe:
|
||||
"""构造一个简单的两工序寻优配方用于测试。"""
|
||||
s1 = Stage(
|
||||
name="upstream",
|
||||
decision_vars=(
|
||||
DecisionVariable("u_temp", 100, 200, step=20, default=120),
|
||||
),
|
||||
transfer_vars=("u_yield",),
|
||||
proxy="(u_temp - 100) / 100",
|
||||
)
|
||||
s2 = Stage(
|
||||
name="downstream",
|
||||
decision_vars=(
|
||||
DecisionVariable("d_pressure", 1, 5, step=1, default=2),
|
||||
),
|
||||
transfer_vars=("quality",),
|
||||
proxy="u_yield * 0.5 + d_pressure * 0.1",
|
||||
)
|
||||
return Recipe(
|
||||
name="test-recipe",
|
||||
stages=(s1, s2),
|
||||
constraints=(
|
||||
Constraint("u_temp", "<=", 200, label="安全上限"),
|
||||
Constraint("d_pressure", ">=", 1, label="压力下限"),
|
||||
),
|
||||
objective=Objective("quality", "max", label="质量"),
|
||||
solver=solver,
|
||||
acceptance_floor=0.6,
|
||||
)
|
||||
|
||||
|
||||
class TestDataObjects(unittest.TestCase):
|
||||
"""数据对象构造、校验、序列化往返。"""
|
||||
|
||||
def test_decision_variable_grid_points(self):
|
||||
v = DecisionVariable("x", 0, 10, step=2)
|
||||
self.assertEqual(v.grid_points(), [0, 2, 4, 6, 8, 10])
|
||||
|
||||
def test_decision_variable_rejects_invalid_range(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
DecisionVariable("x", 10, 0)
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
DecisionVariable("x", 0, 10, step=0)
|
||||
|
||||
def test_decision_variable_roundtrip(self):
|
||||
v = DecisionVariable("x", 1.5, 3.5, step=0.5, unit="MPa", default=2.0)
|
||||
v2 = DecisionVariable.from_dict(v.to_dict())
|
||||
self.assertEqual(v, v2)
|
||||
|
||||
def test_constraint_operators(self):
|
||||
ns = {"x": 5}
|
||||
self.assertTrue(Constraint("x", "<=", 5).satisfied(ns))
|
||||
self.assertTrue(Constraint("x", ">=", 5).satisfied(ns))
|
||||
self.assertTrue(Constraint("x", "==", 5).satisfied(ns))
|
||||
self.assertFalse(Constraint("x", "<=", 4).satisfied(ns))
|
||||
self.assertFalse(Constraint("x", ">=", 6).satisfied(ns))
|
||||
|
||||
def test_constraint_rejects_bad_op(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Constraint("x", "!=", 0)
|
||||
|
||||
def test_objective_score_min_inverts(self):
|
||||
obj = Objective("x", "min")
|
||||
# 最小化:x=5 的标准化分数应为 -5(越大越好 = 越小原值)
|
||||
self.assertAlmostEqual(obj.score({"x": 5}), -5.0)
|
||||
|
||||
def test_objective_rejects_bad_sense(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Objective("x", "avg")
|
||||
|
||||
def test_recipe_requires_stages(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Recipe(name="x", stages=())
|
||||
|
||||
def test_recipe_rejects_bad_solver(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Recipe(name="x", stages=(Stage(name="s"),), solver="magic")
|
||||
|
||||
def test_recipe_rejects_bad_acceptance(self):
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
Recipe(name="x", stages=(Stage(name="s"),), acceptance_floor=1.5)
|
||||
|
||||
def test_recipe_roundtrip(self):
|
||||
r = _two_stage_recipe()
|
||||
r2 = Recipe.from_dict(r.to_dict())
|
||||
self.assertEqual(r, r2)
|
||||
self.assertEqual(r2.stages[0].decision_vars[0].name, "u_temp")
|
||||
|
||||
|
||||
class TestSafeEval(unittest.TestCase):
|
||||
"""受限表达式求值安全性。"""
|
||||
|
||||
def test_safe_eval_basic(self):
|
||||
from model_framework.cross_process_optimizer import _safe_eval
|
||||
self.assertAlmostEqual(_safe_eval("1 + 2 * 3", {}), 7.0)
|
||||
self.assertAlmostEqual(_safe_eval("x + y", {"x": 1, "y": 2}), 3.0)
|
||||
self.assertAlmostEqual(_safe_eval("min(x, y)", {"x": 1, "y": 2}), 1.0)
|
||||
|
||||
def test_safe_eval_rejects_empty(self):
|
||||
from model_framework.cross_process_optimizer import _safe_eval
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
_safe_eval("", {})
|
||||
|
||||
def test_safe_eval_rejects_builtins(self):
|
||||
"""禁止访问 __import__ / open / 任意内建(沙箱保护)。"""
|
||||
from model_framework.cross_process_optimizer import _safe_eval
|
||||
with self.assertRaises(Exception):
|
||||
_safe_eval("__import__('os')", {})
|
||||
with self.assertRaises(Exception):
|
||||
_safe_eval("open('x')", {})
|
||||
|
||||
|
||||
class TestSolvers(unittest.TestCase):
|
||||
"""四种求解器的可行解搜索与目标最大化。"""
|
||||
|
||||
def test_grid_solver_finds_feasible(self):
|
||||
r = _two_stage_recipe("grid")
|
||||
opt = CrossProcessOptimizer(r)
|
||||
res = opt.optimize()
|
||||
self.assertIsInstance(res, OptimizationResult)
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
self.assertGreaterEqual(res.objective_score, res.baseline_score)
|
||||
|
||||
def test_grid_solver_no_feasible_raises(self):
|
||||
# 矛盾约束:温度必须同时 <= 100 且 >= 200
|
||||
r = Recipe(
|
||||
name="infeasible",
|
||||
stages=(Stage(name="s",
|
||||
decision_vars=(DecisionVariable("x", 100, 300, step=50, default=150),)),),
|
||||
constraints=(Constraint("x", "<=", 100), Constraint("x", ">=", 200)),
|
||||
objective=Objective("x", "max"),
|
||||
solver="grid",
|
||||
)
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
|
||||
def test_random_solver_finds_feasible(self):
|
||||
r = _two_stage_recipe("random")
|
||||
res = CrossProcessOptimizer(r).optimize(seed=42)
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
self.assertEqual(res.solver, "random")
|
||||
|
||||
def test_random_solver_uses_solver_params(self):
|
||||
r = _two_stage_recipe("random")
|
||||
r = Recipe.from_dict({**r.to_dict(),
|
||||
"solver_params": {"n_samples": 50, "seed": 7}})
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
|
||||
def test_analytic_solver_single_var(self):
|
||||
# 单变量线性最大化目标:应在 high 边界取得最优
|
||||
r = Recipe(
|
||||
name="single",
|
||||
stages=(Stage(name="s",
|
||||
decision_vars=(DecisionVariable("x", 0, 10, step=1, default=2),)),),
|
||||
objective=Objective("x", "max", label="越大越好"),
|
||||
solver="analytic",
|
||||
)
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
self.assertEqual(res.objective_score, 10.0)
|
||||
# 建议把 x 从默认 2 上调到 10
|
||||
sug = res.suggestions[0]
|
||||
self.assertEqual(sug.new_value, 10.0)
|
||||
self.assertEqual(sug.direction, "上调")
|
||||
|
||||
def test_analytic_falls_back_to_grid_for_multi_var(self):
|
||||
r = _two_stage_recipe("analytic")
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
# 多变量时 analytic 退化为 grid,仍能跑通
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
|
||||
def test_analytic_no_feasible_raises(self):
|
||||
r = Recipe(
|
||||
name="bad",
|
||||
stages=(Stage(name="s",
|
||||
decision_vars=(DecisionVariable("x", 0, 10, step=1, default=5),)),),
|
||||
constraints=(Constraint("x", ">=", 100),),
|
||||
objective=Objective("x", "max"),
|
||||
solver="analytic",
|
||||
)
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
|
||||
def test_stub_solver_returns_default(self):
|
||||
r = _two_stage_recipe("stub")
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
# stub 直接取默认值,改善为 0
|
||||
self.assertEqual(res.improvement, 0.0)
|
||||
self.assertEqual(res.solver, "stub")
|
||||
|
||||
def test_unknown_solver_raises(self):
|
||||
r = Recipe.from_dict({**_two_stage_recipe().to_dict(), "solver": "grid"})
|
||||
# 临时篡改 recipe.solver 为非法值(绕过校验)测主干分支
|
||||
object.__setattr__(r, "solver", "voodoo")
|
||||
with self.assertRaises(CrossProcessOptError):
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
|
||||
|
||||
class TestAcceptanceAndSuggestions(unittest.TestCase):
|
||||
"""采纳率口径(PRD 5.3 ≥ 60%)与可解释建议。"""
|
||||
|
||||
def test_grid_improvement_marks_accepted(self):
|
||||
r = _two_stage_recipe("grid")
|
||||
# 默认值非最优,grid 应能找到更优解 → accepted
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
if res.improvement > 1e-9:
|
||||
self.assertTrue(res.accepted)
|
||||
self.assertGreaterEqual(res.acceptance, res.acceptance_floor)
|
||||
|
||||
def test_suggestion_direction(self):
|
||||
s_up = StageSuggestion("s", "x", 1.0, 3.0, 2.0)
|
||||
self.assertEqual(s_up.direction, "上调")
|
||||
s_down = StageSuggestion("s", "x", 3.0, 1.0, -2.0)
|
||||
self.assertEqual(s_down.direction, "下调")
|
||||
s_keep = StageSuggestion("s", "x", 2.0, 2.0, 0.0)
|
||||
self.assertEqual(s_keep.direction, "保持")
|
||||
|
||||
def test_result_to_dict_serializable(self):
|
||||
r = _two_stage_recipe("stub")
|
||||
res = CrossProcessOptimizer(r).optimize()
|
||||
d = res.to_dict()
|
||||
# 可 JSON 序列化
|
||||
json.dumps(d)
|
||||
self.assertIn("suggestions", d)
|
||||
self.assertIn("accepted", d)
|
||||
|
||||
|
||||
class TestSampleRecipes(unittest.TestCase):
|
||||
"""样例配方(Ti / 树脂)加载与寻优(验收口径)。"""
|
||||
|
||||
def test_sample_recipes_listed(self):
|
||||
names = list_sample_recipes()
|
||||
self.assertIn("recipe.ti.json", names)
|
||||
self.assertIn("recipe.resin.json", names)
|
||||
|
||||
def test_ti_recipe_loads_and_optimizes(self):
|
||||
opt = build_from_recipe(sample_recipe_path("recipe.ti.json"))
|
||||
res = opt.optimize()
|
||||
self.assertEqual(res.solver, "grid")
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
self.assertGreaterEqual(res.objective_score, res.baseline_score)
|
||||
# 工序建议覆盖三道工序
|
||||
stages_covered = {s.stage for s in res.suggestions}
|
||||
self.assertEqual(stages_covered, {"氯化", "精制", "还原"})
|
||||
|
||||
def test_resin_recipe_loads_and_optimizes(self):
|
||||
opt = build_from_recipe(sample_recipe_path("recipe.resin.json"))
|
||||
res = opt.optimize()
|
||||
self.assertEqual(res.solver, "random")
|
||||
self.assertGreater(res.feasible_count, 0)
|
||||
stages_covered = {s.stage for s in res.suggestions}
|
||||
self.assertEqual(stages_covered, {"反应", "水洗", "干燥"})
|
||||
|
||||
def test_two_recipes_same_engine_class(self):
|
||||
"""验收口径:同框架加载两套配方,寻优主干类零改动。"""
|
||||
opt_ti = build_from_recipe(sample_recipe_path("recipe.ti.json"))
|
||||
opt_resin = build_from_recipe(sample_recipe_path("recipe.resin.json"))
|
||||
self.assertIs(type(opt_ti), type(opt_resin))
|
||||
# 两套配方的工序拓扑确实不同
|
||||
self.assertNotEqual(opt_ti.recipe_meta["stages"],
|
||||
opt_resin.recipe_meta["stages"])
|
||||
|
||||
def test_load_recipe_from_temp_file(self):
|
||||
r = _two_stage_recipe()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False, encoding="utf-8") as fh:
|
||||
json.dump(r.to_dict(), fh, ensure_ascii=False)
|
||||
path = fh.name
|
||||
try:
|
||||
r2 = load_recipe(path)
|
||||
self.assertEqual(r, r2)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestRegisterSolver(unittest.TestCase):
|
||||
"""插件式求解器注册。"""
|
||||
|
||||
def test_register_custom_solver(self):
|
||||
called = {"n": 0}
|
||||
|
||||
def my_solver(recipe, **kw):
|
||||
called["n"] += 1
|
||||
return stub_solver(recipe, **kw)
|
||||
|
||||
register_solver("my", my_solver)
|
||||
self.assertIn("my", SOLVERS)
|
||||
# 直接构造主干并替换 recipe.solver 为已注册的自定义求解器
|
||||
r = _two_stage_recipe()
|
||||
object.__setattr__(r, "solver", "my")
|
||||
CrossProcessOptimizer(r).optimize()
|
||||
self.assertEqual(called["n"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user