2026-07-21 11:05:57 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 方案生成 · Explore 沙盒多策略对比(moduleId: domain-scenario, 可重生 ✅)
|
|
|
|
|
|
# plan.md §9.7 + §5.1:一次"对比"在深拷贝沙盒里并行试排多个策略,
|
|
|
|
|
|
# 永不触碰主干世界状态(黄金测试 test_m2_state 固化此不变式);
|
|
|
|
|
|
# 产出方案卡组(ScenarioCard 的 M2 子集),计划员"看验收选方案"。
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations # 前向类型引用
|
|
|
|
|
|
|
|
|
|
|
|
import copy # 沙盒深拷贝
|
|
|
|
|
|
import uuid # 方案/块 ID
|
|
|
|
|
|
from typing import Any # 类型标注
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
from server.aps_domain.constraints import hard_blocking_conflicts
|
|
|
|
|
|
from server.aps_domain.params import get_schedule_params
|
|
|
|
|
|
from server.aps_domain.robustness import run_monte_carlo
|
|
|
|
|
|
from server.aps_domain.scenario_selection import load_balance_score, rank_scenarios
|
2026-07-21 11:05:57 +08:00
|
|
|
|
from server.contracts import ScheduleResult, UIAction, UIBlock # 契约
|
|
|
|
|
|
from server.engines import get_engine # 引擎工厂
|
|
|
|
|
|
from server.engines.base import EngineParams # 引擎入参
|
2026-08-11 00:54:05 +08:00
|
|
|
|
from server.timeutil import add_minutes, fmt_date, parse_dt, today0 # 日期工具
|
2026-07-21 11:05:57 +08:00
|
|
|
|
|
|
|
|
|
|
# 世界状态类型别名
|
|
|
|
|
|
World = dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
# 对比的策略组合(§9.7 硬规则 2:策略维度多样化,不凑数)
|
|
|
|
|
|
_STRATEGIES: list[tuple[str, str]] = [
|
|
|
|
|
|
("DELIVERY_FIRST", "交期优先"), # EDD 内核:保交期
|
2026-08-26 00:25:46 +08:00
|
|
|
|
("KITTING_FIRST", "齐套优先"), # 备料已完成先排
|
|
|
|
|
|
("SKILL_FIRST", "技能优先"), # 高技能需求先排
|
2026-07-21 11:05:57 +08:00
|
|
|
|
("CAPACITY_BALANCE", "产能均衡"), # 均衡负荷:保产线
|
2026-07-23 13:38:43 +08:00
|
|
|
|
("CHANGEOVER_MIN", "换型最小化"), # SC-07:矩阵贪心序
|
|
|
|
|
|
("CAMPAIGN", "战役合并"), # SC-08:同品窗口合并
|
2026-07-21 11:05:57 +08:00
|
|
|
|
("COMPREHENSIVE", "综合优化"), # 默认权衡
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sandbox_counter():
|
|
|
|
|
|
"""沙盒专用发号器:与主干 WorldStore 计数器完全隔离(防串号)。"""
|
|
|
|
|
|
counters: dict[str, int] = {} # 沙盒内独立计数
|
|
|
|
|
|
def next_id(kind: str) -> int: # 闭包发号
|
|
|
|
|
|
counters[kind] = counters.get(kind, 0) + 1000000 # 加大基数以便肉眼区分沙盒对象
|
|
|
|
|
|
return counters[kind] # 返回号
|
|
|
|
|
|
return next_id # 返回闭包
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
def _load_balance(sandbox: World, version_id: int) -> float:
|
|
|
|
|
|
loads = {int(line["id"]): 0.0 for line in sandbox.get("lines") or []}
|
|
|
|
|
|
for work_order in sandbox.get("workOrders") or []:
|
|
|
|
|
|
production_order = next(
|
|
|
|
|
|
(
|
|
|
|
|
|
row for row in sandbox.get("productionOrders") or []
|
|
|
|
|
|
if row.get("id") == work_order.get("productionOrderId")
|
|
|
|
|
|
),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not production_order or production_order.get("schedulingVersionId") != version_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
start_at = parse_dt(work_order["plannedStartTime"])
|
|
|
|
|
|
end_at = parse_dt(work_order["plannedEndTime"])
|
|
|
|
|
|
duration = max(0.0, (end_at - start_at).total_seconds() / 60.0)
|
|
|
|
|
|
line_id = int(work_order["lineId"])
|
|
|
|
|
|
loads[line_id] = loads.get(line_id, 0.0) + duration
|
|
|
|
|
|
return load_balance_score(loads.values())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:05:57 +08:00
|
|
|
|
def compare_scenarios(world: World, engine_type: str = "RULE") -> tuple[str, UIBlock]:
|
|
|
|
|
|
"""在沙盒中并行试排多策略并产出方案卡组(权力等级 P1:只读主干 + 写沙盒)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
world: 主干世界状态(只读,函数内部深拷贝)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
engine_type: 引擎类型(M1/M2 用 RULE 承接)
|
2026-07-21 11:05:57 +08:00
|
|
|
|
Returns:
|
|
|
|
|
|
(回复文案, scenario-cards UI 块)
|
|
|
|
|
|
"""
|
2026-08-11 00:54:05 +08:00
|
|
|
|
from server.aps_domain.explore_boundary import readonly_view, run_explore
|
|
|
|
|
|
|
|
|
|
|
|
def _explore(sandbox: dict[str, Any]) -> tuple[str, UIBlock]:
|
|
|
|
|
|
baseline_view = readonly_view(sandbox) # 统一通道内只读视图(矩阵 55 行)
|
|
|
|
|
|
baseline = (baseline_view["scheduleVersions"][-1]
|
|
|
|
|
|
if baseline_view.get("scheduleVersions") else None)
|
|
|
|
|
|
start = fmt_date(add_minutes(today0(), 24 * 60)) # 统一起排日:明天(可比性)
|
|
|
|
|
|
cards: list[dict[str, Any]] = [] # 方案卡集合
|
|
|
|
|
|
for strategy, label in _STRATEGIES: # 逐策略沙盒试排(子沙盒仍深拷贝隔离)
|
|
|
|
|
|
child = copy.deepcopy(sandbox) # 策略级沙盒:互不污染
|
|
|
|
|
|
params = EngineParams(orderIds=[], engineType=engine_type, strategyTemplate=strategy,
|
|
|
|
|
|
planningHorizonDays=14, startDate=start) # 试排参数
|
|
|
|
|
|
result: ScheduleResult = get_engine(engine_type).solve(child, params, _sandbox_counter()) # 沙盒求解
|
|
|
|
|
|
hard_violations = hard_blocking_conflicts(child, result.versionId, track="fixed")
|
|
|
|
|
|
card = { # 方案卡(ScenarioCard 的 M2 子集)
|
|
|
|
|
|
"scenarioId": uuid.uuid4().hex[:8], # 方案 ID(M2:方案即"可采用的策略",M3 起挂真分支)
|
|
|
|
|
|
"label": label, # 中文名
|
|
|
|
|
|
"strategy": strategy, # 策略模板(采用时重跑用——引擎确定性保证结果一致)
|
|
|
|
|
|
"engine": engine_type, # 引擎类型
|
|
|
|
|
|
"kpi": { # KPI 组(对比维度)
|
|
|
|
|
|
"poCount": result.poCount, "woCount": result.woCount,
|
|
|
|
|
|
"conflictCount": result.conflictCount,
|
|
|
|
|
|
"totalTardiness": round(result.totalTardiness, 1),
|
|
|
|
|
|
"avgUtilization": round(result.avgUtilization, 3),
|
|
|
|
|
|
"totalCost": round(result.totalCost),
|
|
|
|
|
|
"totalChangeoverMin": float(
|
|
|
|
|
|
(child["scheduleVersions"][-1] or {}).get("totalChangeoverMin") or 0
|
|
|
|
|
|
) if child.get("scheduleVersions") else 0.0,
|
|
|
|
|
|
"loadBalance": _load_balance(child, result.versionId),
|
|
|
|
|
|
"poSaved": int(
|
|
|
|
|
|
((child["scheduleVersions"][-1] or {}).get("campaign") or {}).get("poSaved") or 0
|
|
|
|
|
|
) if child.get("scheduleVersions") else 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
# 相对基准版本的差异摘要(§9.7 diff_vs_baseline 的 M2 简化)
|
|
|
|
|
|
"diffVsBaseline": ({
|
|
|
|
|
|
"tardiness": round(result.totalTardiness - baseline["totalTardiness"], 1),
|
|
|
|
|
|
"conflicts": result.conflictCount - baseline["conflictCount"],
|
|
|
|
|
|
"utilization": round(result.avgUtilization - baseline["avgUtilization"], 3),
|
|
|
|
|
|
} if baseline else None),
|
|
|
|
|
|
# 风险提示:从沙盒冲突里提炼要点(最多 2 条)
|
|
|
|
|
|
"risks": [c["description"] for c in child["conflicts"][-result.conflictCount:][:2]] if result.conflictCount else [],
|
|
|
|
|
|
"hardFeasible": not hard_violations,
|
|
|
|
|
|
"hardViolationCount": len(hard_violations),
|
|
|
|
|
|
"hardViolations": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"constraintId": violation.get("constraintId"),
|
|
|
|
|
|
"conflictType": violation.get("conflictType"),
|
|
|
|
|
|
"description": violation.get("description"),
|
|
|
|
|
|
}
|
|
|
|
|
|
for violation in hard_violations[:5]
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
robustness = run_monte_carlo(
|
|
|
|
|
|
sandbox,
|
|
|
|
|
|
strategy=strategy,
|
|
|
|
|
|
engine_type=engine_type,
|
|
|
|
|
|
baseline_kpi=card["kpi"],
|
|
|
|
|
|
)
|
|
|
|
|
|
card["robustness"] = robustness["robustness"]
|
|
|
|
|
|
card["robustnessDetail"] = {
|
|
|
|
|
|
key: value for key, value in robustness.items() if key != "outcomes"
|
|
|
|
|
|
}
|
|
|
|
|
|
cards.append(card) # 收集方案卡
|
|
|
|
|
|
|
|
|
|
|
|
configured_weights = get_schedule_params(sandbox).get("weights") or {}
|
|
|
|
|
|
cards = rank_scenarios(cards, weights={
|
|
|
|
|
|
"totalTardiness": configured_weights.get("tardiness", 0.4),
|
|
|
|
|
|
"totalCost": configured_weights.get("cost", 0.3),
|
|
|
|
|
|
"avgUtilization": configured_weights.get("utilization", 0.2),
|
|
|
|
|
|
"loadBalance": configured_weights.get("balance", 0.1),
|
|
|
|
|
|
"conflictCount": 0.0,
|
|
|
|
|
|
"totalChangeoverMin": 0.0,
|
|
|
|
|
|
})
|
|
|
|
|
|
recommended = next((card for card in cards if card["isRecommended"]), None)
|
|
|
|
|
|
# 组装方案卡组 UI 块(§6.2 scenario-cards 类型;每卡带"采用"动作 P1)
|
|
|
|
|
|
block = UIBlock(
|
|
|
|
|
|
blockId=f"scenarios-{uuid.uuid4().hex[:8]}", # 块 ID
|
|
|
|
|
|
type="scenario-cards", # 块类型
|
|
|
|
|
|
props={"cards": cards, # 卡组数据
|
|
|
|
|
|
"baseline": baseline["versionNo"] if baseline else None,
|
|
|
|
|
|
"recommendationId": recommended["scenarioId"] if recommended else None,
|
|
|
|
|
|
"paretoScenarioIds": [card["scenarioId"] for card in cards if card["isPareto"]],
|
|
|
|
|
|
"selectionMethod": "hard-filter→pareto→normalized-weighted→robustness",
|
|
|
|
|
|
}, # 基准版本号(对照展示)
|
|
|
|
|
|
actions=[UIAction(actionId="scenario.apply", label="采用此方案", power="P1", # 采用动作(前端逐卡渲染)
|
|
|
|
|
|
payload={})],
|
|
|
|
|
|
)
|
|
|
|
|
|
# 回复文案与结构化推荐使用同一排序结果,避免 UI 与文本口径漂移。
|
|
|
|
|
|
text = (f"已在沙盒中并行试排{len(cards)} 种策略(不影响当前方案):\n" +
|
|
|
|
|
|
"\n".join(f"路 {c['label']}:延误 {c['kpi']['totalTardiness']}h / 冲突 {c['kpi']['conflictCount']} / "
|
|
|
|
|
|
f"利用率 {round(c['kpi']['avgUtilization'] * 100)}%" for c in cards))
|
|
|
|
|
|
if recommended:
|
|
|
|
|
|
text += (
|
|
|
|
|
|
f"\n帕累托与归一化加权排序推荐【{recommended['label']}】"
|
|
|
|
|
|
f"(得分 {recommended['weightedScore']},"
|
|
|
|
|
|
f"鲁棒性 {round(float(recommended.get('robustness') or 0) * 100)}%)。"
|
|
|
|
|
|
f"点方案卡上的“采用此方案”即可正式排产。"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
text += "\n所有候选均违反硬约束,本轮不推荐采用;请先处理硬冲突后重新试排。"
|
|
|
|
|
|
return text, block # _explore 内部返回文案与卡组块
|
|
|
|
|
|
|
|
|
|
|
|
# 统一 Explore 通道:主干只读、沙盒可写(矩阵 55 行)
|
|
|
|
|
|
return run_explore(world, _explore)
|