"""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