state definitions

This commit is contained in:
Marcelo
2026-05-02 01:35:27 +00:00
parent 4299ef3478
commit 0491237bad
16 changed files with 2887 additions and 43 deletions

View File

@@ -9,6 +9,7 @@ import {
type TimelineCycleRow,
type TimelineEventRow,
} from "@/lib/recap/timeline";
import { classifyMachineState, type MachineStateResult } from "@/lib/recap/machineState";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type {
RecapDetailResponse,
@@ -16,6 +17,7 @@ import type {
RecapMachineDetail,
RecapMachineStatus,
RecapRangeMode,
RecapStateContext,
RecapSummaryMachine,
RecapSummaryResponse,
} from "@/lib/recap/types";
@@ -175,22 +177,26 @@ function addDays(input: { year: number; month: number; day: number }, days: numb
};
}
// Active stoppage = freshest macrostop episode whose latest event is "active"
// and whose latest event timestamp is within ACTIVE_STALE_MS of rangeEnd.
// Mirrors the same rules used by lib/recap/timeline.ts so the card status
// agrees with the timeline rendering.
const STOPPAGE_ACTIVE_STALE_MS = 2 * 60 * 1000;
// Detect active episodes (macrostop, mold-change) from event rows.
// Returns the latest non-auto-ack episode whose final status is "active"
// and that's been refreshed within ACTIVE_STALE_MS.
const ACTIVE_STALE_MS = 2 * 60 * 1000;
function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: number) {
type ActiveEpisode = { startedAtMs: number; lastTsMs: number };
function detectActiveEpisode(
events: TimelineEventRow[] | undefined,
eventType: "macrostop" | "mold-change",
endMs: number
): ActiveEpisode | null {
if (!events || events.length === 0) return null;
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string };
type Episode = { firstTsMs: number; lastTsMs: number; lastStatus: string; lastCycleTs: number | null };
const episodes = new Map<string, Episode>();
for (const event of events) {
if (String(event.eventType || "").toLowerCase() !== "macrostop") continue;
if (String(event.eventType || "").toLowerCase() !== eventType) continue;
// Defensive: parse data the same way timeline.ts does.
let parsed: unknown = event.data;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); } catch { parsed = null; }
@@ -200,7 +206,6 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
? (parsed as Record<string, unknown>)
: {};
// Drop only the auto-ack pings (same rule as timeline.ts Fix B).
const isAutoAck =
data.is_auto_ack === true ||
data.isAutoAck === true ||
@@ -210,12 +215,18 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
const status = String(data.status ?? "").trim().toLowerCase();
const incidentKey = String(data.incidentKey ?? data.incident_key ?? "").trim()
|| `macrostop:${event.ts.getTime()}`;
|| `${eventType}:${event.ts.getTime()}`;
const tsMs = event.ts.getTime();
const lastCycleTs = Number(data.last_cycle_timestamp);
const existing = episodes.get(incidentKey);
if (!existing) {
episodes.set(incidentKey, { firstTsMs: tsMs, lastTsMs: tsMs, lastStatus: status });
episodes.set(incidentKey, {
firstTsMs: tsMs,
lastTsMs: tsMs,
lastStatus: status,
lastCycleTs: Number.isFinite(lastCycleTs) && lastCycleTs > 0 ? lastCycleTs : null,
});
continue;
}
existing.firstTsMs = Math.min(existing.firstTsMs, tsMs);
@@ -225,39 +236,108 @@ function detectActiveMacrostop(events: TimelineEventRow[] | undefined, endMs: nu
}
}
let activeOngoingMin = 0;
let best: ActiveEpisode | null = null;
for (const ep of episodes.values()) {
if (ep.lastStatus !== "active") continue;
if (endMs - ep.lastTsMs > STOPPAGE_ACTIVE_STALE_MS) continue;
const ongoingMin = Math.max(0, Math.floor((endMs - ep.firstTsMs) / 60000));
if (ongoingMin > activeOngoingMin) activeOngoingMin = ongoingMin;
if (endMs - ep.lastTsMs > ACTIVE_STALE_MS) continue;
// Prefer the freshest active episode (highest lastTsMs)
if (!best || ep.lastTsMs > best.lastTsMs) {
best = {
startedAtMs: ep.lastCycleTs ?? ep.firstTsMs,
lastTsMs: ep.lastTsMs,
};
}
}
return activeOngoingMin > 0 ? activeOngoingMin : null;
return best;
}
function statusFromMachine(machine: RecapMachine, endMs: number, events?: TimelineEventRow[]) {
function statusFromMachine(
machine: RecapMachine,
endMs: number,
events?: TimelineEventRow[]
): {
status: RecapMachineStatus;
result: MachineStateResult;
stateContext: RecapStateContext;
lastSeenMs: number | null;
offlineForMin: number | null;
ongoingStopMin: number | null;
} {
const lastSeenMs = machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).getTime() : null;
const offlineForMs = lastSeenMs == null ? Number.POSITIVE_INFINITY : Math.max(0, endMs - lastSeenMs);
const offline = !Number.isFinite(lastSeenMs ?? Number.NaN) || offlineForMs > OFFLINE_THRESHOLD_MS;
const heartbeatAlive = Number.isFinite(lastSeenMs ?? Number.NaN) && offlineForMs <= OFFLINE_THRESHOLD_MS;
// ongoingStopMin from the legacy heartbeat-based path (typically null) OR
// from the macrostop event detection (preferred — accurate)
const macrostopOngoingMin = detectActiveMacrostop(events, endMs);
const legacyOngoingStopMin = machine.downtime.ongoingStopMin ?? 0;
const ongoingStopMin = macrostopOngoingMin ?? (legacyOngoingStopMin > 0 ? legacyOngoingStopMin : null);
const activeMacrostop = detectActiveEpisode(events, "macrostop", endMs);
const activeMoldChange = detectActiveEpisode(events, "mold-change", endMs);
const moldActive = machine.workOrders.moldChangeInProgress;
// Round 1 limitation: trackingEnabled and untrackedCycles inputs require KPI/cycle queries
// we don't yet plumb here. We approximate from the legacy fields:
// - trackingEnabled: true when there's an active macrostop (Pi only fires those when tracking on)
// OR when an active WO exists and machine.workOrders.moldChangeInProgress is false.
// This is a SIMPLIFICATION; Round 3 will replace with real KPI snapshot read.
// - untrackedCycles: 0 (Round 3 will compute from MachineCycle vs latest KPI)
//
// Effect for Round 1: STOPPED `not_started` reason cannot trigger yet (we always assume tracking
// is on when a WO exists). Only `machine_fault` STOPPED fires. DATA_LOSS cannot fire yet.
// IDLE fires correctly when there's no WO and no recent activity.
const hasActiveWorkOrder = machine.workOrders.active != null;
const trackingEnabledApprox = hasActiveWorkOrder; // see comment above
let status: RecapMachineStatus = "running";
if (offline) status = "offline";
else if (moldActive) status = "mold-change";
else if (ongoingStopMin != null && ongoingStopMin > 0) status = "stopped";
const lastCycleTsMs = (() => {
// Best-effort: use the machine's heartbeat as a "recent activity" proxy.
// The Pi only heartbeats every minute regardless of cycles, so this is a weak signal.
// Round 3 will pass the actual latest cycle ts.
return lastSeenMs;
})();
const result = classifyMachineState(
{
heartbeatAlive,
lastSeenMs,
offlineForMs,
trackingEnabled: trackingEnabledApprox,
hasActiveWorkOrder,
activeMoldChange,
activeMacrostop,
untrackedCycles: { count: 0, oldestTsMs: null },
lastCycleTsMs,
},
endMs
);
// Map the rich classifier result back to the existing RecapMachineStatus union
const status: RecapMachineStatus = result.state;
// Pull common fields out for the caller's convenience
let ongoingStopMin: number | null = null;
if (result.state === "stopped") ongoingStopMin = result.ongoingStopMin;
let stateContext: RecapStateContext = {
stoppedReason: null,
dataLossReason: null,
untrackedCycleCount: null,
};
if (result.state === "stopped") {
stateContext = {
stoppedReason: result.reason,
dataLossReason: null,
untrackedCycleCount: null,
};
} else if (result.state === "data-loss") {
stateContext = {
stoppedReason: null,
dataLossReason: result.reason,
untrackedCycleCount: result.untrackedCycleCount,
};
}
return {
status,
result,
stateContext,
lastSeenMs,
offlineForMin: offline ? Math.max(0, Math.floor(offlineForMs / 60000)) : null,
offlineForMin: result.state === "offline" ? result.offlineForMin : null,
ongoingStopMin,
};
}
@@ -366,6 +446,7 @@ function toSummaryMachine(params: {
status.lastSeenMs == null ? null : Math.max(0, Math.floor((rangeEndMs - status.lastSeenMs) / 60000)),
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
stateContext: status.stateContext,
activeWorkOrderId: machine.workOrders.active?.id ?? null,
moldChange: {
active: machine.workOrders.moldChangeInProgress,
@@ -704,6 +785,8 @@ async function computeRecapMachineDetail(params: {
lastSeenMs: status.lastSeenMs,
offlineForMin: status.offlineForMin,
ongoingStopMin: status.ongoingStopMin,
stateContext: status.stateContext,
moldChange: {
active: machine.workOrders.moldChangeInProgress,
startMs: machine.workOrders.moldChangeStartMs,