Files
iAOP/templates/ti-cl4/recipe-optim/tests/test_advisor.py
T

159 lines
6.2 KiB
Python

# -*- 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()