import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import PizZip from "pizzip"; import { registerImageBuffer, renderDocxReport, patchAnomalyTemplateXml, chunkDetailImageRows, } from "../src/server/reportService/docxReportBuilder.js"; import { buildDetectorSectionPayload, pickKeyTurbines, buildSensorHeatmapData, buildDataCompletenessHeatmapData, buildDetectorHeatmapData, buildSensorRadarData, buildDetectorRadarData, chunkHeatmapData, buildSensorAnomalyRows, mapAnomalyCoverFields, summarizeTurbineAnomalyRate, buildAnomalySummaryRows, buildConclusionRows, formatSensorTypeText, } from "../src/server/reportService/anomalyReportMapper.js"; import { DETECTOR_TEMPLATE_CONFIG, buildAnomalyRadarOption, buildDetectorPlotOption, buildEmptyPlaceholderOption, buildFarmPlotJsons, buildCompletenessHeatmapOption, buildReportHeatmapOption, listDetectorPlotOptions, listFarmPlotOptionsFromTurbines, listFarmChartOptions, optionHasData, plotJsonHasData, turbinePlotsToFarmJson, } from "../src/server/reportService/anomalyChartBuilder.js"; import { renderEchartsOption } from "../src/server/reportService/echartsRenderer.js"; import { initChartService, shutdownChartService, } from "../src/server/utils/chartService/index.js"; import { diagnosticFarmSeriesColor, diagnosticFarmStyle, diagnosticSeriesColor, diagnosticSeriesSymbol, diagnosticSingleSeriesZ, DIAGNOSTIC_COLORS, PITCH_MIN_REPORT_COUNT_COLORS, PITCH_MIN_REPORT_COLOR_STOPS, PAGE_SHUTDOWN_COLORS, } from "../shared/anomalyDiagnosticVisual.mjs"; import { farmAggregateToPanels, formatReportValueAxisLabel, resolvePanelSubtitle, } from "../shared/anomalyDetectorPlotAdapter.mjs"; import { fillFarmPanelsToRule, getFarmPanelRule, } from "../shared/anomalyFarmChartRules.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); function samplePlot(labelPrefix = "") { return { title: `${labelPrefix}偏航`, xaxis: "时间", yaxis: "偏移 (°)", data: [ { label: "正常偏航角", mode: "markers", timeData: [ "2026-07-14 04:00:00", "2026-07-14 08:00:00", "2026-07-14 12:00:00", ], yData: [180, 200, 190], }, { label: "12h均值", mode: "lines", timeData: [ "2026-07-14 04:00:00", "2026-07-14 08:00:00", "2026-07-14 12:00:00", ], yData: [185, 198, 120], }, ], }; } function assert(condition, message) { if (!condition) throw new Error(message); } /** 图例项可能是字符串或 { name, itemStyle } 对象,统一取名称 */ function legendNames(option) { const data = option?.legend?.data || []; return data.map((item) => item && typeof item === "object" ? item.name : item, ); } const headerCover = mapAnomalyCoverFields({ fieldCode: "SVLgVZfi", fieldName: "新疆鄂能风电场", fieldMeta: { provinceName: "大唐新疆公司" }, modelList: [], }); assert( headerCover.Province === "新疆", `页眉 Province 应去重为「新疆」,实际为「${headerCover.Province}」`, ); assert( headerCover.Wind_farm === "鄂能风电场", `页眉 Wind_farm 应去掉省份前缀并保留风电场,实际为「${headerCover.Wind_farm}」`, ); assert( headerCover.Overview_of_the_Wind_Farm === "新疆鄂能风电场位于大唐新疆公司,机型—,共安装0台风机", `项目概要不应重复公司名或多余句号,实际为「${headerCover.Overview_of_the_Wind_Farm}」`, ); const duplicateLocationCover = mapAnomalyCoverFields({ fieldCode: "SVLgVZfi", fieldName: "新疆鄂能风电场", fieldMeta: { provinceName: "大唐新疆公司", cityName: "大唐新疆公司", }, modelList: [{ machineTypeCode: "GW82-1500" }, { machineTypeCode: "GW82-1500" }], }); assert( duplicateLocationCover.Overview_of_the_Wind_Farm === "新疆鄂能风电场位于大唐新疆公司,机型GW82-1500,共安装2台风机", `省/市同名时应去重,实际为「${duplicateLocationCover.Overview_of_the_Wind_Farm}」`, ); assert( !duplicateLocationCover.Overview_of_the_Wind_Farm.includes("。"), "项目概要正文不应自带句号(模板已有)", ); const thirteenCover = mapAnomalyCoverFields({ fieldCode: "FIELD13", fieldName: "新疆十三师风电场", fieldMeta: { provinceName: "大唐新疆能源有限公司" }, overview: { fieldName: "鄂能风电场" }, modelList: [], }); assert( thirteenCover.Province === "新疆" && thirteenCover.Wind_farm === "十三师风电场" && thirteenCover.Company === "大唐新疆能源有限公司", `选中风场应优先用于页眉,实际 Province=${thirteenCover.Province} Wind_farm=${thirteenCover.Wind_farm}`, ); assert( !`${headerCover.Province}${headerCover.Wind_farm}`.includes("大唐大唐"), "页眉不应出现「大唐大唐」重复前缀", ); const conclusionSample = buildConclusionRows({ yaw_error: { anomalyTurbines: 3, anomalyPoints: 12, turbineNames: ["25", "4", "10", "4号"], }, yaw_static: { anomalyTurbines: 0, anomalyPoints: 0, turbineNames: [], }, }); assert(conclusionSample.length === 1, "无异常检测器不应进入检测结论表"); assert( conclusionSample[0].problem_desc.startsWith("主要表现为") && conclusionSample[0].problem_desc.includes("静态偏差"), `问题描述应从「主要表现为」截取,实际为「${conclusionSample[0].problem_desc}」`, ); assert( conclusionSample[0].suggestion.startsWith("建议"), `建议意见应使用排查建议,实际为「${conclusionSample[0].suggestion}」`, ); assert( conclusionSample[0].problem_nature === "偏航与扭缆系统异常", `问题性质应为模块大标题,实际为「${conclusionSample[0].problem_nature}」`, ); assert( conclusionSample[0].turbine_names === "#04、#10、#25", `问题机组应统一罗列,实际为「${conclusionSample[0].turbine_names}」`, ); const conclusionWithoutDeload = buildConclusionRows({ ctrl_deload: { anomalyTurbines: 20, anomalyPoints: 80, turbineNames: ["01", "02", "03"], }, yaw_error: { anomalyTurbines: 1, anomalyPoints: 2, turbineNames: ["05"], }, }); assert( conclusionWithoutDeload.length === 1 && !conclusionWithoutDeload.some((row) => `${row.problem_desc}${row.suggestion}`.includes("降载"), ), "表10-1 检测结论不应统计降载判定", ); assert( !plotJsonHasData({ data: [] }) && !plotJsonHasData(null), "空 JSON 应判定为无图表数据", ); assert( plotJsonHasData({ data: [{ label: "散点", xData: [1], yData: [2] }], }), "含坐标点的 JSON 应判定为有数据", ); const templateSource = path.join( __dirname, "../src/public/file/异常检测数据分析报告模板(大唐版)_修订版_人工对齐版_业务化.docx", ); if (fs.existsSync(templateSource)) { const sourceXml = new PizZip(fs.readFileSync(templateSource)) .file("word/document.xml") .asText(); const patchedXml = patchAnomalyTemplateXml(sourceXml); const tocParas = patchedXml.match(//g) || []; let tocHas42 = false; let tocHasFigure = false; let tocHasModuleTag = false; tocParas.slice(0, 70).forEach((paragraph) => { const plain = paragraph.replace(/<[^>]+>/g, "").trim(); const isToc = /w:pStyle w:val="(?:26|29|30|31)"/.test(paragraph); if (!isToc) return; if (/^4\.2 按功能诊断项统计/.test(plain)) tocHas42 = true; if (/^图[34]-[12]/.test(plain)) tocHasFigure = true; if (/show_module_/.test(plain)) tocHasModuleTag = true; }); assert(tocHas42, "目录应保留 4.2 按功能诊断项统计 条目"); assert(!tocHasFigure, "目录中不应出现图3/图4 图题"); assert(!tocHasModuleTag, "目录中不应插入模块条件标签"); const detectorLoopOpen = patchedXml.indexOf( "{#zn-techcn-replace-tags-data_detector_anomaly-generalFiles}", ); const detectorCaption = patchedXml.indexOf( "{figure_caption}", detectorLoopOpen, ); const detectorImage = patchedXml.indexOf("{%image}", detectorCaption); const detectorLoopClose = patchedXml.indexOf( "{/zn-techcn-replace-tags-data_detector_anomaly-generalFiles}", detectorImage, ); assert( detectorLoopOpen >= 0 && detectorLoopOpen < detectorCaption && detectorCaption < detectorImage && detectorImage < detectorLoopClose, "第4章概览应按图题、图片逐项循环", ); assert( patchAnomalyTemplateXml(patchedXml) === patchedXml, "异常模板补丁应可重复执行", ); const detailGrid = patchedXml.indexOf( "{#zn-techcn-replace-tags-wind_power_curve-generalFiles}", ); const detailImage = patchedXml.indexOf("{%detail_image}", detailGrid); const detailClose = patchedXml.indexOf( "{/zn-techcn-replace-tags-wind_power_curve-generalFiles}", detailImage, ); assert( detailGrid >= 0 && detailGrid < detailImage && detailImage < detailClose, "分图应排成一行三列", ); assert( patchedXml.includes("{#detail_slot1}") && patchedXml.includes("{#detail_slot2}") && patchedXml.includes("{#detail_slot3}"), "分图应预留三列", ); assert( patchedXml.includes( "{#zn-techcn-replace-tags-wind_power_curve-generalFiles}{#detail_slot1}", ) && patchedXml.includes( "{/detail_slot3}{/zn-techcn-replace-tags-wind_power_curve-generalFiles}", ), "分图循环标签应与槽位标签同段,避免第一列下移", ); const farmImage = patchedXml.indexOf( "{#zn-techcn-replace-tags-wind_power_curve-farmSummary}", ); assert( farmImage >= 0 && patchedXml.indexOf("{%image}", farmImage) > farmImage && patchedXml.indexOf("{%image}", farmImage) < patchedXml.indexOf( "{/zn-techcn-replace-tags-wind_power_curve-farmSummary}", farmImage, ), "总图应保持一行一张", ); const chunked = chunkDetailImageRows([ { image: "a" }, { image: "b" }, { image: "c" }, { image: "d" }, ]); assert( chunked.length === 2 && chunked[0].detail_slot3[0].detail_image === "c" && chunked[1].detail_slot1[0].detail_image === "d" && chunked[1].detail_slot2.length === 0, "分图应按 3 张一组,末行空位留白", ); assert( patchedXml.includes("{#conclusionRows}") && patchedXml.includes("{problem_desc}") && patchedXml.includes("{problem_nature}") && patchedXml.includes("{/detectorSummaryRows}"), "表10-1 检测结论应改为 conclusionRows 循环,表4-1 应闭合 detectorSummaryRows", ); assert( (patchedXml.match(/\{sensor_anomaly_type\}/g) || []).length >= 16, "各检测器明细表应保留数据感知异常类型列", ); assert( patchedXml.includes("{#show_module_wind}") && patchedXml.includes("{/show_module_aero}"), "各检测模块章节应包一层 show_module 条件标签", ); const patchedPlain = patchedXml.replace(/<[^>]+>/g, ""); const patchedLoops = [ ...new Set([...patchedPlain.matchAll(/\{#([^{}]+)\}/g)].map((m) => m[1])), ]; const danglingLoops = patchedLoops.filter( (name) => !patchedPlain.includes(`{/${name}}`), ); assert( danglingLoops.length === 0, `模板循环应全部闭合,未闭合:${danglingLoops.join("、")}`, ); assert( patchedXml.includes("{/wind_power_curveRows}") && patchedXml.includes("{/aero_tsr_windRows}"), "检测明细表循环应补齐结尾标签", ); const patchedParas = patchedXml.match(//g) || []; patchedParas.slice(0, 70).forEach((paragraph) => { const plain = paragraph.replace(/<[^>]+>/g, "").trim(); const isToc = /w:pStyle w:val="(?:26|29|30|31)"/.test(paragraph); if (isToc && /show_module_/.test(plain)) { throw new Error("目录 TOC 段落中仍含有 show_module 标签"); } }); } const scatterFarm = buildFarmPlotJsons( [ { engineName: "A", plotJson: { xaxis: "风速", yaxis: "功率", data: [ { label: "散点", mode: "markers", xData: [1, 2], yData: [3, 4] }, { label: "上限", mode: "lines", xData: [1, 2], yData: [8, 9] }, { label: "下限", mode: "lines", xData: [1, 2], yData: [1, 2] }, { label: "参考功率曲线", mode: "lines", xData: [1, 2], yData: [5, 6], }, ], }, }, { engineName: "B", plotJson: { xaxis: "风速", yaxis: "功率", data: [ { label: "散点", mode: "markers", xData: [2, 3], yData: [4, 5] }, { label: "参考功率曲线", mode: "lines", xData: [1, 2], yData: [5, 6], }, ], }, }, ], "wind_power_scatter", "全场散点", ); assert( scatterFarm[0].data.filter((row) => row.__shared).length === 1, "参考功率曲线应只保留一条", ); assert( scatterFarm[0].data[scatterFarm[0].data.length - 1].__shared, "参考/合同功率曲线应放在图例最后", ); assert( !scatterFarm[0].data.some((row) => /上限|下限/.test(row.label)), "散点总图不应含上下限", ); assert( scatterFarm[0].data.some((row) => row.label === "A") && scatterFarm[0].data.some((row) => row.label === "B"), "散点总图应按风机分系列", ); const farmScatterOption = buildDetectorPlotOption( scatterFarm[0], scatterFarm[0].title, "anomalyScatterPO", ); const farmScatterSeries = (farmScatterOption.series || []).filter( (item) => item.type === "scatter3D", ); assert(farmScatterSeries.length > 0, "全场散点应输出 scatter3D 系列"); assert( farmScatterSeries.every((item) => { const size = Number(item.symbolSize); return size >= 4 && size <= 6; }), "全场散点报告正常点应收小,避免糊成色块", ); const emphasisScatter = buildDetectorPlotOption( { detector: "wind_power_scatter", chartType: "series", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", turbines: Array.from({ length: 12 }, (_, index) => ({ turbine: String(index + 1).padStart(2, "0"), xType: "number", xData: [4, 8, 12], yData: [200, 800, 1400], pointAnomaly: [false, true, false], })), }, "全场风功率散点分析", "anomalyScatterPO", ); const emphasisNormal = (emphasisScatter.series || []).find( (item) => item.type === "scatter3D" && /正常/.test(item.name), ); const emphasisAnomaly = (emphasisScatter.series || []).find( (item) => item.type === "scatter3D" && /异常/.test(item.name) && !/正常/.test(item.name), ); assert(emphasisNormal && emphasisAnomaly, "全场散点应按点拆成正常/异常"); assert( Number(emphasisAnomaly.symbolSize) - Number(emphasisNormal.symbolSize) === 2, "报告全场散点异常点只比正常点大一圈", ); assert( Number(emphasisAnomaly.itemStyle?.opacity) > Number(emphasisNormal.itemStyle?.opacity), "报告全场散点异常点应比正常点更不透明", ); assert( emphasisAnomaly.itemStyle?.color === "#FF1E1E", "报告全场散点异常点应为亮红", ); assert( !emphasisAnomaly.itemStyle?.borderWidth, "报告全场散点异常点不描白边", ); assert(farmScatterOption.grid3D, "风功率散点应输出 3D 图表"); const singleScatterOption = buildDetectorPlotOption( { title: "07 风功率散点分析", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "正常运行 (271)", mode: "markers", xData: [6, 8, 10], yData: [400, 900, 1500], }, { label: "近90日功率曲线", mode: "markers", xData: [3, 6, 9, 12, 15], yData: [0, 400, 1200, 1800, 1850], }, ], }, "07 风功率散点分析", "anomalyScatterPO", ); const curveSeries = (singleScatterOption.series || []).find( (item) => item.name === "近90日功率曲线", ); assert(curveSeries?.type === "line", "近90日功率曲线应渲染为折线而非散点"); assert(curveSeries?.showSymbol === false, "近90日功率曲线折线不应显示散点符号"); assert( diagnosticSeriesColor({ label: "正常运行 (271)" }) === DIAGNOSTIC_COLORS.normal && diagnosticSeriesColor({ label: "异常点" }) === DIAGNOSTIC_COLORS.anomaly && diagnosticSeriesColor({ label: "降载" }) === DIAGNOSTIC_COLORS.deload && diagnosticSeriesColor({ label: "停机" }) === DIAGNOSTIC_COLORS.shutdown, "单机风功率散点应按正常蓝/异常红/降载黄/停机黑上色", ); assert( diagnosticSeriesColor({ label: "累计降载率" }) !== DIAGNOSTIC_COLORS.deload, "累计降载率曲线不应被当成散点降载状态色", ); // 页面深色底可换成浅灰;报告与浅色页必须保持 shutdown 深色 assert( diagnosticSeriesColor({ label: "停机" }, 0, { pageTheme: true, darkTheme: true, }) === PAGE_SHUTDOWN_COLORS.dark && diagnosticSeriesColor({ label: "停机" }, 0, { pageTheme: true, darkTheme: false, }) === DIAGNOSTIC_COLORS.shutdown && diagnosticSeriesColor({ label: "停机" }) === DIAGNOSTIC_COLORS.shutdown, "停机点色仅在页面深色主题切换,报告保持 shutdown 深色", ); assert( diagnosticSeriesColor({ label: "正常运行 (271)" }, 0, { pageTheme: true, darkTheme: true, }) === DIAGNOSTIC_COLORS.normal, "页面主题选项不应影响停机以外的状态色", ); const singleCurveWithContract = buildDetectorPlotOption( { title: "07 功率曲线分析", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "近90天功率曲线", mode: "lines", xData: [3, 6, 9, 12], yData: [0, 400, 1200, 1500], }, { label: "合同功率曲线", mode: "lines", xData: [3, 6, 9, 12], yData: [0, 500, 1300, 1500], }, ], }, "07 功率曲线分析", "anomalyPowercurvePO", ); const singleContractSeries = (singleCurveWithContract.series || []).find( (item) => item.name === "合同功率曲线", ); const singlePowerCurveSeries = (singleCurveWithContract.series || []).find( (item) => /近90/.test(item.name || ""), ); assert(singleContractSeries, "单机功率曲线应含合同功率曲线"); assert( Number(singleContractSeries.z) < Number(singlePowerCurveSeries?.z || 2), "单机合同功率曲线层级应低于近90天功率曲线", ); assert( diagnosticSingleSeriesZ({ label: "合同功率曲线" }) < diagnosticSingleSeriesZ({ label: "正常运行" }), "单机合同功率曲线 z 应低于散点", ); const singleStatusScatter = buildDetectorPlotOption( { title: "07 风功率散点分析", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "正常", mode: "markers", xData: [6], yData: [400] }, { label: "异常", mode: "markers", xData: [8], yData: [500] }, { label: "降载", mode: "markers", xData: [7], yData: [300] }, { label: "停机", mode: "markers", xData: [5], yData: [0] }, { label: "合同功率曲线", mode: "lines", xData: [3, 12], yData: [0, 1500], }, ], }, "07 风功率散点分析", "anomalyScatterPO", ); const colorByName = Object.fromEntries( (singleStatusScatter.series || []).map((item) => [ item.name, item.itemStyle?.color || item.lineStyle?.color, ]), ); assert( String(colorByName["正常"] || "").startsWith(DIAGNOSTIC_COLORS.normal), "正常应为蓝色", ); assert( String(colorByName["异常"] || "").startsWith("#FF1E1E"), "报告散点异常点应为亮红", ); assert( String(colorByName["降载"] || "").startsWith(DIAGNOSTIC_COLORS.deload), "降载应为黄色", ); assert( String(colorByName["停机"] || "").startsWith(DIAGNOSTIC_COLORS.shutdown), "停机应为黑色", ); assert( Number( (singleStatusScatter.series || []).find((item) => item.name === "合同功率曲线") ?.z, ) < Number( (singleStatusScatter.series || []).find((item) => item.name === "正常")?.z, ), "单机散点图合同功率曲线应在散点下方", ); const scatterFarmWith90d = buildFarmPlotJsons( [ { engineName: "A", plotJson: { xaxis: "风速", yaxis: "功率", data: [ { label: "散点", mode: "markers", xData: [1, 2], yData: [3, 4] }, { label: "近90日功率曲线", mode: "markers", xData: [1, 2, 3], yData: [5, 6, 7], }, ], }, }, { engineName: "B", plotJson: { xaxis: "风速", yaxis: "功率", data: [ { label: "散点", mode: "markers", xData: [2, 3], yData: [4, 5] }, { label: "近90日功率曲线", mode: "markers", xData: [1, 2, 3], yData: [5, 6, 7], }, ], }, }, ], "wind_power_scatter", "全场散点", ); assert( scatterFarmWith90d[0].data.filter((row) => row.__shared).length === 1, "近90日功率曲线全场应只保留一条", ); assert( scatterFarmWith90d[0].data.some((row) => row.label === "近90日功率曲线"), "近90日功率曲线应出现在全场散点图例", ); const qualityPanels = listDetectorPlotOptions( { panels: [ { panelTitle: "功率因数 | 异常点: 0", xaxis: "时间", yaxis: "功率因数", data: [ { label: "功率因数", mode: "markers", timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"], yData: [0.98, 0.97], }, ], }, { panelTitle: "电流不平衡度", xaxis: "时间", yaxis: "电流不平衡度", data: [ { label: "电流不平衡度", mode: "markers", timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"], yData: [0.12, 0.15], }, ], }, ], }, "DT01 电能质量分析", "anomalyPowerqualityPO", ); assert(qualityPanels.length === 2, "电能质量单机图应按 panel 全部出图"); assert( qualityPanels[1].yAxis.name === "电流不平衡度", "第二张单机图应使用对应 panel 的 Y 轴", ); assert( (qualityPanels[1].series || []).some( (item) => Array.isArray(item.data) && item.data.length > 0, ), "单机图系列不能为空", ); const pitchFarm = buildFarmPlotJsons( [ { engineName: "101", plotJson: { panels: [ { panelTitle: "桨距角时序", xaxis: "时间", yaxis: "桨距角", data: [ { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1], }, { label: "桨叶 2", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [2], }, { label: "桨叶 3", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [3], }, { label: "阈值参考", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [4], }, ], }, { panelTitle: "桨距角差", xaxis: "时间", yaxis: "差值", data: [ { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1], }, ], }, ], }, }, ], "pitch_regulation", "全场变桨一致性", ); assert(pitchFarm.length === 2, "变桨一致性总图应为 2 张"); assert( /各桨叶分风速段/.test(pitchFarm[0].title || ""), "变桨一致性第一张应为各桨叶分风速段中位数趋势", ); assert( /极差/.test(pitchFarm[1].title || ""), "变桨一致性第二张应为桨叶极差时序及异常识别", ); assert( !pitchFarm[0].data.some((row) => /阈值/.test(row.originalLabel || "")), "变桨一致性不应展示阈值参考", ); const pitchFarmReportOptions = listFarmChartOptions( [ { engineName: "101", plotJson: { panels: [ { panelTitle: "桨距角时序", xaxis: "时间", yaxis: "桨距角", data: [ { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1], }, { label: "桨叶 2", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [2], }, { label: "桨叶 3", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [3], }, { label: "阈值参考", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [4], }, ], }, { panelTitle: "桨距角差", xaxis: "时间", yaxis: "差值", data: [ { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1], }, ], }, ], }, }, ], "全场变桨一致性", "anomalyPitchregulationPO", { templateKey: "pitch_regulation", farmMode: true }, ); assert(pitchFarmReportOptions.length === 2, "报告变桨一致性应出 2 张图"); const deloadFarm = buildFarmPlotJsons( [ { engineName: "101", plotJson: { panels: [ { panelTitle: "风速", xaxis: "时间", yaxis: "风速", data: [{ label: "风速", timeData: ["t"], yData: [1] }], }, { panelTitle: "有功功率时序", xaxis: "时间", yaxis: "有功功率", data: [ { label: "正常", mode: "markers", timeData: ["t"], yData: [1] }, { label: "异常", mode: "markers", timeData: ["t"], yData: [2] }, ], }, ], }, }, ], "ctrl_deload", "全场降载", ); assert(deloadFarm.length === 1, "降载总图只出有功功率时序"); assert( /有功功率/.test(deloadFarm[0].title + deloadFarm[0].yaxis), "降载总图应为有功功率", ); assert( diagnosticFarmSeriesColor( { extra: { anomaly: false }, originalLabel: "正常", turbineIndex: 1 }, 0, "ctrl_deload", ) === DIAGNOSTIC_COLORS.normal && diagnosticFarmSeriesColor( { extra: { anomaly: false }, originalLabel: "异常", turbineIndex: 1 }, 1, "ctrl_deload", ) === DIAGNOSTIC_COLORS.normal, "正常降载机组整机应统一蓝色", ); assert( diagnosticFarmSeriesColor( { originalLabel: "正常", turbineIndex: 0 }, 0, "pitch_coord", ) === DIAGNOSTIC_COLORS.normal, "变桨协调总图正常点应统一蓝色", ); assert( diagnosticFarmSeriesColor( { originalLabel: "异常", turbineIndex: 0 }, 0, "pitch_coord", ) === DIAGNOSTIC_COLORS.anomaly, "变桨协调总图异常点应统一红色", ); assert( diagnosticFarmSeriesColor( { label: "07号 正常", originalLabel: "正常", turbineIndex: 0 }, 0, "wind_power_scatter", ) === DIAGNOSTIC_COLORS.normal && diagnosticFarmSeriesColor( { label: "07号 异常", originalLabel: "异常", turbineIndex: 0 }, 0, "wind_power_scatter", ) === DIAGNOSTIC_COLORS.anomaly && diagnosticFarmSeriesColor( { label: "08号 正常", originalLabel: "正常", turbineIndex: 1 }, 1, "wind_power_curve", ) === DIAGNOSTIC_COLORS.normal && diagnosticFarmSeriesColor( { label: "08号 异常", originalLabel: "异常", turbineIndex: 1 }, 1, "wind_power_curve", ) === DIAGNOSTIC_COLORS.anomaly, "风功率曲线/散点 3D 总图应按正常蓝、异常红上色", ); assert( diagnosticFarmSeriesColor( { extra: { anomaly: true }, originalLabel: "正常", turbineIndex: 3 }, 0, "ctrl_deload", ) === DIAGNOSTIC_COLORS.anomaly && diagnosticFarmSeriesColor( { extra: { anomaly: true }, originalLabel: "异常", turbineIndex: 3 }, 1, "ctrl_deload", ) === DIAGNOSTIC_COLORS.anomaly, "异常降载机组整机应统一红色", ); assert( diagnosticFarmSeriesColor( { originalLabel: "降载", engineName: "08号", turbineIndex: 0 }, 0, "ctrl_deload", ) === DIAGNOSTIC_COLORS.anomaly && diagnosticFarmSeriesColor( { originalLabel: "停机", engineName: "08号", turbineIndex: 0 }, 0, "ctrl_deload", ) === DIAGNOSTIC_COLORS.anomaly, "全场降载总图残留降载/停机系列名应标红而非黄/黑", ); assert( diagnosticFarmSeriesColor( { extra: { anomaly: true }, originalLabel: "桨叶 1", turbineIndex: 0 }, 0, "pitch_regulation", ) === DIAGNOSTIC_COLORS.anomaly && diagnosticFarmSeriesColor( { extra: { anomaly: false }, originalLabel: "桨叶 2", turbineIndex: 1 }, 1, "pitch_regulation", ) === DIAGNOSTIC_COLORS.normal, "变桨一致性总图应按机组状态统一红/蓝", ); assert( diagnosticFarmStyle( { extra: { anomaly: true }, originalLabel: "桨叶 1" }, "pitch_regulation", ).z > diagnosticFarmStyle( { extra: { anomaly: false }, originalLabel: "桨叶 2" }, "pitch_regulation", ).z, "变桨一致性异常机组应叠在正常机组之上", ); assert( diagnosticFarmSeriesColor( { anomaly: true, originalLabel: "异常", turbineIndex: 0 }, 0, "aero_cp", ) === DIAGNOSTIC_COLORS.anomaly && diagnosticFarmSeriesColor( { anomaly: false, originalLabel: "正常", turbineIndex: 1 }, 1, "aero_tsr", ) === DIAGNOSTIC_COLORS.normal && diagnosticFarmSeriesColor( { anomaly: false, extra: { anomaly: true }, originalLabel: "正常", turbineIndex: 0, }, 0, "aero_cp", ) === DIAGNOSTIC_COLORS.normal, "气动总图应按 pointAnomaly 拆分后的点状态上色(行级优先于整机)", ); assert( diagnosticSeriesSymbol({ originalLabel: "正常" }) === "circle" && diagnosticSeriesSymbol({ originalLabel: "异常" }) === "circle" && diagnosticSeriesSymbol({ originalLabel: "降载" }) === "circle", "降载总图正常/异常/降载点形应统一为圆形,靠颜色区分", ); const deloadRateOption = listDetectorPlotOptions( { panels: [ { panelTitle: "有功功率时序", xaxis: "时间", yaxis: "有功功率", data: [ { label: "正常运行", mode: "markers", timeData: ["2026-08-22 00:00:00", "2026-08-22 04:00:00"], yData: [1000, 1100], }, ], }, { panelTitle: "累计降载率随时间变化", xaxis: "时间", yaxis: "累计降载率 (%)", data: [ { label: "累计降载率", mode: "markers", timeData: [ "2026-08-22 00:00:00", "2026-08-22 04:00:00", "2026-08-22 08:00:00", ], yData: [14, 2, 3], }, { label: "正常上限 5%", mode: "markers", timeData: ["2026-08-22 00:00:00", "2026-08-22 08:00:00"], yData: [5, 5], }, { label: "频繁阈值 20%", mode: "markers", timeData: ["2026-08-22 00:00:00"], yData: [20], }, ], }, ], }, "002 降载判定分析", "anomalyDeloadPO", { templateKey: "ctrl_deload" }, ); assert(deloadRateOption.length === 2, "单机降载应按 panel 出图"); const rateChart = deloadRateOption[1]; assert( rateChart.series.every((item) => item.type === "line"), "累计降载率应为折线图", ); assert( rateChart.series.find((item) => item.name === "累计降载率")?.lineStyle .type === "solid", "累计降载率应为实线", ); assert( rateChart.series.find((item) => /正常上限/.test(item.name))?.lineStyle .type === "dashed", "正常上限应为虚线", ); assert( rateChart.series.find((item) => /频繁阈值/.test(item.name))?.lineStyle .type === "dashed", "频繁阈值应为虚线", ); assert( (rateChart.series.find((item) => /频繁阈值/.test(item.name))?.data || []) .length >= 2, "阈值线应拉满时间轴", ); const opFarm = buildFarmPlotJsons( [ { engineName: "101", plotJson: { panels: [ { panelTitle: "投影", xaxis: "x", yaxis: "y", data: [{ label: "簇", xData: [1], yData: [1] }], }, { panelTitle: "转速-功率散点", xaxis: "转速", yaxis: "功率", data: [{ label: "散点", xData: [1], yData: [2] }], }, ], }, }, ], "ctrl_op_state", "全场运行状态", ); assert(opFarm.length === 1, "运行状态总图只出一张"); assert( /转速/.test(opFarm[0].title + opFarm[0].xaxis), "运行状态总图应为转速-功率", ); const pqFarm = buildFarmPlotJsons( [ { engineName: "101", plotJson: { panels: [ { panelTitle: "三相电流不平衡度 | 异常点: 0", xaxis: "时间", yaxis: "电流不平衡度", data: [{ label: "点", xData: [1], yData: [1] }], }, { panelTitle: "三相电压不平衡度 | 异常点: 0", xaxis: "时间", yaxis: "电压不平衡度", data: [{ label: "点", xData: [1], yData: [1] }], }, { panelTitle: "功率因数 | 异常点: 0", xaxis: "时间", yaxis: "功率因数", data: [{ label: "点", xData: [1], yData: [1] }], }, ], }, }, ], "ctrl_power_quality", "全场电能质量", ); assert(pqFarm.length === 3, "电能质量总图应出三张"); assert( pqFarm.length === getFarmPanelRule("ctrl_power_quality").count, "电能质量图数必须与共享报告模板规则一致", ); assert( /电流不平衡/.test(pqFarm[0].title + pqFarm[0].yaxis), "第一张应为三相电流不平衡度", ); assert( /电压不平衡/.test(pqFarm[1].title + pqFarm[1].yaxis), "第二张应为三相电压不平衡度", ); assert( /功率因数/.test(pqFarm[2].title + pqFarm[2].yaxis), "第三张应为功率因数", ); function loadLocalFarmJson(name) { const filePath = path.resolve(__dirname, `../../SVLgVZfi/${name}`); if (!fs.existsSync(filePath)) return null; return JSON.parse(fs.readFileSync(filePath, "utf8")); } function dummyFarmPanel(title, yaxis) { return { title, xaxis: "x", yaxis, data: [{ label: "占位", xData: [1], yData: [1] }], }; } const localPitchFarm = loadLocalFarmJson("farm_pitch_regulation.json"); if (localPitchFarm) { const overlay = farmAggregateToPanels(localPitchFarm, { templateKey: "pitch_regulation", }); const filled = fillFarmPanelsToRule( overlay, [ dummyFarmPanel("各桨叶分风速段中位数趋势", "桨距角 (°)"), dummyFarmPanel("桨叶极差时序及异常识别", "桨叶极差 (°)"), ], "pitch_regulation", ); assert(filled.length === 2, "变桨一致性页面总图应为 2 面"); assert( /各桨叶分风速段/.test(filled[0].title), "第一面应为各桨叶分风速段中位数趋势", ); assert(/极差/.test(filled[1].title), "第二面应为桨叶极差时序及异常识别"); assert(filled[1].data.length > 1, "极差面应保留 farm JSON 真实叠加"); } const localCoordFarm = loadLocalFarmJson("farm_pitch_coord.json"); if (localCoordFarm) { const overlay = farmAggregateToPanels(localCoordFarm, { templateKey: "pitch_coord", }); const filled = fillFarmPanelsToRule( overlay, [ dummyFarmPanel("功率-转速散点(桨距角着色)", "有功功率 (kW)"), dummyFarmPanel("桨距角-转速散点", "桨距角 (°)"), ], "pitch_coord", ); assert(filled.length === 2, "变桨协调页面总图应为 2 面"); assert(/着色/.test(filled[0].title), "变桨协调第一面应为桨距角着色"); assert( /桨距角-转速/.test(filled[1].title), "变桨协调第二面应为桨距角-转速散点", ); } const localPqFarm = loadLocalFarmJson("farm_ctrl_power_quality.json"); if (localPqFarm) { const overlay = farmAggregateToPanels(localPqFarm, { templateKey: "ctrl_power_quality", }); const filled = fillFarmPanelsToRule( overlay, [ dummyFarmPanel("三相电流不平衡度", "电流不平衡度"), dummyFarmPanel("三相电压不平衡度", "电压不平衡度"), dummyFarmPanel("功率因数", "功率因数"), ], "ctrl_power_quality", ); assert(filled.length === 3, "电能质量页面总图应为 3 面"); assert(/电流不平衡/.test(filled[0].title), "电能质量第一面应为电流不平衡"); assert(/电压不平衡/.test(filled[1].title), "电能质量第二面应为电压不平衡"); assert(/功率因数/.test(filled[2].title), "电能质量第三面应为功率因数"); assert(filled[2].data.length > 1, "功率因数面应保留 farm JSON 真实叠加"); } const rawPointCount = 9001; const rawX = Array.from({ length: rawPointCount }, (_, index) => index); const rawY = rawX.map((value) => value * 2); const rawPointFarm = buildFarmPlotJsons( [ { engineName: "101", plotJson: { panels: [ { panelTitle: "转速-功率散点", xaxis: "转速", yaxis: "功率", data: [ { label: "散点", mode: "markers", xData: rawX, yData: rawY }, ], }, ], }, }, ], "ctrl_op_state", "全场运行状态", ); assert( rawPointFarm[0].data[0].xData.length === rawPointCount, "全场图不得抽样原始数据点", ); const rawPointOption = buildDetectorPlotOption( rawPointFarm[0], rawPointFarm[0].title, "anomalyOperationPO", ); assert( rawPointOption.series[0].data.length === rawPointCount, "报告图渲染不得抽样原始数据点", ); console.log("farm chart rules ok"); const curveFarm = buildFarmPlotJsons( [ { engineName: "DT01", plotJson: { xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "30天功率曲线", mode: "lines", timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"], xData: [4, 12], yData: [200, 2000], }, { label: "合同功率曲线", mode: "lines", timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"], xData: [4, 12], yData: [180, 2100], }, ], }, }, { engineName: "DT02", plotJson: { xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "30天功率曲线", mode: "lines", timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"], xData: [5, 11], yData: [300, 1900], }, { label: "合同功率曲线", mode: "lines", xData: [4, 12], yData: [180, 2100], }, ], }, }, ], "wind_power_curve", "全场功率曲线", ); assert(curveFarm.length === 1, "功率曲线总图应 1 张"); assert( curveFarm[0].data.filter((row) => row.__shared).length === 1, "合同功率曲线全场只保留一条", ); assert( curveFarm[0].data[curveFarm[0].data.length - 1].label === "合同功率曲线", "合同功率曲线应在图例末尾", ); const curveOption = buildDetectorPlotOption( curveFarm[0], curveFarm[0].title, "anomalyPowercurvePO", ); assert(curveOption.grid3D, "功率曲线应输出 3D 图表"); assert(curveOption.xAxis3D?.name === "风机名称", "3D X 轴应为风机名称"); assert( curveOption.xAxis3D?._turbineLabelMeta?.labels && typeof curveOption.xAxis3D._turbineLabelMeta.labels === "object", "功率曲线风机轴应带可序列化抽样标签,供报告渲染重建 formatter", ); assert(/风速/.test(String(curveOption.yAxis3D?.name || "")), "3D Y 轴应为风速"); assert(/功率/.test(String(curveOption.zAxis3D?.name || "")), "3D Z 轴应为功率"); assert( curveOption.series.some( (item) => (item.type === "scatter3D" || item.type === "line3D") && (item.data?.[0]?.[1] === 4 || item.data?.[0]?.[1] === 5), ), "功率曲线 3D Y 轴应使用风速", ); assert( curveOption.series.some( (item) => item.type === "line3D" && item.data?.[0]?.[0] > 0, ), "功率曲线各风机系列 X 轴应为机组序号", ); assert( !curveOption.series.some( (item) => item.type === "scatter3D" && item.name && !/参考|合同/.test(item.name), ), "功率曲线风机数据不应为 scatter3D", ); const farmWithContractJson = { detector: "wind_power_scatter", chartType: "series", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", turbines: [ { turbine: "03", anomaly: true, xType: "number", xData: [3.25, 8.25], yData: [56.45, 243.89], }, { turbine: "06", anomaly: false, xType: "number", xData: [3.25, 8.25], yData: [61.4, 238.51], }, ], referenceCurve: { label: "合同功率曲线", xData: [0, 12, 25], yData: [0, 595, 850], mode: "lines", }, }; const farmWithContract = farmAggregateToPanels(farmWithContractJson); assert( farmWithContract[0].data .filter((row) => !row.__shared) .every((row) => row.mode === "markers"), "风功率散点总图风机数据应为散点", ); assert( farmWithContract[0].data.some( (row) => row.__shared && row.label === "合同功率曲线" && row.mode === "lines", ), "总图应绘制 referenceCurve 合同功率曲线", ); const contractFarmOption = buildDetectorPlotOption( farmWithContractJson, "全场风功率散点分析", "anomalyScatterPO", ); const contractSeries = (contractFarmOption.series || []).find( (item) => item.name === "合同功率曲线", ); const turbineScatter = (contractFarmOption.series || []).find( (item) => item.name !== "合同功率曲线" && (item.type === "scatter3D" || item.type === "scatter"), ); assert(contractSeries, "合同功率曲线应出现在总图中"); assert( contractSeries.type === "line3D" || contractSeries.type === "line", "合同功率曲线应为折线", ); assert(turbineScatter, "风功率散点总图风机数据应为散点"); assert( /#7C3AED|rgb\(124,\s*58,\s*237\)/i.test( String(contractSeries.lineStyle?.color || ""), ), `合同功率曲线应与异常红区分,实际为 ${contractSeries.lineStyle?.color}`, ); assert( Number(contractSeries.lineStyle?.width) >= 3, "合同功率曲线应比风机散点更醒目", ); assert(contractSeries.lineStyle?.type === "solid", "合同功率曲线应为实线"); const misplacedX = buildDetectorPlotOption( { title: "DT01 功率曲线分析", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "30天功率曲线", mode: "lines", timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"], xData: [6, 10], yData: [400, 1800], }, ], }, "DT01 功率曲线分析", "anomalyPowercurvePO", ); assert(misplacedX.series[0].data[0][0] === 6, "单机功率曲线应取 xData 风速"); assert(!misplacedX.grid3D, "单机功率曲线应保持 2D"); assert(misplacedX.xAxis?.type === "value", "单机功率曲线风速轴应为数值轴"); const pitchCoordFarm = buildFarmPlotJsons( [ { engineName: "DT40", plotJson: { xaxis: "转速 (r/min)", yaxis: "有功功率 (kW)", data: [ { label: "散点", mode: "lines", timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"], xData: [8, 12], yData: [400, 1800], }, ], }, }, ], "pitch_coord", "全场变桨协调", ); const pitchCoordOption = buildDetectorPlotOption( pitchCoordFarm[0], pitchCoordFarm[0].title, "anomalyPitchcoordPO", ); assert(pitchCoordOption.series[0].type === "scatter", "变桨协调应输出散点"); assert(pitchCoordOption.series[0].data[0][0] === 8, "变桨协调 X 轴应为转速"); assert( Number(pitchCoordOption.series[0].symbolSize) >= 3 && Number(pitchCoordOption.series[0].symbolSize) <= 5, "全场变桨协调报告散点应更小", ); const rangedOpFarm = listFarmChartOptions( [ { engineName: "01", plotJson: { panels: [ { panelTitle: "转速-功率散点图", xaxis: "转速(r/min)", yaxis: "有功功率(kW)", xrange: [0, 20], yrange: [0, 500], data: [ { label: "异常点", mode: "markers", xData: [12, 40], yData: [200, 900], }, ], }, ], }, }, ], "全场运行状态分析", "anomalyOperationPO", { templateKey: "ctrl_op_state" }, ); assert(rangedOpFarm.length === 1, "运行状态总图应保留转速-功率散点"); assert( rangedOpFarm[0].xAxis.min == null && rangedOpFarm[0].xAxis.max == null, "运行状态总图横轴应自适应", ); assert( rangedOpFarm[0].yAxis.min == null && rangedOpFarm[0].yAxis.max == null, "运行状态总图纵轴应自适应", ); assert( rangedOpFarm[0].xAxis.scale === true && rangedOpFarm[0].yAxis.scale === true, "运行状态总图应按数据自适应缩放", ); const rangedPitch = listDetectorPlotOptions( { detector_name: "pitch_coord", panels: [ { panelTitle: "功率-转速散点(桨距角着色)", xaxis: "转速 (r/min)", yaxis: "有功功率 (kW)", xrange: [5, 18], yrange: [0, 1500], data: [ { label: "异常点", mode: "markers", xData: [8, 30], yData: [400, 2200], }, ], }, ], }, "01 变桨-转速-功率协调分析", "anomalyPitchcoordPO", { templateKey: "pitch_coord" }, ); assert( rangedPitch[0].xAxis.min === 1000 && rangedPitch[0].xAxis.max == null, "变桨协调分图横轴应从 1000 起,上限自适应", ); assert( rangedPitch[0].yAxis.min === 0 && rangedPitch[0].yAxis.max === 1500, "变桨协调分图纵轴仍按 yrange", ); const pitchCoordFarmTwo = buildFarmPlotJsons( [ { engineName: "001", plotJson: { panels: [ { panelTitle: "功率-转速散点(桨距角着色)", xaxis: "转速 (r/min)", yaxis: "有功功率 (kW)", data: [ { label: "正常数据", mode: "markers", xData: [8, 12], yData: [400, 1800], colorbar: [1, 8], colorbarTitle: "桨距角 (°)", }, { label: "异常点", mode: "markers", xData: [10], yData: [900], colorbar: [12], }, ], }, { panelTitle: "桨距角-转速散点", xaxis: "转速 (r/min)", yaxis: "桨距角 (°)", data: [ { label: "正常数据", mode: "markers", xData: [8, 12], yData: [1, 8], }, ], }, ], }, }, { engineName: "005", plotJson: { panels: [ { panelTitle: "功率-转速散点(桨距角着色)", xaxis: "转速 (r/min)", yaxis: "有功功率 (kW)", data: [ { label: "正常数据", mode: "markers", xData: [9, 11], yData: [500, 1600], colorbar: [2, 7], colorbarTitle: "桨距角 (°)", }, ], }, { panelTitle: "桨距角-转速散点", xaxis: "转速 (r/min)", yaxis: "桨距角 (°)", data: [ { label: "正常数据", mode: "markers", xData: [9, 11], yData: [2, 7], }, ], }, ], }, }, ], "pitch_coord", "全场变桨协调分析检测汇总", ); assert(pitchCoordFarmTwo.length === 2, "变桨协调总图应为 2 张"); assert( /功率-转速散点(桨距角着色)/.test(pitchCoordFarmTwo[0].title), "第一张应为功率-转速散点(桨距角着色)", ); assert( /桨距角-转速散点/.test(pitchCoordFarmTwo[1].title), "第二张应为桨距角-转速散点", ); const pitchColorOption = buildDetectorPlotOption( pitchCoordFarmTwo[0], pitchCoordFarmTwo[0].title, "anomalyPitchcoordPO", ); assert(!pitchColorOption.visualMap, "功率-转速全场图应按状态色而非桨距角着色"); assert( pitchColorOption.series.some((item) => /001/.test(item.name)) && pitchColorOption.series.some((item) => /005/.test(item.name)), "功率-转速全场图应按机组保留系列", ); assert( pitchColorOption.series.reduce( (sum, item) => sum + (Array.isArray(item.data) ? item.data.length : 0), 0, ) === 5, "功率-转速全场图合并后不得丢失原始点", ); const pitchSpeedOption = buildDetectorPlotOption( pitchCoordFarmTwo[1], pitchCoordFarmTwo[1].title, "anomalyPitchcoordPO", ); assert( pitchSpeedOption.series.some((item) => /001/.test(item.name)) && pitchSpeedOption.series.some((item) => /005/.test(item.name)), "桨距角-转速图应按机组保留系列", ); const pitchStyleOption = listDetectorPlotOptions( { xaxis: "风速分箱中心值 (m/s)", yaxis: "桨距角中位数 (°)", data: [ { label: "桨叶 1", mode: "lines", xData: [2, 6], yData: [1, 2], timeData: ["t1", "t2"], }, { label: "桨叶 2", mode: "lines", xData: [2, 6], yData: [1.1, 2.1], }, { label: "桨叶 3", mode: "lines", xData: [2, 6], yData: [1.2, 2.2], }, ], }, "DT01 变桨一致性分析", "anomalyPitchregulationPO", { templateKey: "pitch_regulation" }, )[0]; assert(pitchStyleOption.series.length === 3, "单机变桨一致性应绘出三支桨叶"); assert(pitchStyleOption.series[0].symbol === "rect", "桨叶1应为方形"); assert(pitchStyleOption.series[0].lineStyle.type === "dashed", "桨叶1应为虚线"); assert(pitchStyleOption.series[1].symbol === "circle", "桨叶2应为圆形"); assert(pitchStyleOption.series[1].lineStyle.type === "solid", "桨叶2应为实线"); assert(pitchStyleOption.series[2].symbol === "triangle", "桨叶3应为三角"); assert(pitchStyleOption.series[2].lineStyle.type === "dotted", "桨叶3应为点线"); assert( pitchStyleOption.series[0].data[0][0] === 2, "变桨一致性 X 轴应使用分箱风速", ); const yawErrorOptions = listDetectorPlotOptions( { title: "静态偏航误差分析", xaxis: "对风角度/偏航误差 (°)", yaxis: "平均有功功率 (kW)", data: [ { label: "4.5-5.0 m/s", mode: "lines+markers", xData: [-2, 0, 4], yData: [300, 420, 380], }, { label: "5.0-5.5 m/s", mode: "lines+markers", xData: [-1, 2, 5], yData: [410, 500, 430], }, ], }, "01 静态偏航误差分析", "anomalyYawErrorPO", { templateKey: "yaw_error" }, ); assert(yawErrorOptions.length === 1, "偏航误差单机图应按 data[] 出一张"); assert(yawErrorOptions[0].series.length === 2, "偏航误差应按风速箱分系列"); assert(yawErrorOptions[0].xAxis.min === -20, "偏航误差单机图 X 轴最小应为 -20"); assert(yawErrorOptions[0].xAxis.max === 20, "偏航误差单机图 X 轴最大应为 20"); assert( yawErrorOptions[0].series[0].markPoint?.data?.[0]?.coord?.[0] === 0, "偏航误差应标注平均功率最大的对风角度", ); const yawCountOptions = listDetectorPlotOptions( { title: "偏航次数检测(CW/CCW 跳变)", xaxis: "时间", yaxis: "状态 (0/1)", data: [ { label: "CW 偏航状态", mode: "lines", xData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"], yData: [0, 1], timeData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"], }, { label: "CCW 偏航状态", mode: "lines", xData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"], yData: [0, 0], timeData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"], }, ], }, "01 偏航次数分析", "anomalyYawCountPO", { templateKey: "yaw_count" }, ); assert(yawCountOptions[0].yAxis.min === -0.05, "偏航次数 Y 轴应为 0/1"); assert(yawCountOptions[0].series.length === 2, "偏航次数应绘制 CW/CCW"); assert(yawCountOptions[0].xAxis.type === "time", "偏航次数横轴应为时间"); const pitchMinOptions = listDetectorPlotOptions( { title: "最小桨距角分布", xaxis: "时间", yaxis: "桨距角 (°)", xrange: ["2026-09-05", "2026-09-11"], yrange: [-1.0, 4.5], data: [ { label: "最小桨距角分布", mode: "markers", xData: ["2026-09-05", "2026-09-06"], yData: [-0.5, 0.2], colorbar: [120, 40], colorbarTitle: "点数", }, ], }, "01 最小桨距角分析", "anomalyMinpitchPO", { templateKey: "pitch_min" }, ); assert(pitchMinOptions[0].series[0].type === "scatter", "最小桨距角应为散点"); assert( typeof pitchMinOptions[0].series[0].symbolSize === "number", "最小桨距角点径应统一,不再按点数缩放气泡", ); { const vm = pitchMinOptions[0].visualMap; const colors = Array.isArray(vm) ? vm[0]?.inRange?.color : vm?.inRange?.color; const step = (colors?.length || 1) - 1; const stopsOk = PITCH_MIN_REPORT_COLOR_STOPS.every(([ratio, color]) => { const index = Math.round(ratio * step); return colors?.[index]?.toLowerCase() === color.toLowerCase(); }); assert( stopsOk && colors?.[0]?.toLowerCase() === PITCH_MIN_REPORT_COLOR_STOPS[0][1].toLowerCase() && colors?.slice(-1)[0]?.toLowerCase() === PITCH_MIN_REPORT_COLOR_STOPS.at(-1)[1].toLowerCase(), "最小桨距角报告色带应与 PITCH_MIN_REPORT_COLOR_STOPS 停靠一致", ); } const opStateOptions = listDetectorPlotOptions( { detector_name: "ctrl_op_state", title: "运行状态综合异常检测", panels: [ { panelTitle: "转速-功率散点图", xaxis: "转速(r/min)", yaxis: "有功功率(kW)", xrange: [0, 30], yrange: [0, 800], data: [ { label: "正常数据", mode: "markers", xData: [12, 18], yData: [320, 500], }, ], }, { panelTitle: "PCA 降维投影", xaxis: "PC1", yaxis: "PC2", componentDefinition: [ { name: "PC1", explainedVarianceRatio: 0.63, loadings: [ { feature: "p_active", displayName: "z(功率)", coefficient: 0.81, standardized: true, }, ], }, ], data: [ { label: "投影点", mode: "markers", xData: [0.1], yData: [0.2], }, ], }, ], }, "01 运行状态分析", "anomalyOperationPO", { templateKey: "ctrl_op_state" }, ); assert(opStateOptions.length === 2, "运行状态单机图应按 panels[] 全部出图"); assert( opStateOptions[0].xAxis.min == null && opStateOptions[0].xAxis.max == null, "运行状态转速-功率分图横轴应自适应", ); assert( opStateOptions[0].yAxis.min == null && opStateOptions[0].yAxis.max == null, "运行状态转速-功率分图纵轴应自适应", ); assert( opStateOptions[0].xAxis.scale === true && opStateOptions[0].yAxis.scale === true, "运行状态分图应按数据自适应缩放", ); assert( opStateOptions[1].xAxis.min == null && opStateOptions[1].yAxis.min == null, "运行状态没有 xrange/yrange 的分图应自适应", ); assert( opStateOptions[1].xAxis.scale === true && opStateOptions[1].yAxis.scale === true, "运行状态缺省轴范围时应自适应缩放", ); assert( !String(opStateOptions[1].title?.subtext || "").includes("PC1"), "componentDefinition 不应再被前端拼接为小标题", ); assert( resolvePanelSubtitle({ subtitle: "算法小标题" }, {}) === "算法小标题", "小标题应仅透传 JSON subtitle 字段", ); const farmCurveOptions = listDetectorPlotOptions( { detector: "wind_power_curve", farm: "XX风电场", chartType: "series", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", xrange: [3.25, 25.25], turbines: [ { turbine: "a1", anomaly: false, xType: "number", xData: [3.25, 4], yData: [120, 200], ratio: 1.02, status: "正常", }, { turbine: "a2", anomaly: true, xType: "number", xData: [3.25, 4], yData: [80, 150], ratio: 0.7, status: "欠发", }, ], }, "全场功率曲线分析检测汇总", "anomalyPowercurvePO", { templateKey: "wind_power_curve", farmMode: true, nameMap: { a1: "01", a2: "02" }, }, ); assert(farmCurveOptions.length === 1, "风场功率曲线总图应为一张叠加图"); assert(farmCurveOptions[0].grid3D, "风场功率曲线总图应按全场 3D 渲染"); assert( farmCurveOptions[0].series.some((item) => /01号/.test(item.name)), "风场功率曲线总图应画出正常机组", ); assert( !(Array.isArray(farmCurveOptions[0].legend) ? farmCurveOptions[0].legend : [farmCurveOptions[0].legend] ).some((item) => (item?.data || []).some((name) => /01号/.test(name))), "风场功率曲线总图图例不展示正常机组", ); assert( farmCurveOptions[0].series.some((item) => /02号/.test(item.name)), "风场功率曲线总图应展示异常机组", ); assert( farmCurveOptions[0].legend?.orient === "vertical" && farmCurveOptions[0].legend?.right != null && farmCurveOptions[0].grid3D?.right >= 140, "功率曲线图例应在三维图右侧", ); const manyAnomalyTurbines = Array.from({ length: 9 }, (_, index) => ({ turbine: `b${index + 1}`, anomaly: true, xType: "number", xData: [3.25, 4], yData: [80, 150], status: "异常", })); const packedCurveOptions = listDetectorPlotOptions( { detector: "wind_power_curve", chartType: "series", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", turbines: [ { turbine: "ok", anomaly: false, xData: [3.25, 4], yData: [120, 200], status: "正常", }, ...manyAnomalyTurbines, ], }, "全场功率曲线分析检测汇总", "anomalyPowercurvePO", { templateKey: "wind_power_curve", farmMode: true }, ); assert( packedCurveOptions[0].series.length === 10, "功率曲线总图应画出正常机组和全部异常机组", ); assert( !Array.isArray(packedCurveOptions[0].legend) && packedCurveOptions[0].legend?.orient === "vertical" && packedCurveOptions[0].legend?.data?.length === 9, "异常机组较多时图例仍在右侧且不遗漏", ); const farmYawErrorOptions = listDetectorPlotOptions( { detector: "yaw_error", chartType: "value", turbines: [ { turbine: "a1", anomaly: true, value: 15, powerloss: 2.1, coverage: 0.9, }, { turbine: "a2", anomaly: false, value: 3, powerloss: 0.1, coverage: 0.95, }, ], }, "全场静态偏航误差分析检测汇总", "anomalyYawErrorPO", { templateKey: "yaw_error", farmMode: true, nameMap: { a1: "01", a2: "02" }, }, ); assert( farmYawErrorOptions.length === 1, "风场静态偏航误差总图应为一张三档着色柱图", ); assert( farmYawErrorOptions[0].series[0].type === "bar", "风场偏航误差总图应为柱状对比", ); assert( farmYawErrorOptions[0].legend?.data ?.map((item) => item?.name ?? item) .join(",") === "[0,3],(3,5],>5", "风场偏航误差总图应按绝对值三档着色", ); assert( !farmYawErrorOptions[0].series.some((item) => item.name === "风场均值"), "风场偏航误差总图不应再画均值线", ); assert( farmYawErrorOptions[0].yAxis.name === "静态偏航误差(度)", "风场偏航误差纵轴应直接表示 JSON value", ); const signedYaw = listDetectorPlotOptions( { detector: "yaw_error", chartType: "value", turbines: [{ turbine: "a1", anomaly: true, value: -8 }], }, "全场静态偏航误差分析检测汇总", "anomalyYawErrorPO", { templateKey: "yaw_error", farmMode: true }, ); const signedBar = signedYaw[0].series .flatMap((item) => item.data || []) .find((item) => item && item.value != null); assert(signedBar?.value === -8, "静态偏航误差应使用 JSON value,不取绝对值"); assert( farmYawErrorOptions[0].series.find((item) => item.name === ">5")?.data?.[0] ?.itemStyle?.color === "#F04438", "大于5°的机组应标红", ); assert( farmYawErrorOptions[0].series.find((item) => item.name === "[0,3]")?.data?.[1] ?.itemStyle?.color === "#12B76A", "小于等于3°的机组应标绿", ); const farmYawCountOptions = listDetectorPlotOptions( { detector: "yaw_count", chartType: "value", turbines: [ { turbine: "a1", anomaly: true, value: 18, cw: 10, ccw: 8 }, { turbine: "a2", anomaly: false, value: 6, cw: 3, ccw: 3 }, { turbine: "a3", anomaly: true, value: 22, cw: 12, ccw: 10 }, ], }, "全场偏航次数分析检测汇总", "anomalyYawCountPO", { templateKey: "yaw_count", farmMode: true, nameMap: { a1: "01", a2: "02", a3: "03" }, }, ); assert( legendNames(farmYawCountOptions[0]).join(",") === "正常机组,01号,03号", "偏航次数总图图例应列出正常色和全部异常机组", ); assert( farmYawCountOptions[0].series.find((item) => item.name === "正常机组")?.data?.[1] ?.itemStyle?.color === DIAGNOSTIC_COLORS.normal, "偏航次数正常机组应统一蓝色", ); assert( farmYawCountOptions[0].series.find((item) => item.name === "01号")?.data?.[0] ?.itemStyle?.color === DIAGNOSTIC_COLORS.anomaly, "偏航次数异常机组应标红", ); assert( farmYawCountOptions[0].series.some((item) => item.name === "03号"), "偏航次数图例应包含全部异常机组", ); const farmPitchMinOptions = listDetectorPlotOptions( { detector: "pitch_min", chartType: "value", turbines: [ { turbine: "a1", anomaly: true, value: 2.4, p10: 1.2, baseline: -0.2 }, { turbine: "a2", anomaly: false, value: 0.3, p10: 0.2, baseline: 0 }, ], }, "全场最小桨距角分析检测汇总", "anomalyMinpitchPO", { templateKey: "pitch_min", farmMode: true, nameMap: { a1: "01", a2: "02" }, }, ); assert( legendNames(farmPitchMinOptions[0]).join(",") === "正常机组,01号", "最小桨距角总图图例应列出正常色和异常机组", ); assert( farmPitchMinOptions[0].series.find((item) => item.name === "01号")?.data?.[0] ?.itemStyle?.color === DIAGNOSTIC_COLORS.anomaly, "最小桨距角异常机组应标红", ); const farmAeroOptions = listDetectorPlotOptions( { detector: "aero_cp", chartType: "series", xaxis: "风速 (m/s)", yaxis: "Cp", turbines: [ { turbine: "t1", anomaly: true, xData: [6, 8], yData: [0.4, 0.45], }, { turbine: "t2", anomaly: false, xData: [6, 8], yData: [0.42, 0.48], }, ], }, "全场Cp功率系数分析检测汇总", "anomalyCpPO", { templateKey: "aero_cp", farmMode: true }, ); assert( legendNames(farmAeroOptions[0]).includes("正常机组"), "Cp总图应标明正常机组为蓝色", ); assert( legendNames(farmAeroOptions[0]).includes("t1"), "Cp总图图例应展示异常机组", ); assert( !legendNames(farmAeroOptions[0]).includes("t2"), "Cp总图有异常时图例不列正常机组", ); assert( farmAeroOptions[0].series.find((item) => item.name === "t1")?.itemStyle ?.color === DIAGNOSTIC_COLORS.anomaly || String( farmAeroOptions[0].series.find((item) => item.name === "t1")?.itemStyle ?.color || "", ).startsWith(DIAGNOSTIC_COLORS.anomaly), "Cp异常机组应标红", ); const farmAeroPointOptions = listDetectorPlotOptions( { detector: "aero_cp", chartType: "series", xaxis: "功率 (kW)", yaxis: "功率系数 Cp", turbines: [ { turbine: "26", anomaly: true, xData: [100, 200, 300], yData: [0.2, 0.3, 0.25], pointAnomaly: [false, true, false], }, { turbine: "27", anomaly: false, xData: [120, 220], yData: [0.22, 0.28], pointAnomaly: [false, false], }, ], }, "全场Cp功率系数分析检测汇总", "anomalyCpPO", { templateKey: "aero_cp", farmMode: true, nameMap: { 26: "26", 27: "27" } }, ); const aeroDataSeries = (farmAeroPointOptions[0].series || []).filter( (item) => Array.isArray(item.data) && item.data.length > 0, ); assert( aeroDataSeries.length === 3, "Cp总图应按 pointAnomaly 拆分:26/27号正常点各一条,26号异常点一条", ); const aero26NormalSeries = aeroDataSeries.find( (item) => String(item.name || "").includes("26") && /正常/.test(String(item.name || "")), ); const aero26AnomalySeries = aeroDataSeries.find( (item) => String(item.name || "").includes("26") && !/正常/.test(String(item.name || "")), ); assert( aero26NormalSeries && String(aero26NormalSeries.itemStyle?.color || "").startsWith( DIAGNOSTIC_COLORS.normal, ), "Cp总图 pointAnomaly=false 的点应为蓝色", ); assert( aero26AnomalySeries && String(aero26AnomalySeries.itemStyle?.color || "").startsWith( DIAGNOSTIC_COLORS.anomaly, ), "Cp总图 pointAnomaly=true 的点应为红色", ); assert( aero26NormalSeries?.data?.length === 2 && aero26AnomalySeries?.data?.length === 1, "26号正常点保留 2 个(下标 0/2),异常点仅 1 个(下标 1)", ); assert( !(farmAeroPointOptions[0].series || []).some( (item) => String(item.name || "") === "27号" && item.data?.length, ), "27号无异常点时不单独成异常系列", ); const farmYawStaticOptions = listDetectorPlotOptions( { detector: "yaw_static", chartType: "series", xaxis: "时间", yaxis: "偏航角 (°)", turbines: [ { turbine: "t1", anomaly: true, xType: "time", xData: [ "2026-09-11 00:00:00", "2026-09-11 00:01:00", "2026-09-11 00:02:00", ], yData: [10, 80, 12], pointAnomaly: [false, true, false], }, ], }, "全场偏航分析检测汇总", "anomalyStaticyawPO", { templateKey: "yaw_static", farmMode: true }, ); const yawStaticDataSeries = farmYawStaticOptions[0].series.filter( (item) => item.data?.length, ); assert( yawStaticDataSeries.length === 2, "风场静态偏航总图应按 pointAnomaly 拆成正常/异常点", ); assert( farmYawStaticOptions[0].series.every((item) => { const size = Number(item.symbolSize) || 0; if (!item.data?.length) return true; return size > 0 && size <= 4; }) && new Set( farmYawStaticOptions[0].series .filter((item) => item.data?.length) .map((item) => Number(item.symbolSize)), ).size === 1, "全场静态偏航报告正常点与异常点大小应一致", ); assert( Number(yawStaticDataSeries[0].itemStyle?.opacity) <= 0.7, "全场静态偏航报告散点透明度应更低", ); assert( legendNames(farmYawStaticOptions[0]).includes("正常机组"), "偏航总图应标明正常点为蓝色", ); assert( legendNames(farmYawStaticOptions[0]).includes("t1"), "偏航总图有异常时应在图例展示异常机组", ); assert( farmYawStaticOptions[0].series.some( (item) => item.name === "t1" && item.itemStyle?.color?.startsWith("#F04438"), ), "偏航总图异常点应为统一红色", ); assert( farmYawStaticOptions[0].series.some( (item) => /正常/.test(item.name) && item.itemStyle?.color?.startsWith("#2E90FA"), ), "偏航总图正常点应为统一蓝色", ); assert( typeof farmYawStaticOptions[0].yAxis?.axisLabel?.formatter === "function", "报告 Y 轴应保留 2 位小数", ); assert( formatReportValueAxisLabel(-1416.9293302406602) === "-1,416.93", "Y 轴刻度应格式化为 2 位小数", ); const stitchedFarmJson = turbinePlotsToFarmJson( [ { engineId: "a1", engineName: "01", plotJson: { title: "功率曲线", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "实测功率", mode: "lines", xData: [3.25, 4], yData: [120, 200], ratio: 1.02, status: "正常", }, ], }, }, { engineId: "a2", engineName: "02", po: { detectorIsAnomaly: 1 }, plotJson: { title: "功率曲线", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [ { label: "实测功率", mode: "lines", xData: [3.25, 4], yData: [80, 150], ratio: 0.7, status: "欠发", }, ], }, }, ], "wind_power_curve", ); assert(stitchedFarmJson?.chartType === "series", "单机 JSON 拼总图应为 series"); assert( stitchedFarmJson.turbines.length === 2, "单机 JSON 拼总图应包含全部风机", ); const stitchedFarmOptions = listFarmPlotOptionsFromTurbines( [ { engineId: "a1", engineName: "01", plotJson: { title: "功率曲线", xaxis: "风速 (m/s)", yaxis: "有功功率 (kW)", data: [{ label: "实测功率", xData: [3.25, 4], yData: [120, 200] }], }, }, ], "全场功率曲线分析检测汇总", "anomalyPowercurvePO", { templateKey: "wind_power_curve", nameMap: { a1: "01" }, }, ); assert(stitchedFarmOptions[0].grid3D, "报告总图应与页面一样走全场 3D"); const yawErrorFarmFromTurbines = listFarmPlotOptionsFromTurbines( [ { engineId: "a1", engineName: "01", po: { detectorIsAnomaly: 1, detectorAnomalyRate: 15 }, plotJson: { xaxis: "对风角度/偏航误差 (°)", yaxis: "平均有功功率 (kW)", data: [ { label: "4.5-5.0 m/s", xData: [-2, 0, 4], yData: [300, 420, 380], }, { label: "5.0-5.5 m/s", xData: [-1, 2, 5], yData: [410, 500, 430], }, ], }, }, { engineId: "a2", engineName: "02", po: { detectorIsAnomaly: 0, detectorAnomalyRate: 3 }, plotJson: { data: [ { label: "4.5-5.0 m/s", xData: [-1, 3], yData: [200, 260], }, ], }, }, ], "全场静态偏航误差分析检测汇总", "anomalyYawErrorPO", { templateKey: "yaw_error", nameMap: { a1: "01", a2: "02" }, }, ); assert( yawErrorFarmFromTurbines.length === 1, "报告偏航误差总图应为一张三档着色柱图", ); assert( yawErrorFarmFromTurbines[0].series[0].type === "bar", "报告偏航误差总图应与页面一样为单值柱状", ); assert( legendNames(yawErrorFarmFromTurbines[0]).join(",") === "[0,3],(3,5],>5", "报告偏航误差总图应按绝对值三档着色", ); const emptyOption = buildEmptyPlaceholderOption("全场检测"); assert(!optionHasData(emptyOption), "占位图不应视为有数据"); assert( JSON.stringify(emptyOption.title).includes("暂无"), "无 JSON 时应输出暂无占位", ); const watchlist = pickKeyTurbines( [ { engineId: "1", turbineName: "DT01", anomalyPoints: 99 }, { engineId: "2", turbineName: "DT02", anomalyPoints: 1 }, ], [ { engineId: "2", model3PitchPitchcoordRatio: 0.6 }, { engineId: "1", model3PitchPitchcoordRatio: 0.1 }, ], ); assert( watchlist.length === 1 && watchlist[0].turbineName === "DT02", "第6章应只取关注机组", ); assert( pickKeyTurbines([{ engineId: "1" }], []).length === 0, "无关注机组时应为空", ); assert( pickKeyTurbines( [ { engineId: "1", turbineName: "DT01" }, { engineId: "2", turbineName: "DT02" }, { engineId: "3", turbineName: "DT03" }, { engineId: "4", turbineName: "DT04" }, ], [ { engineId: "1", model2YawYawcountRatio: 0.9 }, { engineId: "2", model2YawYawerrorRatio: 0.9 }, { engineId: "3", model3PitchMinpitchRatio: 0.9 }, { engineId: "4", model4CtrlparamDeloadRatio: 0.9 }, { engineId: "5", model1WindpwrPowercurveRatio: 0.9 }, ], ).length === 0, "偏航次数/静态偏航误差/最小桨距角/降载/功率曲线不应参与关注机组判定", ); const emptyRowsPayload = buildDetectorSectionPayload( DETECTOR_TEMPLATE_CONFIG.find((cfg) => cfg.templateKey === "pitch_coord"), { farmImages: [{ image: "demo" }], turbineImages: [{ image: "demo" }], rows: [], hasChartData: true, }, ); assert( emptyRowsPayload.pitch_coordRows[0].turbine_name === "暂无", "异常机组清单无数据时应写暂无", ); const emptySensorRows = buildSensorAnomalyRows([]); assert( emptySensorRows[0].turbine_name === "暂无", "数据感知统计无数据时应写暂无", ); const sensorTableRows = buildSensorAnomalyRows([ { engineName: "10", sensorAnomalyWindPwr: 1, sensorAnomalyWindPwrRatio: 0.086, }, ]); assert(sensorTableRows.length === 1, "数据感知表应按异常类型分行"); assert( sensorTableRows[0].ratio === "8.60%", `数据感知占比应来自 ratio 字段,实际 ${sensorTableRows[0].ratio}`, ); assert( sensorTableRows[0].sensor_anomaly_type === "风速-功率逻辑异常", "数据感知表异常类型应与 flag 字段对应", ); const majorTempRows = buildSensorAnomalyRows([ { engineName: "10", sensorAnomalyMajorTemp: 1, sensorAnomalyMajorTempRatio: 0.3, }, ]); assert( majorTempRows.length === 1 && majorTempRows[0].sensor_anomaly_type === "大部件温度异常" && majorTempRows[0].ratio === "30.00%", "数据感知表应支持大部件温度异常行", ); assert( formatSensorTypeText({ sensorAnomalyType: "9" }) === "大部件温度异常", "数据感知类型 9 应翻译为大部件温度异常", ); assert( formatSensorTypeText({ sensorAnomalyType: "1,5" }) === "功率异常、扭矩异常", "多个数据感知类型编码应按顿号拼接", ); assert( formatSensorTypeText({}) === "暂无异常", "无数据感知异常时应写暂无异常", ); const sensorHeat = buildSensorHeatmapData([ { engineName: "09", sensorAnomalyPowerRatio: 0.12, sensorAnomalyWindRatio: 0, }, { engineName: "16", sensorAnomalyPowerRatio: 0, sensorAnomalyWindRatio: 0.2, sensorAnomalyMajorTempRatio: 0.35, }, ]); assert( sensorHeat.xLabels.join(",") === "09号,16号", "数据感知热力图风机名应带号", ); assert(sensorHeat.yLabels[0] === "功率异常", "数据感知热力图首行应为功率异常"); assert( sensorHeat.yLabels.includes("偏航角、扭缆角、偏航误差等偏航"), "数据感知热力图应含偏航角、扭缆角、偏航误差等偏航", ); assert( sensorHeat.yLabels.includes("大部件温度异常"), "数据感知热力图应含大部件温度异常", ); assert(sensorHeat.yLabels.length === 9, "数据感知热力图应为9行"); assert( sensorHeat.matrix[8][1] === 35 && sensorHeat.matrix[8][0] === 0, "大部件温度异常行应按 sensorAnomalyMajorTempRatio 取占比", ); assert(sensorHeat.matrix[0][0] === 12, "数据感知热力图应按占比×100"); assert(chunkHeatmapData(sensorHeat, 1).length === 2, "热力图应按风机分片"); const detectorHeat = buildDetectorHeatmapData([ { engineName: "01", model2YawYawerrorRatio: 18, model2YawYawcountRatio: 110, model3PitchMinpitchRatio: 1, model4CtrlparamDeloadRatio: 0.01, }, ]); assert( detectorHeat.yLabels.includes("静态偏航误差"), "功能诊断热力图应含静态偏航误差", ); assert( detectorHeat.matrix[detectorHeat.yLabels.indexOf("静态偏航误差")][0] === 18, "静态偏航误差热力图应按角度原值", ); assert( detectorHeat.matrix[detectorHeat.yLabels.indexOf("偏航次数")][0] === 110, "偏航次数热力图应按次数原值", ); assert( detectorHeat.matrix[detectorHeat.yLabels.indexOf("最小桨距角异常")][0] === 1, "最小桨距角热力图应按角度原值", ); assert( detectorHeat.matrix[detectorHeat.yLabels.indexOf("降载情况")][0] === 1, "功能诊断热力图占比换算错误", ); const detectorHeatOption = buildReportHeatmapOption( detectorHeat, "各机组功能诊断异常分布", ); const heatRamp = (label) => { const y = detectorHeat.yLabels.indexOf(label); const seriesIndex = detectorHeatOption.series.findIndex((item) => (item.data || []).some((cell) => cell.value[1] === y), ); const maps = [].concat(detectorHeatOption.visualMap || []); return (maps.find((item) => item.seriesIndex === seriesIndex)?.inRange?.color || []).join(","); }; assert( Array.isArray(detectorHeatOption.visualMap) && detectorHeatOption.visualMap.length > 1 && heatRamp("静态偏航误差") !== heatRamp("降载情况") && heatRamp("偏航次数") !== heatRamp("静态偏航误差") && heatRamp("风功率曲线情况") !== heatRamp("降载情况"), "功能诊断热力图应按符合度、角度、次数、异常率分色", ); assert( summarizeTurbineAnomalyRate( [ { title: "降载判定分析", points: 477, rate: 0.3286 }, { title: "变桨协调分析", points: 8, rate: 0.0052 }, ], { model4CtrlparamDeloadRatio: 0.3286, model3PitchPitchcoordRatio: 0.0052 }, ).toFixed(4) === "0.3233", "表4-2 机组异常率应按异常点数加权,并优先使用热力图单项占比", ); const summaryWithoutDeload = buildAnomalySummaryRows([ { turbineName: "07", anomalyDetectorCount: 1, anomalyPoints: 250, anomalyRate: 0.0893, mainAnomalyType: "降载判定分析", detectorHits: [{ title: "降载判定分析", points: 250, rate: 0.0893 }], }, { turbineName: "08", anomalyDetectorCount: 2, anomalyPoints: 220, anomalyRate: 0.1, mainAnomalyType: "降载判定分析", detectorHits: [ { title: "降载判定分析", points: 200, rate: 0.12 }, { title: "变桨协调分析", points: 20, rate: 0.01 }, ], }, ]); assert( summaryWithoutDeload.length === 1 && summaryWithoutDeload[0].turbine_name === "08" && summaryWithoutDeload[0].anomaly_detector_count === "变桨协调分析" && summaryWithoutDeload[0].main_anomaly_type === "变桨协调异常率" && summaryWithoutDeload[0].anomaly_rate === "1.00%", "表4-2 不应收录仅降载异常机组,其余检测项应分行给出名称、异常率和说明", ); const summaryMetricRows = buildAnomalySummaryRows([ { turbineName: "009", detectorHits: [ { title: "静态偏航误差分析", templateKey: "yaw_error", points: 4, rate: 8 }, { title: "偏航次数分析", templateKey: "yaw_count", points: 2, rate: 110 }, { title: "最小桨距角分析", templateKey: "pitch_min", points: 1, rate: 1.5 }, { title: "变桨一致性分析", templateKey: "pitch_regulation", points: 3, rate: 0.021 }, ], }, ]); assert( summaryMetricRows.map((row) => row.anomaly_detector_count).join(",") === "静态偏航误差分析,变桨一致性分析,偏航次数分析,最小桨距角分析", "表4-2 功能诊断项应为检测器名称", ); assert( summaryMetricRows.map((row) => row.anomaly_rate).join(",") === "8°,2.10%,110次,1.5°", "表4-2 数值应按度、次数或异常率分别格式化", ); assert( summaryMetricRows.map((row) => row.main_anomaly_type).join(",") === "静态偏航误差度,变桨一致性异常率,偏航次数,最小桨距角", "表4-2 数值说明应写明指标含义", ); const heatOption = buildReportHeatmapOption( sensorHeat, "各机组数据感知异常分布", ); assert(heatOption.series[0].type === "heatmap", "图3-1应为热力图"); assert( heatOption.series[0].data[0]?.name?.includes("%"), "报告热力图单元格应预置占比文本", ); assert(optionHasData(heatOption), "数据感知热力图应视为有数据"); const sensorRadar = buildSensorRadarData( { sensorAnomalyPowerCount: 2, sensorAnomalyWindCount: 1 }, 20, ); assert(sensorRadar.values[0] === 2, "数据感知雷达图应映射异常台数"); assert(sensorRadar.max === 3, "数据感知雷达图应按异常台数自适应上限"); assert( sensorRadar.indicators.length === 9 && sensorRadar.indicators[8].name === "大部件温度异常" && sensorRadar.values[8] === 0, "数据感知雷达图应含大部件温度异常轴", ); const majorTempRadar = buildSensorRadarData( { sensorAnomalyMajorTempCount: 5 }, 20, ); assert( majorTempRadar.values[8] === 5, "大部件温度异常台数应取自 sensorAnomalyMajorTempCount", ); const detectorRadar = buildDetectorRadarData( { model1Count: 3, model2Count: 1 }, 20, ); assert(detectorRadar.max === 4, "功能诊断雷达图应按异常台数自适应上限"); const sensorRadarOption = buildAnomalyRadarOption( sensorRadar, "数据感知异常台数", ); assert(sensorRadarOption.series[0].type === "radar", "概览区应输出雷达图"); assert( Number(sensorRadarOption.series[0].lineStyle?.width) >= 2, "雷达图 series 应配置连线宽度", ); assert( sensorRadarOption.radar.indicator[0].name.includes("2台"), "雷达图维度应标注台数", ); assert( sensorRadarOption.radar.indicator.every((item) => item.max === 3), "雷达图外圈应按本图最大台数自适应", ); assert(sensorRadarOption.radar.splitNumber === 3, "雷达图刻度应落到整数台数"); console.log("axis/watchlist/placeholder rules ok"); await initChartService(); const imageBufferMap = {}; const sensorRadarBuf = await renderEchartsOption(sensorRadarOption, { width: 520, height: 360, }); registerImageBuffer(imageBufferMap, "anomaly_sensor_radar", sensorRadarBuf); const detectorRadarBuf = await renderEchartsOption( buildAnomalyRadarOption(detectorRadar, "功能诊断异常台数"), { width: 520, height: 360 }, ); registerImageBuffer(imageBufferMap, "anomaly_detector_radar", detectorRadarBuf); const sensorHeatBuf = await renderEchartsOption(heatOption, { width: 920, height: 420, }); registerImageBuffer(imageBufferMap, "anomaly_sensor_dist", sensorHeatBuf); const detectorHeatBuf = await renderEchartsOption( buildReportHeatmapOption(detectorHeat, "各机组功能诊断异常分布"), { width: 920, height: 560 }, ); registerImageBuffer(imageBufferMap, "anomaly_detector_dist", detectorHeatBuf); const completenessHeatmap = buildDataCompletenessHeatmapData( [ { engineName: "111", sourceDatetime: new Date("2026-08-01").getTime(), minuteDataCompleteness: 96.5, }, { engineName: "112", sourceDatetime: new Date("2026-08-01").getTime(), minuteDataCompleteness: 88.2, }, { engineName: "111", sourceDatetime: new Date("2026-08-02").getTime(), minuteDataCompleteness: 91, }, ], "minuteDataCompleteness", ); assert( completenessHeatmap.yLabels.length === 2 && completenessHeatmap.xLabels.length === 2, "数据完整度热力图应正确展开机组×日期", ); assert( completenessHeatmap.xLabels.join(",") === "08-01,08-02", "数据完整度 X 轴应为日期", ); assert( completenessHeatmap.yLabels.length === 2 && completenessHeatmap.yLabels.every((name) => /111|112/.test(name)), "数据完整度 Y 轴应为风机", ); const completenessMinuteBuf = await renderEchartsOption( buildCompletenessHeatmapOption( completenessHeatmap, "分钟级数据逐日完整度(近90天,%)", ), { width: 920, height: 420 }, ); const completenessOption = buildCompletenessHeatmapOption( completenessHeatmap, "分钟级数据逐日完整度(近90天,%)", ); assert( completenessOption.xAxis?.name === "日期" && completenessOption.yAxis?.name === "风机", "数据完整度热力图轴名称应为日期/风机", ); registerImageBuffer( imageBufferMap, "anomaly_data_completeness_minute", completenessMinuteBuf, ); registerImageBuffer( imageBufferMap, "anomaly_data_completeness_second", completenessMinuteBuf, ); const farmPlots = buildFarmPlotJsons( [ { engineName: "111", plotJson: samplePlot("111") }, { engineName: "112", plotJson: samplePlot("112") }, ], "yaw_static", "全场偏航分析检测汇总", ); const farmLabels = (farmPlots[0]?.data || []).map((row) => row.label); if (farmLabels.join(",") !== "111,112") { throw new Error(`全场偏航分色失败: ${farmLabels.join(",")}`); } if ( (farmPlots[0]?.data || []).some((row) => /均值/.test(row.originalLabel || "")) ) { throw new Error("全场偏航仍包含 2h/12h 均值"); } const farmBuf = await renderEchartsOption( buildDetectorPlotOption( farmPlots[0], farmPlots[0].title, "anomalyStaticyawPO", ), { width: 820, height: 420 }, ); registerImageBuffer(imageBufferMap, "anomaly_farm_yaw_static", farmBuf); const turbineBuf = await renderEchartsOption( buildDetectorPlotOption(samplePlot("111"), "111 偏航分析"), { width: 760, height: 400 }, ); registerImageBuffer(imageBufferMap, "anomaly_111_yaw_static", turbineBuf); const denseCount = 4500; const denseTimes = Array.from({ length: denseCount }, (_, i) => { const t = new Date("2026-08-01T00:00:00").getTime() + i * 60000; const pad = (n) => String(n).padStart(2, "0"); const d = new Date(t); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad( d.getHours(), )}:${pad(d.getMinutes())}:00`; }); const denseOption = buildDetectorPlotOption( { title: "DT01 电能质量分析", xaxis: "时间", yaxis: "电流不平衡度", data: [ { label: "电流不平衡度", mode: "markers", timeData: denseTimes, yData: Array.from( { length: denseCount }, (_, i) => 0.08 + (i % 20) / 200, ), }, ], }, "DT01 电能质量分析", "anomalyPowerqualityPO", ); assert(denseOption.series[0].large === false, "密点单机图不应使用 large"); assert( Number(denseOption.series[0].symbolSize) <= 6, "报告散点 symbolSize 应缩小以提升清晰度", ); assert(denseOption.backgroundColor === "#eef4fb", "报告图表应使用浅蓝灰底色"); const denseBuf = await renderEchartsOption(denseOption, { width: 760, height: 420, }); assert(denseBuf.length > 10000, "密点单机图截图过小,可能未画出数据"); const renderData = { reportNo: "AD-SMOKE-20260824", Province: "测试省", Wind_farm: "烟雾风场", Year_now: "2026", Month_now: "08", machineTypeCode: "WD", turbine_count: "2", anomaly_turbine_count: "1", total_anomaly_points: "3", anomaly_rate: "50.00", Overview_of_the_Wind_Farm: "烟雾风场用于模板渲染校验。", target_date: "2026-08-24", anomaly_module_count: "1", main_problem_modules: "偏航与扭缆", top_problem_description: "偏航分析异常点数最多(3)", sensorAnomalyRows: buildSensorAnomalyRows([ { engineName: "111", sensorAnomalyWindPwr: 1, sensorAnomalyWindPwrRatio: 0.125, }, { engineName: "16", sensorAnomalyWind: 1, sensorAnomalyWindRatio: 0.2, sensorAnomalyMajorTemp: 1, sensorAnomalyMajorTempRatio: 0.3, }, ]), detectorSummaryRows: [ { detector_name: "偏航分析", module_name: "偏航与扭缆", data_granularity: "秒级", anomaly_turbines: "1", anomaly_points: "3", avg_anomaly_rate: "12.00%", }, ], anomalySummaryRows: [ { turbine_name: "111", anomaly_detector_count: "偏航分析", anomaly_points: "3", anomaly_rate: "12.00%", main_anomaly_type: "偏航异常率", }, ], priorityList: "111", keyTurbineLoop: [ { turbine_name: "111", turbineDetailRows: [ { detector_name: "偏航分析", anomaly_points: "3", anomaly_rate: "12.00%", conclusion: "建议结合现场复核", }, ], "zn-techcn-replace-tags-key_turbine-generalFiles": [ { image: "anomaly_111_yaw_static" }, ], }, ], conclusionRows: [ { index: "1", problem_desc: "主要表现为偏航角越限、突变或长时间不动作。", suggestion: "建议检查偏航编码器、风向标、偏航制动器及对风控制参数,对异常机组进行现场复核。", problem_nature: "偏航与扭缆系统异常", turbine_names: "#111", }, ], "zn-techcn-replace-tags-data_sensor_anomaly-generalFiles": [ { image: "anomaly_sensor_radar", figure_caption: "图3-1 数据感知异常台数", }, { image: "anomaly_sensor_dist", figure_caption: "图3-2 各机组数据感知异常分布", }, ], "zn-techcn-replace-tags-data_detector_anomaly-generalFiles": [ { image: "anomaly_detector_radar", figure_caption: "图4-1 功能诊断异常台数", }, { image: "anomaly_detector_dist", figure_caption: "图4-2 各机组功能诊断异常分布", }, ], "zn-techcn-replace-tags-data_completeness_minute-farmSummary": [ { image: "anomaly_data_completeness_minute" }, ], "zn-techcn-replace-tags-data_completeness_second-farmSummary": [ { image: "anomaly_data_completeness_second" }, ], "zn-techcn-replace-tags-key_turbine-generalFiles": [], show_module_wind: [], show_module_yaw: [{}], show_module_pitch: [], show_module_run: [], show_module_aero: [], }; DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => { if (cfg.templateKey === "yaw_static") { Object.assign( renderData, buildDetectorSectionPayload(cfg, { farmImages: [{ image: "anomaly_farm_yaw_static" }], turbineImages: [{ image: "anomaly_111_yaw_static" }], rows: [ { turbine_name: "111", anomaly_points: "3", anomaly_rate: "12.00%", sensor_anomaly_type: "大部件温度异常", comment: "功能诊断异常", }, ], anomalyPoints: 3, anomalyRate: 0.12, anomalyTurbines: 1, hasChartData: true, }), ); return; } const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`; const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`; renderData[`show-${fileTag}`] = []; renderData[farmTag] = []; renderData[fileTag] = []; renderData[`${cfg.templateKey}Rows`] = []; }); const buffer = await renderDocxReport({ templateName: "异常检测数据分析报告模板(大唐版)_修订版_人工对齐版.docx", renderData, imageBufferMap, }); const outDir = path.join(__dirname, "../templates"); fs.mkdirSync(outDir, { recursive: true }); const outPath = path.join(outDir, "_smoke_anomaly_report.docx"); fs.writeFileSync(outPath, buffer); console.log("smoke anomaly report written:", outPath, "bytes=", buffer.length); const smokeZip = new PizZip(buffer); Object.keys(smokeZip.files) .filter((name) => /^word\/footer\d+\.xml$/.test(name)) .sort() .forEach((name) => { const xml = smokeZip.file(name).asText(); const plain = xml.replace(/<[^>]+>/g, "").trim(); assert(!/PAGE/.test(xml), `${name} 不应含页码域`); assert(!plain, `${name} 页脚应为空,实际为「${plain}」`); }); const smokeDocText = smokeZip .file("word/document.xml") .asText() .replace(/<[^>]+>/g, ""); assert( smokeDocText.includes("数据感知异常类型"), "报告中检测器明细表应包含数据感知异常类型列", ); assert( smokeDocText.includes("大部件温度异常"), "报告应输出大部件温度异常", ); assert( smokeDocText.includes("大部件温度(齿轮箱油温、发电机轴承"), "表3-1 应包含大部件温度关联测点说明", ); assert( !/\{[A-Za-z_]/.test(smokeDocText), "报告中不应残留模板占位符", ); await shutdownChartService();