aps-agent/server/aps_domain/scenario.py

177 lines
10 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# 方案生成 · 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
2026-07-21 11:05:57 +08:00
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 # 日期工具
2026-07-21 11:05:57 +08:00
# 世界状态类型别名
World = dict[str, Any]
# 对比的策略组合(§9.7 硬规则 2:策略维度多样化,不凑数)
_STRATEGIES: list[tuple[str, str]] = [
("DELIVERY_FIRST", "交期优先"), # EDD 内核:保交期
("CAPACITY_BALANCE", "产能均衡"), # 均衡负荷:保产线
("CHANGEOVER_MIN", "换型最小化"), # SC-07:矩阵贪心序
("CAMPAIGN", "战役合并"), # SC-08:同品窗口合并
2026-07-21 11:05:57 +08:00
("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())
2026-07-21 11:05:57 +08:00
def compare_scenarios(world: World, engine_type: str = "RULE") -> tuple[str, UIBlock]:
"""在沙盒中并行试排多策略并产出方案卡组(权力等级 P1:只读主干 + 写沙盒)。
Args:
world: 主干世界状态(只读,函数内部深拷贝)
engine_type: 引擎类型(M1/M2 用 RULE 承接)
2026-07-21 11:05:57 +08:00
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)