aps-agent/server/engines/pool_engine.py

568 lines
31 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.

# ============================================================
# 柔性排产引擎 PoolEngine(moduleId: engines-pool, 可重生 ✅, 黄金测试 tests/golden/test_pool_engine.py)
# 吸收 demand/ 康尼芜湖方案:设备能力池 + 虚拟产线 + 瓶颈锚 / 正排 / 倒排。
#
# 与固定产线 RuleEngine 的本质区别:
# RuleEngine:订单 → 选一条固定产线 → 工序绑该线工位 → 占工位槽
# PoolEngine:订单 → 每道工序从"能力池"(拥有该工序能力的设备集合)动态取设备
# → 组装一条"虚拟产线"(VL)→ 占设备槽(含模具换型与设备移动耗时)
#
# 数据来源:world 的 flex* 键(见 server/state/seed.py 与 docs/product/demand-data-intake.md)。
# 产物:写入 flexScheduleVersions / flexVirtualLines / flexWorkOrders / flexConflicts。
# 权力等级 P1:只改内存 world,落盘由调用方决定。
# ============================================================
from __future__ import annotations # 前向类型引用
from datetime import datetime # 时间类型
from typing import Any, Callable # 类型标注
from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0 # 日期工具
World = dict[str, Any] # 世界状态类型别名
# 排产模式
SORT_ASC = "ASC" # 正排:自开始日向后
SORT_DESC = "DESC" # 倒排:自交期向前(本切片用交期升序近似)
SORT_BOTTLENECK = "BOTTLENECK" # 瓶颈锚:瓶颈工序优先保障
# 滚动窗口别名 → 默认时长(可被 flexParams.rollingWindows 覆盖)
WINDOW_DEFAULTS = {"realtime": "60m", "short": "2h", "mid": "2d", "long": "7d", "full": "30d"}
def parse_duration(spec: str | None) -> int:
"""解析 '2h'/'2d'/'7d'/'120m' → 分钟数。"""
if not spec:
return 0
s = str(spec).strip().lower()
if s.endswith("d"):
return int(float(s[:-1]) * 24 * 60)
if s.endswith("h"):
return int(float(s[:-1]) * 60)
if s.endswith("m"):
return int(float(s[:-1]))
return int(float(s))
def resolve_window_minutes(params: dict, window: str | None) -> int | None:
"""按 realtime/short/mid/long/full 解析滚动时域分钟;None=不限制(等同 full)。"""
if not window or window.upper() in ("FULL", "ALL", "L4"):
return None
key = window.lower()
if key in ("l2",):
key = "short"
elif key in ("l3", "day"):
key = "mid"
elif key in ("live", "rt", "minute", "min"):
key = "realtime"
rw = (params or {}).get("rollingWindows") or {}
spec = rw.get(key) or WINDOW_DEFAULTS.get(key)
mins = parse_duration(spec)
return mins or None
def window_step_minutes(window: str | None) -> int:
"""滚动层级对应的排产步长:实时 1min / 短窗 15min / 中窗 60min / 长窗 4h。"""
if not window:
return 15
w = window.lower()
if w in ("realtime", "live", "rt", "minute", "min"):
return 1
if w in ("short", "l2"):
return 15
if w in ("mid", "l3", "day"):
return 60
if w in ("long",):
return 240
return 15
class PoolEngine:
"""柔性能力池排产引擎:能力池选设备 + 虚拟产线组装 + 瓶颈感知排序。"""
name = "FLEX" # 引擎标识
def solve(self, world: World, next_id: Callable[[str], int],
sort_mode: str | None = None, order_ids: list[int] | None = None,
start_date: str | None = None, name: str | None = None,
window: str | None = None,
seed_busy: dict[int, list[tuple[datetime, datetime]]] | None = None,
enforce_teams: bool | None = None) -> dict[str, Any]:
"""执行一次柔性排产,返回结果摘要 dict。
Args:
world: 世界状态(读 flex* 主数据,写 flex* 产物表)
next_id: 发号函数 next_id(kind)->int
sort_mode: ASC/DESC/BOTTLENECK(None 时取 flexParams.sortMode)
order_ids: 目标订单 ID(None/空=全部 RELEASED/CREATED)
start_date: 起始日 YYYY-MM-DD(None=明天)
name: 版本名
window: 滚动窗 short/mid/long/full(SC-12);限制精排时域与步长
seed_busy: 预占用设备区间(窗外冻结工单,DY-01 L2/L3)
enforce_teams: 是否启用班组人力并发约束(SC-11;默认有 flexTeams 则启用)
"""
params = world.get("flexParams", {}) # 柔性参数
mode = (sort_mode or params.get("sortMode") or SORT_BOTTLENECK).upper() # 排产模式
if mode not in (SORT_ASC, SORT_DESC, SORT_BOTTLENECK):
mode = SORT_BOTTLENECK # 非法回落瓶颈锚
horizon_min = resolve_window_minutes(params, window)
step_min = window_step_minutes(window)
use_teams = enforce_teams if enforce_teams is not None else bool(world.get("flexTeams"))
from server.aps_domain.constraints import is_enabled, material_shortage_severity, profile_snapshot
if enforce_teams is None:
use_teams = use_teams and is_enabled(world, "C12_team")
kit_sev = material_shortage_severity(world)
use_tooling = is_enabled(world, "C12_tooling")
use_freeze = is_enabled(world, "C11_freeze")
# ---- ① 索引主数据 ----
ops_by_code = {o["code"]: o for o in world.get("flexOperations", [])} # 工序索引
equipment = world.get("flexEquipment", []) # 设备清单
molds = world.get("flexMolds", []) # 模具清单
routings = world.get("flexRoutings", []) # 工艺路线
mats_by_code = {m["code"]: m for m in world.get("flexMaterials", [])} # 物料索引
bom = world.get("flexBom", []) # BOM
team_cap = self._team_capacity(world) if use_teams else {} # 工序→可用人数
# ---- ② 收集待排订单 ----
orders = []
for o in world.get("flexOrders", []):
if order_ids and o["id"] not in order_ids: # 指定集过滤
continue
if o["status"] not in ("RELEASED", "CREATED"): # 仅未完成
continue
orders.append(o)
# ---- ③ 派工排序(吸收排产逻辑 PPT:正排 EDD / 倒排最晚优先 / 瓶颈锚)----
if mode == SORT_DESC:
# 倒排:交期最晚的订单先占资源(自交期向前的派工近似)
orders.sort(key=lambda o: (o["dueDate"], o["priority"]), reverse=True)
elif mode == SORT_BOTTLENECK:
# 瓶颈锚:含瓶颈工序的产品订单优先(保障瓶颈资源不被非瓶颈单挤占),再 EDD
orders.sort(key=lambda o: (0 if self._has_bottleneck(o["productCode"], routings, ops_by_code) else 1,
o["dueDate"], o["priority"]))
else:
# 正排:EDD(最早交期优先)+ 优先级
orders.sort(key=lambda o: (o["dueDate"], o["priority"]))
# ---- ④ 版本对象 ----
wall_now = datetime.now()
business_day = parse_dt(start_date + " 00:00") if start_date else today0()
now = business_day.replace(
hour=wall_now.hour,
minute=wall_now.minute,
second=wall_now.second,
microsecond=wall_now.microsecond,
)
vid = next_id("flexScheduleVersion")
base_start = (parse_dt(start_date + " 08:00") if start_date
else add_minutes(today0(), 24 * 60)) # 默认明天
horizon_end = add_minutes(base_start, horizon_min) if horizon_min else None
version = {
"id": vid,
"versionNo": "FV" + fmt_date(now).replace("-", "") + f"-{len(world['flexScheduleVersions']) + 1:03d}",
"versionName": name or f"柔性排产 {fmt_dt(now)}",
"sortMode": mode, "engineType": "FLEX", "status": "DRAFT",
"window": window or "full",
"windowMinutes": horizon_min,
"stepMinutes": step_min,
"enforceTeams": use_teams,
"orderCount": len(orders), "vlCount": 0, "woCount": 0,
"totalTardiness": 0.0, "avgUtilization": 0.0, "conflictCount": 0,
"bottleneck": self._bottleneck_summary(routings, ops_by_code, equipment),
"constraintProfile": profile_snapshot(world),
"createdBy": "agent", "createdAt": fmt_dt(now),
}
world["flexScheduleVersions"].append(version)
# ---- ⑤ 占用登记(可注入窗外冻结占槽)----
eq_busy: dict[int, list[tuple[datetime, datetime]]] = {
k: list(v) for k, v in (seed_busy or {}).items()
}
eq_last_mold: dict[int, str | None] = {} # 设备 → 上次装的模具(判换型)
eq_used_min: dict[int, float] = {
k: sum((e - s).total_seconds() / 60 for s, e in ivs)
for k, ivs in eq_busy.items()
}
team_busy: dict[str, list[tuple[datetime, datetime]]] = {} # 工序 → 人力占用区间
conflicts: list[dict] = []
new_wo_ids: list[int] = []
deferred = 0
# ---- ⑥ 逐订单组装虚拟产线 ----
for o in orders:
product_steps = self._routing_of(o["productCode"], routings) # 该产品工艺步骤(有序)
if not product_steps:
conflicts.append({"conflictType": "NO_ROUTING", "severity": "CRITICAL",
"resourceType": "ROUTING", "orderNo": o["orderNo"],
"description": f"{o['orderNo']} 产品 {o['productCode']} 无工艺路线",
"suggestedSolution": "维护柔性工艺路线"})
continue
# 物料齐套检查(SC-04:可关/软硬)
material_ready = base_start
if kit_sev:
for b in [x for x in bom if x["productCode"] == o["productCode"]]:
mat = mats_by_code.get(b["materialCode"])
if not mat:
continue
need = b["quantity"] * o["quantity"]
if mat["stock"] < need and mat["stock"] + mat["inTransit"] >= need:
eta = add_minutes(today0(), 3 * 24 * 60) # 在途补货 +3 天
material_ready = max(material_ready, eta)
elif mat["stock"] + mat["inTransit"] < need:
conflicts.append({"conflictType": "MATERIAL_SHORTAGE", "severity": kit_sev,
"resourceType": "MATERIAL", "resourceName": mat["name"], "orderNo": o["orderNo"],
"description": f"{o['orderNo']} {mat['name']} 缺料 "
f"{int(need - mat['stock'] - mat['inTransit'] + 0.999)} {mat['unit']}",
"suggestedSolution": "紧急采购或启用替代物料"})
# SC-12:滚动短/中窗 — 首道工序若已超出时域则整单延期到下一滚动周期
if use_freeze and horizon_end and material_ready >= horizon_end:
deferred += 1
conflicts.append({"conflictType": "WINDOW_DEFERRED", "severity": "MINOR",
"resourceType": "WINDOW", "orderNo": o["orderNo"],
"description": f"{o['orderNo']} 超出滚动窗"
f"({window or 'custom'}={horizon_min}min),本轮不排",
"suggestedSolution": "扩大滚动窗或等待下一滚动周期"})
continue
# Per-order atomic boundary: roll back all artifacts and resource state until the full routing succeeds.
order_wo_start = len(world["flexWorkOrders"])
order_vl_start = len(world["flexVirtualLines"])
version_wo_before = version["woCount"]
version_vl_before = version["vlCount"]
eq_busy_before = {key: list(intervals) for key, intervals in eq_busy.items()}
eq_last_mold_before = dict(eq_last_mold)
eq_used_min_before = dict(eq_used_min)
team_busy_before = {key: list(intervals) for key, intervals in team_busy.items()}
mold_before = [(mold, mold.get("lifeUsed"), mold.get("status")) for mold in molds]
conflict_boundary = len(conflicts)
vl_id = next_id("flexVirtualLine")
vl = {
"id": vl_id, "vlNo": f"VL-{o['orderNo']}", "versionId": vid,
"orderNo": o["orderNo"], "productCode": o["productCode"],
"quantity": o["quantity"], "wbs": o.get("wbs"),
"productionController": o.get("productionController"),
"assignments": [], "plannedStart": None, "plannedEnd": None,
}
cursor = max(base_start, material_ready) # 排产游标
vl_start = None
vl_end = cursor
ok = True
for step in product_steps: # 逐工序占设备
op = ops_by_code.get(step["operationCode"])
need_mold = bool(step.get("requireMold", False)) and use_tooling
op_code = step["operationCode"]
# 能力池:拥有该工序能力且状态可用的设备
pool = [e for e in equipment
if op_code in e.get("capabilities", []) and e.get("status") == "RUNNING"]
if not pool:
conflicts.append({"conflictType": "NO_CAPABILITY", "severity": "CRITICAL",
"resourceType": "EQUIPMENT", "orderNo": o["orderNo"],
"description": f"{o['orderNo']} 工序 {op_code} 无能力设备",
"suggestedSolution": "为设备登记该工序能力或采购设备"})
ok = False
break
# SC-11:无班组覆盖该工序 → 冲突(有班组数据时)
if use_teams and team_cap and op_code not in team_cap:
conflicts.append({"conflictType": "NO_TEAM", "severity": "CRITICAL",
"resourceType": "TEAM", "orderNo": o["orderNo"],
"description": f"{o['orderNo']} 工序 {op_code} 无班组资格覆盖",
"suggestedSolution": "为班组登记 supportOps 或外包该工序"})
ok = False
break
# 选模具(若需要):按工序取可用且有适配设备的模具
chosen_mold = None
if need_mold:
cands = [m for m in molds if m["operationCode"] == op_code
and m.get("status") == "AVAILABLE" and m["lifeUsed"] < m["lifeTotal"]]
if not cands:
conflicts.append({"conflictType": "NO_MOLD", "severity": "CRITICAL",
"resourceType": "MOLD", "orderNo": o["orderNo"],
"description": f"{o['orderNo']} 工序 {op_code} 无可用模具",
"suggestedSolution": "更换/采购模具或解除锁定"})
ok = False
break
chosen_mold = cands[0] # MVP:取第一副可用模具
# 池收敛到能装该模具的设备
pool = [e for e in pool if e["code"] in chosen_mold["adaptableEquipment"]] or pool
# 选设备:能力池内取累计占用最少者(负载均衡 → 虚拟产线动态组装)
chosen = min(pool, key=lambda e: eq_used_min.get(e["id"], 0.0))
# 工时 = 单件工时 × 数量 / 可动率;单件工时优先取路线覆盖,否则设备默认
per = step.get("stdTimePerUnit") or chosen.get("opStdTime", {}).get(op_code, 1)
run_min = (per * o["quantity"]) / (chosen.get("availabilityRate") or 1)
# 换型:换模具或工序自身换型时间
changeover = op.get("changeoverMin", 0) if op else 0
if need_mold and eq_last_mold.get(chosen["id"]) != chosen_mold["code"]:
changeover = max(changeover, chosen_mold.get("changeoverMin", 0))
# 设备移动:可移动设备加一次移动耗时
move_min = chosen.get("moveTimeMin", 0) if chosen.get("movable") else 0
duration = changeover + move_min + run_min
start, end = self._place(
cursor, duration, chosen["id"], eq_busy, world,
step_min=step_min, horizon_end=horizon_end,
team_op=op_code if use_teams else None,
team_busy=team_busy, team_capacity=team_cap.get(op_code, 0) if use_teams else 0,
)
# A routing that crosses the rolling window fails as a whole; no partial work orders survive.
if use_freeze and horizon_end and start >= horizon_end:
conflicts.append({"conflictType": "WINDOW_TRUNCATED", "severity": "MINOR",
"resourceType": "WINDOW", "orderNo": o["orderNo"],
"description": f"{o['orderNo']} 工序 {op_code} 越出滚动窗,后续步骤本轮不排",
"suggestedSolution": "下一滚动周期继续排或升窗到 mid/long"})
deferred += 1
ok = False
break
wo_id = next_id("flexWorkOrder")
wo = {
"id": wo_id, "orderNo": f"{o['orderNo']}-{step['seq']}", "versionId": vid, "vlId": vl_id,
"flexOrderNo": o["orderNo"], "productCode": o["productCode"], "quantity": o["quantity"],
"operationCode": op_code,
"operationName": op["name"] if op else op_code, "seq": step["seq"],
"equipmentId": chosen["id"], "equipmentCode": chosen["code"], "equipmentName": chosen["name"],
"moldCode": chosen_mold["code"] if chosen_mold else None,
"zone": chosen.get("zone"),
"teamCode": self._pick_team(world, op_code),
"changeoverMin": round(changeover, 1), "moveMin": round(move_min, 1),
"runMin": round(run_min, 1),
"plannedStartTime": fmt_dt(start), "plannedEndTime": fmt_dt(end),
"isBottleneck": bool(op and op.get("isBottleneck")),
"status": "PENDING", "frozen": False,
}
world["flexWorkOrders"].append(wo)
new_wo_ids.append(wo_id)
vl["assignments"].append({
"seq": step["seq"], "operationCode": op_code,
"equipmentCode": chosen["code"], "moldCode": wo["moldCode"],
"start": wo["plannedStartTime"], "end": wo["plannedEndTime"],
})
# 登记占用/负载/模具/人力/游标
eq_busy.setdefault(chosen["id"], []).append((start, end))
eq_used_min[chosen["id"]] = eq_used_min.get(chosen["id"], 0.0) + (end - start).total_seconds() / 60
if use_teams and op_code:
team_busy.setdefault(op_code, []).append((start, end))
if chosen_mold:
eq_last_mold[chosen["id"]] = chosen_mold["code"]
# SC-10 / MD-07:排产占用消耗模具寿命;到限自动锁定
chosen_mold["lifeUsed"] = int(chosen_mold.get("lifeUsed", 0)) + int(o["quantity"])
if chosen_mold["lifeUsed"] >= int(chosen_mold.get("lifeTotal") or 0):
chosen_mold["status"] = "LOCKED"
conflicts.append({
"conflictType": "MOLD_LIFE", "severity": "MAJOR",
"resourceType": "MOLD", "resourceName": chosen_mold["code"],
"orderNo": o["orderNo"],
"description": f"模具 {chosen_mold['code']} 寿命已用尽"
f"({chosen_mold['lifeUsed']}/{chosen_mold['lifeTotal']}),已锁定",
"suggestedSolution": "更换模具或重置寿命后解除锁定",
})
cursor = end # 下一工序自本工序结束起(工序 precedence)
vl_start = vl_start or start
vl_end = end
version["woCount"] += 1
if not ok:
# Roll back candidate artifacts and mutable capacity/tooling state for this order.
del world["flexWorkOrders"][order_wo_start:]
del world["flexVirtualLines"][order_vl_start:]
version["woCount"] = version_wo_before
version["vlCount"] = version_vl_before
eq_busy = eq_busy_before
eq_last_mold = eq_last_mold_before
eq_used_min = eq_used_min_before
team_busy = team_busy_before
for mold, life_used, status in mold_before:
mold["lifeUsed"] = life_used
mold["status"] = status
conflicts[conflict_boundary:] = [
conflict for conflict in conflicts[conflict_boundary:]
if conflict.get("conflictType") != "MOLD_LIFE"
]
continue
if not vl["assignments"]:
# Defensive rollback: an empty candidate can never become a production/MES object.
del world["flexWorkOrders"][order_wo_start:]
version["woCount"] = version_wo_before
eq_busy = eq_busy_before
eq_last_mold = eq_last_mold_before
eq_used_min = eq_used_min_before
team_busy = team_busy_before
for mold, life_used, status in mold_before:
mold["lifeUsed"] = life_used
mold["status"] = status
continue
vl["plannedStart"] = fmt_dt(vl_start) if vl_start else None
vl["plannedEnd"] = fmt_dt(vl_end)
world["flexVirtualLines"].append(vl)
version["vlCount"] += 1
# 交期延迟冲突
due = parse_dt(o["dueDate"] + " 18:00")
if vl_end > due:
hours = (vl_end - due).total_seconds() / 3600
version["totalTardiness"] += hours
conflicts.append({"conflictType": "DELAY", "severity": "MAJOR",
"resourceType": "TIME", "orderNo": o["orderNo"],
"description": f"{o['orderNo']} 预计完成晚于交期 {hours:.1f} 小时",
"suggestedSolution": "提高优先级/增派瓶颈设备/分批"})
# ---- ⑦ 冲突入库 ----
for cf in conflicts:
cf["id"] = next_id("flexConflict")
cf["versionId"] = vid
cf["isResolved"] = False
world["flexConflicts"].append(cf)
version["conflictCount"] = len(conflicts)
version["deferredCount"] = deferred
# Recompute counters from committed rows so failed candidates can never drift version metadata.
version["vlCount"] = sum(
1 for vl in world["flexVirtualLines"] if vl.get("versionId") == vid
)
version["woCount"] = sum(
1 for wo in world["flexWorkOrders"] if wo.get("versionId") == vid
)
# ---- ⑧ 设备利用率(瓶颈产能法的观测基础)----
version["avgUtilization"] = self._utilization(world, vid, base_start,
params.get("afterDays", 30))
# ---- ⑨ makespan(最晚完工)与准时单数 ----
my_vls = [v for v in world["flexVirtualLines"] if v["versionId"] == vid]
makespan = max((v["plannedEnd"] for v in my_vls), default=None)
on_time = 0
for v in my_vls:
o = next((x for x in world["flexOrders"] if x["orderNo"] == v["orderNo"]), None)
if o and v["plannedEnd"] and parse_dt(v["plannedEnd"]) <= parse_dt(o["dueDate"] + " 18:00"):
on_time += 1
version["makespan"] = makespan
version["onTimeCount"] = on_time
return {
"versionId": vid, "versionNo": version["versionNo"], "engineType": "FLEX",
"sortMode": mode, "status": "DRAFT",
"window": version["window"], "windowMinutes": horizon_min, "stepMinutes": step_min,
"enforceTeams": use_teams, "deferredCount": deferred,
"orderCount": version["orderCount"], "vlCount": version["vlCount"], "woCount": version["woCount"],
"conflictCount": version["conflictCount"], "totalTardiness": version["totalTardiness"],
"avgUtilization": version["avgUtilization"], "bottleneck": version["bottleneck"],
"makespan": makespan, "onTimeCount": on_time,
}
# ---------------- 占槽:设备级 + 可选班组并发(SC-11) ----------------
def _place(self, cursor: datetime, duration_min: float, eq_id: int,
eq_busy: dict[int, list[tuple[datetime, datetime]]], world: World,
step_min: int = 15, horizon_end: datetime | None = None,
team_op: str | None = None,
team_busy: dict[str, list[tuple[datetime, datetime]]] | None = None,
team_capacity: int = 0) -> tuple[datetime, datetime]:
"""在设备时间线上找首个不与已占区间重叠的起点;可选班组并发上限。"""
shifts = world.get("flexCalendar", [])
workdays = shifts[0].get("workdays", [1, 2, 3, 4, 5]) if shifts else [1, 2, 3, 4, 5]
intervals = eq_busy.get(eq_id, [])
t = cursor
max_steps = max(30 * 24 * 4, int((30 * 24 * 60) / max(step_min, 1)))
for _ in range(max_steps):
if horizon_end and t >= horizon_end:
break
if (t.isoweekday() in workdays) and self._in_shift(t, shifts):
end = add_minutes(t, duration_min)
overlap = any(t < iv_end and end > iv_start for iv_start, iv_end in intervals)
team_ok = True
if team_op and team_capacity > 0 and team_busy is not None:
concurrent = sum(1 for s, e in team_busy.get(team_op, []) if t < e and end > s)
team_ok = concurrent < team_capacity
if not overlap and team_ok:
return (t, end)
t = add_minutes(t, step_min)
return (cursor, add_minutes(cursor, duration_min)) # 兜底硬排
@staticmethod
def _team_capacity(world: World) -> dict[str, int]:
"""工序 → 可用班组人数合计(supportOps 覆盖)。"""
cap: dict[str, int] = {}
for team in world.get("flexTeams", []) or []:
n = int(team.get("memberCount") or 0)
for op in team.get("supportOps") or []:
cap[op] = cap.get(op, 0) + n
return cap
@staticmethod
def _pick_team(world: World, op_code: str) -> str | None:
"""为工序挑一个覆盖班组编码(展示用)。"""
for team in world.get("flexTeams", []) or []:
if op_code in (team.get("supportOps") or []):
return team.get("code")
return None
@staticmethod
def _in_shift(t: datetime, shifts: list[dict]) -> bool:
"""t 是否落在任一班次工作时段内(扣除休息段)。"""
if not shifts:
return True
for sh in shifts:
s = datetime.strptime(sh["startTime"], "%H:%M").time()
e = datetime.strptime(sh["endTime"], "%H:%M").time()
if s <= t.time() < e:
for bp in sh.get("breaks", []):
bs = datetime.strptime(bp["start"], "%H:%M").time()
be = datetime.strptime(bp["end"], "%H:%M").time()
if bs <= t.time() < be:
return False # 落在休息段
return True
return False
# ---------------- 辅助 ----------------
@staticmethod
def _routing_of(product_code: str, routings: list[dict]) -> list[dict]:
"""取产品工艺步骤(按 seq 升序)。"""
steps = [r for r in routings if r["productCode"] == product_code]
return sorted(steps, key=lambda r: r["seq"])
def _has_bottleneck(self, product_code: str, routings: list[dict], ops_by_code: dict) -> bool:
"""产品是否含瓶颈工序。"""
for s in self._routing_of(product_code, routings):
op = ops_by_code.get(s["operationCode"])
if op and op.get("isBottleneck"):
return True
return False
@staticmethod
def _bottleneck_summary(routings: list[dict], ops_by_code: dict, equipment: list[dict]) -> list[dict]:
"""瓶颈工序清单及其能力池设备数(瓶颈产能法的观测口径)。"""
out = []
seen = set()
for r in routings:
code = r["operationCode"]
if code in seen:
continue
op = ops_by_code.get(code)
if op and op.get("isBottleneck"):
seen.add(code)
pool_n = sum(1 for e in equipment if code in e.get("capabilities", []))
out.append({"operationCode": code, "operationName": op["name"], "poolEquipmentCount": pool_n})
return out
@staticmethod
def _utilization(world: World, vid: int, base_start: datetime, horizon_days: int) -> float:
"""本版本设备平均利用率(占用分钟 ÷ 展望期内单班可用分钟)。"""
wos = [w for w in world["flexWorkOrders"] if w["versionId"] == vid]
if not wos:
return 0.0
used: dict[int, float] = {}
for w in wos:
dur = (parse_dt(w["plannedEndTime"]) - parse_dt(w["plannedStartTime"])).total_seconds() / 60
used[w["equipmentId"]] = used.get(w["equipmentId"], 0.0) + dur
# 单班有效分钟 ≈ 8h(480min),工作日按展望期内 5/7 估算
avail_per_eq = 480 * max(1, int(horizon_days * 5 / 7))
utils = [min(u / avail_per_eq, 1.5) for u in used.values()]
return round(sum(utils) / len(utils), 4) if utils else 0.0