# ============================================================ # 主控参数敏感性分析(moduleId: domain-sensitivity, SC-06,可重生 ✅) # 沙盒 one-at-a-time 扰动 + 固定种子蒙特卡洛;永不写主干 # ============================================================ from __future__ import annotations import copy import uuid from typing import Any from server.aps_domain.params import get_schedule_params from server.aps_domain.robustness import run_monte_carlo from server.contracts import UIBlock from server.engines import get_engine from server.engines.base import EngineParams from server.timeutil import add_minutes, fmt_date, today0 World = dict[str, Any] def _sandbox_counter(): counters: dict[str, int] = {} def next_id(kind: str) -> int: counters[kind] = counters.get(kind, 0) + 2000000 return counters[kind] return next_id def _kpi_from_result(result, sandbox: World) -> dict[str, float]: ver = sandbox["scheduleVersions"][-1] if sandbox.get("scheduleVersions") else {} return { "tardiness": round(float(result.totalTardiness), 2), "conflicts": float(result.conflictCount), "utilization": round(float(result.avgUtilization), 4), "changeoverMin": float(ver.get("totalChangeoverMin") or 0), "poCount": float(result.poCount), "woCount": float(result.woCount), } def _run_sandbox( world: World, *, strategy: str, horizon: int, start: str, vip_weight: float | None = None, delivery_buffer: float | None = None, freeze_hours: float | None = None, efficiency_scale: float | None = None, ) -> dict[str, float]: sandbox = copy.deepcopy(world) sandbox["scheduleVersions"] = [] sandbox["productionOrders"] = [p for p in sandbox.get("productionOrders", []) if p.get("status") == "PUBLISHED"] sandbox["workOrders"] = [w for w in sandbox.get("workOrders", []) if w.get("status") not in ("PENDING", "DRAFT", None)] # 清空草稿工单/PO,避免干扰;简化:只保留非本沙盒相关——种子通常无已发布,直接清空排产结果 sandbox["productionOrders"] = [] sandbox["workOrders"] = [] sandbox["conflicts"] = [] sp = sandbox.setdefault("scheduleParams", {}) if vip_weight is not None: levels = dict(sp.get("customerLevelWeights") or {}) levels["VIP"] = float(vip_weight) sp["customerLevelWeights"] = levels if efficiency_scale is not None and efficiency_scale > 0: for ln in sandbox.get("lines") or []: base = float(ln.get("efficiencyFactor") or 1.0) ln["efficiencyFactor"] = round(base * float(efficiency_scale), 4) params = EngineParams( orderIds=[], engineType="RULE", strategyTemplate=strategy, planningHorizonDays=horizon, startDate=start, deliveryBufferRatio=delivery_buffer, freezeWindowHours=freeze_hours, name=f"sensitivity-{uuid.uuid4().hex[:6]}", constraints={ "materialKit": False, "equipment": True, "personnel": True, "changeover": True, "capacity": True, "dueDate": True, "tooling": True, "freeze": True, }, ) result = get_engine("RULE").solve(sandbox, params, _sandbox_counter()) return _kpi_from_result(result, sandbox) def run_sensitivity( world: World, *, strategy: str = "COMPREHENSIVE", ) -> dict[str, Any]: """ SC-06:Tornado 单因子排序 + 固定种子蒙特卡洛鲁棒性。 经统一 Explore 通道执行(矩阵 55 行):fn 只拿深拷贝沙盒,主干永不外泄写引用; _run_sandbox 内部再做子沙盒扰动。 """ from server.aps_domain.explore_boundary import run_explore return run_explore(world, lambda sandbox: _sensitivity_impl( sandbox, strategy=strategy, )) def _sensitivity_impl(world: World, *, strategy: str) -> dict[str, Any]: """Tornado/蒙特卡洛核心实现(在统一通道沙盒内运行)。""" sp = get_schedule_params(world) base_horizon = int(sp.get("planningHorizonDays") or 14) base_vip = float((sp.get("customerLevelWeights") or {}).get("VIP") or 3.0) base_buffer = float(sp.get("deliveryBufferRatio") or 0.95) base_freeze = float(sp.get("freezeWindowHours") or 24) start = fmt_date(add_minutes(today0(), 24 * 60)) baseline = _run_sandbox( world, strategy=strategy, horizon=base_horizon, start=start, vip_weight=base_vip, delivery_buffer=base_buffer, freeze_hours=base_freeze, efficiency_scale=1.0, ) # 每因子:低/高 两档(相对基线) factors: list[dict[str, Any]] = [ { "id": "planningHorizonDays", "label": "展望期(天)", "baseline": base_horizon, "low": {"label": f"{max(3, base_horizon // 2)} 天", "kwargs": {"horizon": max(3, base_horizon // 2)}}, "high": {"label": f"{min(60, base_horizon * 2)} 天", "kwargs": {"horizon": min(60, base_horizon * 2)}}, }, { "id": "vipWeight", "label": "VIP 等级权重", "baseline": base_vip, "low": {"label": "VIP=1", "kwargs": {"vip_weight": 1.0}}, "high": {"label": "VIP=10", "kwargs": {"vip_weight": 10.0}}, }, { "id": "deliveryBufferRatio", "label": "交期缓冲比", "baseline": base_buffer, "low": {"label": "缓冲 0.85", "kwargs": {"delivery_buffer": 0.85}}, "high": {"label": "缓冲 1.00", "kwargs": {"delivery_buffer": 1.0}}, }, { "id": "freezeWindowHours", "label": "冻结窗口(时)", "baseline": base_freeze, "low": {"label": "冻结 0h", "kwargs": {"freeze_hours": 0.0}}, "high": {"label": "冻结 48h", "kwargs": {"freeze_hours": 48.0}}, }, { "id": "lineEfficiency", "label": "产线效率系数", "baseline": 1.0, "low": {"label": "效率×0.8", "kwargs": {"efficiency_scale": 0.8}}, "high": {"label": "效率×1.2", "kwargs": {"efficiency_scale": 1.2}}, }, ] rows: list[dict[str, Any]] = [] for fac in factors: common = { "strategy": strategy, "horizon": base_horizon, "start": start, "vip_weight": base_vip, "delivery_buffer": base_buffer, "freeze_hours": base_freeze, "efficiency_scale": 1.0, } low_kw = {**common, **fac["low"]["kwargs"]} high_kw = {**common, **fac["high"]["kwargs"]} # kwargs 用 horizon 键 def _call(kw: dict) -> dict[str, float]: return _run_sandbox( world, strategy=kw["strategy"], horizon=int(kw["horizon"]), start=kw["start"], vip_weight=kw.get("vip_weight"), delivery_buffer=kw.get("delivery_buffer"), freeze_hours=kw.get("freeze_hours"), efficiency_scale=kw.get("efficiency_scale"), ) low_kpi = _call(low_kw) high_kpi = _call(high_kw) d_low = round(low_kpi["tardiness"] - baseline["tardiness"], 2) d_high = round(high_kpi["tardiness"] - baseline["tardiness"], 2) rows.append({ "factorId": fac["id"], "label": fac["label"], "baselineValue": fac["baseline"], "lowLabel": fac["low"]["label"], "highLabel": fac["high"]["label"], "low": { "tardiness": low_kpi["tardiness"], "conflicts": low_kpi["conflicts"], "utilization": low_kpi["utilization"], "deltaTardiness": d_low, "deltaConflicts": round(low_kpi["conflicts"] - baseline["conflicts"], 1), }, "high": { "tardiness": high_kpi["tardiness"], "conflicts": high_kpi["conflicts"], "utilization": high_kpi["utilization"], "deltaTardiness": d_high, "deltaConflicts": round(high_kpi["conflicts"] - baseline["conflicts"], 1), }, "swing": round(abs(d_low) + abs(d_high), 2), "impactMetric": "tardiness", }) rows.sort(key=lambda r: r["swing"], reverse=True) monte_carlo = run_monte_carlo( world, strategy=strategy, engine_type="RULE", baseline_kpi=baseline, planning_horizon_days=base_horizon, start_date=start, delivery_buffer_ratio=base_buffer, freeze_window_hours=base_freeze, constraints={ "materialKit": False, "equipment": True, "personnel": True, "changeover": True, "capacity": True, "dueDate": True, "tooling": True, "freeze": True, }, reset_schedule_products=True, ) md_lines = [ "# 敏感性分析(Tornado)", "", f"- 策略基线:`{strategy}`", f"- 基线 KPI:延期 **{baseline['tardiness']}** h · 冲突 **{int(baseline['conflicts'])}** · " f"利用率 **{round(baseline['utilization'] * 100, 1)}%** · 换型 **{baseline['changeoverMin']}** 分", "- 说明:沙盒 one-at-a-time,不写主干;超时秒数对 RULE 无意义,未纳入。", "", "| 因子 | 低档 Δ延期 | 高档 Δ延期 | 摆幅 |", "| --- | ---: | ---: | ---: |", ] for r in rows: md_lines.append( f"| {r['label']}({r['lowLabel']} / {r['highLabel']}) | " f"{r['low']['deltaTardiness']:+} | {r['high']['deltaTardiness']:+} | {r['swing']} |" ) md_lines.append("") md_lines.append("摆幅 = |低档Δ延期| + |高档Δ延期|;越大表示该参数对延期越敏感。") md_lines.extend([ "", "## 固定种子蒙特卡洛鲁棒性", "", f"- 种子:`{monte_carlo['seed']}` · 样本:**{monte_carlo['trials']}** 次", f"- 鲁棒性:**{round(monte_carlo['robustness'] * 100, 1)}%**", f"- 延期分布:P50 **{monte_carlo['distribution']['tardinessP50']}h** · " f"P90 **{monte_carlo['distribution']['tardinessP90']}h**", ]) return { "strategy": strategy, "baseline": baseline, "rows": rows, "monteCarlo": monte_carlo, "markdown": "\n".join(md_lines), "hint": "敏感性只跑沙盒。若要固化参数,请到设置 → 排产参数 改权重/展望期并确认(P2)。", } def sensitivity_as_block(report: dict[str, Any]) -> UIBlock: """产出 report UI 块(可预览/下载)。""" return UIBlock( blockId=f"sensitivity-{uuid.uuid4().hex[:8]}", type="report", props={ "reportId": uuid.uuid4().hex[:10], "title": f"敏感性分析 Tornado({report.get('strategy')})", "markdown": report.get("markdown") or "", "reportType": "sensitivity-tornado", "baseline": report.get("baseline"), "rows": report.get("rows"), "monteCarlo": report.get("monteCarlo"), }, )