208 lines
7.8 KiB
Python
208 lines
7.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Ti-2 配方优化求解器集成 单元测试(Issue #79)。
|
||
|
||
覆盖:
|
||
- 求解器配置(grid_steps/max_combinations 合法性);
|
||
- 候选取值生成(bounds 等分/integer 去重/choices 枚举);
|
||
- 网格求解(全局最优、可行性、target 达成、无可行解降级);
|
||
- 坐标下降(规模超限降级、收敛);
|
||
- solve 统一入口(自动选策略、静态校验失败、空问题);
|
||
- Solution 序列化;
|
||
- 加载 #78 模板后端到端求解。
|
||
"""
|
||
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,
|
||
Sense,
|
||
load_problem,
|
||
)
|
||
from recipe_optim.solver import ( # noqa: E402
|
||
SolverConfig,
|
||
SolverError,
|
||
Solution,
|
||
candidate_values,
|
||
solve,
|
||
)
|
||
|
||
CONFIG_PATH = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"config", "recipe_optim.template.yaml",
|
||
)
|
||
|
||
|
||
def _toy_problem() -> OptimizationProblem:
|
||
"""minimize -(x+y),x∈[0,4] 5 点,y∈[0,4] 5 点 → 网格 25 组合。"""
|
||
return OptimizationProblem(
|
||
variables=[
|
||
DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 4.0)),
|
||
DecisionVariable("y", DomainKind.BOUNDS, bounds=(0.0, 4.0)),
|
||
],
|
||
objective=ObjectiveSpec(Sense.MAXIMIZE,
|
||
terms=[ObjectiveTerm("x", 1.0), ObjectiveTerm("y", 1.0)]),
|
||
)
|
||
|
||
|
||
class TestSolverConfig(unittest.TestCase):
|
||
def test_defaults(self):
|
||
c = SolverConfig()
|
||
self.assertGreaterEqual(c.grid_steps, 2)
|
||
self.assertGreaterEqual(c.max_combinations, 1)
|
||
|
||
def test_invalid_grid_steps(self):
|
||
with self.assertRaises(SolverError):
|
||
SolverConfig(grid_steps=1)
|
||
|
||
def test_invalid_max_combinations(self):
|
||
with self.assertRaises(SolverError):
|
||
SolverConfig(max_combinations=0)
|
||
|
||
|
||
class TestCandidateValues(unittest.TestCase):
|
||
def test_bounds_grid(self):
|
||
v = DecisionVariable("t", DomainKind.BOUNDS, bounds=(0.0, 4.0))
|
||
vals = candidate_values(v, SolverConfig(grid_steps=5))
|
||
self.assertEqual(vals[0], 0.0)
|
||
self.assertEqual(vals[-1], 4.0)
|
||
self.assertEqual(len(vals), 5)
|
||
|
||
def test_integer_dedupe(self):
|
||
v = DecisionVariable("n", DomainKind.BOUNDS, bounds=(0.0, 4.0), integer=True)
|
||
vals = candidate_values(v, SolverConfig(grid_steps=5))
|
||
self.assertEqual(vals, [0, 1, 2, 3, 4])
|
||
|
||
def test_choices_enum(self):
|
||
v = DecisionVariable("c", DomainKind.CHOICES, choices=["A", "B", "C"])
|
||
self.assertEqual(candidate_values(v, SolverConfig()), ["A", "B", "C"])
|
||
self.assertEqual(candidate_values(v, SolverConfig(enumerate_choices=False)), ["A"])
|
||
|
||
|
||
class TestSolveGrid(unittest.TestCase):
|
||
def test_global_optimum_maximize(self):
|
||
p = _toy_problem()
|
||
sol = solve(p, SolverConfig(grid_steps=5))
|
||
self.assertTrue(sol.feasible)
|
||
# 最优 x=y=4 → obj=8
|
||
self.assertAlmostEqual(sol.objective_value, 8.0)
|
||
self.assertEqual(sol.assignment["x"], 4.0)
|
||
self.assertEqual(sol.assignment["y"], 4.0)
|
||
self.assertEqual(sol.strategy, "grid")
|
||
|
||
def test_minimize(self):
|
||
p = OptimizationProblem(
|
||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 4.0))],
|
||
objective=ObjectiveSpec(Sense.MINIMIZE, terms=[ObjectiveTerm("x", 1.0)]),
|
||
)
|
||
sol = solve(p, SolverConfig(grid_steps=5))
|
||
self.assertTrue(sol.feasible)
|
||
self.assertEqual(sol.assignment["x"], 0.0)
|
||
self.assertAlmostEqual(sol.objective_value, 0.0)
|
||
|
||
def test_target_met(self):
|
||
p = OptimizationProblem(
|
||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 10.0))],
|
||
objective=ObjectiveSpec(Sense.MAXIMIZE, target_value=8.0,
|
||
terms=[ObjectiveTerm("x", 1.0)]),
|
||
)
|
||
sol = solve(p, SolverConfig(grid_steps=11))
|
||
self.assertTrue(sol.feasible)
|
||
self.assertTrue(sol.target_met) # x=10 >= 8
|
||
|
||
def test_target_not_met(self):
|
||
p = OptimizationProblem(
|
||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 5.0))],
|
||
objective=ObjectiveSpec(Sense.MAXIMIZE, target_value=99.0,
|
||
terms=[ObjectiveTerm("x", 1.0)]),
|
||
)
|
||
sol = solve(p, SolverConfig(grid_steps=6))
|
||
self.assertTrue(sol.feasible)
|
||
self.assertFalse(sol.target_met)
|
||
|
||
def test_no_feasible_solution(self):
|
||
# box 收紧到与域不交 → 无可行
|
||
p = OptimizationProblem(
|
||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 1.0))],
|
||
constraints=[ConstraintSpec(ConstraintKind.BOX, variable="x", bounds=(5.0, 6.0))],
|
||
objective=ObjectiveSpec(Sense.MAXIMIZE, terms=[ObjectiveTerm("x", 1.0)]),
|
||
)
|
||
sol = solve(p, SolverConfig(grid_steps=3))
|
||
self.assertFalse(sol.feasible)
|
||
self.assertIn("无可行解", sol.message)
|
||
|
||
|
||
class TestCoordinateDescent(unittest.TestCase):
|
||
def test_falls_back_when_grid_too_large(self):
|
||
# 三个变量 × grid_steps=5 = 125;设 max_combinations=10 → 降级
|
||
p = OptimizationProblem(
|
||
variables=[
|
||
DecisionVariable(f"v{i}", DomainKind.BOUNDS, bounds=(0.0, 4.0))
|
||
for i in range(3)
|
||
],
|
||
objective=ObjectiveSpec(Sense.MAXIMIZE,
|
||
terms=[ObjectiveTerm(f"v{i}", 1.0) for i in range(3)]),
|
||
)
|
||
sol = solve(p, SolverConfig(grid_steps=5, max_combinations=10))
|
||
self.assertEqual(sol.strategy, "coordinate_descent")
|
||
# 坐标下降应能爬到各维上界附近(贪心可收敛到此线性目标的全局最优)
|
||
self.assertTrue(sol.feasible)
|
||
self.assertAlmostEqual(sol.objective_value, 12.0, places=6)
|
||
|
||
|
||
class TestSolveEntry(unittest.TestCase):
|
||
def test_validate_failure_returns_infeasible(self):
|
||
p = OptimizationProblem(
|
||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 1.0))],
|
||
objective=ObjectiveSpec(Sense.MAXIMIZE,
|
||
terms=[ObjectiveTerm("ghost", 1.0)]),
|
||
)
|
||
sol = solve(p, SolverConfig())
|
||
self.assertFalse(sol.feasible)
|
||
self.assertEqual(sol.strategy, "validate")
|
||
self.assertIn("校验失败", sol.message)
|
||
|
||
def test_empty_problem_trivially_feasible(self):
|
||
p = OptimizationProblem()
|
||
sol = solve(p, SolverConfig())
|
||
self.assertTrue(sol.feasible)
|
||
self.assertEqual(sol.strategy, "empty")
|
||
|
||
def test_solution_to_dict(self):
|
||
p = _toy_problem()
|
||
sol = solve(p, SolverConfig(grid_steps=3))
|
||
d = sol.to_dict()
|
||
self.assertIn("assignment", d)
|
||
self.assertIn("objective_value", d)
|
||
self.assertIn("evaluated", d)
|
||
self.assertTrue(d["feasible"])
|
||
|
||
|
||
class TestEndToEndFromTemplate(unittest.TestCase):
|
||
def test_solve_loaded_problem(self):
|
||
p = load_problem(CONFIG_PATH)
|
||
sol = solve(p, SolverConfig(grid_steps=7, max_combinations=200000))
|
||
# 模板含 4 变量;7^3 * 3 = 1029 组合 < 上限 → 网格
|
||
self.assertEqual(sol.strategy, "grid")
|
||
self.assertTrue(sol.feasible)
|
||
# 取值应满足 box 收紧(clf_temp∈[820,900])与 forbidden(非 C@910)
|
||
self.assertGreaterEqual(sol.assignment["clf_temp"], 820 - 1e-6)
|
||
self.assertLessEqual(sol.assignment["clf_temp"], 900 + 1e-6)
|
||
self.assertFalse(sol.assignment["catalyst"] == "C"
|
||
and sol.assignment.get("clf_temp") == 910)
|
||
# evaluated>0
|
||
self.assertGreater(sol.evaluated, 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|