feat: 完成 issue #78 [Ti-2] 配方优化问题建模(决策变量/目标/约束声明式规格+求解器无关+零依赖YAML加载)

This commit is contained in:
2026-08-05 03:58:25 +08:00
parent fc4ac42007
commit 2aa70d078b
7 changed files with 1276 additions and 0 deletions
@@ -0,0 +1,291 @@
# -*- 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()