# -*- coding: utf-8 -*- """Ti-2 配方优化问题建模 单元测试(Issue #78)。 覆盖: - 决策变量域(bounds/choices/integer/越界/夹紧); - 目标函数(线性求值、min/max、target 记录); - 约束(box/linear/ratio/forbidden 可行性判定 + 未知变量静态校验); - OptimizationProblem(聚合校验、可行性、违反约束枚举、序列化往返); - load_problem YAML 子集加载(模板资产)。 """ import os import sys import unittest sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _bootstrap # noqa: E402 挂载 recipe_optim 包 from recipe_optim.problem import ( # noqa: E402 ConstraintKind, ConstraintSpec, DecisionVariable, DomainKind, ObjectiveSpec, ObjectiveTerm, OptimizationProblem, ProblemError, Sense, load_problem, ) CONFIG_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "recipe_optim.template.yaml", ) def _ti_problem() -> OptimizationProblem: """构造一份与 config 同构的内存问题(用于无需 YAML 的断言)。""" return OptimizationProblem( variables=[ DecisionVariable("clf_temp", DomainKind.BOUNDS, "温度", "℃", bounds=(800, 920), initial=860), DecisionVariable("cl2_ratio", DomainKind.BOUNDS, "配比", "ratio", bounds=(0.8, 1.4), initial=1.0), DecisionVariable("catalyst", DomainKind.CHOICES, "催化剂", "", choices=["A", "B", "C"]), ], objective=ObjectiveSpec( sense=Sense.MAXIMIZE, target="Ti_purity", target_value=99.5, terms=[ObjectiveTerm("clf_temp", 0.01), ObjectiveTerm("cl2_ratio", 2.0)], ), constraints=[ ConstraintSpec(ConstraintKind.BOX, variable="clf_temp", bounds=(820, 900), reason="温度安全区间"), ConstraintSpec(ConstraintKind.LINEAR, coefficients={"clf_temp": 1.0}, op="<=", rhs=900), ConstraintSpec(ConstraintKind.FORBIDDEN, combination={"catalyst": "C"}), ], ) class TestDecisionVariable(unittest.TestCase): def test_bounds_valid_and_contains(self): v = DecisionVariable("t", DomainKind.BOUNDS, bounds=(0.0, 10.0)) self.assertTrue(v.contains(5)) self.assertTrue(v.contains(0)) self.assertTrue(v.contains(10)) self.assertFalse(v.contains(-0.1)) self.assertFalse(v.contains(10.1)) self.assertFalse(v.contains("x")) def test_bounds_reversed_rejected(self): with self.assertRaises(ProblemError): DecisionVariable("t", DomainKind.BOUNDS, bounds=(10.0, 0.0)) def test_choices_domain(self): v = DecisionVariable("cat", DomainKind.CHOICES, choices=["A", "B"]) self.assertTrue(v.contains("A")) self.assertFalse(v.contains("Z")) def test_integer_bounds_noninteger_rejected(self): with self.assertRaises(ProblemError): DecisionVariable("n", DomainKind.BOUNDS, bounds=(1.5, 5.0), integer=True) def test_integer_contains_and_clamp(self): v = DecisionVariable("n", DomainKind.BOUNDS, bounds=(0.0, 10.0), integer=True) self.assertFalse(v.contains(1.5)) self.assertTrue(v.contains(3)) # clamp 把越界夹回 + 取整 self.assertEqual(v.clamp(12.4), 10.0) self.assertEqual(v.clamp(-3), 0.0) self.assertEqual(v.clamp(4.7), 5.0) def test_continuous_clamp(self): v = DecisionVariable("t", DomainKind.BOUNDS, bounds=(0.0, 10.0)) self.assertEqual(v.clamp(15), 10) self.assertEqual(v.clamp(-2), 0) def test_empty_name_rejected(self): with self.assertRaises(ProblemError): DecisionVariable(" ", DomainKind.BOUNDS, bounds=(0, 1)) def test_missing_domain_payload_rejected(self): with self.assertRaises(ProblemError): DecisionVariable("t", DomainKind.BOUNDS) with self.assertRaises(ProblemError): DecisionVariable("t", DomainKind.CHOICES, choices=[]) def test_roundtrip(self): v = DecisionVariable("t", DomainKind.BOUNDS, "温度", "℃", bounds=(1.0, 2.0), initial=1.5) v2 = DecisionVariable.from_dict(v.to_dict()) self.assertEqual(v2.bounds, v.bounds) self.assertEqual(v2.initial, v.initial) class TestObjective(unittest.TestCase): def test_evaluate_maximize(self): obj = ObjectiveSpec(Sense.MAXIMIZE, terms=[ObjectiveTerm("a", 2.0), ObjectiveTerm("b", -1.0)]) self.assertEqual(obj.evaluate({"a": 3, "b": 1}), 5.0) # 未知变量按 0 self.assertEqual(obj.evaluate({"a": 3}), 6.0) def test_minimize_label(self): self.assertEqual(Sense.MINIMIZE.label, "最小化") self.assertEqual(Sense.MAXIMIZE.label, "最大化") def test_unknown_sense_rejected(self): with self.assertRaises(ProblemError): ObjectiveSpec.from_dict({"sense": "extreme"}) def test_roundtrip_with_target(self): obj = ObjectiveSpec(Sense.MAXIMIZE, target="Ti_purity", target_value=99.5, terms=[ObjectiveTerm("a", 0.5)]) obj2 = ObjectiveSpec.from_dict(obj.to_dict()) self.assertEqual(obj2.target, "Ti_purity") self.assertEqual(obj2.target_value, 99.5) self.assertEqual(obj2.terms[0].coefficient, 0.5) class TestConstraint(unittest.TestCase): def test_box_satisfied(self): c = ConstraintSpec(ConstraintKind.BOX, variable="t", bounds=(0, 10)) self.assertTrue(c.satisfied_by({"t": 5})) self.assertFalse(c.satisfied_by({"t": 11})) # 未知取值不判 self.assertTrue(c.satisfied_by({})) def test_linear_ops(self): c = ConstraintSpec(ConstraintKind.LINEAR, coefficients={"a": 1, "b": 1}, op="<=", rhs=10) self.assertTrue(c.satisfied_by({"a": 4, "b": 6})) self.assertFalse(c.satisfied_by({"a": 6, "b": 6})) # 部分未知视为未约束 self.assertTrue(c.satisfied_by({"a": 4})) def test_ratio_constraint(self): c = ConstraintSpec(ConstraintKind.RATIO, numerator="x", denominator="y", op=">=", value=0.5) self.assertTrue(c.satisfied_by({"x": 1, "y": 2})) self.assertFalse(c.satisfied_by({"x": 1, "y": 4})) # 分母为 0 视为未约束 self.assertTrue(c.satisfied_by({"x": 1, "y": 0})) def test_forbidden_combination(self): c = ConstraintSpec(ConstraintKind.FORBIDDEN, combination={"cat": "C"}) self.assertFalse(c.satisfied_by({"cat": "C"})) self.assertTrue(c.satisfied_by({"cat": "A"})) # 多键需全部命中才算触发 c2 = ConstraintSpec(ConstraintKind.FORBIDDEN, combination={"cat": "C", "t": 900}) self.assertTrue(c2.satisfied_by({"cat": "C", "t": 100})) self.assertFalse(c2.satisfied_by({"cat": "C", "t": 900})) def test_linear_bad_op_rejected(self): with self.assertRaises(ProblemError): ConstraintSpec(ConstraintKind.LINEAR, coefficients={"a": 1}, op="~=") with self.assertRaises(ProblemError): ConstraintSpec(ConstraintKind.LINEAR, op="<=") def test_errors_against_unknown_var(self): c = ConstraintSpec(ConstraintKind.BOX, variable="missing", bounds=(0, 1)) self.assertTrue(c.errors_against({"t": object()})) def test_roundtrip(self): c = ConstraintSpec(ConstraintKind.LINEAR, reason="x", coefficients={"a": 1.0}, op=">=", rhs=5) c2 = ConstraintSpec.from_dict(c.to_dict()) self.assertEqual(c2.op, ">=") self.assertEqual(c2.rhs, 5) class TestOptimizationProblem(unittest.TestCase): def test_validate_ok(self): self.assertEqual(_ti_problem().validate(), []) def test_validate_duplicate_variable(self): p = _ti_problem() p.variables.append(DecisionVariable("clf_temp", DomainKind.BOUNDS, bounds=(0, 1))) errs = p.validate() self.assertTrue(any("重复定义" in e for e in errs)) def test_validate_unknown_var_in_objective(self): p = _ti_problem() p.objective.terms.append(ObjectiveTerm("nope")) errs = p.validate() self.assertTrue(any("nope" in e for e in errs)) def test_validate_unknown_var_in_constraint(self): p = _ti_problem() p.constraints.append(ConstraintSpec(ConstraintKind.BOX, variable="ghost", bounds=(0, 1))) errs = p.validate() self.assertTrue(any("ghost" in e for e in errs)) def test_is_feasible_and_violated(self): p = _ti_problem() # 合法取值 self.assertTrue(p.is_feasible({"clf_temp": 850, "cl2_ratio": 1.0, "catalyst": "A"})) # 越出 box 收紧(820~900) self.assertFalse(p.is_feasible({"clf_temp": 910, "cl2_ratio": 1.0, "catalyst": "A"})) # forbidden 组合 self.assertFalse(p.is_feasible({"clf_temp": 850, "cl2_ratio": 1.0, "catalyst": "C"})) viol = p.violated_constraints({"clf_temp": 950, "cl2_ratio": 1.0, "catalyst": "A"}) self.assertGreater(len(viol), 0) def test_roundtrip(self): p = _ti_problem() p2 = OptimizationProblem.from_dict(p.to_dict()) self.assertEqual([v.name for v in p2.variables], [v.name for v in p.variables]) self.assertEqual(p2.objective.sense, p.objective.sense) self.assertEqual(len(p2.constraints), len(p.constraints)) class TestLoadProblemYaml(unittest.TestCase): def test_load_template(self): self.assertTrue(os.path.exists(CONFIG_PATH), f"缺少模板 {CONFIG_PATH}") p = load_problem(CONFIG_PATH) names = [v.name for v in p.variables] self.assertEqual(names, ["clf_temp", "cl2_ratio", "feed_rate", "catalyst"]) # 校验通过 self.assertEqual(p.validate(), []) # 目标与约束就位 self.assertEqual(p.objective.target, "Ti_purity") self.assertEqual(p.objective.target_value, 99.5) kinds = {c.kind for c in p.constraints} self.assertEqual(kinds, {ConstraintKind.BOX, ConstraintKind.LINEAR, ConstraintKind.RATIO, ConstraintKind.FORBIDDEN}) # 合法初值可行 self.assertTrue(p.is_feasible({"clf_temp": 850, "cl2_ratio": 1.0, "feed_rate": 450, "catalyst": "A"})) def test_load_choices_parsed(self): p = load_problem(CONFIG_PATH) cat = p.variable_map["catalyst"] self.assertEqual(cat.kind, DomainKind.CHOICES) self.assertEqual(cat.choices, ["A", "B", "C"]) def test_integer_flag_parsed(self): # feed_rate integer: false p = load_problem(CONFIG_PATH) self.assertFalse(p.variable_map["feed_rate"].integer) class TestScalarParser(unittest.TestCase): """直接覆盖 _parse_scalar 的边界(内联列表/映射/数字/字符串/布尔)。""" def test_scalars(self): from recipe_optim.problem import _parse_scalar self.assertEqual(_parse_scalar("1"), 1) self.assertEqual(_parse_scalar("1.5"), 1.5) self.assertIs(_parse_scalar("true"), True) self.assertIs(_parse_scalar("False"), False) self.assertEqual(_parse_scalar("99.5"), 99.5) self.assertEqual(_parse_scalar('"A"'), "A") self.assertEqual(_parse_scalar("[1, 2, 3]"), [1, 2, 3]) self.assertEqual(_parse_scalar("[A, B]"), ["A", "B"]) self.assertEqual(_parse_scalar("{a: 1, b: 2}"), {"a": 1, "b": 2}) if __name__ == "__main__": unittest.main()