feat: 完成 issue #81 [Ti-2] 优化建议生成与可解释性(整合求解结果+跨工序权重+约束依据,输出可溯源建议报告)
This commit is contained in:
@@ -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