Downtime catalog
This commit is contained in:
@@ -289,6 +289,7 @@ async function computeRecap(params: Required<Pick<RecapQuery, "orgId">> & {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
workOrderId: true,
|
||||
theoreticalCycleTime: true,
|
||||
sku: true,
|
||||
goodDelta: true,
|
||||
scrapDelta: true,
|
||||
|
||||
@@ -23,9 +23,6 @@ export type MachineStateName =
|
||||
| "idle"
|
||||
| "running";
|
||||
|
||||
export type StoppedReason = "machine_fault" | "not_started";
|
||||
export type DataLossReason = "untracked";
|
||||
|
||||
export type MachineStateResult =
|
||||
| { state: "offline"; lastSeenMs: number | null; offlineForMin: number }
|
||||
| {
|
||||
@@ -35,17 +32,9 @@ export type MachineStateResult =
|
||||
}
|
||||
| {
|
||||
state: "stopped";
|
||||
reason: StoppedReason;
|
||||
ongoingStopMin: number;
|
||||
stopStartedAtMs: number | null;
|
||||
}
|
||||
| {
|
||||
state: "data-loss";
|
||||
reason: DataLossReason;
|
||||
untrackedCycleCount: number;
|
||||
untrackedSinceMs: number | null;
|
||||
untrackedForMin: number;
|
||||
}
|
||||
| { state: "idle" }
|
||||
| { state: "running" };
|
||||
|
||||
@@ -74,8 +63,6 @@ export type MachineStateInputs = {
|
||||
* Caller computes by counting MachineCycle rows in the last UNTRACKED_WINDOW_MS
|
||||
* where ts > latestKpi.ts (so they're "after" the tracking-off snapshot).
|
||||
*/
|
||||
untrackedCycles: { count: number; oldestTsMs: number | null };
|
||||
|
||||
/**
|
||||
* Most recent cycle timestamp regardless of tracking — used as a sanity check
|
||||
* for IDLE classification.
|
||||
@@ -84,8 +71,7 @@ export type MachineStateInputs = {
|
||||
};
|
||||
|
||||
// Trigger thresholds — tunable
|
||||
const DATA_LOSS_MIN_CYCLES = 5;
|
||||
const DATA_LOSS_MIN_DURATION_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
const RECENT_CYCLE_MS = 15 * 60 * 1000; // for IDLE check — "no cycles in 15 min"
|
||||
|
||||
export function classifyMachineState(
|
||||
@@ -116,51 +102,22 @@ export function classifyMachineState(
|
||||
// 3. DATA_LOSS — tracking off but cycles arriving. Operator forgot START.
|
||||
// Check this BEFORE STOPPED because cycles ARE arriving (so the "no cycles" branch
|
||||
// would never fire), but we still want to flag it.
|
||||
if (!inputs.trackingEnabled && inputs.untrackedCycles.count > 0) {
|
||||
const oldest = inputs.untrackedCycles.oldestTsMs;
|
||||
const durationMs = oldest != null ? nowMs - oldest : 0;
|
||||
const tripped =
|
||||
inputs.untrackedCycles.count >= DATA_LOSS_MIN_CYCLES ||
|
||||
durationMs >= DATA_LOSS_MIN_DURATION_MS;
|
||||
|
||||
if (tripped) {
|
||||
return {
|
||||
state: "data-loss",
|
||||
reason: "untracked",
|
||||
untrackedCycleCount: inputs.untrackedCycles.count,
|
||||
untrackedSinceMs: oldest,
|
||||
untrackedForMin: Math.max(0, Math.floor(durationMs / 60000)),
|
||||
};
|
||||
}
|
||||
// Not yet tripped — fall through to other checks (likely RUNNING since cycles are coming)
|
||||
}
|
||||
|
||||
// 4. STOPPED — should be producing, isn't. Two reasons:
|
||||
// a) machine_fault: operator pressed START, macrostop event active → mechanical issue
|
||||
// b) not_started: operator never pressed START but a WO is loaded
|
||||
if (inputs.activeMacrostop && inputs.trackingEnabled) {
|
||||
// 4. STOPPED — machine should be producing, isn't.
|
||||
// The Pi only emits macrostop events when tracking is on AND a WO is active,
|
||||
// so the presence of an active macrostop event is sufficient.
|
||||
if (inputs.activeMacrostop) {
|
||||
const startedAt = inputs.activeMacrostop.startedAtMs;
|
||||
return {
|
||||
state: "stopped",
|
||||
reason: "machine_fault",
|
||||
ongoingStopMin: Math.max(0, Math.floor((nowMs - startedAt) / 60000)),
|
||||
stopStartedAtMs: startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (inputs.hasActiveWorkOrder && !inputs.trackingEnabled) {
|
||||
// Operator hasn't started production despite a loaded WO.
|
||||
// We don't have a precise "since when" for this — best estimate is "since latest
|
||||
// KPI snapshot reported trackingEnabled=false," but that's not in the inputs.
|
||||
// For now, report ongoingStopMin=0 and let the caller refine if needed.
|
||||
return {
|
||||
state: "stopped",
|
||||
reason: "not_started",
|
||||
ongoingStopMin: 0,
|
||||
stopStartedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. IDLE — no one expects this machine to be doing anything right now.
|
||||
// No tracking, no WO, no recent cycles. Calm gray.
|
||||
const cycledRecently =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ export type TimelineCycleRow = {
|
||||
ts: Date;
|
||||
cycleCount: number | null;
|
||||
actualCycleTime: number;
|
||||
theoreticalCycleTime: number | null;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
};
|
||||
@@ -554,19 +555,21 @@ export function buildTimelineSegments(input: {
|
||||
let currentProduction: RawSegment | null = null;
|
||||
for (const cycle of dedupedCycles) {
|
||||
if (!cycle.workOrderId) continue;
|
||||
const cycleStartMs = cycle.ts.getTime();
|
||||
// Pi stores cycle.ts at COMPLETION time; the cycle ran in [ts - actual, ts].
|
||||
const completionMs = cycle.ts.getTime();
|
||||
const cycleDurationMs = Math.max(
|
||||
1000,
|
||||
Math.min(600000, Math.trunc((safeNum(cycle.actualCycleTime) ?? 1) * 1000))
|
||||
);
|
||||
const cycleEndMs = cycleStartMs + cycleDurationMs;
|
||||
const cycleStartMs = completionMs - cycleDurationMs;
|
||||
const cycleEndMs = completionMs;
|
||||
|
||||
if (
|
||||
currentProduction &&
|
||||
currentProduction.type === "production" &&
|
||||
currentProduction.workOrderId === cycle.workOrderId &&
|
||||
currentProduction.sku === cycle.sku &&
|
||||
cycleStartMs <= currentProduction.endMs + 5 * 60 * 1000
|
||||
cycleStartMs <= currentProduction.endMs + MERGE_GAP_MS
|
||||
) {
|
||||
currentProduction.endMs = Math.max(currentProduction.endMs, cycleEndMs);
|
||||
continue;
|
||||
@@ -652,7 +655,11 @@ export function buildTimelineSegments(input: {
|
||||
episode.firstTsMs = Math.min(episode.firstTsMs, tsMs);
|
||||
episode.lastTsMs = Math.max(episode.lastTsMs, tsMs);
|
||||
|
||||
const startMs = safeNum(data.start_ms) ?? safeNum(data.startMs);
|
||||
const startMs =
|
||||
safeNum(data.start_ms) ??
|
||||
safeNum(data.startMs) ??
|
||||
safeNum(data.last_cycle_timestamp) ??
|
||||
safeNum(data.lastCycleTimestamp);
|
||||
const endMs = safeNum(data.end_ms) ?? safeNum(data.endMs);
|
||||
const durationSec =
|
||||
safeNum(data.duration_sec) ??
|
||||
@@ -679,7 +686,7 @@ export function buildTimelineSegments(input: {
|
||||
}
|
||||
|
||||
for (const episode of eventEpisodes.values()) {
|
||||
const startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||
let startMs = Math.trunc(episode.startMs ?? episode.firstTsMs);
|
||||
let endMs = Math.trunc(episode.endMs ?? episode.lastTsMs);
|
||||
|
||||
if (episode.statusActive && !episode.statusResolved) {
|
||||
@@ -694,7 +701,13 @@ export function buildTimelineSegments(input: {
|
||||
}
|
||||
}
|
||||
} else if (endMs <= startMs && episode.durationSec != null && episode.durationSec > 0) {
|
||||
endMs = startMs + episode.durationSec * 1000;
|
||||
// Event ts is end-of-stop; subtract duration to recover start.
|
||||
// Only adjust if we don't already have an explicit startMs from data.
|
||||
if (episode.startMs == null) {
|
||||
startMs = endMs - episode.durationSec * 1000;
|
||||
} else {
|
||||
endMs = startMs + episode.durationSec * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (endMs <= startMs) continue;
|
||||
@@ -730,7 +743,35 @@ export function buildTimelineSegments(input: {
|
||||
const clustered = absorbMicroStopClusters(withIdle, MICRO_CLUSTER_GAP_MS);
|
||||
const normalized = fillGapsWithIdle(clustered, rangeStartMs, rangeEndMs);
|
||||
const absorbed = absorbShortSegments(normalized, ABSORB_SHORT_SEGMENT_MS);
|
||||
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
|
||||
const finalSegments = fillGapsWithIdle(absorbed, rangeStartMs, rangeEndMs);
|
||||
|
||||
// Live tail: machine cycling now, last cycle not yet completed.
|
||||
// Extend production through right edge until microstop threshold passes.
|
||||
const lastCycle = dedupedCycles[dedupedCycles.length - 1];
|
||||
const idealCT = safeNum(lastCycle?.theoreticalCycleTime) ?? 120;
|
||||
const MICRO_MS = idealCT * 1.5 * 1000;
|
||||
|
||||
// Live-tail: extend whatever the last real state was, until microstop threshold passes.
|
||||
if (finalSegments.length >= 2) {
|
||||
const last = finalSegments[finalSegments.length - 1];
|
||||
const prev = finalSegments[finalSegments.length - 2];
|
||||
if (last.type === "idle" && last.endMs >= rangeEndMs - 2000) {
|
||||
const gapMs = last.endMs - prev.endMs;
|
||||
let shouldExtend = false;
|
||||
if (prev.type === "production" && gapMs < MICRO_MS) {
|
||||
// mid-cycle: still running up to microstop threshold
|
||||
shouldExtend = true;
|
||||
} else if (prev.type === "microstop" || prev.type === "macrostop") {
|
||||
// stoppage in progress: extend until resolved/next cycle
|
||||
shouldExtend = true;
|
||||
}
|
||||
if (shouldExtend) {
|
||||
prev.endMs = last.endMs;
|
||||
prev.durationSec = Math.max(0, Math.trunc((prev.endMs - prev.startMs) / 1000));
|
||||
finalSegments.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalSegments;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ export async function getRecapTimelineForMachine(params: {
|
||||
ts: true,
|
||||
cycleCount: true,
|
||||
actualCycleTime: true,
|
||||
theoreticalCycleTime: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
},
|
||||
@@ -151,10 +152,10 @@ export async function getRecapTimelineForMachine(params: {
|
||||
ts: row.ts,
|
||||
cycleCount: row.cycleCount,
|
||||
actualCycleTime: row.actualCycleTime,
|
||||
theoreticalCycleTime: row.theoreticalCycleTime,
|
||||
workOrderId: row.workOrderId,
|
||||
sku: row.sku,
|
||||
}));
|
||||
|
||||
const events: TimelineEventRow[] = eventsRaw.map((row) => ({
|
||||
ts: row.ts,
|
||||
eventType: row.eventType,
|
||||
|
||||
@@ -121,23 +121,14 @@ export type RecapQuery = {
|
||||
shift?: string;
|
||||
};
|
||||
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "data-loss" | "offline" | "idle";
|
||||
|
||||
export type RecapStoppedReason = "machine_fault" | "not_started";
|
||||
export type RecapDataLossReason = "untracked";
|
||||
export type RecapMachineStatus = "running" | "mold-change" | "stopped" | "offline" | "idle";
|
||||
|
||||
/**
|
||||
* Reason context for STOPPED and DATA_LOSS states.
|
||||
* - When status is "stopped": stoppedReason is set, dataLossReason is null.
|
||||
* - When status is "data-loss": dataLossReason is set, stoppedReason is null.
|
||||
* - All other states: both are null.
|
||||
* Reason context — currently empty in practice because the only STOPPED cause
|
||||
* we can detect (given Node-RED's constraints) is machine_fault. Kept as a
|
||||
* struct so future expansion doesn't require a type change downstream.
|
||||
*/
|
||||
export type RecapStateContext = {
|
||||
stoppedReason: RecapStoppedReason | null;
|
||||
dataLossReason: RecapDataLossReason | null;
|
||||
/** For data-loss: how many untracked cycles have been detected so far. */
|
||||
untrackedCycleCount: number | null;
|
||||
};
|
||||
export type RecapStateContext = Record<string, never>;
|
||||
|
||||
|
||||
export type RecapSummaryMachine = {
|
||||
|
||||
Reference in New Issue
Block a user