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