159 lines
6.2 KiB
Python
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()
|