aps-agent/scripts/flex_simulation.py

243 lines
13 KiB
Python
Raw Permalink 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.

"""康尼芜湖柔性排产 · 全流程模拟(in-process,不依赖服务端)。
跑通 demand/ 方案的完整链路并打印可读结果:
0 主数据与能力池概览
1 初始排产(瓶颈锚)→ 虚拟产线组装 + 甘特文本
2 瓶颈产能法评估(替代台账法)
3 三种排产模式对比(正排/倒排/瓶颈锚)
4 交期承诺模拟(询单 → 乐观/预计完工)
5 动态调度A:紧急插单(VIP 高优先小批量)
6 动态调度B:设备故障(压接机停机 → 产能池收缩 → 重排)
运行: .venv\\Scripts\\python.exe scripts\\flex_simulation.py
"""
from __future__ import annotations
import os
import sys
# 让脚本从仓库根可直接运行(把仓库根加入模块搜索路径)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Windows 控制台 UTF-8(中文输出)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from server.aps_domain.flex import capability_pools, capacity_analysis # 领域能力
from server.engines import PoolEngine # 柔性引擎
from server.state.seed import seed_world # 种子(含 flex*)
from server.timeutil import add_minutes, fmt_date, today0 # 日期工具
# ---------------- 通用工具 ----------------
def rule(title: str) -> None:
"""分隔标题。"""
print("\n" + "=" * 72)
print(f" {title}")
print("=" * 72)
def sub(title: str) -> None:
print(f"\n— {title} —")
def counter():
"""独立发号器(纯内存、确定性)。"""
c: dict[str, int] = {}
def nid(kind: str) -> int:
c[kind] = c.get(kind, 0) + 1
return c[kind]
return nid
def run_flex(world, sort_mode: str, start_date: str):
"""在给定 world 上跑一次柔性排产(清空既有产物,隔离多次运行)。"""
for k in ("flexScheduleVersions", "flexVirtualLines", "flexWorkOrders", "flexConflicts"):
world[k] = []
return PoolEngine().solve(world, counter(), sort_mode=sort_mode, start_date=start_date)
def product_name(world, code: str) -> str:
m = next((x for x in world["flexMaterials"] if x["code"] == code), None)
return m["name"] if m else code
def print_virtual_lines(world, limit_steps: bool = False) -> None:
"""打印本版本每条虚拟产线的工序-设备-模具-时间。"""
vls = world["flexVirtualLines"]
wos = {(w["vlId"], w["seq"]): w for w in world["flexWorkOrders"]}
for vl in vls:
pname = product_name(world, vl["productCode"])
print(f"\n ▶ {vl['vlNo']} {pname} × {vl['quantity']}套 "
f"[{vl['plannedStart']} → {vl['plannedEnd']}] "
f"WBS={vl.get('wbs')} 控制者={vl.get('productionController')}")
for a in vl["assignments"]:
w = wos.get((vl["id"], a["seq"]))
flag = " ★瓶颈" if (w and w.get("isBottleneck")) else ""
mold = f" 模具={a['moldCode']}" if a.get("moldCode") else ""
extra = ""
if w:
extra = f"(换型{w['changeoverMin']:.0f}+移动{w['moveMin']:.0f}+加工{w['runMin']:.0f}min)"
print(f" {a['seq']:>2} {a['operationCode']:<9} → {a['equipmentCode']:<9}"
f"{mold:<18} {a['start']} → {a['end']} {extra}{flag}")
def print_conflicts(world) -> None:
cfs = world["flexConflicts"]
if not cfs:
print(" 冲突:无")
return
print(f" 冲突({len(cfs)}):")
for c in cfs:
print(f" · [{c['severity']}] {c['conflictType']} {c['description']}")
# ============================================================
# 主流程
# ============================================================
def main() -> None:
world = seed_world()
start = fmt_date(add_minutes(today0(), 24 * 60)) # 明天作为排产起点
# ---------- 0 主数据与能力池概览 ----------
rule("阶段 0 | 主数据与产能池(设备虚拟化 + 产能池化)")
print(f" 布局区域:{', '.join(z['code'] + '(' + z['name'] + ')' for z in world['flexZones'])}")
sub("产能池(工序能力 → 设备集合;★=瓶颈工序)")
for p in capability_pools(world):
star = "★" if p["isBottleneck"] else " "
eqs = ", ".join(f"{e['code']}{'(可移动)' if e['movable'] else ''}" for e in p["equipment"])
print(f" {star} {p['operationCode']:<9} {p['operationName']:<6} 设备{p['equipmentCount']}台"
f"(可移动{p['movableCount']}) : {eqs}")
sub("模具(工装适配 + 寿命)")
for m in world["flexMolds"]:
life = f"{m['lifeUsed']}/{m['lifeTotal']}"
print(f" {m['code']:<12} {m['name']:<10} 工序{m['operationCode']:<9} "
f"适配{','.join(m['adaptableEquipment'])} 寿命{life} 换型{m['changeoverMin']}min")
sub("待排订单")
for o in world["flexOrders"]:
print(f" {o['orderNo']:<8} {product_name(world, o['productCode']):<8} ×{o['quantity']:<4} "
f"交期{o['dueDate']} 优先级{o['priority']} 状态{o['status']} WBS={o['wbs']}")
# ---------- 1 初始排产(瓶颈锚) ----------
rule("阶段 1 | 初始排产:瓶颈锚模式 → 动态组装虚拟产线")
r = run_flex(world, "BOTTLENECK", start)
print(f" 版本 {r['versionNo']}|模式 {r['sortMode']}|虚拟产线 {r['vlCount']} 条|"
f"工单 {r['woCount']} 个|设备平均利用率 {r['avgUtilization']*100:.1f}%|"
f"总延迟 {r['totalTardiness']:.1f}h")
print(f" 识别瓶颈工序:" + ",".join(
f"{b['operationName']}({b['operationCode']}, 池内{b['poolEquipmentCount']}台)" for b in r["bottleneck"]))
print_virtual_lines(world)
print()
print_conflicts(world)
# ---------- 2 瓶颈产能法 ----------
rule("阶段 2 | 产能评估:瓶颈产能法(替代台账法)")
cap = capacity_analysis(world)
print(f" {'工序池':<20}{'设备':>4}{'日可用(min)':>12}{'单件(min)':>10}{'日产能(件)':>10}{'本版工作量(折合日)':>16}")
for p in cap["pools"]:
star = "★" if p["isBottleneck"] else " "
print(f" {star}{p['operationCode']+'/'+p['operationName']:<19}{p['equipmentCount']:>4}"
f"{p['dailyMinutes']:>12}{p['stdTimePerUnit']:>10}{p['dailyCapacity']:>10}"
f"{p['workDays']:>15.2f}天")
bp = cap["bottleneckPool"]
if bp:
print(f"\n → 限制性瓶颈:{bp['operationName']}({bp['operationCode']}),"
f"全厂日产能上界≈{bp['dailyCapacity']}件/天。这一道最慢,产能由它决定。")
print(f" → 对照台账法:压接池 4 台×480min÷1min=1920件/天(虚高);瓶颈法只认最慢的焊接池。")
# ---------- 3 三模式对比(在压力场景下才能显出差异) ----------
rule("阶段 3 | 排产模式对比:正排 / 倒排 / 瓶颈锚(吸收《排产逻辑》PPT)")
print(" 为显差异,统一压缩交期至 +3~+5 天制造产能压力:")
modes = {"ASC": "正排(EDD 最早交期优先)", "DESC": "倒排(最晚交期优先)", "BOTTLENECK": "瓶颈锚(含瓶颈单优先)"}
print(f" {'模式':<24}{'总延迟(h)':>10}{'准时单':>8}{'最晚完工':>18}{'利用率':>8}{'冲突':>6}")
for mode, label in modes.items():
w2 = seed_world()
_compress_due(w2) # 压缩交期制造压力
rr = run_flex(w2, mode, start)
print(f" {label:<22}{rr['totalTardiness']:>10.1f}{rr['onTimeCount']:>6}/5"
f"{(rr['makespan'] or '—'):>19}{rr['avgUtilization']*100:>7.1f}%{rr['conflictCount']:>6}")
print(" 说明:交期宽松时三模式结果趋同;压力下正排保最早交期、倒排后置、瓶颈锚优先喂饱瓶颈。")
# ---------- 4 交期承诺模拟 ----------
rule("阶段 4 | 交期承诺模拟:询单「PDU 500套 何时能交?」")
# 复用 world(已含在制订单)做一次 in-process 模拟
probe_qty = 500
# 乐观:独占资源
w_opt = seed_world()
w_opt["flexOrders"] = [{"id": 99, "orderNo": "PROBE", "productCode": "PDU-UNIT", "quantity": probe_qty,
"dueDate": "2999-12-31", "priority": 1, "wbs": None,
"productionController": None, "status": "RELEASED"}]
run_flex(w_opt, "BOTTLENECK", start)
opt_vl = next((v for v in w_opt["flexVirtualLines"] if v["orderNo"] == "PROBE"), None)
# 预计:插入现有订单集竞争资源
w_exp = seed_world()
w_exp["flexOrders"].append({"id": 99, "orderNo": "PROBE", "productCode": "PDU-UNIT", "quantity": probe_qty,
"dueDate": "2999-12-31", "priority": 9, "wbs": None,
"productionController": None, "status": "RELEASED"})
run_flex(w_exp, "BOTTLENECK", start)
exp_vl = next((v for v in w_exp["flexVirtualLines"] if v["orderNo"] == "PROBE"), None)
print(f" 询单:PDU配电单元 × {probe_qty} 套")
print(f" · 乐观(独占产能) :最早 {opt_vl['plannedEnd'] if opt_vl else '—'} 完成")
print(f" · 预计(与在制竞争):预计 {exp_vl['plannedEnd'] if exp_vl else '—'} 完成")
print(f" · 资源缺口提示:PDU 瓶颈为激光焊接(1台);若要提前,需增焊接设备或外包焊接工序。")
# ---------- 5 动态调度A:紧急插单 ----------
rule("阶段 5 | 动态调度A:紧急插单(VIP 高压线束 50套,交期紧)")
w5 = seed_world()
r5a = run_flex(w5, "BOTTLENECK", start) # 先有一版基线
print(f" 插单前:{r5a['vlCount']} 条虚拟产线,最晚完工 {r5a['makespan']},总延迟 {r5a['totalTardiness']:.1f}h")
w5["flexOrders"].append({"id": 90, "orderNo": "RUSH-001", "productCode": "HV-HARNESS", "quantity": 50,
"dueDate": fmt_date(add_minutes(today0(), 3 * 24 * 60)), "priority": 0,
"wbs": "WBS-HV", "productionController": "李强", "status": "RELEASED"})
r5 = run_flex(w5, "BOTTLENECK", start) # 冻结→重排(本切片=全量重排 L4)
rush = next((v for v in w5["flexVirtualLines"] if v["orderNo"] == "RUSH-001"), None)
print(f" 插单后:{r5['vlCount']} 条虚拟产线,最晚完工 {r5['makespan']},"
f"准时 {r5['onTimeCount']}/{r5['vlCount']},总延迟 {r5['totalTardiness']:.1f}h")
if rush:
print(f" · 插单 RUSH-001 排入:{rush['plannedStart']} → {rush['plannedEnd']}(优先占用瓶颈压接池)")
print_conflicts(w5)
# ---------- 6 动态调度B:设备故障(两个对比子案例) ----------
rule("阶段 6 | 动态调度B:设备故障 → 产能池实时收缩 → 重排")
sub("案例①:可移动压接机 PRESS-01 故障(压接池有冗余 → 影响小)")
w6 = seed_world()
_compress_due(w6) # 用压力场景才能看出延迟变化
r6a = run_flex(w6, "BOTTLENECK", start)
crimp_before = next(p for p in capacity_analysis(w6)["pools"] if p["operationCode"] == "OP-CRIMP")
print(f" 故障前:压接池 {crimp_before['equipmentCount']} 台,日产能 {crimp_before['dailyCapacity']} 件,"
f"总延迟 {r6a['totalTardiness']:.1f}h,准时 {r6a['onTimeCount']}/5")
next(e for e in w6["flexEquipment"] if e["code"] == "PRESS-01")["status"] = "MAINTENANCE"
r6b = run_flex(w6, "BOTTLENECK", start)
crimp_after = next(p for p in capacity_analysis(w6)["pools"] if p["operationCode"] == "OP-CRIMP")
print(f" 故障后:压接池 {crimp_after['equipmentCount']} 台,日产能 {crimp_after['dailyCapacity']} 件,"
f"总延迟 {r6b['totalTardiness']:.1f}h,准时 {r6b['onTimeCount']}/5")
print(f" · 池化韧性:自动剔除 PRESS-01 用剩余 {crimp_after['equipmentCount']} 台重排,"
f"日产能降 {crimp_before['dailyCapacity']-crimp_after['dailyCapacity']} 件但仍有冗余,交期基本不受影响。")
sub("案例②:单点瓶颈激光焊接机 WELD-01 故障(无备机 → PDU 停摆)")
w6b = seed_world()
run_flex(w6b, "BOTTLENECK", start)
next(e for e in w6b["flexEquipment"] if e["code"] == "WELD-01")["status"] = "MAINTENANCE"
r6c = run_flex(w6b, "BOTTLENECK", start)
print(f" 故障后:虚拟产线 {r6c['vlCount']} 条(PDU 单无法组线),冲突 {r6c['conflictCount']}")
print_conflicts(w6b)
print(" · 单点瓶颈无冗余:系统立即暴露 NO_CAPABILITY,提示应备用焊接机 / 外包焊接工序 / 议定交期。")
rule("模拟结束")
print(" 全流程覆盖:设备虚拟化→产能池→虚拟产线排产→瓶颈产能评估→模式对比→交期承诺→动态重排。")
print(" 数据口径见 docs/product/demand-data-intake.md;真实数据逐表替换 flex* 即可复跑。")
def _compress_due(world) -> None:
"""把订单交期压缩到 +3~+5 天,制造产能压力(用于模式对比/故障影响演示)。"""
offs = [4, 3, 4, 5, 5]
for o, off in zip(world["flexOrders"], offs):
o["dueDate"] = fmt_date(add_minutes(today0(), off * 24 * 60))
if __name__ == "__main__":
main()