1100 lines
40 KiB
TypeScript
1100 lines
40 KiB
TypeScript
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);
|
||
}
|