feat: 完成 issue #81 [Ti-2] 优化建议生成与可解释性(整合求解结果+跨工序权重+约束依据,输出可溯源建议报告)
This commit is contained in:
@@ -14,9 +14,11 @@
|
|||||||
坐标下降轻量求解器 + `solve()` 统一入口,求解器无关契约。
|
坐标下降轻量求解器 + `solve()` 统一入口,求解器无关契约。
|
||||||
- `cross_process.py` — 跨工序关联寻优(**#80**):纯标准库岭回归 +
|
- `cross_process.py` — 跨工序关联寻优(**#80**):纯标准库岭回归 +
|
||||||
`CrossProcessModel`(上游指标→下游质量,fit/predict/evaluate R²/可解释权重/序列化)。
|
`CrossProcessModel`(上游指标→下游质量,fit/predict/evaluate R²/可解释权重/序列化)。
|
||||||
|
- `advisor.py` — 优化建议生成与可解释性(**#81**):整合 #78/#79/#80 输出
|
||||||
|
可溯源建议报告(变量级/跨工序佐证/风险提示/溯源链路)。
|
||||||
- `config/recipe_optim.template.yaml` — Template-Ti 配方优化模板资产。
|
- `config/recipe_optim.template.yaml` — Template-Ti 配方优化模板资产。
|
||||||
- `tests/` — 单元测试(`python -m unittest discover -s tests`,65 用例)。
|
- `tests/` — 单元测试(`python -m unittest discover -s tests`,76 用例)。
|
||||||
- `_sanity_check.py` — 部署期一键自检(7 能力点)。
|
- `_sanity_check.py` — 部署期一键自检(8 能力点)。
|
||||||
|
|
||||||
## 设计
|
## 设计
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
from problem import ConstraintKind, OptimizationProblem, load_problem # noqa: E402
|
from problem import ConstraintKind, OptimizationProblem, load_problem # noqa: E402
|
||||||
from solver import SolverConfig, solve # noqa: E402
|
from solver import SolverConfig, solve # noqa: E402
|
||||||
from cross_process import CrossProcessModel, CrossProcessModelConfig, CrossProcessSample # noqa: E402
|
from cross_process import CrossProcessModel, CrossProcessModelConfig, CrossProcessSample # noqa: E402
|
||||||
|
from advisor import generate_advice # noqa: E402
|
||||||
|
|
||||||
CONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
CONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
"config", "recipe_optim.template.yaml")
|
"config", "recipe_optim.template.yaml")
|
||||||
@@ -71,12 +72,24 @@ def main() -> int:
|
|||||||
if not (report.get("r2_down", 0.0) > 0.99):
|
if not (report.get("r2_down", 0.0) > 0.99):
|
||||||
failures.append(f"跨工序模型 R² 过低: {report}")
|
failures.append(f"跨工序模型 R² 过低: {report}")
|
||||||
|
|
||||||
|
# 8) 优化建议生成(#81)端到端:可解释、可溯源建议
|
||||||
|
advice = generate_advice(p, sol, cross_process_weights={
|
||||||
|
"Ti_purity": {"clf_temp": 0.8, "cl2_ratio": 1.2}})
|
||||||
|
if not advice.feasible:
|
||||||
|
failures.append("建议生成器标记不可行")
|
||||||
|
if len(advice.items) != len(p.variables):
|
||||||
|
failures.append("建议条目数与变量数不一致")
|
||||||
|
if not all(it.evidence for it in advice.items):
|
||||||
|
failures.append("存在无依据的建议条目(违反可溯源要求)")
|
||||||
|
if not advice.trace:
|
||||||
|
failures.append("溯源链路为空")
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
print("❌ recipe-optim 自检失败:")
|
print("❌ recipe-optim 自检失败:")
|
||||||
for f in failures:
|
for f in failures:
|
||||||
print(" -", f)
|
print(" -", f)
|
||||||
return 1
|
return 1
|
||||||
print("✅ recipe-optim 自检通过(7 能力点)")
|
print("✅ recipe-optim 自检通过(8 能力点)")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Ti-2 配方优化 · 优化建议生成与可解释性(Issue #81 / PRD 5.3 ② + 5.4)。
|
||||||
|
|
||||||
|
整合 #78(问题建模)/ #79(求解器)/ #80(跨工序关联),把"求解结果"翻译成
|
||||||
|
**工艺工程师可读、可溯源**的优化建议(PRD:李工"要求结果可解释、可溯源,要引用
|
||||||
|
依据")。
|
||||||
|
|
||||||
|
PRD 设计口径
|
||||||
|
------------
|
||||||
|
- 场景B(优化):「下一批次质量目标下达 → 工艺优化模型给出参数建议 → 李工 review
|
||||||
|
→ 下发 DCS → 实际质量反馈回流训练」(PRD §2.2)。
|
||||||
|
- 架构表:``出:参数/配方建议``;用户画像:"要求结果可解释、可溯源(要引用依据)"。
|
||||||
|
- 风险表:二期。故本期交付**确定性、可测试**的建议生成器,把上游链路结构化输出
|
||||||
|
汇编成建议条目;数据/LLM 就绪后可再叠加自然语言润色(注入 llm-gateway)。
|
||||||
|
|
||||||
|
本模块交付
|
||||||
|
----------
|
||||||
|
1. **``AdviceItem``**:单条建议(变量、当前值、建议值、变化方向/幅度、依据来源
|
||||||
|
`source`、工艺含义 `meaning`、可溯源引用 `evidence`)。
|
||||||
|
2. **``AdviceReport``**:建议报告(条目列表 + 摘要 + 是否达标 + 风险提示 + 溯源
|
||||||
|
链路),可序列化。
|
||||||
|
3. **``AdviceConfig``**:建议生成配置(变化阈值、是否提示风险、溯源前缀)。
|
||||||
|
4. **``generate_advice``**:核心生成函数——输入 #79 的 ``Solution`` + #78 的
|
||||||
|
``OptimizationProblem`` +(可选)#80 的 ``CrossProcessModel`` 特征权重 + 当前
|
||||||
|
配方,产出 ``AdviceReport``,每条建议带:
|
||||||
|
- **变量级**:建议调整 X 从 a→b(变化幅度/方向),引用变量 meaning;
|
||||||
|
- **依据级**:若被求解过程约束收紧/禁止组合影响,引用约束 reason;
|
||||||
|
- **跨工序级**(可选):引用 #80 上游→下游影响权重作为佐证。
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
- **零第三方依赖**(纯标准库);可注入 LLM 做润色但非必需(保证可用性)。
|
||||||
|
- **可溯源**:每条建议标注 ``source``(problem/solver/cross_process)与 ``evidence``
|
||||||
|
(具体约束/权重值),对齐 PRD"引用依据"。
|
||||||
|
- **风险前置**:违反约束或未达标时在报告 ``warnings`` 列出,需人工 review(PRD
|
||||||
|
场景B 的"李工 review"环节)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# 复用上游契约
|
||||||
|
try: # pragma: no cover
|
||||||
|
from recipe_optim.problem import ( # type: ignore[import-not-found]
|
||||||
|
ConstraintSpec,
|
||||||
|
DecisionVariable,
|
||||||
|
OptimizationProblem,
|
||||||
|
Sense,
|
||||||
|
_is_num,
|
||||||
|
)
|
||||||
|
from recipe_optim.solver import Solution # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
from problem import ( # type: ignore[import-not-found]
|
||||||
|
ConstraintSpec,
|
||||||
|
DecisionVariable,
|
||||||
|
OptimizationProblem,
|
||||||
|
Sense,
|
||||||
|
_is_num,
|
||||||
|
)
|
||||||
|
from solver import Solution # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
|
||||||
|
class AdvisorError(ValueError):
|
||||||
|
"""建议生成错误。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdviceItem:
|
||||||
|
"""单条优化建议(可解释、可溯源)。"""
|
||||||
|
|
||||||
|
variable: str
|
||||||
|
current_value: Any
|
||||||
|
suggested_value: Any
|
||||||
|
direction: str # "↑" / "↓" / "→"(不变)
|
||||||
|
delta: float = 0.0 # 建议值 - 当前值(数值变量)
|
||||||
|
meaning: str = "" # 工艺含义(来自 DecisionVariable.meaning)
|
||||||
|
unit: str = ""
|
||||||
|
source: str = "solver" # solver / cross_process / problem
|
||||||
|
evidence: str = "" # 可溯源依据(约束 reason / 权重值)
|
||||||
|
reason_text: str = "" # 人话依据
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"variable": self.variable,
|
||||||
|
"current_value": self.current_value,
|
||||||
|
"suggested_value": self.suggested_value,
|
||||||
|
"direction": self.direction,
|
||||||
|
"delta": self.delta,
|
||||||
|
"meaning": self.meaning,
|
||||||
|
"unit": self.unit,
|
||||||
|
"source": self.source,
|
||||||
|
"evidence": self.evidence,
|
||||||
|
"reason_text": self.reason_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdviceReport:
|
||||||
|
"""优化建议报告(多条建议 + 摘要 + 风险提示)。"""
|
||||||
|
|
||||||
|
items: List[AdviceItem] = field(default_factory=list)
|
||||||
|
summary: str = ""
|
||||||
|
target_met: bool = False
|
||||||
|
objective_value: float = 0.0
|
||||||
|
feasible: bool = False
|
||||||
|
warnings: List[str] = field(default_factory=list)
|
||||||
|
trace: List[str] = field(default_factory=list) # 溯源链路(PRD"引用依据")
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"items": [i.to_dict() for i in self.items],
|
||||||
|
"summary": self.summary,
|
||||||
|
"target_met": self.target_met,
|
||||||
|
"objective_value": self.objective_value,
|
||||||
|
"feasible": self.feasible,
|
||||||
|
"warnings": list(self.warnings),
|
||||||
|
"trace": list(self.trace),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdviceConfig:
|
||||||
|
"""建议生成配置。"""
|
||||||
|
|
||||||
|
change_threshold: float = 1e-6 # 变化幅度低于此值视为"不变"
|
||||||
|
show_warnings: bool = True
|
||||||
|
cross_process_prefix: str = "跨工序关联"
|
||||||
|
|
||||||
|
|
||||||
|
def _direction_and_delta(cur: Any, sug: Any) -> tuple:
|
||||||
|
"""计算变化方向与幅度(数值变量)。"""
|
||||||
|
if _is_num(cur) and _is_num(sug):
|
||||||
|
delta = float(sug) - float(cur)
|
||||||
|
if delta > 1e-12:
|
||||||
|
return "↑", delta
|
||||||
|
if delta < -1e-12:
|
||||||
|
return "↓", delta
|
||||||
|
return "→", 0.0
|
||||||
|
return "→" if cur == sug else "≠", 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def generate_advice(
|
||||||
|
problem: OptimizationProblem,
|
||||||
|
solution: Solution,
|
||||||
|
current: Optional[Dict[str, Any]] = None,
|
||||||
|
cross_process_weights: Optional[Dict[str, Dict[str, float]]] = None,
|
||||||
|
config: Optional[AdviceConfig] = None,
|
||||||
|
) -> AdviceReport:
|
||||||
|
"""根据求解结果生成可解释、可溯源的优化建议。
|
||||||
|
|
||||||
|
参数
|
||||||
|
----
|
||||||
|
problem : #78 的优化问题(取变量 meaning/unit + 约束 reason 作依据)。
|
||||||
|
solution : #79 的求解结果(取建议取值 + 可行性 + 违反约束)。
|
||||||
|
current : 当前配方/工况取值(缺省取各变量 ``initial``);用于计算"从 a→b"。
|
||||||
|
cross_process_weights : #80 的 ``feature_weights``(目标→{特征:权重}),
|
||||||
|
作为跨工序佐证(可选)。
|
||||||
|
config : 建议生成配置。
|
||||||
|
"""
|
||||||
|
cfg = config or AdviceConfig()
|
||||||
|
cur = dict(current or {})
|
||||||
|
report = AdviceReport(
|
||||||
|
objective_value=solution.objective_value,
|
||||||
|
feasible=solution.feasible,
|
||||||
|
target_met=solution.target_met,
|
||||||
|
)
|
||||||
|
report.trace.append("建议生成依据链:#78 问题建模 → #79 求解 → #80 跨工序关联(可选)")
|
||||||
|
|
||||||
|
if not solution.feasible:
|
||||||
|
report.warnings.append(
|
||||||
|
"求解器未找到可行解,下列建议仅供参考,需人工复核(PRD 场景B「李工 review」)")
|
||||||
|
report.summary = solution.message or "无可行解"
|
||||||
|
# 仍输出违反约束作为风险依据
|
||||||
|
for c in solution.violated:
|
||||||
|
if c.reason:
|
||||||
|
report.warnings.append(f"违反约束:{c.reason}")
|
||||||
|
return report
|
||||||
|
|
||||||
|
vmap = problem.variable_map
|
||||||
|
# 1) 变量级建议
|
||||||
|
for var in problem.variables:
|
||||||
|
sug = solution.assignment.get(var.name)
|
||||||
|
base = cur.get(var.name, var.initial)
|
||||||
|
if sug is None:
|
||||||
|
continue
|
||||||
|
direction, delta = _direction_and_delta(base, sug)
|
||||||
|
if abs(delta) < cfg.change_threshold and direction == "→":
|
||||||
|
# 无变化也输出一条"保持",便于完整呈现配方
|
||||||
|
item = AdviceItem(
|
||||||
|
variable=var.name, current_value=base, suggested_value=sug,
|
||||||
|
direction="→", delta=0.0, meaning=var.meaning, unit=var.unit,
|
||||||
|
source="solver", evidence="求解器最优解保持当前值",
|
||||||
|
reason_text=f"保持 {var.name}({var.meaning})不变:最优解与当前一致")
|
||||||
|
else:
|
||||||
|
item = AdviceItem(
|
||||||
|
variable=var.name, current_value=base, suggested_value=sug,
|
||||||
|
direction=direction, delta=delta, meaning=var.meaning, unit=var.unit,
|
||||||
|
source="solver", evidence=f"目标 {problem.objective.sense.value} 下最优",
|
||||||
|
reason_text=_var_reason(var, direction, delta, problem.objective.sense))
|
||||||
|
report.items.append(item)
|
||||||
|
|
||||||
|
# 2) 跨工序佐证(可选):把 #80 权重作为依据附加到相关变量
|
||||||
|
if cross_process_weights:
|
||||||
|
for target, weights in cross_process_weights.items():
|
||||||
|
for var in problem.variables:
|
||||||
|
w = weights.get(var.name)
|
||||||
|
if _is_num(w) and abs(w) > 1e-9:
|
||||||
|
# 找到该变量的已有建议,追加跨工序证据
|
||||||
|
for item in report.items:
|
||||||
|
if item.variable == var.name:
|
||||||
|
sign = "正向" if w > 0 else "负向"
|
||||||
|
extra = (f"{cfg.cross_process_prefix}:{var.name} 对下游 "
|
||||||
|
f"{target} 影响 {sign}(权重 {w:.4g})")
|
||||||
|
item.evidence = (item.evidence + ";" + extra) if item.evidence else extra
|
||||||
|
item.reason_text = item.reason_text + "。" + extra
|
||||||
|
report.trace.append(extra)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3) 风险与达标提示
|
||||||
|
if cfg.show_warnings:
|
||||||
|
for var in problem.variables:
|
||||||
|
sug = solution.assignment.get(var.name)
|
||||||
|
if sug is not None and not var.contains(sug):
|
||||||
|
report.warnings.append(
|
||||||
|
f"{var.name}({var.meaning})建议值 {sug} 越出合法域,需人工复核")
|
||||||
|
for c in problem.constraints:
|
||||||
|
if c.reason and not c.satisfied_by(solution.assignment):
|
||||||
|
report.warnings.append(f"约束风险:{c.reason}")
|
||||||
|
|
||||||
|
# 4) 摘要
|
||||||
|
n_change = sum(1 for it in report.items if it.direction in ("↑", "↓", "≠"))
|
||||||
|
if problem.objective.target_value is not None:
|
||||||
|
report.summary = (
|
||||||
|
f"目标 {problem.objective.target} {'已达成' if solution.target_met else '未达成'}"
|
||||||
|
f"(目标值 {problem.objective.target_value},预测 {solution.objective_value:.4g});"
|
||||||
|
f"共 {len(report.items)} 项参数,其中 {n_change} 项建议调整")
|
||||||
|
else:
|
||||||
|
report.summary = (
|
||||||
|
f"预测目标值 {solution.objective_value:.4g}({problem.objective.sense.value});"
|
||||||
|
f"共 {len(report.items)} 项参数,其中 {n_change} 项建议调整")
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _var_reason(var: DecisionVariable, direction: str, delta: float,
|
||||||
|
sense: Sense) -> str:
|
||||||
|
"""构造变量级人话依据。"""
|
||||||
|
arrow = {"↑": "提高", "↓": "降低", "≠": "调整为"}[direction] if direction in ("↑", "↓", "≠") else "调整"
|
||||||
|
verb = "有利于" if (sense == Sense.MAXIMIZE) == (delta > 0) else "换取"
|
||||||
|
target_word = "最大化" if sense == Sense.MAXIMIZE else "最小化"
|
||||||
|
return (f"{arrow} {var.name}({var.meaning}){abs(delta):.4g}{var.unit}:"
|
||||||
|
f"{verb}{target_word}目标")
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Ti-2 优化建议生成与可解释性 单元测试(Issue #81)。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- 单条建议方向/幅度计算;
|
||||||
|
- generate_advice:变量级建议、跨工序佐证、风险与达标提示、不可行降级、摘要;
|
||||||
|
- 序列化;
|
||||||
|
- 端到端(#78→#79→#81 链路 + 跨工序权重注入)。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import _bootstrap # noqa: E402
|
||||||
|
|
||||||
|
from recipe_optim.problem import ( # noqa: E402
|
||||||
|
ConstraintKind,
|
||||||
|
ConstraintSpec,
|
||||||
|
DecisionVariable,
|
||||||
|
DomainKind,
|
||||||
|
ObjectiveSpec,
|
||||||
|
ObjectiveTerm,
|
||||||
|
OptimizationProblem,
|
||||||
|
Sense,
|
||||||
|
load_problem,
|
||||||
|
)
|
||||||
|
from recipe_optim.solver import Solution, SolverConfig, solve # noqa: E402
|
||||||
|
from recipe_optim.advisor import ( # noqa: E402
|
||||||
|
AdviceConfig,
|
||||||
|
AdviceItem,
|
||||||
|
AdviceReport,
|
||||||
|
AdvisorError,
|
||||||
|
generate_advice,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _problem() -> OptimizationProblem:
|
||||||
|
return OptimizationProblem(
|
||||||
|
variables=[
|
||||||
|
DecisionVariable("clf_temp", DomainKind.BOUNDS, "反应温度", "℃",
|
||||||
|
bounds=(800.0, 920.0), initial=860.0),
|
||||||
|
DecisionVariable("cl2_ratio", DomainKind.BOUNDS, "氯气配比", "ratio",
|
||||||
|
bounds=(0.8, 1.4), initial=1.0),
|
||||||
|
],
|
||||||
|
objective=ObjectiveSpec(Sense.MAXIMIZE, target="Ti_purity", target_value=10.0,
|
||||||
|
terms=[ObjectiveTerm("clf_temp", 0.01),
|
||||||
|
ObjectiveTerm("cl2_ratio", 2.0)]),
|
||||||
|
constraints=[ConstraintSpec(ConstraintKind.BOX, variable="clf_temp",
|
||||||
|
bounds=(820.0, 900.0), reason="温度安全区间")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectionDelta(unittest.TestCase):
|
||||||
|
def test_up(self):
|
||||||
|
from recipe_optim.advisor import _direction_and_delta
|
||||||
|
self.assertEqual(_direction_and_delta(1.0, 1.5), ("↑", 0.5))
|
||||||
|
|
||||||
|
def test_down(self):
|
||||||
|
from recipe_optim.advisor import _direction_and_delta
|
||||||
|
self.assertEqual(_direction_and_delta(2.0, 1.0), ("↓", -1.0))
|
||||||
|
|
||||||
|
def test_equal(self):
|
||||||
|
from recipe_optim.advisor import _direction_and_delta
|
||||||
|
self.assertEqual(_direction_and_delta(1.0, 1.0), ("→", 0.0))
|
||||||
|
|
||||||
|
def test_non_numeric(self):
|
||||||
|
from recipe_optim.advisor import _direction_and_delta
|
||||||
|
d, delta = _direction_and_delta("A", "B")
|
||||||
|
self.assertEqual(d, "≠")
|
||||||
|
self.assertEqual(delta, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateAdvice(unittest.TestCase):
|
||||||
|
def test_variable_level_advice(self):
|
||||||
|
p = _problem()
|
||||||
|
sol = solve(p, SolverConfig(grid_steps=11))
|
||||||
|
report = generate_advice(p, sol, current={"clf_temp": 860.0, "cl2_ratio": 1.0})
|
||||||
|
self.assertTrue(report.feasible)
|
||||||
|
self.assertEqual(len(report.items), 2)
|
||||||
|
# 应当有变化项(求解器会爬到温度/配比上界附近)
|
||||||
|
changes = [it for it in report.items if it.direction in ("↑", "↓")]
|
||||||
|
self.assertGreater(len(changes), 0)
|
||||||
|
# 含工艺含义
|
||||||
|
meanings = {it.meaning for it in report.items}
|
||||||
|
self.assertIn("反应温度", meanings)
|
||||||
|
|
||||||
|
def test_target_met_summary(self):
|
||||||
|
p = _problem()
|
||||||
|
sol = solve(p, SolverConfig(grid_steps=11))
|
||||||
|
report = generate_advice(p, sol)
|
||||||
|
self.assertIn("Ti_purity", report.summary)
|
||||||
|
|
||||||
|
def test_cross_process_evidence_appended(self):
|
||||||
|
p = _problem()
|
||||||
|
sol = solve(p, SolverConfig(grid_steps=11))
|
||||||
|
weights = {"sponge_titanium_grade": {"clf_temp": 0.5, "cl2_ratio": -0.3}}
|
||||||
|
report = generate_advice(p, sol, cross_process_weights=weights)
|
||||||
|
joined = " ".join(it.evidence for it in report.items)
|
||||||
|
self.assertIn("跨工序关联", joined)
|
||||||
|
self.assertTrue(any("sponge_titanium_grade" in t for t in report.trace))
|
||||||
|
|
||||||
|
def test_warnings_on_infeasible(self):
|
||||||
|
p = _problem()
|
||||||
|
# 构造一个不可行 Solution
|
||||||
|
sol = Solution(feasible=False, target_met=False,
|
||||||
|
violated=[ConstraintSpec(ConstraintKind.BOX, variable="clf_temp",
|
||||||
|
bounds=(820.0, 900.0), reason="温度安全区间")],
|
||||||
|
message="无可行解(约束过紧)")
|
||||||
|
report = generate_advice(p, sol)
|
||||||
|
self.assertFalse(report.feasible)
|
||||||
|
self.assertTrue(any("可行" in w for w in report.warnings))
|
||||||
|
self.assertIn("温度安全区间", " ".join(report.warnings))
|
||||||
|
|
||||||
|
def test_keep_unchanged_item(self):
|
||||||
|
p = OptimizationProblem(
|
||||||
|
variables=[DecisionVariable("x", DomainKind.BOUNDS, "X", "",
|
||||||
|
bounds=(0.0, 10.0), initial=5.0)],
|
||||||
|
objective=ObjectiveSpec(Sense.MAXIMIZE, terms=[ObjectiveTerm("x", 0.0)]),
|
||||||
|
constraints=[ConstraintSpec(ConstraintKind.BOX, variable="x", bounds=(5.0, 5.0))],
|
||||||
|
)
|
||||||
|
sol = solve(p, SolverConfig(grid_steps=3))
|
||||||
|
report = generate_advice(p, sol, current={"x": 5.0})
|
||||||
|
self.assertEqual(len(report.items), 1)
|
||||||
|
self.assertEqual(report.items[0].direction, "→")
|
||||||
|
|
||||||
|
def test_serialization(self):
|
||||||
|
p = _problem()
|
||||||
|
sol = solve(p, SolverConfig(grid_steps=5))
|
||||||
|
report = generate_advice(p, sol)
|
||||||
|
d = report.to_dict()
|
||||||
|
self.assertIn("items", d)
|
||||||
|
self.assertIn("summary", d)
|
||||||
|
self.assertTrue(d["feasible"])
|
||||||
|
# item dict 完整
|
||||||
|
if d["items"]:
|
||||||
|
self.assertIn("reason_text", d["items"][0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndFromTemplate(unittest.TestCase):
|
||||||
|
def test_template_chain(self):
|
||||||
|
cfg_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"config", "recipe_optim.template.yaml")
|
||||||
|
p = load_problem(cfg_path)
|
||||||
|
sol = solve(p, SolverConfig(grid_steps=7, max_combinations=200000))
|
||||||
|
report = generate_advice(p, sol, cross_process_weights={
|
||||||
|
"Ti_purity": {"clf_temp": 0.8, "cl2_ratio": 1.2, "feed_rate": 0.1}})
|
||||||
|
self.assertTrue(report.feasible)
|
||||||
|
self.assertEqual(len(report.items), len(p.variables))
|
||||||
|
# 每条建议都有依据
|
||||||
|
for it in report.items:
|
||||||
|
self.assertTrue(it.evidence)
|
||||||
|
# 溯源链路非空
|
||||||
|
self.assertGreater(len(report.trace), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user