接入 V2 原生 CP-SAT 求解器(round 89 slice 1)
新增 server/engines/optimize_cpsat.py:SchedulingProblemV2 -> CP-SAT -> SchedulingSolutionV2,目标最小化总拖期;含设备日历与维保阻塞区间、同设备不重叠、工序链前驱约束、合格设备二选一、provenance 绑定 problem hash。本轮只做问题到解,不物化 flex* 行(slice 2 负责)。验证:隔离解释器(numpy 1.26.4 + ortools 9.11)2 passed,72/72 工序排入且 validate_solution valid;项目 venv 因 NumPy X86_V2 基线与本机 CPU 不兼容按策略 skip。
This commit is contained in:
parent
7de23a0ccd
commit
65b539e010
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Round 89: optimize 原生 CP-SAT 求解器接入
|
||||||
|
|
||||||
|
更新时间:2026-09-16
|
||||||
|
|
||||||
|
## 1. 本轮目标
|
||||||
|
|
||||||
|
把 `OptimizeEngine` 从「选规则 + 复用 PoolEngine 物化」推进到真正的 V2 原生求解:
|
||||||
|
输入 `SchedulingProblemV2`,由 OR-Tools CP-SAT 决定资源分配与时序,输出
|
||||||
|
`SchedulingSolutionV2`,并且必须通过 APS 独立的 `SchedulingValidator`。
|
||||||
|
|
||||||
|
上游依据:`docs/round-85-optimize-integration-shape-plan.md` 第 14 节明确记着
|
||||||
|
「CP-SAT 的 V2 原生求解器仍应作为后续轮次接入,本轮不把 PoolEngine 适配器冒充为
|
||||||
|
CP-SAT 最优证明」。本轮就是那一轮。
|
||||||
|
|
||||||
|
## 2. 现状(本轮开始时的事实)
|
||||||
|
|
||||||
|
- `OptimizeEngine.solve_flex()` 只做算法选择 + provenance 标注,实际物化交给
|
||||||
|
`PoolEngine`(贪心占槽)。七种派工规则已跑通,但它们不是 CP-SAT。
|
||||||
|
- `server/engines/cp_engine.py` 是**经典轨**(operations/routings/workstations)
|
||||||
|
的 CP-SAT,不是 flex 闭环 V2 原生;`solver_process.py` 是它的子进程安全边界。
|
||||||
|
- 集成环境限制:项目 `.venv` 里的 NumPy 以 X86_V2 为基线构建,而本机 CPU
|
||||||
|
(sd-server,Family 6 Model 15)不支持该指令集,`ortools.sat.python.cp_model`
|
||||||
|
导入即失败。这与 `round-85` 记录的全量 CP/Excel 测试受限是同一个原因。
|
||||||
|
|
||||||
|
## 3. 本轮范围
|
||||||
|
|
||||||
|
### Slice 1(本轮完成)
|
||||||
|
|
||||||
|
- 新增 `server/engines/optimize_cpsat.py`:`SchedulingProblemV2 -> CpsatOutcome`,
|
||||||
|
内含解与可审计元数据(solverId/solverVersion/OR-Tools 状态/耗时/种子/规模)。
|
||||||
|
- 模型:每道工序在合格设备中选一台(`AddExactlyOne` + 可选定长区间),同设备
|
||||||
|
`AddNoOverlap`,设备日历与维保之外的时间作为阻塞区间一并进入 NoOverlap,工序链按
|
||||||
|
`predecessorActivityIds` 串行,目标为最小化总拖期。
|
||||||
|
- provenance 与 problem hash 绑定,与 `flex_version_to_solution_v2` 同一套口径。
|
||||||
|
- 新增 `tests/golden/test_optimize_cpsat_native.py`:解通过独立 V2 校验;总拖期不劣于
|
||||||
|
同口径 EDD 基线。
|
||||||
|
|
||||||
|
### Slice 2(下一轮)
|
||||||
|
|
||||||
|
- `OptimizeEngine.solve_flex` 增加 CP-SAT 算法路径(`algorithmId=optimize.cpsat`),
|
||||||
|
物化到 flex* 行,落版本、证据链,走 `/api/flex/schedule` 端到端。
|
||||||
|
- 目标函数与 PoolEngine `totalTardiness` 口径对齐(当前两者定义不同,不能直接比较)。
|
||||||
|
- 时间离散化(分钟 -> 5/15 分钟桶)、派工解热启动、缩短求解时间。
|
||||||
|
- 冻结/在制/模具寿命/班组与工装累计容量接入模型。
|
||||||
|
|
||||||
|
### 明确不做
|
||||||
|
|
||||||
|
- 不改 WorldStore 权威语义、审批门禁和审计链。
|
||||||
|
- 不新建第二套订单/资源/版本模型。
|
||||||
|
- 不声称 PoolEngine 适配器结果是 CP-SAT 最优证明。
|
||||||
|
|
||||||
|
## 4. 验收
|
||||||
|
|
||||||
|
- `pytest tests/golden/test_optimize_cpsat_native.py`:在具备可用 OR-Tools 的环境
|
||||||
|
通过;在 NumPy/OR-Tools 不可用的环境按既有策略 skip,不得 error。
|
||||||
|
- 解必须 `validate_solution(...).valid is True` 且无 hard violation。
|
||||||
|
- 现有回归不受影响:`tests/golden/test_optimize_simulation_world_pack.py`、
|
||||||
|
`tests/golden/test_optimize_engine.py`。
|
||||||
|
- 报告必须区分证据等级:代码、测试、真实运行;环境性跳过要写清原因。
|
||||||
|
|
||||||
|
## 5. 停止条件
|
||||||
|
|
||||||
|
- V2 契约无法表达所需约束,且无法通过局部兼容字段解决。
|
||||||
|
- 需要放宽校验才能让测试通过。
|
||||||
|
- 需要新增生产依赖或许可而没有明确运行环境。
|
||||||
|
|
||||||
|
## 6. Slice 1 证据(2026-09-16)
|
||||||
|
|
||||||
|
- 隔离解释器(CPython 3.11.15 + NumPy 1.26.4 + OR-Tools 9.11.4210):
|
||||||
|
`2 passed`;模拟数据包 72/72 工序全部排入、`validate_solution` valid、
|
||||||
|
总拖期 1,782,876 分钟不劣于同口径 EDD 基线。
|
||||||
|
- 项目 `.venv`:`1 skipped`(OR-Tools 因 NumPy 基线与 CPU 不兼容不可用)。
|
||||||
|
- 已知限制:`INFEASIBLE` 曾因拖期变量上界未包含「历史欠交」而误判,已修(
|
||||||
|
交期早于计划起点的部分必须计入上界);当前只做到 FEASIBLE,未证明最优。
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
"""V2-native CP-SAT solver for the APS closed loop (round 89, slice 1).
|
||||||
|
|
||||||
|
输入是 APS 的 `SchedulingProblemV2`,输出是 `SchedulingSolutionV2`:资源分配和
|
||||||
|
时序由 OR-Tools CP-SAT 决定,准入、校验、版本物化和审计仍然全部归 APS。
|
||||||
|
|
||||||
|
本切片只做「问题 -> 解」的原生求解:不写 flex* 行、不物化版本,也不改
|
||||||
|
WorldStore。候选一旦物化,仍必须经过 `validate_solution` 才会成为 APS 版本。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from server.aps_domain.scheduling_problem_v2 import (
|
||||||
|
PeggingAllocation,
|
||||||
|
ResourceKind,
|
||||||
|
ScheduledActivity,
|
||||||
|
ScheduledResourceAllocation,
|
||||||
|
SchedulingProblemV2,
|
||||||
|
SchedulingSolutionV2,
|
||||||
|
SolveStatus,
|
||||||
|
SolutionProvenance,
|
||||||
|
UnscheduledRequirement,
|
||||||
|
scheduling_problem_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
SOLVER_ID = "optimize-cpsat"
|
||||||
|
SOLVER_VERSION = "0.1.0"
|
||||||
|
DEFAULT_TIME_LIMIT_SECONDS = 20.0
|
||||||
|
DEFAULT_SEED = 42
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CpsatOutcome:
|
||||||
|
"""一次 CP-SAT 求解的解与可审计元数据。"""
|
||||||
|
|
||||||
|
solution: SchedulingSolutionV2
|
||||||
|
meta: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_between(base: datetime, moment: datetime | None) -> int | None:
|
||||||
|
"""把时间点换算成相对基准的整数分钟(向下取整)。"""
|
||||||
|
|
||||||
|
if moment is None:
|
||||||
|
return None
|
||||||
|
return int(math.floor((moment - base).total_seconds() / 60.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _minutes_ceil(base: datetime, moment: datetime | None) -> int | None:
|
||||||
|
"""把时间点换算成相对基准的整数分钟(向上取整,用于日历右端点)。"""
|
||||||
|
|
||||||
|
if moment is None:
|
||||||
|
return None
|
||||||
|
return int(math.ceil((moment - base).total_seconds() / 60.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_windows(windows: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||||
|
merged: list[tuple[int, int]] = []
|
||||||
|
for start, end in sorted(windows):
|
||||||
|
if end <= start:
|
||||||
|
continue
|
||||||
|
if merged and start <= merged[-1][1]:
|
||||||
|
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
||||||
|
else:
|
||||||
|
merged.append((start, end))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_windows(resource: Any, base: datetime, horizon: int) -> list[tuple[int, int]]:
|
||||||
|
"""资源在 [0, horizon] 内不可排的分钟区间 = 日历与维保之外的补集。"""
|
||||||
|
|
||||||
|
open_windows: list[tuple[int, int]] = []
|
||||||
|
for interval in resource.calendarIntervals:
|
||||||
|
start = _minutes_between(base, interval.start)
|
||||||
|
end = _minutes_ceil(base, interval.end)
|
||||||
|
if start is None or end is None:
|
||||||
|
continue
|
||||||
|
start = max(0, start)
|
||||||
|
end = min(horizon, end)
|
||||||
|
if end <= start:
|
||||||
|
continue
|
||||||
|
open_windows.append((start, end))
|
||||||
|
|
||||||
|
maintenance: list[tuple[int, int]] = []
|
||||||
|
for interval in resource.maintenanceIntervals:
|
||||||
|
start = _minutes_between(base, interval.start)
|
||||||
|
end = _minutes_ceil(base, interval.end)
|
||||||
|
if start is None or end is None:
|
||||||
|
continue
|
||||||
|
start, end = max(0, start), min(horizon, end)
|
||||||
|
if end > start:
|
||||||
|
maintenance.append((start, end))
|
||||||
|
|
||||||
|
merged_open = _merge_windows(open_windows)
|
||||||
|
blocked: list[tuple[int, int]] = []
|
||||||
|
cursor = 0
|
||||||
|
for start, end in merged_open:
|
||||||
|
if start > cursor:
|
||||||
|
blocked.append((cursor, start))
|
||||||
|
cursor = max(cursor, end)
|
||||||
|
if cursor < horizon:
|
||||||
|
blocked.append((cursor, horizon))
|
||||||
|
return _merge_windows([*blocked, *maintenance])
|
||||||
|
|
||||||
|
|
||||||
|
def _duration_minutes(value: float) -> int:
|
||||||
|
return max(1, int(math.ceil(float(value))))
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_resources(activity: Any, resource_ids: set[str]) -> list[str]:
|
||||||
|
candidates: list[str] = []
|
||||||
|
for requirement in activity.resourceRequirements:
|
||||||
|
if requirement.kind != ResourceKind.EQUIPMENT:
|
||||||
|
continue
|
||||||
|
candidates.extend(requirement.eligibleResourceIds)
|
||||||
|
if not candidates:
|
||||||
|
candidates.extend(activity.eligibleResourceIds)
|
||||||
|
unique = [rid for rid in dict.fromkeys(candidates) if rid in resource_ids]
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def solve_problem_v2(
|
||||||
|
problem: SchedulingProblemV2,
|
||||||
|
*,
|
||||||
|
time_limit_seconds: float = DEFAULT_TIME_LIMIT_SECONDS,
|
||||||
|
seed: int = DEFAULT_SEED,
|
||||||
|
horizon_minutes: int | None = None,
|
||||||
|
) -> CpsatOutcome:
|
||||||
|
"""用 CP-SAT 求一个 `SchedulingProblemV2` 候选解。
|
||||||
|
|
||||||
|
目标:最小化总拖期(`objectivePolicy` 里 tardiness 权重)。资源分配为每个工序
|
||||||
|
在合格设备中选一台,同设备工序不重叠,工序链按前驱顺序串行,并且不允许落在
|
||||||
|
设备日历与维保之外。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ortools.sat.python import cp_model
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
base = problem.planningStart
|
||||||
|
span = int(math.floor((problem.planningEnd - base).total_seconds() / 60.0))
|
||||||
|
horizon = span if horizon_minutes is None else min(span, int(horizon_minutes))
|
||||||
|
if horizon <= 0:
|
||||||
|
raise ValueError("planning window must be positive")
|
||||||
|
|
||||||
|
resources_by_id = {resource.resourceId: resource for resource in problem.resources}
|
||||||
|
equipment_resources = [r for r in problem.resources if r.kind == ResourceKind.EQUIPMENT]
|
||||||
|
equipment_ids = {r.resourceId for r in equipment_resources}
|
||||||
|
|
||||||
|
model = cp_model.CpModel()
|
||||||
|
start_vars: dict[str, Any] = {}
|
||||||
|
end_vars: dict[str, Any] = {}
|
||||||
|
presence: dict[tuple[str, str], Any] = {}
|
||||||
|
intervals_by_resource: dict[str, list[Any]] = {r.resourceId: [] for r in equipment_resources}
|
||||||
|
unschedulable: list[str] = []
|
||||||
|
|
||||||
|
for activity in problem.activities:
|
||||||
|
duration = _duration_minutes(activity.durationMin)
|
||||||
|
eligible = _eligible_resources(activity, equipment_ids)
|
||||||
|
if not eligible or duration > horizon:
|
||||||
|
unschedulable.append(activity.activityId)
|
||||||
|
continue
|
||||||
|
release = int(max(0, _minutes_between(base, activity.materialReleaseAt) or 0))
|
||||||
|
if release + duration > horizon:
|
||||||
|
unschedulable.append(activity.activityId)
|
||||||
|
continue
|
||||||
|
start = model.NewIntVar(release, horizon - duration, f"start:{activity.activityId}")
|
||||||
|
end = model.NewIntVar(release + duration, horizon, f"end:{activity.activityId}")
|
||||||
|
model.Add(end == start + duration)
|
||||||
|
start_vars[activity.activityId] = start
|
||||||
|
end_vars[activity.activityId] = end
|
||||||
|
|
||||||
|
picks = []
|
||||||
|
for resource_id in eligible:
|
||||||
|
chosen = model.NewBoolVar(f"pick:{activity.activityId}:{resource_id}")
|
||||||
|
presence[(activity.activityId, resource_id)] = chosen
|
||||||
|
picks.append(chosen)
|
||||||
|
intervals_by_resource[resource_id].append(
|
||||||
|
model.NewOptionalFixedSizeIntervalVar(
|
||||||
|
start, duration, chosen, f"interval:{activity.activityId}:{resource_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model.AddExactlyOne(picks)
|
||||||
|
|
||||||
|
for activity in problem.activities:
|
||||||
|
target = start_vars.get(activity.activityId)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
for predecessor_id in activity.predecessorActivityIds:
|
||||||
|
predecessor_end = end_vars.get(predecessor_id)
|
||||||
|
if predecessor_end is not None:
|
||||||
|
model.Add(target >= predecessor_end)
|
||||||
|
|
||||||
|
blocked_total = 0
|
||||||
|
for resource in equipment_resources:
|
||||||
|
blocked = _blocked_windows(resource, base, horizon)
|
||||||
|
blocked_total += len(blocked)
|
||||||
|
for index, (start, end) in enumerate(blocked):
|
||||||
|
intervals_by_resource[resource.resourceId].append(
|
||||||
|
model.NewFixedSizeIntervalVar(start, end - start, f"closed:{resource.resourceId}:{index}")
|
||||||
|
)
|
||||||
|
if intervals_by_resource[resource.resourceId]:
|
||||||
|
model.AddNoOverlap(intervals_by_resource[resource.resourceId])
|
||||||
|
|
||||||
|
activities_by_requirement: dict[str, list[str]] = {}
|
||||||
|
for activity in problem.activities:
|
||||||
|
activities_by_requirement.setdefault(activity.requirementId, []).append(activity.activityId)
|
||||||
|
|
||||||
|
tardiness_weight = float((problem.objectivePolicy.weights or {}).get("tardiness", 1.0) or 1.0)
|
||||||
|
tardiness_terms = []
|
||||||
|
capacity = sum(_duration_minutes(a.durationMin) for a in problem.activities) or 1
|
||||||
|
due_offsets = [
|
||||||
|
offset
|
||||||
|
for offset in (_minutes_between(base, requirement.requiredAt) for requirement in problem.requirements)
|
||||||
|
if offset is not None
|
||||||
|
]
|
||||||
|
# 交期可能早于计划起点(历史欠交),拖期上界必须把这段「已经迟到」的量算进去,
|
||||||
|
# 否则 tardy 变量的域会把模型判成不可行。
|
||||||
|
already_late = max(0, -(min(due_offsets) if due_offsets else 0))
|
||||||
|
tardy_upper = horizon + already_late + capacity + 1
|
||||||
|
for requirement in problem.requirements:
|
||||||
|
activity_ids = activities_by_requirement.get(requirement.requirementId) or []
|
||||||
|
if not activity_ids:
|
||||||
|
continue
|
||||||
|
ends = [end_vars[aid] for aid in activity_ids if aid in end_vars]
|
||||||
|
if not ends:
|
||||||
|
continue
|
||||||
|
completion = model.NewIntVar(0, horizon, f"completion:{requirement.requirementId}")
|
||||||
|
model.AddMaxEquality(completion, ends)
|
||||||
|
due = _minutes_between(base, requirement.requiredAt)
|
||||||
|
if due is None:
|
||||||
|
continue
|
||||||
|
tardy = model.NewIntVar(0, tardy_upper, f"tardy:{requirement.requirementId}")
|
||||||
|
model.Add(tardy >= completion - due)
|
||||||
|
tardiness_terms.append((requirement.requirementId, tardy))
|
||||||
|
|
||||||
|
if tardiness_terms:
|
||||||
|
model.Minimize(
|
||||||
|
sum(int(round(tardiness_weight * 1000)) * term for _, term in tardiness_terms)
|
||||||
|
)
|
||||||
|
|
||||||
|
solver = cp_model.CpSolver()
|
||||||
|
solver.parameters.max_time_in_seconds = float(time_limit_seconds)
|
||||||
|
solver.parameters.random_seed = int(seed)
|
||||||
|
solver.parameters.num_search_workers = 1 # 单线程保证可复现
|
||||||
|
status = solver.Solve(model)
|
||||||
|
elapsed = time.perf_counter() - started
|
||||||
|
|
||||||
|
status_name = solver.StatusName(status)
|
||||||
|
solve_status = {
|
||||||
|
cp_model.OPTIMAL: SolveStatus.OPTIMAL,
|
||||||
|
cp_model.FEASIBLE: SolveStatus.FEASIBLE,
|
||||||
|
}.get(status, SolveStatus.INFEASIBLE if status == cp_model.INFEASIBLE else SolveStatus.ERROR)
|
||||||
|
|
||||||
|
scheduled: list[ScheduledActivity] = []
|
||||||
|
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||||||
|
for activity in problem.activities:
|
||||||
|
if activity.activityId not in start_vars:
|
||||||
|
continue
|
||||||
|
start_minute = int(solver.Value(start_vars[activity.activityId]))
|
||||||
|
chosen_resource = None
|
||||||
|
for resource_id in _eligible_resources(activity, equipment_ids):
|
||||||
|
pick = presence.get((activity.activityId, resource_id))
|
||||||
|
if pick is not None and solver.Value(pick):
|
||||||
|
chosen_resource = resource_id
|
||||||
|
break
|
||||||
|
if chosen_resource is None:
|
||||||
|
continue
|
||||||
|
end_minute = start_minute + _duration_minutes(activity.durationMin)
|
||||||
|
units = float(activity.requiredResourceUnits or 1.0)
|
||||||
|
scheduled.append(
|
||||||
|
ScheduledActivity(
|
||||||
|
activityId=activity.activityId,
|
||||||
|
activityIdentity=activity.activityIdentity,
|
||||||
|
requirementId=activity.requirementId,
|
||||||
|
operationId=activity.operationId,
|
||||||
|
sequence=activity.sequence,
|
||||||
|
resourceId=chosen_resource,
|
||||||
|
start=base + timedelta(minutes=start_minute),
|
||||||
|
end=base + timedelta(minutes=end_minute),
|
||||||
|
resourceUnits=units,
|
||||||
|
resourceAllocations=(
|
||||||
|
ScheduledResourceAllocation(
|
||||||
|
resourceId=chosen_resource,
|
||||||
|
kind=ResourceKind.EQUIPMENT,
|
||||||
|
units=units,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_ids = {row.activityId for row in scheduled}
|
||||||
|
unscheduled_ids = set(unschedulable) | {
|
||||||
|
activity.activityId for activity in problem.activities if activity.activityId not in scheduled_ids
|
||||||
|
}
|
||||||
|
unscheduled = tuple(
|
||||||
|
UnscheduledRequirement(
|
||||||
|
requirementId=requirement.requirementId,
|
||||||
|
quantity=float(requirement.quantity),
|
||||||
|
reasonCode="UNSCHEDULED_ACTIVITY",
|
||||||
|
details="存在未落到候选解的制造活动",
|
||||||
|
)
|
||||||
|
for requirement in problem.requirements
|
||||||
|
if any(
|
||||||
|
activity_id in unscheduled_ids
|
||||||
|
for activity_id in activities_by_requirement.get(requirement.requirementId, [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_tardiness = 0.0
|
||||||
|
if tardiness_terms and status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||||||
|
total_tardiness = sum(float(solver.Value(term)) for _, term in tardiness_terms)
|
||||||
|
|
||||||
|
if solve_status in (SolveStatus.OPTIMAL, SolveStatus.FEASIBLE) and unscheduled:
|
||||||
|
solve_status = SolveStatus.PARTIAL
|
||||||
|
|
||||||
|
objective_values = {
|
||||||
|
"totalTardiness": total_tardiness,
|
||||||
|
"scheduledActivities": float(len(scheduled)),
|
||||||
|
"totalActivities": float(len(problem.activities)),
|
||||||
|
}
|
||||||
|
best_bound = None
|
||||||
|
objective_value = total_tardiness
|
||||||
|
gap = None
|
||||||
|
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE) and tardiness_terms:
|
||||||
|
best_bound = float(solver.BestObjectiveBound()) / (tardiness_weight * 1000.0)
|
||||||
|
objective_value = float(solver.ObjectiveValue()) / (tardiness_weight * 1000.0)
|
||||||
|
if objective_value > 0:
|
||||||
|
gap = max(0.0, (objective_value - best_bound) / objective_value)
|
||||||
|
|
||||||
|
generated_at = datetime.now(tz=base.tzinfo or None)
|
||||||
|
solution = SchedulingSolutionV2(
|
||||||
|
problemId=problem.problemId,
|
||||||
|
solveStatus=solve_status,
|
||||||
|
objectiveValues=objective_values,
|
||||||
|
bestBound=best_bound,
|
||||||
|
gap=gap,
|
||||||
|
activities=tuple(scheduled),
|
||||||
|
pegging=(),
|
||||||
|
unscheduledRequirements=unscheduled,
|
||||||
|
provenance=SolutionProvenance(
|
||||||
|
runId=f"cpsat:{problem.problemId}:{int(started * 1000)}",
|
||||||
|
solverId=SOLVER_ID,
|
||||||
|
solverVersion=SOLVER_VERSION,
|
||||||
|
generatedAt=generated_at,
|
||||||
|
businessDate=problem.businessDate,
|
||||||
|
problemHash=scheduling_problem_hash(problem),
|
||||||
|
sourceRevision=problem.sourceRevision,
|
||||||
|
sourceFingerprints=problem.sourceFingerprints,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
meta = {
|
||||||
|
"solverId": SOLVER_ID,
|
||||||
|
"solverVersion": SOLVER_VERSION,
|
||||||
|
"ortoolsStatus": status_name,
|
||||||
|
"wallTimeSeconds": round(elapsed, 3),
|
||||||
|
"timeLimitSeconds": float(time_limit_seconds),
|
||||||
|
"seed": int(seed),
|
||||||
|
"horizonMinutes": horizon,
|
||||||
|
"activityCount": len(problem.activities),
|
||||||
|
"resourceCount": len(equipment_resources),
|
||||||
|
"blockedWindowCount": blocked_total,
|
||||||
|
"precedenceEdges": sum(len(a.predecessorActivityIds) for a in problem.activities),
|
||||||
|
"objective": "weightedTardiness",
|
||||||
|
"objectiveValue": objective_value,
|
||||||
|
}
|
||||||
|
return CpsatOutcome(solution=solution, meta=meta)
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""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
|
||||||
Loading…
Reference in New Issue