Files
iAOP/templates/ti-cl4/recipe-optim/tests/test_problem.py
T

292 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()