aps-agent/server/aps_domain/scenario.py

179 lines
10 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.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
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 # 日期工具
# 世界状态类型别名
World = dict[str, Any]
# 对比的策略组合(§9.7 硬规则 2:策略维度多样化,不凑数)
_STRATEGIES: list[tuple[str, str]] = [
("DELIVERY_FIRST", "交期优先"), # EDD 内核:保交期
("KITTING_FIRST", "齐套优先"), # 备料已完成先排
("SKILL_FIRST", "技能优先"), # 高技能需求先排
("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 _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())
def compare_scenarios(world: World, engine_type: str = "RULE") -> tuple[str, UIBlock]:
"""在沙盒中并行试排多策略并产出方案卡组(权力等级 P1:只读主干 + 写沙盒)。
Args:
world: 主干世界状态(只读,函数内部深拷贝)
engine_type: 引擎类型(M1/M2 用 RULE 承接)
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)