133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
|
|
# ============================================================
|
|||
|
|
# SC-03 CP-SAT 引擎黄金测试
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from server.engines import get_engine
|
|||
|
|
from server.engines.base import EngineParams
|
|||
|
|
from server.engines.cp_engine import CpSatEngine
|
|||
|
|
from server.engines.rule_engine import RuleEngine
|
|||
|
|
from server.state.seed import seed_world
|
|||
|
|
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _next_id_factory():
|
|||
|
|
counters: dict[str, int] = {}
|
|||
|
|
|
|||
|
|
def next_id(kind: str) -> int:
|
|||
|
|
counters[kind] = counters.get(kind, 0) + 1
|
|||
|
|
return counters[kind]
|
|||
|
|
|
|||
|
|
return next_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run(world, strategy="COMPREHENSIVE", engine="CP", time_limit=8.0):
|
|||
|
|
start = fmt_date(add_minutes(today0(), 24 * 60))
|
|||
|
|
params = EngineParams(
|
|||
|
|
orderIds=[], engineType=engine, strategyTemplate=strategy,
|
|||
|
|
planningHorizonDays=14, startDate=start, timeLimitSeconds=time_limit,
|
|||
|
|
)
|
|||
|
|
return get_engine(engine).solve(world, params, _next_id_factory())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_get_engine_cp_is_cpsat():
|
|||
|
|
eng = get_engine("CP")
|
|||
|
|
assert isinstance(eng, CpSatEngine)
|
|||
|
|
assert eng.name == "CP"
|
|||
|
|
assert eng.supports_anytime is True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_cp_engine_type_and_solver_meta():
|
|||
|
|
world = seed_world()
|
|||
|
|
result = _run(world)
|
|||
|
|
assert result.engineType == "CP"
|
|||
|
|
assert world["scheduleVersions"][0]["engineType"] == "CP"
|
|||
|
|
meta = world["scheduleVersions"][0].get("solverMeta") or {}
|
|||
|
|
assert meta.get("backend") == "OR-Tools CP-SAT"
|
|||
|
|
assert result.solveStatus in ("OPTIMAL", "FEASIBLE", "UNKNOWN", "INFEASIBLE")
|
|||
|
|
assert result.solveTimeSec is not None
|
|||
|
|
assert result.poCount == 7
|
|||
|
|
assert result.woCount == 30
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_cp_no_workstation_overlap():
|
|||
|
|
world = seed_world()
|
|||
|
|
_run(world)
|
|||
|
|
by_ws: dict[int, list] = {}
|
|||
|
|
for wo in world["workOrders"]:
|
|||
|
|
by_ws.setdefault(wo["workstationId"], []).append(
|
|||
|
|
(parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])))
|
|||
|
|
for ws_id, ivs in by_ws.items():
|
|||
|
|
ivs.sort()
|
|||
|
|
for (s1, e1), (s2, e2) in zip(ivs, ivs[1:]):
|
|||
|
|
assert e1 <= s2, f"工位 {ws_id} 双占"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_cp_tardiness_not_worse_than_rule():
|
|||
|
|
"""同一输入:CP 延期不劣于 RULE(允许微小浮点差)。"""
|
|||
|
|
start = fmt_date(add_minutes(today0(), 24 * 60))
|
|||
|
|
params_r = EngineParams(
|
|||
|
|
orderIds=[], engineType="RULE", strategyTemplate="COMPREHENSIVE",
|
|||
|
|
planningHorizonDays=14, startDate=start,
|
|||
|
|
)
|
|||
|
|
params_c = EngineParams(
|
|||
|
|
orderIds=[], engineType="CP", strategyTemplate="COMPREHENSIVE",
|
|||
|
|
planningHorizonDays=14, startDate=start, timeLimitSeconds=10.0,
|
|||
|
|
)
|
|||
|
|
w_r, w_c = seed_world(), seed_world()
|
|||
|
|
r = RuleEngine().solve(w_r, params_r, _next_id_factory())
|
|||
|
|
c = CpSatEngine().solve(w_c, params_c, _next_id_factory())
|
|||
|
|
assert c.totalTardiness <= r.totalTardiness + 1e-3
|
|||
|
|
# 冲突数:允许 CP 因选线不同略不同,但不应显著恶化
|
|||
|
|
assert c.conflictCount <= r.conflictCount + 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_cp_timeout_returns_feasible_or_status():
|
|||
|
|
"""极短时限:必须返回带 status 的结果,不得静默变 RULE。"""
|
|||
|
|
world = seed_world()
|
|||
|
|
result = _run(world, time_limit=0.5)
|
|||
|
|
assert result.engineType == "CP"
|
|||
|
|
assert result.solveStatus is not None
|
|||
|
|
meta = world["scheduleVersions"][0]["solverMeta"]
|
|||
|
|
assert meta["backend"] == "OR-Tools CP-SAT"
|
|||
|
|
# anytime:通常至少 FEASIBLE/OPTIMAL/UNKNOWN;若 INFEASIBLE 则有 fallback 留痕
|
|||
|
|
assert result.poCount >= 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("requested", ["RULE", "GA"])
|
|||
|
|
def test_non_cp_still_rule_proxy(requested):
|
|||
|
|
world = seed_world()
|
|||
|
|
result = _run(world, engine=requested)
|
|||
|
|
assert result.engineType == requested
|
|||
|
|
assert isinstance(get_engine(requested), RuleEngine)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_hybrid_is_real_pipeline():
|
|||
|
|
from server.engines.cp_engine import HybridEngine
|
|||
|
|
eng = get_engine("HYBRID")
|
|||
|
|
assert isinstance(eng, HybridEngine)
|
|||
|
|
world = seed_world()
|
|||
|
|
result = _run(world, engine="HYBRID")
|
|||
|
|
assert result.engineType == "HYBRID"
|
|||
|
|
meta = world["scheduleVersions"][0].get("solverMeta") or {}
|
|||
|
|
assert meta.get("warmStart") == "RULE"
|
|||
|
|
assert "RULE→CP-SAT" in str(meta.get("pipeline") or "")
|
|||
|
|
assert result.solveStatus is not None
|
|||
|
|
assert result.poCount == 7
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_hybrid_tardiness_not_worse_than_rule():
|
|||
|
|
start = fmt_date(add_minutes(today0(), 24 * 60))
|
|||
|
|
w_r, w_h = seed_world(), seed_world()
|
|||
|
|
r = RuleEngine().solve(w_r, EngineParams(
|
|||
|
|
orderIds=[], engineType="RULE", strategyTemplate="COMPREHENSIVE",
|
|||
|
|
planningHorizonDays=14, startDate=start,
|
|||
|
|
), _next_id_factory())
|
|||
|
|
h = get_engine("HYBRID").solve(w_h, EngineParams(
|
|||
|
|
orderIds=[], engineType="HYBRID", strategyTemplate="COMPREHENSIVE",
|
|||
|
|
planningHorizonDays=14, startDate=start, timeLimitSeconds=10.0,
|
|||
|
|
), _next_id_factory())
|
|||
|
|
assert h.totalTardiness <= r.totalTardiness + 1e-3
|