# ============================================================ # 方案生成 · 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 # 类型标注 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 from server.contracts import ScheduleResult, UIAction, UIBlock # 契约 from server.engines import get_engine # 引擎工厂 from server.engines.base import EngineParams # 引擎入参 from server.timeutil import add_minutes, fmt_date, parse_dt, today0 # 日期工具 # 世界状态类型别名 World = dict[str, Any] # 对比的策略组合(§9.7 硬规则 2:策略维度多样化,不凑数) _STRATEGIES: list[tuple[str, str]] = [ ("DELIVERY_FIRST", "交期优先"), # EDD 内核:保交期 ("CAPACITY_BALANCE", "产能均衡"), # 均衡负荷:保产线 ("CHANGEOVER_MIN", "换型最小化"), # SC-07:矩阵贪心序 ("CAMPAIGN", "战役合并"), # SC-08:同品窗口合并 ("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 # 返回闭包 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()) def compare_scenarios(world: World, engine_type: str = "RULE") -> tuple[str, UIBlock]: """在沙盒中并行试排多策略并产出方案卡组(权力等级 P1:只读主干 + 写沙盒)。 Args: world: 主干世界状态(只读,函数内部深拷贝) engine_type: 引擎类型(M1/M2 用 RULE 承接) Returns: (回复文案, scenario-cards UI 块) """ 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)