aps-agent/server/aps_domain/analytics.py

434 lines
18 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.

# ============================================================
# 分析视图(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