aps-agent/server/aps_domain/analytics.py

434 lines
18 KiB
Python
Raw Normal View History

# ============================================================
# 分析视图(moduleId: domain-analytics, 可重生 ✅)
# EX-07 方案对比表(KPI 逐项 diff)/ EX-08 KPI 仪表盘
# ============================================================
from __future__ import annotations
from typing import Any
from server.aps_domain.views import world_summary
from server.timeutil import parse_dt
World = dict[str, Any]
def build_utilization_report(world: World, track: str = "flex", days: int = 7) -> dict:
"""EX-06:资源利用率增强报告(柔性设备 + 班组)。"""
from server.state.seed import ensure_flex_seed
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
tr = (track or "flex").lower()
if tr != "flex":
# 固定轨复用已有 load_view 语义,此处返回提示
from server.aps_domain.views import load_view, world_summary
lv = load_view(world, days=days)
s = world_summary(world)
return {
"track": "fixed",
"summary": {
"avgUtilization": s.get("avgUtilization"),
"versionNo": s.get("versionNo"),
"overloaded": sum(1 for r in lv.get("rows", [])
for c in r.get("cells", []) if c.get("ratio", 0) > 1),
},
"equipment": [], "teams": [], "days": lv.get("days", []),
"hint": "固定轨请看「负荷热力」视口;本报告聚焦柔性设备/班组。",
"load": lv,
}
ensure_flex_seed(world)
versions = world.get("flexScheduleVersions") or []
latest = versions[-1] if versions else None
vid = latest["id"] if latest else None
wos = [w for w in world.get("flexWorkOrders", [])
if (not vid or w.get("versionId") == vid) and not w.get("frozen")]
cal = (world.get("flexCalendar") or [{}])[0]
# 单班可用分钟(扣休息)
try:
sh0 = parse_dt(f"2000-01-01 {cal.get('startTime', '08:00')}")
sh1 = parse_dt(f"2000-01-01 {cal.get('endTime', '17:00')}")
shift = (sh1 - sh0).total_seconds() / 60
for br in cal.get("breaks") or []:
b0 = parse_dt(f"2000-01-01 {br['start']}")
b1 = parse_dt(f"2000-01-01 {br['end']}")
shift -= (b1 - b0).total_seconds() / 60
except Exception:
shift = 480.0
shift = max(shift, 60)
base = today0()
day_list = [fmt_date(add_minutes(base, i * 24 * 60)) for i in range(max(1, days))]
workdays = set(cal.get("workdays") or [1, 2, 3, 4, 5]) # Mon=1
def _is_workday(dstr: str) -> bool:
try:
wd = parse_dt(dstr + " 00:00").isoweekday()
return wd in workdays
except Exception:
return True
# ---- 设备行 ----
eq_rows = []
for eq in world.get("flexEquipment", []) or []:
eid = eq["id"]
used_total = 0.0
cells = []
for d in day_list:
ds, de = parse_dt(d + " 00:00"), parse_dt(d + " 23:59")
used = 0.0
for w in wos:
if w.get("equipmentId") != eid:
continue
try:
ws, we = parse_dt(w["plannedStartTime"]), parse_dt(w["plannedEndTime"])
except Exception:
continue
ov = max(0.0, (min(we, de) - max(ws, ds)).total_seconds() / 60)
used += ov
avail = shift * (eq.get("availabilityRate") or 1) if _is_workday(d) else 0.0
ratio = (used / avail) if avail else 0.0
cells.append({
"date": d, "used": round(used, 1), "avail": round(avail, 1),
"ratio": round(ratio, 3),
})
used_total += used
work_n = sum(1 for d in day_list if _is_workday(d))
avail_total = shift * (eq.get("availabilityRate") or 1) * max(work_n, 1)
util = used_total / avail_total if avail_total else 0.0
eq_rows.append({
"code": eq.get("code"), "name": eq.get("name"), "zone": eq.get("zone"),
"status": eq.get("status"), "movable": bool(eq.get("movable")),
"usedMinutes": round(used_total, 1),
"availMinutes": round(avail_total, 1),
"utilization": round(util, 3),
"peakRatio": max((c["ratio"] for c in cells), default=0),
"cells": cells,
"alert": "crit" if util >= 0.95 or any(c["ratio"] > 1 for c in cells)
else "warn" if util >= 0.85 else "ok",
})
eq_rows.sort(key=lambda r: -r["utilization"])
# ---- 班组行(SC-11):按工序并发峰值 / 人数 ----
team_rows = []
for team in world.get("flexTeams", []) or []:
ops = list(team.get("supportOps") or [])
members = int(team.get("memberCount") or 0)
# 扫描工单:同班组覆盖工序的重叠最大并发
intervals = []
for w in wos:
if w.get("operationCode") not in ops:
continue
if team.get("code") and w.get("teamCode") and w.get("teamCode") != team.get("code"):
# 若工单已钉具体班组,只计本班组
continue
try:
intervals.append((parse_dt(w["plannedStartTime"]), parse_dt(w["plannedEndTime"])))
except Exception:
pass
peak = 0
for i, (s, e) in enumerate(intervals):
concurrent = sum(1 for s2, e2 in intervals if s < e2 and e > s2)
peak = max(peak, concurrent)
# 粗利用率:本班组工单总分钟 / (人数 × 班次 × 工作日)
used = 0.0
for s, e in intervals:
used += (e - s).total_seconds() / 60
work_n = sum(1 for d in day_list if _is_workday(d))
avail = members * shift * max(work_n, 1)
util = used / avail if avail else 0.0
team_rows.append({
"code": team.get("code"), "name": team.get("name"),
"memberCount": members, "supportOps": ops,
"skillLevel": team.get("skillLevel"),
"peakConcurrent": peak,
"usedMinutes": round(used, 1),
"availMinutes": round(avail, 1),
"utilization": round(util, 3),
"saturated": members > 0 and peak >= members,
"alert": "crit" if (members > 0 and peak > members) or util >= 0.95
else "warn" if peak >= members or util >= 0.85 else "ok",
})
team_rows.sort(key=lambda r: -r["utilization"])
utils = [r["utilization"] for r in eq_rows if r["status"] == "RUNNING"]
avg = round(sum(utils) / len(utils), 3) if utils else 0.0
return {
"track": "flex",
"versionNo": latest.get("versionNo") if latest else None,
"versionStatus": latest.get("status") if latest else None,
"shiftMinutes": round(shift),
"days": day_list,
"summary": {
"avgUtilization": avg,
"equipmentCount": len(eq_rows),
"runningCount": sum(1 for r in eq_rows if r["status"] == "RUNNING"),
"overloaded": sum(1 for r in eq_rows if r["alert"] == "crit"),
"warnCount": sum(1 for r in eq_rows if r["alert"] == "warn"),
"idleCount": sum(1 for r in eq_rows if r["utilization"] < 0.15 and r["status"] == "RUNNING"),
"teamSaturated": sum(1 for r in team_rows if r.get("saturated")),
},
"equipment": eq_rows,
"teams": team_rows,
"hint": "绿<50% 黄<85% 橙≤100% 红超载;班组 saturated=并发触及人数上限(SC-11)。",
}
def build_kpi_dashboard(world: World) -> dict:
"""EX-08:固定轨 + 柔性轨 + 执行进度聚合仪表盘(只读)。"""
from server.state.seed import ensure_flex_seed
ensure_flex_seed(world)
fixed = world_summary(world)
# 准时率(固定):非超期 PO / 全部 PO
on_time_rate = None
wip = 0
overdue_po = 0
if fixed.get("hasVersion"):
v = world["scheduleVersions"][-1]
pos = [p for p in world["productionOrders"] if p.get("schedulingVersionId") == v["id"]]
so_by = {s["id"]: s for s in world["salesOrders"]}
for po in pos:
so = so_by.get(po.get("salesOrderId"))
if so and po.get("plannedEndDate"):
late = parse_dt(po["plannedEndDate"]) > parse_dt(so["deliveryDate"] + " 18:00")
if late:
overdue_po += 1
if po.get("status") in ("CONFIRMED", "IN_PROGRESS", "RELEASED"):
wip += 1
if pos:
on_time_rate = round((len(pos) - overdue_po) / len(pos), 3)
flex_ver = (world.get("flexScheduleVersions") or [None])[-1]
flex = None
if flex_ver:
oc = flex_ver.get("orderCount") or flex_ver.get("vlCount") or 0
ot = flex_ver.get("onTimeCount") or 0
flex = {
"versionNo": flex_ver.get("versionNo"),
"status": flex_ver.get("status"),
"sortMode": flex_ver.get("sortMode"),
"woCount": flex_ver.get("woCount", 0),
"vlCount": flex_ver.get("vlCount", 0),
"conflictCount": flex_ver.get("conflictCount", 0),
"totalTardiness": round(flex_ver.get("totalTardiness") or 0, 1),
"avgUtilization": round(flex_ver.get("avgUtilization") or 0, 3),
"onTimeCount": ot,
"orderCount": oc,
"onTimeRate": round(ot / oc, 3) if oc else None,
"makespan": flex_ver.get("makespan"),
}
# 冲突未解决
flex_cf = sum(1 for c in world.get("flexConflicts", []) if not c.get("isResolved"))
fixed_cf = sum(1 for c in world.get("conflicts", [])
if not c.get("isResolved") and (
not fixed.get("hasVersion")
or c.get("versionId") == world["scheduleVersions"][-1]["id"]))
# MES 执行
mes_rows = [w for w in world.get("flexWorkOrders", []) if w.get("mesExternalId")]
if flex_ver:
mes_rows = [w for w in mes_rows if w.get("versionId") == flex_ver["id"]]
mes_done = sum(1 for w in mes_rows
if w.get("status") == "COMPLETED" or (w.get("progressPct") or 0) >= 100)
mes_pct = round(mes_done / len(mes_rows), 3) if mes_rows else None
# 柔性在制
flex_wip = sum(1 for w in world.get("flexWorkOrders", [])
if flex_ver and w.get("versionId") == flex_ver["id"]
and w.get("status") in ("RUNNING", "RELEASED", "PENDING"))
cards = [
{"id": "util", "label": "平均利用率",
"value": (f"{round((fixed.get('avgUtilization') or 0) * 100)}%"
if fixed.get("hasVersion") else
(f"{round((flex or {}).get('avgUtilization', 0) * 100)}%" if flex else "—")),
"hint": "固定轨优先,否则柔性"},
{"id": "ontime", "label": "准时率",
"value": (f"{round(on_time_rate * 100)}%" if on_time_rate is not None
else (f"{round(flex['onTimeRate'] * 100)}%" if flex and flex.get("onTimeRate") is not None
else "—")),
"hint": "按期完工订单占比"},
{"id": "delay", "label": "总延迟",
"value": (f"{fixed.get('totalTardiness')}h" if fixed.get("hasVersion")
else (f"{flex['totalTardiness']}h" if flex else "—")),
"hint": "版本合计 tardiness"},
{"id": "wip", "label": "在制工单",
"value": str(wip + flex_wip),
"hint": f"固定 {wip} + 柔性 {flex_wip}"},
{"id": "conflict", "label": "未解冲突",
"value": str(fixed_cf + flex_cf),
"hint": f"固定 {fixed_cf} / 柔性 {flex_cf}"},
{"id": "mes", "label": "MES 完工率",
"value": (f"{round(mes_pct * 100)}%" if mes_pct is not None else "—"),
"hint": f"已下发 {len(mes_rows)} · 完工 {mes_done}"},
]
return {
"cards": cards,
"fixed": fixed if fixed.get("hasVersion") else None,
"flex": flex,
"execution": {
"dispatched": len(mes_rows), "completed": mes_done,
"progressRate": mes_pct,
},
"conflicts": {"fixed": fixed_cf, "flex": flex_cf, "total": fixed_cf + flex_cf},
"generatedAt": None,
}
def _matrix_from_cards(
columns: list[dict],
metrics: list[tuple[str, str, bool]],
) -> dict:
"""metrics: (key, label, lower_is_better)。从 columns[].kpi 抽逐项表。"""
rows = []
for key, label, lower_better in metrics:
values = []
for col in columns:
kpi = col.get("kpi") or col
val = kpi.get(key)
if key == "avgUtilization" and val is not None and val <= 1:
val = round(val * 100, 1)
values.append(val)
numeric = [(i, v) for i, v in enumerate(values) if isinstance(v, (int, float))]
best_idx = None
if numeric:
best_idx = (min if lower_better else max)(numeric, key=lambda x: x[1])[0]
best_val = values[best_idx] if best_idx is not None else None
deltas = []
for v in values:
if best_val is None or not isinstance(v, (int, float)):
deltas.append(None)
else:
deltas.append(round(v - best_val, 2) if isinstance(best_val, float)
else v - best_val)
rows.append({
"metric": key, "label": label, "lowerIsBetter": lower_better,
"values": values, "bestIdx": best_idx, "deltas": deltas,
})
return {"columns": columns, "rows": rows}
def matrix_from_fixed_cards(cards: list[dict], *, baseline: str | None = None) -> dict:
cols = [{
"id": c["strategy"], "label": c["label"], "strategy": c["strategy"],
"kpi": c["kpi"], "diffVsBaseline": c.get("diffVsBaseline"),
"risks": c.get("risks") or [],
} for c in cards]
metrics = [
("totalTardiness", "总延迟 (h)", True),
("conflictCount", "冲突数", True),
("avgUtilization", "利用率 (%)", False),
("totalCost", "预估成本", True),
("woCount", "工单数", True),
("poCount", "生产订单", True),
]
section = _matrix_from_cards(cols, metrics)
best = min(cards, key=lambda c: (c["kpi"]["totalTardiness"], c["kpi"]["conflictCount"]))
section.update({
"id": "fixed", "title": "固定轨 · 策略对比",
"baseline": baseline,
"recommendation": best["label"],
"recommendationId": best["strategy"],
})
return section
def matrix_from_flex_rows(rows: list[dict], *, hint: str | None = None,
data_quality: dict | None = None) -> dict:
cols = [{
"id": r["sortMode"], "label": r["label"], "sortMode": r["sortMode"],
"strategyStatus": str(r.get("strategyStatus") or "READY").upper(),
"strategyEvidence": r.get("strategyEvidence") or [],
"resultStatus": r.get("resultStatus"),
"kpi": {
"totalTardiness": r["totalTardiness"],
"conflictCount": r["conflictCount"],
"avgUtilization": r["avgUtilization"],
"onTimeCount": r["onTimeCount"],
"orderCount": r["orderCount"],
"vlCount": r["vlCount"],
},
} for r in rows]
metrics = [
("totalTardiness", "总延迟 (h)", True),
("conflictCount", "冲突数", True),
("avgUtilization", "利用率 (%)", False),
("onTimeCount", "准时订单", False),
("vlCount", "虚拟产线", True),
("orderCount", "订单数", True),
]
section = _matrix_from_cards(cols, metrics)
quality = data_quality or {}
production_ready = bool(quality.get("productionReady", True))
ready_rows = [
row for row in rows
if str(row.get("strategyStatus") or "READY").upper() == "READY"
and str(row.get("resultStatus") or "READY").upper() in {"READY", "WITH_CONFLICTS"}
]
best = min(
ready_rows,
key=lambda r: (
r["totalTardiness"], r["conflictCount"], -int(r.get("onTimeCount") or 0),
),
) if ready_rows else None
if best is None:
recommendation_status = "UNAVAILABLE"
recommendation_reason = "没有 strategyStatus=READY 的策略,请先补齐策略输入数据后重新试排。"
elif production_ready:
recommendation_status = "READY"
recommendation_reason = "策略输入与生产数据质量检查通过。"
else:
recommendation_status = "TRIAL_ONLY"
warning_messages = [
str(warning.get("message"))
for warning in (quality.get("warnings") or [])
if warning.get("message")
]
recommendation_reason = "数据待补:" + (
";".join(warning_messages) if warning_messages else "生产数据质量尚未就绪"
)
section.update({
"id": "flex", "title": "柔性轨 · 正排/倒排/瓶颈锚/齐套优先/技能优先",
"hint": hint,
"dataQuality": quality,
"productionReady": production_ready,
"recommendationStatus": recommendation_status,
"recommendationReason": recommendation_reason,
"recommendation": best["label"] if best else None,
"recommendationId": best["sortMode"] if best else None,
"eligibleRecommendationIds": [row["sortMode"] for row in ready_rows],
})
return section
def build_compare_table(world: World, track: str = "both") -> dict:
"""EX-07:方案对比逐项 diff 表。track ∈ fixed|flex|both。"""
from server.aps_domain.scenario import compare_scenarios
from server.aps_domain.flex import compare_sort_modes
from server.state.seed import ensure_flex_seed
tr = (track or "both").lower()
out: dict[str, Any] = {"track": tr, "sections": []}
if tr in ("fixed", "both"):
_, block = compare_scenarios(world)
cards = block.props.get("cards") or []
out["sections"].append(matrix_from_fixed_cards(
cards, baseline=block.props.get("baseline")))
if tr in ("flex", "both"):
ensure_flex_seed(world)
class _Tmp:
def __init__(self, data):
self.data = data
flex = compare_sort_modes(_Tmp(world), compress_due=True)
out["sections"].append(matrix_from_flex_rows(
flex["rows"], hint=flex.get("hint"), data_quality=flex.get("dataQuality")))
return out