101 lines
4.7 KiB
Python
101 lines
4.7 KiB
Python
"""Round 89 slice 1: V2 原生 CP-SAT 求解器(SchedulingProblemV2 -> SchedulingSolutionV2)。
|
||
|
||
本文件只验证「问题 -> 解」这一段:CP-SAT 自己决定资源分配和时序,解必须通过
|
||
APS 的独立 V2 校验,且总拖期不劣于同口径的 EDD 基线。物化到 flex* 行仍由 APS
|
||
既有通道负责(slice 2)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from datetime import date, timedelta
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
try: # 环境缺 OR-Tools,或 venv 内 NumPy 基线与该机 CPU 不兼容(当前 sd-server 即如此)
|
||
from ortools.sat.python import cp_model # noqa: F401
|
||
except Exception as exc: # pragma: no cover - 环境分支
|
||
pytest.skip(f"OR-Tools 在此环境不可用:{type(exc).__name__}: {exc}", allow_module_level=True)
|
||
|
||
from server.aps_domain.closed_loop_problem import build_closed_loop_problem # noqa: E402
|
||
from server.aps_domain.closed_loop_runtime import closed_loop_to_problem_v2 # noqa: E402
|
||
from server.aps_domain.scheduling_problem_v2 import SchedulingProblemV2, SolveStatus # noqa: E402
|
||
from server.aps_domain.scheduling_validator import validate_solution # noqa: E402
|
||
from server.engines.optimize_cpsat import solve_problem_v2 # noqa: E402
|
||
from server.state.packs import load_pack # noqa: E402
|
||
|
||
PACK_PATH = Path(__file__).resolve().parents[2] / "server" / "data" / "packs" / "optimize-simulation-v1.json"
|
||
|
||
|
||
def _pack_problem() -> tuple[dict, SchedulingProblemV2]:
|
||
world = load_pack(str(PACK_PATH))
|
||
closed_loop = build_closed_loop_problem(world, business_date=date.today().isoformat(), strict=True)
|
||
return world, closed_loop_to_problem_v2(world, closed_loop)
|
||
|
||
|
||
def _edd_baseline_tardiness(problem: SchedulingProblemV2) -> float:
|
||
"""同口径基线:完全按交期排序、每台设备串行占用的贪心解总拖期(分钟)。"""
|
||
|
||
base = problem.planningStart
|
||
order_index = {requirement.requirementId: idx for idx, requirement in enumerate(
|
||
sorted(problem.requirements, key=lambda row: row.requiredAt))}
|
||
activities_by_requirement: dict[str, list] = {}
|
||
for activity in problem.activities:
|
||
activities_by_requirement.setdefault(activity.requirementId, []).append(activity)
|
||
|
||
cursor: dict[str, timedelta] = {}
|
||
end_of: dict[str, timedelta] = {}
|
||
total = 0.0
|
||
for requirement_id in sorted(activities_by_requirement, key=lambda rid: order_index[rid]):
|
||
requirement = next(row for row in problem.requirements if row.requirementId == requirement_id)
|
||
for activity in sorted(activities_by_requirement[requirement_id], key=lambda row: row.sequence):
|
||
duration = timedelta(minutes=math.ceil(activity.durationMin))
|
||
earliest = max(
|
||
[timedelta(0)]
|
||
+ [end_of[predecessor] for predecessor in activity.predecessorActivityIds if predecessor in end_of]
|
||
)
|
||
resource_id = sorted(activity.eligibleResourceIds)[0]
|
||
start = max(earliest, cursor.get(resource_id, timedelta(0)))
|
||
end = start + duration
|
||
cursor[resource_id] = end
|
||
end_of[activity.activityId] = end
|
||
completion = max(
|
||
(end_of[activity.activityId] for activity in activities_by_requirement[requirement_id]),
|
||
default=timedelta(0),
|
||
)
|
||
total += max(0.0, (base + completion - requirement.requiredAt).total_seconds() / 60.0)
|
||
return total
|
||
|
||
|
||
def test_native_cpsat_schedules_every_pack_activity_and_passes_v2_validator():
|
||
world, problem = _pack_problem()
|
||
|
||
outcome = solve_problem_v2(problem, time_limit_seconds=15.0)
|
||
solution = outcome.solution
|
||
|
||
assert solution.solveStatus in (SolveStatus.OPTIMAL, SolveStatus.FEASIBLE)
|
||
assert len(solution.activities) == len(problem.activities) == 72
|
||
assert solution.unscheduledRequirements == ()
|
||
assert outcome.meta["solverId"] == "optimize-cpsat"
|
||
|
||
activity_by_id = {activity.activityId: activity for activity in problem.activities}
|
||
for row in solution.activities:
|
||
activity = activity_by_id[row.activityId]
|
||
assert row.resourceId in activity.eligibleResourceIds # 只能用声明的合格设备
|
||
assert row.start >= problem.planningStart
|
||
span = (row.end - row.start).total_seconds() / 60.0
|
||
assert activity.durationMin <= span <= activity.durationMin + 1 # 工时按分钟向上取整
|
||
|
||
report = validate_solution(problem, solution, world=world)
|
||
assert report.valid is True
|
||
assert not report.hardViolations
|
||
|
||
|
||
def test_native_cpsat_is_not_worse_than_the_edd_baseline_in_the_same_metric():
|
||
_, problem = _pack_problem()
|
||
|
||
outcome = solve_problem_v2(problem, time_limit_seconds=15.0)
|
||
baseline = _edd_baseline_tardiness(problem)
|
||
|
||
assert outcome.solution.objectiveValues["totalTardiness"] <= baseline + 0.001
|