smoke-anomaly-report.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import fs from "fs";
  2. import path from "path";
  3. import { fileURLToPath } from "url";
  4. import {
  5. registerImageBuffer,
  6. renderDocxReport,
  7. } from "../src/server/reportService/docxReportBuilder.js";
  8. import {
  9. DETECTOR_TEMPLATE_CONFIG,
  10. buildDetectorPlotOption,
  11. buildFarmPlotJsons,
  12. listDetectorPlotOptions,
  13. } from "../src/server/reportService/anomalyChartBuilder.js";
  14. import { buildDetectorSectionPayload } from "../src/server/reportService/anomalyReportMapper.js";
  15. import { renderEchartsOption } from "../src/server/reportService/echartsRenderer.js";
  16. import {
  17. initChartService,
  18. shutdownChartService,
  19. } from "../src/server/utils/chartService/index.js";
  20. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  21. const EMPTY_PNG = Buffer.from(
  22. "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC",
  23. "base64",
  24. );
  25. function samplePlot(labelPrefix = "") {
  26. return {
  27. title: `${labelPrefix}偏航`,
  28. xaxis: "时间",
  29. yaxis: "偏移 (°)",
  30. data: [
  31. {
  32. label: "正常偏航角",
  33. mode: "markers",
  34. timeData: [
  35. "2026-07-14 04:00:00",
  36. "2026-07-14 08:00:00",
  37. "2026-07-14 12:00:00",
  38. ],
  39. yData: [180, 200, 190],
  40. },
  41. {
  42. label: "12h均值",
  43. mode: "lines",
  44. timeData: [
  45. "2026-07-14 04:00:00",
  46. "2026-07-14 08:00:00",
  47. "2026-07-14 12:00:00",
  48. ],
  49. yData: [185, 198, 120],
  50. },
  51. ],
  52. };
  53. }
  54. function assert(condition, message) {
  55. if (!condition) throw new Error(message);
  56. }
  57. const scatterFarm = buildFarmPlotJsons(
  58. [
  59. {
  60. engineName: "A",
  61. plotJson: {
  62. xaxis: "风速",
  63. yaxis: "功率",
  64. data: [
  65. { label: "散点", mode: "markers", xData: [1, 2], yData: [3, 4] },
  66. { label: "上限", mode: "lines", xData: [1, 2], yData: [8, 9] },
  67. { label: "下限", mode: "lines", xData: [1, 2], yData: [1, 2] },
  68. { label: "参考功率曲线", mode: "lines", xData: [1, 2], yData: [5, 6] },
  69. ],
  70. },
  71. },
  72. {
  73. engineName: "B",
  74. plotJson: {
  75. xaxis: "风速",
  76. yaxis: "功率",
  77. data: [
  78. { label: "散点", mode: "markers", xData: [2, 3], yData: [4, 5] },
  79. { label: "参考功率曲线", mode: "lines", xData: [1, 2], yData: [5, 6] },
  80. ],
  81. },
  82. },
  83. ],
  84. "wind_power_scatter",
  85. "全场散点",
  86. );
  87. assert(scatterFarm[0].data.filter((row) => row.__shared).length === 1, "参考功率曲线应只保留一条");
  88. assert(!scatterFarm[0].data.some((row) => /上限|下限/.test(row.label)), "散点总图不应含上下限");
  89. assert(
  90. scatterFarm[0].data.some((row) => row.label === "A") &&
  91. scatterFarm[0].data.some((row) => row.label === "B"),
  92. "散点总图应按风机分系列",
  93. );
  94. const farmScatterOption = buildDetectorPlotOption(
  95. scatterFarm[0],
  96. scatterFarm[0].title,
  97. "anomalyScatterPO",
  98. );
  99. const farmScatterSeries = (farmScatterOption.series || []).filter(
  100. (item) => item.type === "scatter",
  101. );
  102. assert(farmScatterSeries.length > 0, "全场散点应输出 scatter 系列");
  103. assert(
  104. farmScatterSeries.every((item) => Number(item.symbolSize) >= 10),
  105. "全场散点 symbolSize 过小",
  106. );
  107. assert(
  108. farmScatterSeries.every((item) => item.large === false),
  109. "报告散点不应开启 large 模式",
  110. );
  111. const qualityPanels = listDetectorPlotOptions(
  112. {
  113. panels: [
  114. {
  115. panelTitle: "功率因数 | 异常点: 0",
  116. xaxis: "时间",
  117. yaxis: "功率因数",
  118. data: [
  119. {
  120. label: "功率因数",
  121. mode: "markers",
  122. timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
  123. yData: [0.98, 0.97],
  124. },
  125. ],
  126. },
  127. {
  128. panelTitle: "电流不平衡度",
  129. xaxis: "时间",
  130. yaxis: "电流不平衡度",
  131. data: [
  132. {
  133. label: "电流不平衡度",
  134. mode: "markers",
  135. timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
  136. yData: [0.12, 0.15],
  137. },
  138. ],
  139. },
  140. ],
  141. },
  142. "DT01 电能质量分析",
  143. "anomalyPowerqualityPO",
  144. );
  145. assert(qualityPanels.length === 2, "电能质量单机图应按 panel 全部出图");
  146. assert(
  147. qualityPanels[1].yAxis.name === "电流不平衡度",
  148. "第二张单机图应使用对应 panel 的 Y 轴",
  149. );
  150. assert(
  151. (qualityPanels[1].series || []).some(
  152. (item) => Array.isArray(item.data) && item.data.length > 0,
  153. ),
  154. "单机图系列不能为空",
  155. );
  156. const pitchFarm = buildFarmPlotJsons(
  157. [
  158. {
  159. engineName: "101",
  160. plotJson: {
  161. panels: [
  162. {
  163. panelTitle: "桨距角时序",
  164. xaxis: "时间",
  165. yaxis: "桨距角",
  166. data: [
  167. { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1] },
  168. { label: "桨叶 2", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [2] },
  169. { label: "桨叶 3", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [3] },
  170. { label: "阈值参考", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [4] },
  171. ],
  172. },
  173. {
  174. panelTitle: "桨距角差",
  175. xaxis: "时间",
  176. yaxis: "差值",
  177. data: [
  178. { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1] },
  179. ],
  180. },
  181. ],
  182. },
  183. },
  184. ],
  185. "pitch_regulation",
  186. "全场变桨一致性",
  187. );
  188. assert(pitchFarm.length === 2, "变桨一致性总图应为 2 张");
  189. assert(
  190. !pitchFarm[0].data.some((row) => /阈值/.test(row.originalLabel || "")),
  191. "变桨一致性不应展示阈值参考",
  192. );
  193. const deloadFarm = buildFarmPlotJsons(
  194. [
  195. {
  196. engineName: "101",
  197. plotJson: {
  198. panels: [
  199. {
  200. panelTitle: "风速",
  201. xaxis: "时间",
  202. yaxis: "风速",
  203. data: [{ label: "风速", timeData: ["t"], yData: [1] }],
  204. },
  205. {
  206. panelTitle: "有功功率时序",
  207. xaxis: "时间",
  208. yaxis: "有功功率",
  209. data: [
  210. { label: "正常", mode: "markers", timeData: ["t"], yData: [1] },
  211. { label: "异常", mode: "markers", timeData: ["t"], yData: [2] },
  212. ],
  213. },
  214. ],
  215. },
  216. },
  217. ],
  218. "ctrl_deload",
  219. "全场降载",
  220. );
  221. assert(deloadFarm.length === 1, "降载总图只出有功功率时序");
  222. assert(/有功功率/.test(deloadFarm[0].title + deloadFarm[0].yaxis), "降载总图应为有功功率");
  223. const opFarm = buildFarmPlotJsons(
  224. [
  225. {
  226. engineName: "101",
  227. plotJson: {
  228. panels: [
  229. {
  230. panelTitle: "投影",
  231. xaxis: "x",
  232. yaxis: "y",
  233. data: [{ label: "簇", xData: [1], yData: [1] }],
  234. },
  235. {
  236. panelTitle: "转速-功率散点",
  237. xaxis: "转速",
  238. yaxis: "功率",
  239. data: [{ label: "散点", xData: [1], yData: [2] }],
  240. },
  241. ],
  242. },
  243. },
  244. ],
  245. "ctrl_op_state",
  246. "全场运行状态",
  247. );
  248. assert(opFarm.length === 1, "运行状态总图只出一张");
  249. assert(/转速/.test(opFarm[0].title + opFarm[0].xaxis), "运行状态总图应为转速-功率");
  250. const pqFarm = buildFarmPlotJsons(
  251. [
  252. {
  253. engineName: "101",
  254. plotJson: {
  255. panels: [
  256. { panelTitle: "功率因数", xaxis: "x", yaxis: "y", data: [{ label: "点", xData: [1], yData: [1] }] },
  257. { panelTitle: "不平衡度", xaxis: "x", yaxis: "y", data: [{ label: "点", xData: [1], yData: [1] }] },
  258. { panelTitle: "频率", xaxis: "x", yaxis: "y", data: [{ label: "点", xData: [1], yData: [1] }] },
  259. ],
  260. },
  261. },
  262. ],
  263. "ctrl_power_quality",
  264. "全场电能质量",
  265. );
  266. assert(pqFarm.length === 3, "电能质量总图应为 3 张");
  267. console.log("farm chart rules ok");
  268. await initChartService();
  269. const imageBufferMap = {};
  270. registerImageBuffer(imageBufferMap, "anomaly_sensor_dist", EMPTY_PNG);
  271. const farmPlots = buildFarmPlotJsons(
  272. [
  273. { engineName: "111", plotJson: samplePlot("111") },
  274. { engineName: "112", plotJson: samplePlot("112") },
  275. ],
  276. "yaw_static",
  277. "全场静态偏航分析检测汇总",
  278. );
  279. const farmLabels = (farmPlots[0]?.data || []).map((row) => row.label);
  280. if (farmLabels.join(",") !== "111,112") {
  281. throw new Error(`全场偏航分色失败: ${farmLabels.join(",")}`);
  282. }
  283. if ((farmPlots[0]?.data || []).some((row) => /均值/.test(row.originalLabel || ""))) {
  284. throw new Error("全场偏航仍包含 2h/12h 均值");
  285. }
  286. const farmBuf = await renderEchartsOption(
  287. buildDetectorPlotOption(farmPlots[0], farmPlots[0].title, "anomalyStaticyawPO"),
  288. { width: 820, height: 420 },
  289. );
  290. registerImageBuffer(imageBufferMap, "anomaly_farm_yaw_static", farmBuf);
  291. const turbineBuf = await renderEchartsOption(
  292. buildDetectorPlotOption(samplePlot("111"), "111 静态偏航分析"),
  293. { width: 760, height: 400 },
  294. );
  295. registerImageBuffer(imageBufferMap, "anomaly_111_yaw_static", turbineBuf);
  296. const denseCount = 4500;
  297. const denseTimes = Array.from({ length: denseCount }, (_, i) => {
  298. const t = new Date("2026-08-01T00:00:00").getTime() + i * 60000;
  299. const pad = (n) => String(n).padStart(2, "0");
  300. const d = new Date(t);
  301. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
  302. d.getHours(),
  303. )}:${pad(d.getMinutes())}:00`;
  304. });
  305. const denseOption = buildDetectorPlotOption(
  306. {
  307. title: "DT01 电能质量分析",
  308. xaxis: "时间",
  309. yaxis: "电流不平衡度",
  310. data: [
  311. {
  312. label: "电流不平衡度",
  313. mode: "markers",
  314. timeData: denseTimes,
  315. yData: Array.from({ length: denseCount }, (_, i) => 0.08 + (i % 20) / 200),
  316. },
  317. ],
  318. },
  319. "DT01 电能质量分析",
  320. "anomalyPowerqualityPO",
  321. );
  322. assert(denseOption.series[0].large === false, "密点单机图不应使用 large");
  323. assert(Number(denseOption.series[0].symbolSize) >= 8, "密点单机图 symbolSize 过小");
  324. const denseBuf = await renderEchartsOption(denseOption, { width: 760, height: 420 });
  325. assert(denseBuf.length > 20000, "密点单机图截图过小,可能未画出数据");
  326. const renderData = {
  327. reportNo: "AD-SMOKE-20260824",
  328. Province: "测试省",
  329. Wind_farm: "烟雾风场",
  330. Year_now: "2026",
  331. Month_now: "08",
  332. machineTypeCode: "WD",
  333. turbine_count: "2",
  334. anomaly_turbine_count: "1",
  335. total_anomaly_points: "3",
  336. anomaly_rate: "50.00",
  337. Overview_of_the_Wind_Farm: "烟雾风场用于模板渲染校验。",
  338. target_date: "2026-08-24",
  339. anomaly_module_count: "1",
  340. main_problem_modules: "偏航与扭缆",
  341. top_problem_description: "静态偏航分析异常点数最多(3)",
  342. sensorAnomalyRows: [
  343. {
  344. turbine_name: "111",
  345. sensor_anomaly_type: "风速异常",
  346. anomaly_points: "1",
  347. ratio: "—",
  348. },
  349. ],
  350. detectorSummaryRows: [
  351. {
  352. detector_name: "静态偏航分析",
  353. module_name: "偏航与扭缆",
  354. data_granularity: "秒级",
  355. anomaly_turbines: "1",
  356. anomaly_points: "3",
  357. avg_anomaly_rate: "12.00%",
  358. },
  359. ],
  360. anomalySummaryRows: [
  361. {
  362. turbine_name: "111",
  363. anomaly_detector_count: "1",
  364. anomaly_points: "3",
  365. anomaly_rate: "12.00%",
  366. main_anomaly_type: "静态偏航分析",
  367. },
  368. ],
  369. priorityList: "111",
  370. keyTurbineLoop: [
  371. {
  372. turbine_name: "111",
  373. turbineDetailRows: [
  374. {
  375. detector_name: "静态偏航分析",
  376. anomaly_points: "3",
  377. anomaly_rate: "12.00%",
  378. conclusion: "建议结合现场复核",
  379. },
  380. ],
  381. "zn-techcn-replace-tags-key_turbine-generalFiles": [
  382. { image: "anomaly_111_yaw_static" },
  383. ],
  384. },
  385. ],
  386. conclusionRows: [
  387. {
  388. index: "1",
  389. problem_type: "静态偏航分析",
  390. turbine_names: "111",
  391. anomaly_points: "3",
  392. risk_level: "P2",
  393. suggestion: "核对偏航角越限情况。",
  394. },
  395. ],
  396. "zn-techcn-replace-tags-data_sensor_anomaly-generalFiles": [
  397. { image: "anomaly_sensor_dist" },
  398. ],
  399. "zn-techcn-replace-tags-key_turbine-generalFiles": [],
  400. };
  401. DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => {
  402. if (cfg.templateKey === "yaw_static") {
  403. Object.assign(
  404. renderData,
  405. buildDetectorSectionPayload(cfg, {
  406. farmImages: [{ image: "anomaly_farm_yaw_static" }],
  407. turbineImages: [{ image: "anomaly_111_yaw_static" }],
  408. rows: [
  409. {
  410. turbine_name: "111",
  411. anomaly_points: "3",
  412. anomaly_rate: "12.00%",
  413. comment: "功能诊断异常",
  414. },
  415. ],
  416. anomalyPoints: 3,
  417. anomalyRate: 0.12,
  418. anomalyTurbines: 1,
  419. }),
  420. );
  421. return;
  422. }
  423. const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
  424. const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
  425. renderData[`show-${fileTag}`] = [];
  426. renderData[farmTag] = [];
  427. renderData[fileTag] = [];
  428. renderData[`${cfg.templateKey}Rows`] = [];
  429. });
  430. const buffer = await renderDocxReport({
  431. templateName: "异常检测数据分析报告模板(大唐版).docx",
  432. renderData,
  433. imageBufferMap,
  434. });
  435. const outDir = path.join(__dirname, "../templates");
  436. fs.mkdirSync(outDir, { recursive: true });
  437. const outPath = path.join(outDir, "_smoke_anomaly_report.docx");
  438. fs.writeFileSync(outPath, buffer);
  439. console.log("smoke anomaly report written:", outPath, "bytes=", buffer.length);
  440. await shutdownChartService();