aps-agent/server/aps_domain/sensitivity.py.bak33

278 lines
11 KiB
Plaintext
Raw Normal View History

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