Compare commits

...

2 Commits

Author SHA1 Message Date
ssk 65b539e010 接入 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。
2026-09-16 17:32:32 +08:00
ssk 7de23a0ccd 修复模拟数据包订单契约与日期基准,消除交期看板 500
/api/world/due 直取订单客户字段,而模拟数据包只填了 8 个字段,导致 KeyError 500。补齐 10 张订单的订单契约字段(客户/等级/加急/下单日等),并把 baseDate 从转换日 2026-09-03 改为案例业务日 2026-08-02,避免世界日期随运行日漂移、黄金测试输入每天变化。验证:隔离实例 GET /api/world/due 返回 200(10 行);定向测试 8 passed, 1 skipped。
2026-09-16 17:32:29 +08:00
6 changed files with 705 additions and 3 deletions

View File

@ -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,未证明最优。

View File

@ -2,8 +2,8 @@
"packVersion": 1, "packVersion": 1,
"name": "Optimize synthetic debug V1", "name": "Optimize synthetic debug V1",
"aliases": ["Optimize 模拟数据", "Optimize synthetic data", "simulation"], "aliases": ["Optimize 模拟数据", "Optimize synthetic data", "simulation"],
"description": "Generic APS world pack: 10 synthetic orders, 72 operations, and 6 virtual resources for Optimize development debugging only.", "description": "Generic APS world pack: 10 synthetic orders, 72 operations, and 6 virtual resources for Optimize development debugging only. baseDate is the case business day of the replayed Kangni case.",
"baseDate": "2026-09-03", "baseDate": "2026-08-02",
"world": { "world": {
"factories": [], "factories": [],
"workshops": [], "workshops": [],
@ -147,10 +147,23 @@
{ {
"id": 1, "id": 1,
"orderNo": "102285668", "orderNo": "102285668",
"customerId": "CUST001",
"customerName": "模拟客户A",
"customerLevel": "A",
"orderDate": "2026-06-25",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-08-08", "deliveryDate": "2026-08-08",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 1000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-06-25 09:00",
"updatedAt": "2026-06-25 09:00",
"items": [ "items": [
{ {
"id": 1, "id": 1,
@ -167,10 +180,23 @@
{ {
"id": 2, "id": 2,
"orderNo": "102289293", "orderNo": "102289293",
"customerId": "CUST002",
"customerName": "模拟客户B",
"customerLevel": "VIP",
"orderDate": "2026-07-06",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-07-21", "deliveryDate": "2026-07-21",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 1000.0,
"isRush": true,
"rushStrategy": "STRATEGY_SHIFT",
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-07-06 09:00",
"updatedAt": "2026-07-06 09:00",
"items": [ "items": [
{ {
"id": 2, "id": 2,
@ -187,10 +213,23 @@
{ {
"id": 3, "id": 3,
"orderNo": "102342090", "orderNo": "102342090",
"customerId": "CUST003",
"customerName": "模拟客户C",
"customerLevel": "B",
"orderDate": "2026-01-15",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-01-15", "deliveryDate": "2026-01-15",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 2000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-01-15 09:00",
"updatedAt": "2026-01-15 09:00",
"items": [ "items": [
{ {
"id": 3, "id": 3,
@ -207,10 +246,23 @@
{ {
"id": 4, "id": 4,
"orderNo": "102342085", "orderNo": "102342085",
"customerId": "CUST004",
"customerName": "模拟客户C",
"customerLevel": "B",
"orderDate": "2026-01-15",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-01-15", "deliveryDate": "2026-01-15",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 2000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-01-15 09:00",
"updatedAt": "2026-01-15 09:00",
"items": [ "items": [
{ {
"id": 4, "id": 4,
@ -227,10 +279,23 @@
{ {
"id": 5, "id": 5,
"orderNo": "102342092", "orderNo": "102342092",
"customerId": "CUST005",
"customerName": "模拟客户D",
"customerLevel": "C",
"orderDate": "2026-01-15",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-01-15", "deliveryDate": "2026-01-15",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 4000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-01-15 09:00",
"updatedAt": "2026-01-15 09:00",
"items": [ "items": [
{ {
"id": 5, "id": 5,
@ -247,10 +312,23 @@
{ {
"id": 6, "id": 6,
"orderNo": "102352579", "orderNo": "102352579",
"customerId": "CUST006",
"customerName": "模拟客户A",
"customerLevel": "A",
"orderDate": "2026-04-03",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-04-03", "deliveryDate": "2026-04-03",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 1000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-04-03 09:00",
"updatedAt": "2026-04-03 09:00",
"items": [ "items": [
{ {
"id": 6, "id": 6,
@ -267,10 +345,23 @@
{ {
"id": 7, "id": 7,
"orderNo": "102314266", "orderNo": "102314266",
"customerId": "CUST007",
"customerName": "模拟客户D",
"customerLevel": "C",
"orderDate": "2026-03-16",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-03-16", "deliveryDate": "2026-03-16",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 1000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-03-16 09:00",
"updatedAt": "2026-03-16 09:00",
"items": [ "items": [
{ {
"id": 7, "id": 7,
@ -287,10 +378,23 @@
{ {
"id": 8, "id": 8,
"orderNo": "102375367", "orderNo": "102375367",
"customerId": "CUST008",
"customerName": "模拟客户B",
"customerLevel": "VIP",
"orderDate": "2026-04-03",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-04-03", "deliveryDate": "2026-04-03",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 1000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-04-03 09:00",
"updatedAt": "2026-04-03 09:00",
"items": [ "items": [
{ {
"id": 8, "id": 8,
@ -307,10 +411,23 @@
{ {
"id": 9, "id": 9,
"orderNo": "102375368", "orderNo": "102375368",
"customerId": "CUST009",
"customerName": "模拟客户C",
"customerLevel": "B",
"orderDate": "2026-04-03",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-04-03", "deliveryDate": "2026-04-03",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 1000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-04-03 09:00",
"updatedAt": "2026-04-03 09:00",
"items": [ "items": [
{ {
"id": 9, "id": 9,
@ -327,10 +444,23 @@
{ {
"id": 10, "id": 10,
"orderNo": "102378688", "orderNo": "102378688",
"customerId": "CUST010",
"customerName": "模拟客户A",
"customerLevel": "A",
"orderDate": "2026-04-03",
"status": "APPROVED", "status": "APPROVED",
"deliveryDate": "2026-04-03", "deliveryDate": "2026-04-03",
"priority": 1, "priority": 1,
"manualPriority": null,
"source": "FLEX", "source": "FLEX",
"specialRequirements": "",
"totalAmount": 1000.0,
"isRush": false,
"rushStrategy": null,
"changes": [],
"createdBy": "synthetic-pack",
"createdAt": "2026-04-03 09:00",
"updatedAt": "2026-04-03 09:00",
"items": [ "items": [
{ {
"id": 10, "id": 10,

View File

@ -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)

View File

@ -3,7 +3,7 @@
# 把「一套完整世界数据」封装为可重放的 JSON 包: # 把「一套完整世界数据」封装为可重放的 JSON 包:
# - 演示厂 = server/data/packs/demo.json(由脚本从种子生成) # - 演示厂 = server/data/packs/demo.json(由脚本从种子生成)
# - 行业种子 / 客户现场数据都可做成包,data.reset 即「重放数据包」 # - 行业种子 / 客户现场数据都可做成包,data.reset 即「重放数据包」
# 日期重基:包内记录 baseDate(生成日),加载时把所有 YYYY-MM-DD # 日期重基:包内记录 baseDate(世界业务基准日:演示包=生成日,回放包=案例业务日),加载时把所有 YYYY-MM-DD
# 字面日期整体平移到「今天」,保证任意运行日行为一致(黄金测试稳定)。 # 字面日期整体平移到「今天」,保证任意运行日行为一致(黄金测试稳定)。
# ============================================================ # ============================================================
from __future__ import annotations from __future__ import annotations

View File

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

View File

@ -2,9 +2,11 @@ from __future__ import annotations
import copy import copy
import json import json
from datetime import date
from pathlib import Path from pathlib import Path
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
from server.aps_domain.views import due_view
from server.state.packs import load_pack, list_packs from server.state.packs import load_pack, list_packs
@ -62,3 +64,30 @@ def test_simulation_world_pack_runs_all_optimize_dispatch_rules():
assert result["woCount"] == 72, rule assert result["woCount"] == 72, rule
assert result["validation"]["valid"] is True, rule assert result["validation"]["valid"] is True, rule
assert result["validation"]["hardViolations"] == [], rule assert result["validation"]["hardViolations"] == [], rule
def test_simulation_world_pack_keeps_sales_order_fields_the_due_board_reads():
"""交期承诺看板直取订单客户字段,数据包必须按订单契约补齐(回归:/api/world/due 500)。"""
world = load_pack(str(PACK_PATH))
rows = due_view(world)
assert len(rows) == len(world["salesOrders"]) == 10
assert all(row["customerName"] and row["customerLevel"] for row in rows)
assert all(isinstance(row["isRush"], bool) for row in rows)
assert any(row["isRush"] for row in rows) # 至少一张插单,加急标记在 UI 有数据可验
assert {row["customerLevel"] for row in rows} >= {"VIP", "A", "B", "C"} # 等级过滤有数据可筛
def test_simulation_world_pack_timeline_follows_its_case_business_day():
"""回归:baseDate 必须是案例业务日,否则世界日期随运行日漂移、黄金测试输入每天变。
源案例(kangni-simulation-package-v1)的业务日交期结构固定为「9 张逾期 + 最晚交期在业务日 +6 天」,
世界日期整体贴着 baseDate 平移后,这个结构在任意运行日都应原样落在"今天"上。
"""
world = load_pack(str(PACK_PATH))
due_dates = sorted(date.fromisoformat(order["dueDate"]) for order in world["flexOrders"])
today = date.today()
assert (due_dates[-1] - today).days == 6 # 最晚交期 = 案例业务日 +6 天
assert sum(1 for due in due_dates if due < today) == 9 # 案例本身是 9 张逾期单