Files
next-tjwater-drainage-frontend/features/workbench/data/scheduled-conditions.ts
T

1100 lines
40 KiB
TypeScript
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.
import type {
ScheduledConditionAnalysisInsight,
ScheduledConditionItem,
ScheduledConditionKpi,
ScheduledConditionReport,
ScheduledConditionRiskLevel,
ScheduledConditionStatus,
ScheduledConditionTaskId
} from "../types";
export const SCHEDULED_CONDITION_REFRESH_INTERVAL_MS = 5_000;
const HISTORY_WINDOW_MINUTES = 6 * 60;
const GENERATED_SESSION_ID_PREFIX = "scheduled-condition-";
const MODEL_NAME = "DrainFlow Realtime Simulator";
const GUARANTEED_RUNNING_TASK_ID: ConditionTaskId = "scada-diagnosis";
type ConditionTaskId = ScheduledConditionTaskId;
type ConditionTaskDefinition = {
id: ConditionTaskId;
title: string;
summary: string;
intervalMinutes: number;
durationMinutes: number;
activeWindow?: (minutesOfDay: number) => boolean;
};
type TaskSignal = {
completeness: number;
deviation: number;
target: string;
sampleCount: number;
taskId: ConditionTaskId;
};
type ManualWorkOrderDefinition = {
id: string;
title: string;
summary: string;
detail: string;
recommendation: string;
source: string;
location: string;
dispatcher: string;
assignee: string;
priority: "normal" | "urgent";
startMinute: number;
durationMinutes: number;
dispatchLeadMinutes: number;
replyWindowMinutes: number;
riskLevel: ScheduledConditionRiskLevel;
replyRequirements: string[];
};
type MockWorkOrderTemplate = Omit<ManualWorkOrderDefinition, "startMinute" | "durationMinutes" | "dispatchLeadMinutes" | "replyWindowMinutes"> & {
stage: "dispatch" | "execution" | "reply";
durationMinutes: number;
dispatchLeadMinutes: number;
replyWindowMinutes: number;
};
type DispatchInstruction = {
action: string;
target: string;
command: string;
verification: string;
};
const CONDITION_TASKS: ConditionTaskDefinition[] = [
{
id: "scada-diagnosis",
title: "SCADA 数据诊断",
summary: "对压力、流量 SCADA 数据进行新鲜度、完整率、突变和一致性诊断,覆盖分区压力巡检范围。",
intervalMinutes: 5,
durationMinutes: 3
},
{
id: "smart-dispatch",
title: "管网智能调度",
summary: "结合实时工况生成管网要素调整指令,输出泵站、阀门或分区边界的建议动作。",
intervalMinutes: 15,
durationMinutes: 4
},
{
id: "network-simulation",
title: "管网定时模拟",
summary: "基于最新遥测、泵站启停和阀门开度滚动复核全网水力状态。",
intervalMinutes: 15,
durationMinutes: 6
},
{
id: "pump-energy",
title: "泵站能耗检查",
summary: "检查泵组负载、单位排水电耗、启停频率和供压稳定性。",
intervalMinutes: 60,
durationMinutes: 10
}
];
const MOCK_WORK_ORDER_TEMPLATES: MockWorkOrderTemplate[] = [
{
id: "mock-incident-valve-dispatch",
title: "系统工单:华山路阀门边界确认",
summary: "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
detail: "该工单由异常事件分析流程生成,用于演示人工确认后提交系统工单并通知管网要素操作。",
recommendation: "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
source: "异常事件分析",
location: "华山路沿线阀门组 VG-11",
dispatcher: "调度中心值班长",
assignee: "调度执行岗",
priority: "urgent",
durationMinutes: 40,
dispatchLeadMinutes: 30,
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "dispatch",
replyRequirements: ["确认阀门当前开度", "记录影响分区和用户范围", "回传派发确认截图"]
},
{
id: "mock-valve-operation-execution",
title: "系统工单:北辰分区阀门开度调整",
summary: "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
detail: "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
recommendation: "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
source: "调度方案确认",
location: "北辰分区边界阀门 BV-07",
dispatcher: "管网调度岗",
assignee: "北辰抢修二组",
priority: "urgent",
durationMinutes: 55,
dispatchLeadMinutes: 25,
replyWindowMinutes: 20,
riskLevel: "attention",
stage: "execution",
replyRequirements: ["上传阀门操作前后照片", "记录最终开度", "复核北辰末端压力不低于 0.22MPa"]
},
{
id: "mock-pump-boundary-reply",
title: "系统工单:泵站边界复核复令",
summary: "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
detail: "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
recommendation: "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
source: "模型复核建议",
location: "北辰二级泵站 P-02",
dispatcher: "模型复核岗",
assignee: "泵站运行班组",
priority: "normal",
durationMinutes: 45,
dispatchLeadMinutes: 20,
replyWindowMinutes: 35,
riskLevel: "normal",
stage: "reply",
replyRequirements: ["上传泵站运行截图", "填写出水压力复核结果", "确认模型边界已同步更新"]
}
];
const MANUAL_WORK_ORDERS: ManualWorkOrderDefinition[] = [
{
id: "huashan-valve-well-service",
title: "人工工单:华山路阀井现场处置",
summary: "现场班组复核华山路阀井积水与井盖松动问题,完成照片和处置记录回传。",
detail: "该工单由调度员人工创建,来源为现场巡检反馈,不属于周期工况任务。",
recommendation: "到点后确认现场安全措施、处置照片和回填记录,必要时关联资产缺陷单。",
source: "现场巡检反馈",
location: "华山路阀井",
dispatcher: "调度中心值班长",
assignee: "北片区抢修一组",
priority: "urgent",
startMinute: 9 * 60 + 40,
durationMinutes: 35,
dispatchLeadMinutes: 20,
replyWindowMinutes: 15,
riskLevel: "attention",
replyRequirements: ["上传处置前后照片", "记录阀井积水处理结果", "复核井盖密封和周边安全状态"]
},
{
id: "renmin-road-meter-change",
title: "人工工单:人民路流量计更换",
summary: "更换人民路 DN300 支线流量计通信模块,并复测回传质量。",
detail: "该工单由计量维护计划人工创建,执行前需确认旁通和作业窗口。",
recommendation: "作业完成后核对 15 分钟回传稳定性,并记录新模块编号。",
source: "计量维护计划",
location: "人民路 DN300 支线",
dispatcher: "计量调度岗",
assignee: "计量维护二组",
priority: "normal",
startMinute: 10 * 60 + 30,
durationMinutes: 50,
dispatchLeadMinutes: 30,
replyWindowMinutes: 20,
riskLevel: "normal",
replyRequirements: ["登记新通信模块编号", "回传 15 分钟稳定性截图", "确认旁通恢复和计量数据连续"]
},
{
id: "beichen-fire-hydrant-repair",
title: "人工工单:北辰消防栓维修",
summary: "处理北辰片区消防栓渗漏缺陷,现场确认关停影响和恢复时间。",
detail: "该工单来自客服缺陷转派,由调度员人工排入当日维修窗口。",
recommendation: "维修前通知影响点位,完成后复核周边压力和栓体密封状态。",
source: "客服缺陷转派",
location: "北辰片区消防栓",
dispatcher: "客服联动调度",
assignee: "北辰维修班组",
priority: "urgent",
startMinute: 14 * 60,
durationMinutes: 60,
dispatchLeadMinutes: 45,
replyWindowMinutes: 20,
riskLevel: "attention",
replyRequirements: ["回填关停影响范围", "上传维修完成照片", "复测周边压力和消防栓密封状态"]
}
];
export function createOperationalScheduledConditions(now = new Date()): ScheduledConditionItem[] {
const currentMinutesOfDay = getMinutesOfDay(now);
const historyStart = Math.max(0, currentMinutesOfDay - HISTORY_WINDOW_MINUTES);
const historicalConditions = CONDITION_TASKS.flatMap((task) =>
createHistoricalConditionsForTask(task, now, historyStart, currentMinutesOfDay)
);
const runningCondition = createGuaranteedRunningCondition(now, currentMinutesOfDay);
const operationalConditions =
historicalConditions.some((condition) => condition.status === "running") || !runningCondition
? historicalConditions
: [runningCondition, ...historicalConditions.filter((condition) => condition.id !== runningCondition.id)];
const futureWorkOrders = createUpcomingWorkOrders(now, currentMinutesOfDay);
return [...operationalConditions, ...futureWorkOrders].sort(
(a, b) => Date.parse(b.scheduledAt) - Date.parse(a.scheduledAt)
);
}
export function isGeneratedScheduledConditionSessionId(sessionId: string) {
return sessionId.startsWith(GENERATED_SESSION_ID_PREFIX);
}
function createHistoricalConditionsForTask(
task: ConditionTaskDefinition,
now: Date,
historyStart: number,
currentMinutesOfDay: number
) {
const firstMinute = ceilToInterval(historyStart, task.intervalMinutes);
const lastMinute = floorToInterval(currentMinutesOfDay, task.intervalMinutes);
const conditions: ScheduledConditionItem[] = [];
for (let minute = firstMinute; minute <= lastMinute; minute += task.intervalMinutes) {
if (task.activeWindow && !task.activeWindow(minute)) {
continue;
}
const scheduledAtDate = setMinutesOfDay(now, minute);
const scheduledAt = formatLocalIsoWithOffset(scheduledAtDate);
const signal = createTaskSignal(task, scheduledAtDate);
const status = getConditionStatus(task, scheduledAtDate, now, signal);
const riskLevel = getRiskLevel(status, signal);
const kpis = createTaskKpis(task, signal);
const durationMinutes = getTaskExecutionDuration(task);
conditions.push({
id: `${GENERATED_SESSION_ID_PREFIX}${task.id}-${formatCompactLocalTime(scheduledAtDate)}`,
kind: "condition",
taskId: task.id,
scheduledAt,
title: task.title,
summary: task.summary,
status,
riskLevel,
sessionId: `${GENERATED_SESSION_ID_PREFIX}${task.id}-${formatCompactLocalTime(scheduledAtDate)}`,
updatedAt: Math.min(now.getTime(), scheduledAtDate.getTime() + durationMinutes * 60_000),
detail: createDetail(task, scheduledAtDate, status, signal),
evidence: createEvidence(task, signal, status),
recommendation: createRecommendation(status, riskLevel, task, signal),
analysisInsight: createAnalysisInsight(task, status, riskLevel, signal),
kpis,
report: createConditionReport(task, status, riskLevel, signal, kpis),
modelName: MODEL_NAME,
durationMinutes
});
}
return conditions;
}
function createGuaranteedRunningCondition(now: Date, currentMinutesOfDay: number): ScheduledConditionItem | null {
const task = CONDITION_TASKS.find((item) => item.id === GUARANTEED_RUNNING_TASK_ID);
if (!task) {
return null;
}
const scheduledMinute = floorToInterval(currentMinutesOfDay, task.intervalMinutes);
if (task.activeWindow && !task.activeWindow(scheduledMinute)) {
return null;
}
const scheduledAtDate = setMinutesOfDay(now, scheduledMinute);
const scheduledAt = formatLocalIsoWithOffset(scheduledAtDate);
const signal = createTaskSignal(task, scheduledAtDate);
const status: ScheduledConditionStatus = "running";
const riskLevel = getRiskLevel(status, signal);
const kpis = createTaskKpis(task, signal);
const durationMinutes = getTaskExecutionDuration(task);
return {
id: `${GENERATED_SESSION_ID_PREFIX}${task.id}-${formatCompactLocalTime(scheduledAtDate)}`,
kind: "condition",
taskId: task.id,
scheduledAt,
title: task.title,
summary: task.summary,
status,
riskLevel,
sessionId: `${GENERATED_SESSION_ID_PREFIX}${task.id}-${formatCompactLocalTime(scheduledAtDate)}`,
updatedAt: now.getTime(),
detail: createDetail(task, scheduledAtDate, status, signal),
evidence: createEvidence(task, signal, status),
recommendation: createRecommendation(status, riskLevel, task, signal),
analysisInsight: createAnalysisInsight(task, status, riskLevel, signal),
kpis,
report: createConditionReport(task, status, riskLevel, signal, kpis),
modelName: MODEL_NAME,
durationMinutes
};
}
function createUpcomingWorkOrders(now: Date, currentMinutesOfDay: number): ScheduledConditionItem[] {
const workOrders = [...MANUAL_WORK_ORDERS, ...createMockWorkOrders(currentMinutesOfDay)];
return workOrders.filter(
(workOrder) => currentMinutesOfDay <= workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
)
.map((workOrder) => {
const scheduledAtDate = setMinutesOfDay(now, workOrder.startMinute);
const replyDeadlineMinute = workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes;
return {
id: `manual-work-order-${workOrder.id}-${formatCompactLocalTime(scheduledAtDate)}`,
kind: "work_order" as const,
code: `WO-${formatCompactLocalTime(scheduledAtDate)}-${workOrder.id.toUpperCase().slice(0, 4)}`,
scheduledAt: formatLocalIsoWithOffset(scheduledAtDate),
title: workOrder.title,
summary: workOrder.summary,
status: currentMinutesOfDay < workOrder.startMinute ? ("pending" as const) : ("running" as const),
riskLevel: workOrder.riskLevel,
updatedAt: now.getTime(),
detail: workOrder.detail,
recommendation: workOrder.recommendation,
durationMinutes: workOrder.durationMinutes,
source: workOrder.source,
location: workOrder.location,
dispatcher: workOrder.dispatcher,
assignee: workOrder.assignee,
priority: workOrder.priority,
replyWindowMinutes: workOrder.replyWindowMinutes,
stages: createWorkOrderStages(workOrder, now, scheduledAtDate, currentMinutesOfDay),
replyRequirements:
currentMinutesOfDay > replyDeadlineMinute
? []
: workOrder.replyRequirements
};
})
.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt));
}
function createMockWorkOrders(currentMinutesOfDay: number): ManualWorkOrderDefinition[] {
return MOCK_WORK_ORDER_TEMPLATES.map((template) => {
const startMinute = getMockWorkOrderStartMinute(template, currentMinutesOfDay);
return {
...template,
startMinute
};
});
}
function getMockWorkOrderStartMinute(template: MockWorkOrderTemplate, currentMinutesOfDay: number) {
if (template.stage === "dispatch") {
return Math.min(currentMinutesOfDay + 30, 24 * 60 - 1);
}
if (template.stage === "execution") {
return Math.max(0, currentMinutesOfDay - Math.min(20, template.durationMinutes - 5));
}
return currentMinutesOfDay - template.durationMinutes - Math.min(10, template.replyWindowMinutes - 5);
}
function createWorkOrderStages(
workOrder: ManualWorkOrderDefinition,
now: Date,
scheduledAtDate: Date,
currentMinutesOfDay: number
) {
const dispatchAt = setMinutesOfDay(now, Math.max(0, workOrder.startMinute - workOrder.dispatchLeadMinutes));
const executionEnd = setMinutesOfDay(now, workOrder.startMinute + workOrder.durationMinutes);
const replyEnd = setMinutesOfDay(
now,
workOrder.startMinute + workOrder.durationMinutes + workOrder.replyWindowMinutes
);
const executionEndMinute = workOrder.startMinute + workOrder.durationMinutes;
return [
{
id: "dispatch" as const,
title: "派发",
status: currentMinutesOfDay >= workOrder.startMinute ? ("done" as const) : ("current" as const),
owner: workOrder.dispatcher,
timeLabel: formatClock(dispatchAt),
description: `确认${workOrder.location}任务范围、优先级和到场窗口,派发给${workOrder.assignee}。`
},
{
id: "execution" as const,
title: "执行",
status:
currentMinutesOfDay >= executionEndMinute
? ("done" as const)
: currentMinutesOfDay >= workOrder.startMinute
? ("current" as const)
: ("pending" as const),
owner: workOrder.assignee,
timeLabel: formatClockRange(scheduledAtDate, workOrder.durationMinutes),
description: workOrder.summary
},
{
id: "reply" as const,
title: "复令",
status:
currentMinutesOfDay >= executionEndMinute
? ("current" as const)
: ("pending" as const),
owner: `${workOrder.assignee} → 调度中心`,
timeLabel: formatClockRange(executionEnd, workOrder.replyWindowMinutes),
description: `提交处置结果、现场照片和影响复核,最迟 ${formatClock(replyEnd)} 前完成复令。`
}
];
}
function getConditionStatus(
task: ConditionTaskDefinition,
scheduledAt: Date,
now: Date,
signal: TaskSignal
): ScheduledConditionStatus {
const elapsedMinutes = (now.getTime() - scheduledAt.getTime()) / 60_000;
const durationMinutes = getTaskExecutionDuration(task);
if (elapsedMinutes >= 0 && elapsedMinutes < durationMinutes) {
return "running";
}
if (task.id === "smart-dispatch") {
return "completed";
}
if (signal.completeness < 90) {
return "error";
}
if (signal.deviation >= 0.72) {
return "warning";
}
return "completed";
}
function getTaskExecutionDuration(task: ConditionTaskDefinition) {
if (task.id === GUARANTEED_RUNNING_TASK_ID) {
return Math.max(task.durationMinutes, task.intervalMinutes);
}
return task.durationMinutes;
}
function getRiskLevel(status: ScheduledConditionStatus, signal: TaskSignal): ScheduledConditionRiskLevel {
if (signal.taskId === "smart-dispatch" && status === "completed") {
return "normal";
}
if (status === "error" || signal.deviation >= 0.9) {
return "critical";
}
if (status === "warning" || status === "running" || signal.deviation >= 0.62) {
return "attention";
}
return "normal";
}
function createTaskSignal(task: ConditionTaskDefinition, scheduledAt: Date): TaskSignal {
const seed = hashString(`${task.id}-${formatCompactLocalTime(scheduledAt)}`);
const targetIndex = (seed % 7) + 1;
const deviation = ((seed >> 3) % 100) / 100;
const completenessBase = 96 - ((seed >> 5) % 7);
return {
completeness: deviation > 0.94 ? 88 : completenessBase,
deviation,
target: getSignalTarget(task.id, targetIndex),
sampleCount: task.id === "scada-diagnosis" ? 96 + (seed % 56) : 24 + (seed % 18),
taskId: task.id
};
}
function getSignalTarget(taskId: ConditionTaskId, index: number) {
if (taskId === "scada-diagnosis") {
return `SCADA 诊断域-${index}`;
}
if (taskId === "smart-dispatch") {
return `调度指令组-${index}`;
}
if (taskId === "network-simulation") {
return `全网模型边界-${index}`;
}
return `${index}# 泵站`;
}
function createDetail(
task: ConditionTaskDefinition,
scheduledAt: Date,
status: ScheduledConditionStatus,
signal: TaskSignal
) {
const timeLabel = formatClock(scheduledAt);
if (status === "running") {
return `${timeLabel}${task.title}正在执行,正在汇总 ${signal.target} 的实时遥测、模型边界和异常阈值。`;
}
if (task.id === "smart-dispatch") {
const instructions = createDispatchInstructions(signal);
return `${timeLabel}${task.title}已完成,已形成${instructions.map((item) => item.action).join("、")}指令。`;
}
if (status === "error") {
return `${timeLabel}${task.title}未完整闭环,${signal.target} 数据完整率为 ${signal.completeness}%,需要先复核数据源。`;
}
if (status === "warning") {
return `${timeLabel}${task.title}发现关注偏差,${signal.target} 偏离运行基线 ${(signal.deviation * 100).toFixed(0)}%。`;
}
return `${timeLabel}${task.title}已完成,${signal.target} 关键指标处于当前调度可解释范围。`;
}
function createEvidence(task: ConditionTaskDefinition, signal: TaskSignal, status: ScheduledConditionStatus) {
if (task.id === "smart-dispatch" && status !== "running") {
const instructions = createDispatchInstructions(signal);
return [
`${task.title}${task.intervalMinutes} 分钟周期执行,本轮对象为 ${signal.target}。`,
`已生成 ${instructions.length} 条管网要素调整指令,并标记调度员复核后下发。`,
...instructions.map((item, index) => `指令 ${index + 1}${item.target}${item.command}`)
];
}
const statusEvidence =
status === "error"
? "数据源完整率低于闭环阈值,已保留失败节点供人工复核。"
: status === "warning"
? "偏差超过关注阈值,建议下一轮继续观察同一对象趋势。"
: "未发现需要立即处置的连锁风险。";
return [
`${task.title}${task.intervalMinutes} 分钟周期执行,本轮对象为 ${signal.target}。`,
`已读取 ${signal.sampleCount} 个遥测样本,关键测点完整率 ${signal.completeness}%。`,
statusEvidence
];
}
function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): ScheduledConditionKpi[] {
if (task.id === "scada-diagnosis") {
const pressureFreshness = Math.max(82, Math.round(99 - signal.deviation * 12));
const flowFreshness = Math.max(80, Math.round(98 - signal.deviation * 13));
const abnormalSamples = Math.round(signal.deviation * 9);
const consistencyScore = Math.max(75, Math.round(99 - signal.deviation * 20));
return [
{
id: "pressure-scada-freshness",
label: "压力 SCADA 新鲜度",
value: pressureFreshness.toString(),
unit: "%",
threshold: "关注 < 94%,异常 < 90%",
status: getInverseMetricRisk(pressureFreshness, 94, 90),
description: "压力监测点最近 5 分钟内有效回传占比,覆盖 DMA 分区压力巡检范围。"
},
{
id: "flow-scada-freshness",
label: "流量 SCADA 新鲜度",
value: flowFreshness.toString(),
unit: "%",
threshold: "关注 < 93%,异常 < 88%",
status: getInverseMetricRisk(flowFreshness, 93, 88),
description: "流量计最近 5 分钟内有效回传占比,用于识别数据滞后和通信异常。"
},
{
id: "abnormal-scada-samples",
label: "异常样本数",
value: abnormalSamples.toString(),
unit: "个",
threshold: "关注 >= 5,异常 >= 8",
status: getMetricRisk(abnormalSamples, 5, 8),
description: "压力、流量样本中出现突变、越界或跨源不一致的数量。"
},
{
id: "pressure-flow-consistency",
label: "压流一致性评分",
value: consistencyScore.toString(),
unit: "分",
threshold: "关注 < 86,异常 < 80",
status: getInverseMetricRisk(consistencyScore, 86, 80),
description: "压力变化与流量变化是否符合水力逻辑的综合评分。"
}
];
}
if (task.id === "smart-dispatch") {
const instructions = createDispatchInstructions(signal);
const confidence = Math.max(86, Math.round(98 - signal.deviation * 8));
const expectedPressureGain = roundToOneDecimal(0.8 + signal.deviation * 2.6);
return [
{
id: "dispatch-command-count",
label: "调整指令数",
value: instructions.length.toString(),
unit: "条",
threshold: "常规 2-4 条",
status: "normal",
description: "本轮生成的管网要素调整指令数量。"
},
{
id: "dispatch-confidence",
label: "指令置信度",
value: confidence.toString(),
unit: "%",
threshold: "关注 < 85%,异常 < 75%",
status: getInverseMetricRisk(confidence, 85, 75),
description: "基于实时工况、SCADA 数据质量和模拟结果的调度指令可信度。"
},
{
id: "expected-pressure-gain",
label: "预计压力改善",
value: expectedPressureGain.toString(),
unit: "m",
threshold: "常规 0.5-3.5m",
status: "normal",
description: "执行调整指令后,目标片区末端压力的预计改善幅度。"
}
];
}
if (task.id === "network-simulation") {
const pressureDeviation = roundToOneDecimal(1.8 + signal.deviation * 10.6);
const flowDeviation = roundToOneDecimal(2.4 + signal.deviation * 12.2);
const maxPressureDelta = roundToOneDecimal(0.7 + signal.deviation * 7.8);
const balanceScore = Math.max(72, Math.round(98 - signal.deviation * 22));
return [
{
id: "pressure-deviation",
label: "模拟-实测压力偏差",
value: pressureDeviation.toString(),
unit: "%",
baseline: "近 7 日同窗模型偏差",
threshold: "关注 > 8%,异常 > 12%",
status: getMetricRisk(pressureDeviation, 8, 12),
description: "对比在线水力模拟结果与 SCADA 压力监测值的平均相对偏差。"
},
{
id: "flow-deviation",
label: "模拟-实测流量偏差",
value: flowDeviation.toString(),
unit: "%",
baseline: "近 7 日同窗流量偏差",
threshold: "关注 > 10%,异常 > 15%",
status: getMetricRisk(flowDeviation, 10, 15),
description: "对比模型计算流量与 SCADA 流量计回传值的偏差。"
},
{
id: "max-pressure-delta",
label: "最大节点压差",
value: maxPressureDelta.toString(),
unit: "m",
threshold: "关注 > 5m,异常 > 8m",
status: getMetricRisk(maxPressureDelta, 5, 8),
description: "模拟节点压力与实测折算压力之间的最大差值。"
},
{
id: "hydraulic-balance-score",
label: "水力平衡评分",
value: balanceScore.toString(),
unit: "分",
threshold: "关注 < 84,异常 < 78",
status: getInverseMetricRisk(balanceScore, 84, 78),
description: "综合压力、流量、边界条件一致性后的模型可信评分。"
}
];
}
if (task.id === "pump-energy") {
const energyDeviation = roundToOneDecimal(2 + signal.deviation * 13);
const unitEnergy = roundToTwoDecimals(0.285 + signal.deviation * 0.095);
const starts = 1 + Math.round(signal.deviation * 5);
return [
{
id: "energy-deviation",
label: "单位排水电耗偏差",
value: energyDeviation.toString(),
unit: "%",
threshold: "关注 > 10%,异常 > 15%",
status: getMetricRisk(energyDeviation, 10, 15),
description: "本轮单位排水电耗相对同窗基线的偏差。"
},
{
id: "unit-energy",
label: "单位排水电耗",
value: unitEnergy.toString(),
unit: "kWh/m3",
baseline: "同窗经济运行区间 0.28-0.34",
threshold: "关注 > 0.35,异常 > 0.38",
status: getMetricRisk(unitEnergy, 0.35, 0.38),
description: "按泵站出水量折算后的实时能耗水平。"
},
{
id: "pump-starts",
label: "泵组启停次数",
value: starts.toString(),
unit: "次",
threshold: "关注 >= 4,异常 >= 6",
status: getMetricRisk(starts, 4, 6),
description: "当前窗口内泵组启停频率,用于识别低效联动。"
}
];
}
return [createCompletenessKpi(signal)];
}
function createCompletenessKpi(signal: TaskSignal): ScheduledConditionKpi {
return {
id: "telemetry-completeness",
label: "遥测完整率",
value: signal.completeness.toString(),
unit: "%",
threshold: "关注 < 93%,异常 < 90%",
status: getInverseMetricRisk(signal.completeness, 93, 90),
description: "本轮参与判断的 SCADA/遥测样本完整程度。"
};
}
function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
const templates: DispatchInstruction[][] = [
[
{
action: "泵站目标压力微调",
target: "2# 泵站出水压力",
command: "将目标压力从 0.42MPa 调整至 0.44MPa,保持变频泵优先运行",
verification: "15 分钟后复核 DMA-03 末端压力是否提升 1.5m 以上"
},
{
action: "边界阀开度校正",
target: "DMA-03 / DMA-05 边界阀 BV-031",
command: "开度由 38% 调整至 42%,限制单次调整幅度不超过 5%",
verification: "观察相邻分区压差是否回落至 3m 内"
},
{
action: "末端压力观察",
target: "华山路末端压力点 P-117",
command: "将该点加入 15 分钟高频复核队列",
verification: "若压力低于 0.18MPa 持续两轮,转人工调度确认"
}
],
[
{
action: "泵组组合优化",
target: "1# 泵站 3# / 4# 泵组",
command: "保持 3# 泵运行,延后 4# 泵启动 10 分钟,避免低效并联区间",
verification: "复核单位排水电耗是否低于 0.34kWh/m3"
},
{
action: "高区排水边界收敛",
target: "高区联络阀 HV-204",
command: "开度下调 3%,减少高区向中区回流",
verification: "确认高区末端压力不低于 0.22MPa"
},
{
action: "流量计复核",
target: "人民路 DN300 流量计 F-088",
command: "将该流量计设为本轮调度结果校验点",
verification: "15 分钟内流量波动需低于 6%"
}
],
[
{
action: "DMA 边界切换准备",
target: "DMA-07 北侧边界阀 BV-072",
command: "保持现状,预置 45% 开度为备选边界方案",
verification: "若下一轮 SCADA 诊断正常,可进入调度员确认"
},
{
action: "低压片区补压",
target: "北辰片区支线 PRV-12",
command: "出口压力设定值上调 0.01MPa,禁止联动超过 0.03MPa",
verification: "复核北辰消防栓维修工单影响范围内压力恢复"
},
{
action: "模型边界刷新",
target: "全网模型边界条件",
command: "使用最新泵站出水、阀门开度和 SCADA 流量刷新模拟边界",
verification: "模拟-实测压力偏差需回落至 8% 内"
}
],
[
{
action: "阀门开度回退",
target: "华山路沿线阀门组 VG-11",
command: "将昨日临时开度回退 2%,恢复常规排水边界",
verification: "观察华山路和人民路支线压差不超过 2.5m"
},
{
action: "泵站压力保持",
target: "3# 泵站出口压力",
command: "维持 0.40MPa 不变,禁止自动升压策略介入",
verification: "若末端压力连续两轮低于 0.19MPa 再触发升压建议"
},
{
action: "异常测点隔离",
target: "SCADA 诊断异常测点组",
command: "调度计算暂不采用异常测点,使用相邻测点插补边界",
verification: "等待下一轮 5 分钟 SCADA 数据诊断恢复后解除隔离"
}
]
];
const templateIndex = Math.round(signal.deviation * 100) % templates.length;
const selected = templates[templateIndex];
const count = 2 + (Math.round(signal.deviation * 10) % 2);
return selected.slice(0, count);
}
function createConditionReport(
task: ConditionTaskDefinition,
status: ScheduledConditionStatus,
riskLevel: ScheduledConditionRiskLevel,
signal: TaskSignal,
kpis: ScheduledConditionKpi[]
): ScheduledConditionReport {
const attentionKpis = kpis.filter((kpi) => kpi.status !== "normal");
const conclusion =
status === "running"
? `本轮${task.title}正在形成报告,已先返回 ${signal.target} 的阶段性关键指标。`
: attentionKpis.length > 0
? `本轮${task.title}发现 ${attentionKpis.length} 项关键指标需要关注,建议结合下一轮趋势复核。`
: `本轮${task.title}关键指标均处于可解释范围,可维持当前调度方案。`;
return {
title: `${task.title}报告`,
conclusion,
sections: [
{
title: "核心判断",
items: [
`${signal.target} 本轮样本数 ${signal.sampleCount},遥测完整率 ${signal.completeness}%。`,
attentionKpis.length > 0
? `关注指标:${attentionKpis.map((kpi) => kpi.label).join("、")}。`
: "未发现超过关注阈值的关键指标。",
riskLevel === "critical" ? "当前风险等级为异常,建议先复核数据源和边界条件。" : "当前风险未达到自动处置阈值。"
]
},
{
title: "调度指导",
items: createGuidanceItems(task, attentionKpis, signal)
}
]
};
}
function createGuidanceItems(
task: ConditionTaskDefinition,
attentionKpis: ScheduledConditionKpi[],
signal: TaskSignal
) {
if (task.id === "scada-diagnosis") {
return [
"优先处理新鲜度或一致性异常的压力、流量测点,避免后续模拟和调度引用失真数据。",
"异常样本集中在同一 DMA 时,可直接替代分区压力巡检形成关注对象。",
"若连续两轮 SCADA 诊断异常,应通知数据维护或现场巡检确认通信链路。"
];
}
if (task.id === "smart-dispatch") {
const instructions = createDispatchInstructions(signal);
return [
...instructions.map((item, index) => `指令 ${index + 1}${item.target}${item.command}${item.verification}`),
attentionKpis.length > 0 ? "执行前需先确认 SCADA 数据诊断关注项,必要时等待下一轮数据质量恢复。" : "本轮指令满足自动建议条件,可由调度员复核后下发执行。"
];
}
if (task.id === "network-simulation") {
return [
"优先核对压力和流量偏差最大的 SCADA 测点,确认模型边界是否滞后。",
"若连续两轮模拟-实测偏差扩大,应重新校准泵站出水边界和关键阀门开度。",
"偏差回落前,不建议直接依据模型结果执行大范围阀门或泵站动作。"
];
}
if (task.id === "pump-energy") {
return [
"关注单位排水电耗和启停频率,判断是否存在泵组组合不经济。",
"若电耗偏差持续升高,建议比较相邻泵组效率曲线并优化启停策略。",
"调整前需确认供压稳定性,避免节能动作引发末端低压。"
];
}
return ["保留本轮诊断结果,等待下一轮定时任务复核。"];
}
function getMetricRisk(value: number, attentionThreshold: number, criticalThreshold: number): ScheduledConditionRiskLevel {
if (value >= criticalThreshold) {
return "critical";
}
if (value >= attentionThreshold) {
return "attention";
}
return "normal";
}
function getInverseMetricRisk(value: number, attentionThreshold: number, criticalThreshold: number): ScheduledConditionRiskLevel {
if (value <= criticalThreshold) {
return "critical";
}
if (value <= attentionThreshold) {
return "attention";
}
return "normal";
}
function roundToOneDecimal(value: number) {
return Math.round(value * 10) / 10;
}
function roundToTwoDecimals(value: number) {
return Math.round(value * 100) / 100;
}
function createRecommendation(
status: ScheduledConditionStatus,
riskLevel: ScheduledConditionRiskLevel,
task: ConditionTaskDefinition,
signal: TaskSignal
) {
if (status === "running") {
return "等待本轮工况完成后再确认是否生成处置建议。";
}
if (task.id === "smart-dispatch") {
const instructions = createDispatchInstructions(signal);
return `建议复核并下发本轮管网要素调整指令:${instructions.map((item) => `${item.target} ${item.command}`).join("")}。`;
}
if (status === "error") {
return "先复查遥测源连通性和模型输入边界,再决定是否转人工调度复核。";
}
if (status === "warning" || riskLevel === "attention") {
return `保留${task.title}关注项,下一轮继续比对同一对象趋势并复核阈值。`;
}
return "维持当前调度方案,无需新增人工工单。";
}
function createAnalysisInsight(
task: ConditionTaskDefinition,
status: ScheduledConditionStatus,
riskLevel: ScheduledConditionRiskLevel,
signal: TaskSignal
): ScheduledConditionAnalysisInsight | undefined {
if (status === "error") {
return {
summary: `Agent 判断本轮${task.title}未完成闭环,优先按数据源异常与局部水力异常并行排查。`,
notes: [
`${signal.target} 数据完整率为 ${signal.completeness}%,低于自动闭环阈值。`,
"异常证据已保留,可继续打开 Agent 会话补充上下文。",
"处置前建议先确认影响分区,避免误触发调度动作。"
],
solutionOptions: [
{
id: "source-recheck",
title: "复核数据源",
scenario: "遥测超时或采样完整率不足时优先执行。",
action: "重试关键测点拉取,核对 SCADA 新鲜度,并标记缺失源。",
tradeoff: "动作最稳妥,但会延后现场处置判断。"
},
{
id: "hydraulic-compare",
title: "水力模型比对",
scenario: "压力/流量同时偏离基线,疑似局部水力异常。",
action: "调用最近基线工况对比异常分区,输出可能影响范围。",
tradeoff: "可快速缩小范围,但依赖模型参数和边界条件质量。"
},
{
id: "manual-dispatch",
title: "人工调度复核",
scenario: "异常持续超过一个调度窗口或伴随用户侧反馈。",
action: "转入人工复核,准备阀门、泵站和现场巡检联动清单。",
tradeoff: "处置闭环更完整,但会占用调度员和巡检资源。"
}
]
};
}
if (status === "warning" || riskLevel === "attention") {
return {
summary: `Agent 将本轮${task.title}标记为关注项,当前偏差不足以触发自动处置。`,
notes: [
`${signal.target} 偏离运行基线 ${(signal.deviation * 100).toFixed(0)}%,建议保留本轮证据。`,
"重点观察压力/流量偏差是否持续扩大,避免单点噪声误判。",
"可在 Agent 面板继续追问偏差来源和相关历史会话。"
],
escalationCriteria: [
"连续两轮工况仍高于关注阈值。",
"关键测点完整率继续下降或出现多源超时。",
"同一分区出现用户投诉、低压或水质联动线索。"
]
};
}
return undefined;
}
function getMinutesOfDay(date: Date) {
return date.getHours() * 60 + date.getMinutes();
}
function setMinutesOfDay(baseDate: Date, minutesOfDay: number) {
const nextDate = new Date(baseDate);
nextDate.setHours(Math.floor(minutesOfDay / 60), minutesOfDay % 60, 0, 0);
return nextDate;
}
function ceilToInterval(value: number, interval: number) {
return Math.ceil(value / interval) * interval;
}
function floorToInterval(value: number, interval: number) {
return Math.floor(value / interval) * interval;
}
function formatClock(date: Date) {
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
}
function formatClockRange(start: Date, durationMinutes: number) {
const end = new Date(start.getTime() + durationMinutes * 60_000);
return `${formatClock(start)} - ${formatClock(end)}`;
}
function formatCompactLocalTime(date: Date) {
const yyyy = date.getFullYear().toString();
const mm = (date.getMonth() + 1).toString().padStart(2, "0");
const dd = date.getDate().toString().padStart(2, "0");
const hh = date.getHours().toString().padStart(2, "0");
const min = date.getMinutes().toString().padStart(2, "0");
return `${yyyy}${mm}${dd}${hh}${min}`;
}
function formatLocalIsoWithOffset(date: Date) {
const yyyy = date.getFullYear().toString();
const mm = (date.getMonth() + 1).toString().padStart(2, "0");
const dd = date.getDate().toString().padStart(2, "0");
const hh = date.getHours().toString().padStart(2, "0");
const min = date.getMinutes().toString().padStart(2, "0");
const ss = date.getSeconds().toString().padStart(2, "0");
const offsetMinutes = -date.getTimezoneOffset();
const offsetSign = offsetMinutes >= 0 ? "+" : "-";
const offsetAbs = Math.abs(offsetMinutes);
const offsetHours = Math.floor(offsetAbs / 60).toString().padStart(2, "0");
const offsetMins = (offsetAbs % 60).toString().padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}${offsetSign}${offsetHours}:${offsetMins}`;
}
function hashString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) | 0;
}
return Math.abs(hash);
}