278 lines
11 KiB
Plaintext
278 lines
11 KiB
Plaintext
|
|
# ============================================================
|
|||
|
|
# 主控参数敏感性分析(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"),
|
|||
|
|
},
|
|||
|
|
)
|