370 lines
15 KiB
Python
370 lines
15 KiB
Python
"""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)
|