aps-agent/server/aps_domain/scenario.py

101 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 方案生成 · 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.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, 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 compare_scenarios(world: World, engine_type: str = "RULE") -> tuple[str, UIBlock]:
"""在沙盒中并行试排多策略并产出方案卡组(权力等级 P1:只读主干 + 写沙盒)。
Args:
world: 主干世界状态(只读,函数内部深拷贝)
engine_type: 引擎类型(M1/M2 由 RULE 承接)
Returns:
(回复文案, scenario-cards UI 块)
"""
baseline = world["scheduleVersions"][-1] if world["scheduleVersions"] else None # 基准=当前最新版本(diff 对照)
start = fmt_date(add_minutes(today0(), 24 * 60)) # 统一起排日:明天(可比性)
cards: list[dict[str, Any]] = [] # 方案卡集合
for strategy, label in _STRATEGIES: # 逐策略沙盒试排
sandbox = copy.deepcopy(world) # Explore 沙盒:深拷贝隔离(§5.1 铁律)
params = EngineParams(orderIds=[], engineType=engine_type, strategyTemplate=strategy,
planningHorizonDays=14, startDate=start) # 试排参数
result: ScheduleResult = get_engine(engine_type).solve(sandbox, params, _sandbox_counter()) # 沙盒求解
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(
(sandbox["scheduleVersions"][-1] or {}).get("totalChangeoverMin") or 0
) if sandbox.get("scheduleVersions") else 0.0,
"poSaved": int(
((sandbox["scheduleVersions"][-1] or {}).get("campaign") or {}).get("poSaved") or 0
) if sandbox.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 sandbox["conflicts"][-result.conflictCount:][:2]] if result.conflictCount else [],
}
cards.append(card) # 收集方案卡
# 组装方案卡组 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}, # 基准版本号(对照展示)
actions=[UIAction(actionId="scenario.apply", label="采用此方案", power="P1", # 采用动作(前端逐卡渲染)
payload={})],
)
# 回复文案:一句话对比结论(找延迟最小者作为推荐)
best = min(cards, key=lambda c: (c["kpi"]["totalTardiness"], c["kpi"]["conflictCount"])) # 简单推荐规则
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) +
f"\n综合看【{best['label']}】表现最好。点方案卡上的“采用此方案”即可正式排产。") # 推荐话术
return text, block # 返回文案与卡组块