feat(history): show event context with online simulation and SCADA
This commit is contained in:
@@ -2,6 +2,7 @@ import { api } from "@/lib/api";
|
||||
import {
|
||||
getAnalysisResults,
|
||||
getAnalysisScheme,
|
||||
getAnalysisHistoryContext,
|
||||
listAnalysisSchemes,
|
||||
} from "./analysisRuns";
|
||||
|
||||
@@ -94,3 +95,49 @@ describe("analysis runs API adapter", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis history context", () => {
|
||||
const run = {
|
||||
run_id: "run-1", name: "event", run_type: "burst_analysis",
|
||||
created_by: "alice", created_at: "2026-11-03T05:00:00Z",
|
||||
started_at: "2026-08-25T09:00:00+08:00", status: "completed", parameters: {},
|
||||
};
|
||||
|
||||
it.each([
|
||||
["burst_analysis", { modify_total_duration: 3600 }],
|
||||
["flushing_analysis", { duration: 3600 }],
|
||||
["contaminant_analysis", { duration: 3600 }],
|
||||
])("uses the simulated interval of %s rather than its creation time", (run_type, parameters) => {
|
||||
expect(getAnalysisHistoryContext({ ...run, run_type, parameters })).toEqual({
|
||||
simulationRunId: "run-1",
|
||||
eventRange: { start: "2026-08-25T01:00:00.000Z", end: "2026-08-25T02:00:00.000Z" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a detection run as a hydraulic simulation", () => {
|
||||
expect(getAnalysisHistoryContext({
|
||||
...run, run_type: "burst_detection", parameters: {
|
||||
scada_window: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
|
||||
},
|
||||
})).toEqual({
|
||||
eventRange: { start: "2026-08-25T01:00:00Z", end: "2026-08-25T02:00:00Z" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the linked hydraulic run for a localization analysis", () => {
|
||||
expect(getAnalysisHistoryContext({
|
||||
...run, run_type: "burst_location", parameters: {
|
||||
result_payload: {
|
||||
simulation_run: { run_id: "simulation-1" },
|
||||
scada_window: { burst_start: "2026-08-25T01:00:00Z", burst_end: "2026-08-25T02:00:00Z" },
|
||||
},
|
||||
},
|
||||
}).simulationRunId).toBe("simulation-1");
|
||||
});
|
||||
|
||||
it("rejects invalid or missing event durations without inventing an interval", () => {
|
||||
expect(getAnalysisHistoryContext(run).eventRange).toBeNull();
|
||||
expect(getAnalysisHistoryContext({ ...run, parameters: { duration: -1 } }).eventRange).toBeNull();
|
||||
expect(getAnalysisHistoryContext({ ...run, started_at: "invalid", parameters: { duration: 3600 } }).eventRange).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,66 @@ export type AnalysisRun = {
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AnalysisHistoryContext = {
|
||||
simulationRunId?: string;
|
||||
eventRange: { start: string; end: string } | null;
|
||||
};
|
||||
|
||||
const SIMULATION_RUN_TYPES = new Set([
|
||||
"burst_analysis",
|
||||
"flushing_analysis",
|
||||
"contaminant_analysis",
|
||||
"valve_close_analysis",
|
||||
"pressure_regulation",
|
||||
]);
|
||||
|
||||
export const getAnalysisHistoryContext = (
|
||||
run: AnalysisRun,
|
||||
): AnalysisHistoryContext => {
|
||||
const parameters = normalizeParameters(run.parameters);
|
||||
if (SIMULATION_RUN_TYPES.has(run.run_type)) {
|
||||
const start = Date.parse(run.started_at);
|
||||
const duration = parameters.modify_total_duration ?? parameters.duration;
|
||||
const end = new Date(
|
||||
typeof duration === "number" ? start + duration * 1000 : NaN,
|
||||
).getTime();
|
||||
return {
|
||||
simulationRunId: run.run_id,
|
||||
eventRange:
|
||||
Number.isFinite(start) && Number.isFinite(end) && end > start
|
||||
? {
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = normalizeParameters(parameters.result_payload);
|
||||
const window = normalizeParameters(
|
||||
parameters.scada_window ?? payload.scada_window,
|
||||
);
|
||||
const start = window.burst_start ?? window.start ?? parameters.scada_start;
|
||||
const end = window.burst_end ?? window.end ?? parameters.scada_end;
|
||||
const simulation = normalizeParameters(
|
||||
payload.simulation_run ?? payload.simulation_scheme,
|
||||
);
|
||||
const simulationRunId = parameters.simulation_run_id ?? simulation.run_id;
|
||||
return {
|
||||
simulationRunId:
|
||||
typeof simulationRunId === "string" && simulationRunId.trim()
|
||||
? simulationRunId.trim()
|
||||
: undefined,
|
||||
eventRange:
|
||||
typeof start === "string" &&
|
||||
typeof end === "string" &&
|
||||
Number.isFinite(Date.parse(start)) &&
|
||||
Date.parse(end) > Date.parse(start)
|
||||
? { start, end }
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export type AnalysisResult = {
|
||||
result_id: string;
|
||||
run_id: string;
|
||||
|
||||
@@ -28,8 +28,46 @@ describe("element history display conversion", () => {
|
||||
];
|
||||
|
||||
const points = toTimeSeriesPoints(series);
|
||||
expect(points[0].timestamp).toBe("2026-09-01T00:00:00Z");
|
||||
expect(points[0].values["P-1_sim"]).toBeCloseTo(83.3333333334);
|
||||
expect(points[0].timestamp).toBe("2026-09-01T00:00:00.000Z");
|
||||
expect(points[0].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(83.3333333334);
|
||||
expect(points[0].values["pressure-1_clean"]).toBeCloseTo(1.019716213);
|
||||
});
|
||||
|
||||
it("aligns equivalent timestamps and keeps same-name pipes and junctions separate", () => {
|
||||
const base: ElementHistorySeries = {
|
||||
element_id: "shared-id", element_type: "pipe", device_id: null,
|
||||
metric: "flow", source: "realtime_simulation", source_unit: "L/s",
|
||||
display_unit: "m³/h", unit_inferred: false,
|
||||
points: [{ time: "2026-09-01T08:00:00+08:00", value: 10 }],
|
||||
};
|
||||
const points = toTimeSeriesPoints([
|
||||
base,
|
||||
{ ...base, element_type: "junction", metric: "pressure", source_unit: "m",
|
||||
points: [{ time: "2026-09-01T00:00:00Z", value: 20 }] },
|
||||
{ ...base, source: "analysis_simulation", points: [
|
||||
{ time: "2026-09-01T00:00:00.000Z", value: null },
|
||||
] },
|
||||
]);
|
||||
expect(points).toHaveLength(1);
|
||||
expect(points[0].values).toEqual({
|
||||
'["pipe","shared-id","realtime_simulation"]': 36,
|
||||
'["junction","shared-id","realtime_simulation"]': 20,
|
||||
'["pipe","shared-id","analysis_simulation"]': null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps online IDs ending in _scheme distinct from another element's scheme series", () => {
|
||||
const base: ElementHistorySeries = {
|
||||
element_id: "P_scheme", element_type: "pipe", device_id: null,
|
||||
metric: "flow", source: "realtime_simulation", source_unit: "m3/h",
|
||||
display_unit: "m³/h", unit_inferred: false,
|
||||
points: [{ time: "2026-09-01T00:00:00Z", value: 100 }],
|
||||
};
|
||||
const points = toTimeSeriesPoints([
|
||||
base,
|
||||
{ ...base, element_id: "P", source: "analysis_simulation",
|
||||
points: [{ time: "2026-09-01T00:00:00Z", value: 5 }] },
|
||||
]);
|
||||
expect(Object.values(points[0].values).sort((a, b) => Number(a) - Number(b))).toEqual([5, 100]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,8 +49,10 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
||||
analysis_simulation: "scheme_sim",
|
||||
};
|
||||
|
||||
export const historySeriesKey = (series: ElementHistorySeries) =>
|
||||
`${series.device_id ?? series.element_id}_${SOURCE_SUFFIX[series.source]}`;
|
||||
export const historySeriesKey = (series: ElementHistorySeries) => {
|
||||
if (series.device_id) return `${series.device_id}_${SOURCE_SUFFIX[series.source]}`;
|
||||
return JSON.stringify([series.element_type, series.element_id, series.source]);
|
||||
};
|
||||
|
||||
export const historySourceLabel = (source: HistorySource) => {
|
||||
switch (source) {
|
||||
@@ -65,8 +67,11 @@ export const historySourceLabel = (source: HistorySource) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const historySeriesLabel = (series: ElementHistorySeries) =>
|
||||
`${series.device_id ?? series.element_id} (${historySourceLabel(series.source)})`;
|
||||
export const historySeriesLabel = (series: ElementHistorySeries) => {
|
||||
const target = series.device_id ??
|
||||
`${series.element_type === "pipe" ? "管道" : "节点"} ${series.element_id}`;
|
||||
return `${target} (${historySourceLabel(series.source)})`;
|
||||
};
|
||||
|
||||
export const toTimeSeriesPoints = (
|
||||
seriesList: ElementHistorySeries[],
|
||||
@@ -75,13 +80,14 @@ export const toTimeSeriesPoints = (
|
||||
seriesList.forEach((series) => {
|
||||
const key = historySeriesKey(series);
|
||||
series.points.forEach((point) => {
|
||||
const values = timeMap.get(point.time) ?? {};
|
||||
const timestamp = new Date(point.time).toISOString();
|
||||
const values = timeMap.get(timestamp) ?? {};
|
||||
values[key] = toDisplayValue(
|
||||
point.value,
|
||||
series.metric,
|
||||
series.source_unit,
|
||||
);
|
||||
timeMap.set(point.time, values);
|
||||
timeMap.set(timestamp, values);
|
||||
});
|
||||
});
|
||||
return Array.from(timeMap, ([timestamp, values]) => ({ timestamp, values })).sort(
|
||||
|
||||
Reference in New Issue
Block a user