import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { registerImageBuffer, renderDocxReport, } from "../src/server/reportService/docxReportBuilder.js"; import { buildDetectorSectionPayload, pickKeyTurbines, buildSensorHeatmapData, buildDetectorHeatmapData, buildSensorRadarData, buildDetectorRadarData, chunkHeatmapData, buildSensorAnomalyRows, } from "../src/server/reportService/anomalyReportMapper.js"; import { DETECTOR_TEMPLATE_CONFIG, buildAnomalyRadarOption, buildDetectorPlotOption, buildEmptyPlaceholderOption, buildFarmPlotJsons, buildReportHeatmapOption, listDetectorPlotOptions, listFarmPlotOptionsFromTurbines, listFarmChartOptions, optionHasData, 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, diagnosticSeriesSymbol, FARM_ANOMALY_WARM_COLORS, FARM_NORMAL_COOL_COLORS, } from "../shared/anomalyDiagnosticVisual.mjs"; import { farmAggregateToPanels } 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); } 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) => Number(item.symbolSize) >= 3), "全场散点 3D symbolSize 过小", ); assert(farmScatterOption.grid3D, "风功率散点应输出 3D 图表"); 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( { originalLabel: "正常", turbineIndex: 1 }, 0, "ctrl_deload", ) === diagnosticFarmSeriesColor( { originalLabel: "异常", turbineIndex: 1 }, 1, "ctrl_deload", ), "降载图同一风机的正常和异常点应使用相同颜色", ); assert( FARM_NORMAL_COOL_COLORS.includes( diagnosticFarmSeriesColor( { originalLabel: "正常", turbineIndex: 0 }, 0, "pitch_coord", ), ), "总图正常风机应使用冷色", ); assert( FARM_ANOMALY_WARM_COLORS.includes( diagnosticFarmSeriesColor( { originalLabel: "异常", turbineIndex: 0 }, 0, "pitch_coord", ), ), "总图异常风机应使用红橙暖色", ); assert( diagnosticFarmSeriesColor( { extra: { anomaly: true }, originalLabel: "正常", turbineIndex: 3 }, 0, "ctrl_deload", ) === diagnosticFarmSeriesColor( { extra: { anomaly: true }, originalLabel: "异常", turbineIndex: 3 }, 1, "ctrl_deload", ) && FARM_ANOMALY_WARM_COLORS.includes( diagnosticFarmSeriesColor( { extra: { anomaly: true }, turbineIndex: 3 }, 0, "ctrl_deload", ), ), "异常降载风机应整机暖色且正常/异常点同色", ); assert( diagnosticSeriesSymbol({ originalLabel: "正常" }) !== diagnosticSeriesSymbol({ originalLabel: "异常" }), "降载图应使用不同点形区分正常和异常", ); 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(/风速/.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 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) >= 12, "全场变桨协调散点应加大", ); 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(Array.isArray(pitchColorOption.visualMap), "功率-转速图应按桨距角着色"); assert( pitchColorOption.series.some((item) => item.name === "正常数据"), "桨距角着色图应按正常/异常分系列而非每台风机", ); assert( pitchColorOption.series.find((item) => item.name === "正常数据")?.data .length === 4 && pitchColorOption.series.find((item) => item.name === "异常点")?.data .length === 1, "桨距角着色图合并系列时不得丢失原始点", ); const pitchSpeedOption = buildDetectorPlotOption( pitchCoordFarmTwo[1], pitchCoordFarmTwo[1].title, "anomalyPitchcoordPO", ); assert( pitchSpeedOption.series.some((item) => item.name === "001") && pitchSpeedOption.series.some((item) => item.name === "005"), "桨距角-转速图应按风机分色", ); 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].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 === "function", "最小桨距角气泡大小应跟随点数", ); 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( String(opStateOptions[1].title?.subtext || "").includes("PC1"), "运行状态 PCA 面板应展示主成分定义", ); 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)), "风场总图应把 engineId 映射成风机名", ); 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[0].series[0].type === "bar", "风场偏航误差总图应为柱状对比"); assert( farmYawErrorOptions[0].series.some((item) => item.name === "风场均值"), "风场 value 型总图应绘制均值线", ); 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 }, ); assert( farmYawStaticOptions[0].series.length === 2, "风场静态偏航总图应按 pointAnomaly 拆成正常/异常点", ); 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[0].series[0].type === "bar", "报告偏航误差总图应与页面一样为单值柱状", ); assert( yawErrorFarmFromTurbines[0].series.some((item) => item.name === "风场均值"), "报告 value 型总图应绘制均值线", ); 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, "无关注机组时应为空", ); const emptyRowsPayload = buildDetectorSectionPayload( DETECTOR_TEMPLATE_CONFIG.find((cfg) => cfg.templateKey === "pitch_coord"), { farmImages: [], turbineImages: [], rows: [] }, ); assert( emptyRowsPayload.pitch_coordRows[0].turbine_name === "暂无", "异常机组清单无数据时应写暂无", ); const emptySensorRows = buildSensorAnomalyRows([]); assert( emptySensorRows[0].turbine_name === "暂无", "数据感知统计无数据时应写暂无", ); const sensorHeat = buildSensorHeatmapData([ { engineName: "09", sensorAnomalyPowerRatio: 0.12, sensorAnomalyWindRatio: 0, }, { engineName: "16", sensorAnomalyPowerRatio: 0, sensorAnomalyWindRatio: 0.2 }, ]); assert( sensorHeat.xLabels.join(",") === "09号,16号", "数据感知热力图风机名应带号", ); assert(sensorHeat.yLabels[0] === "功率异常", "数据感知热力图首行应为功率异常"); assert( sensorHeat.yLabels.includes("偏航角、扭缆角、偏航误差等偏航"), "数据感知热力图应含偏航角、扭缆角、偏航误差等偏航", ); assert(sensorHeat.yLabels.length === 8, "数据感知热力图应为8行"); assert(sensorHeat.matrix[0][0] === 12, "数据感知热力图应按占比×100"); assert(chunkHeatmapData(sensorHeat, 1).length === 2, "热力图应按风机分片"); const detectorHeat = buildDetectorHeatmapData([ { engineName: "01", model2YawYawerrorRatio: 0.9247, model4CtrlparamDeloadRatio: 0, }, ]); assert( detectorHeat.yLabels.includes("静态偏航误差"), "功能诊断热力图应含静态偏航误差", ); assert( detectorHeat.matrix[detectorHeat.yLabels.indexOf("静态偏航误差")][0] === 92.47, "功能诊断热力图占比换算错误", ); 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, "数据感知雷达图应映射异常台数"); const detectorRadar = buildDetectorRadarData( { model1Count: 3, model2Count: 1 }, 20, ); 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台"), "雷达图维度应标注台数", ); 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 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) >= 8, "密点单机图 symbolSize 过小", ); 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: [ { turbine_name: "111", sensor_anomaly_type: "风速异常", anomaly_points: "1", ratio: "—", }, ], 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: "1", 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_type: "静态偏航分析", turbine_names: "111", anomaly_points: "3", risk_level: "P2", suggestion: "核对偏航角越限情况。", }, ], "zn-techcn-replace-tags-anomaly_overview-generalFiles": [ { image: "anomaly_sensor_radar" }, { image: "anomaly_detector_radar" }, ], "zn-techcn-replace-tags-data_sensor_anomaly-generalFiles": [ { image: "anomaly_sensor_dist" }, { image: "anomaly_detector_dist" }, ], "zn-techcn-replace-tags-key_turbine-generalFiles": [], }; 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%", comment: "功能诊断异常", }, ], anomalyPoints: 3, anomalyRate: 0.12, anomalyTurbines: 1, }), ); 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); await shutdownChartService();