292 lines
12 KiB
Python
292 lines
12 KiB
Python
# -*- 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()
|