feat(history): show event context with online simulation and SCADA
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s

This commit is contained in:
2026-11-03 12:40:39 +08:00
parent d013f34493
commit 0dc7187de2
6 changed files with 556 additions and 117 deletions
+47
View File
@@ -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();
});
});
+60
View File
@@ -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;
+40 -2
View File
@@ -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]);
});
});
+12 -6
View File
@@ -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(