smoke-anomaly-report.mjs 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679
  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. buildDetectorSectionPayload,
  10. pickKeyTurbines,
  11. buildSensorHeatmapData,
  12. buildDetectorHeatmapData,
  13. buildSensorRadarData,
  14. buildDetectorRadarData,
  15. chunkHeatmapData,
  16. buildSensorAnomalyRows,
  17. } from "../src/server/reportService/anomalyReportMapper.js";
  18. import {
  19. DETECTOR_TEMPLATE_CONFIG,
  20. buildAnomalyRadarOption,
  21. buildDetectorPlotOption,
  22. buildEmptyPlaceholderOption,
  23. buildFarmPlotJsons,
  24. buildReportHeatmapOption,
  25. listDetectorPlotOptions,
  26. listFarmPlotOptionsFromTurbines,
  27. listFarmChartOptions,
  28. optionHasData,
  29. turbinePlotsToFarmJson,
  30. } from "../src/server/reportService/anomalyChartBuilder.js";
  31. import { renderEchartsOption } from "../src/server/reportService/echartsRenderer.js";
  32. import {
  33. initChartService,
  34. shutdownChartService,
  35. } from "../src/server/utils/chartService/index.js";
  36. import {
  37. diagnosticFarmSeriesColor,
  38. diagnosticSeriesSymbol,
  39. FARM_ANOMALY_WARM_COLORS,
  40. FARM_NORMAL_COOL_COLORS,
  41. } from "../shared/anomalyDiagnosticVisual.mjs";
  42. import { farmAggregateToPanels } from "../shared/anomalyDetectorPlotAdapter.mjs";
  43. import { fillFarmPanelsToRule, getFarmPanelRule } from "../shared/anomalyFarmChartRules.mjs";
  44. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  45. function samplePlot(labelPrefix = "") {
  46. return {
  47. title: `${labelPrefix}偏航`,
  48. xaxis: "时间",
  49. yaxis: "偏移 (°)",
  50. data: [
  51. {
  52. label: "正常偏航角",
  53. mode: "markers",
  54. timeData: [
  55. "2026-07-14 04:00:00",
  56. "2026-07-14 08:00:00",
  57. "2026-07-14 12:00:00",
  58. ],
  59. yData: [180, 200, 190],
  60. },
  61. {
  62. label: "12h均值",
  63. mode: "lines",
  64. timeData: [
  65. "2026-07-14 04:00:00",
  66. "2026-07-14 08:00:00",
  67. "2026-07-14 12:00:00",
  68. ],
  69. yData: [185, 198, 120],
  70. },
  71. ],
  72. };
  73. }
  74. function assert(condition, message) {
  75. if (!condition) throw new Error(message);
  76. }
  77. const scatterFarm = buildFarmPlotJsons(
  78. [
  79. {
  80. engineName: "A",
  81. plotJson: {
  82. xaxis: "风速",
  83. yaxis: "功率",
  84. data: [
  85. { label: "散点", mode: "markers", xData: [1, 2], yData: [3, 4] },
  86. { label: "上限", mode: "lines", xData: [1, 2], yData: [8, 9] },
  87. { label: "下限", mode: "lines", xData: [1, 2], yData: [1, 2] },
  88. {
  89. label: "参考功率曲线",
  90. mode: "lines",
  91. xData: [1, 2],
  92. yData: [5, 6],
  93. },
  94. ],
  95. },
  96. },
  97. {
  98. engineName: "B",
  99. plotJson: {
  100. xaxis: "风速",
  101. yaxis: "功率",
  102. data: [
  103. { label: "散点", mode: "markers", xData: [2, 3], yData: [4, 5] },
  104. {
  105. label: "参考功率曲线",
  106. mode: "lines",
  107. xData: [1, 2],
  108. yData: [5, 6],
  109. },
  110. ],
  111. },
  112. },
  113. ],
  114. "wind_power_scatter",
  115. "全场散点",
  116. );
  117. assert(
  118. scatterFarm[0].data.filter((row) => row.__shared).length === 1,
  119. "参考功率曲线应只保留一条",
  120. );
  121. assert(
  122. scatterFarm[0].data[scatterFarm[0].data.length - 1].__shared,
  123. "参考/合同功率曲线应放在图例最后",
  124. );
  125. assert(
  126. !scatterFarm[0].data.some((row) => /上限|下限/.test(row.label)),
  127. "散点总图不应含上下限",
  128. );
  129. assert(
  130. scatterFarm[0].data.some((row) => row.label === "A") &&
  131. scatterFarm[0].data.some((row) => row.label === "B"),
  132. "散点总图应按风机分系列",
  133. );
  134. const farmScatterOption = buildDetectorPlotOption(
  135. scatterFarm[0],
  136. scatterFarm[0].title,
  137. "anomalyScatterPO",
  138. );
  139. const farmScatterSeries = (farmScatterOption.series || []).filter(
  140. (item) => item.type === "scatter3D",
  141. );
  142. assert(farmScatterSeries.length > 0, "全场散点应输出 scatter3D 系列");
  143. assert(
  144. farmScatterSeries.every((item) => Number(item.symbolSize) >= 3),
  145. "全场散点 3D symbolSize 过小",
  146. );
  147. assert(farmScatterOption.grid3D, "风功率散点应输出 3D 图表");
  148. const qualityPanels = listDetectorPlotOptions(
  149. {
  150. panels: [
  151. {
  152. panelTitle: "功率因数 | 异常点: 0",
  153. xaxis: "时间",
  154. yaxis: "功率因数",
  155. data: [
  156. {
  157. label: "功率因数",
  158. mode: "markers",
  159. timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
  160. yData: [0.98, 0.97],
  161. },
  162. ],
  163. },
  164. {
  165. panelTitle: "电流不平衡度",
  166. xaxis: "时间",
  167. yaxis: "电流不平衡度",
  168. data: [
  169. {
  170. label: "电流不平衡度",
  171. mode: "markers",
  172. timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
  173. yData: [0.12, 0.15],
  174. },
  175. ],
  176. },
  177. ],
  178. },
  179. "DT01 电能质量分析",
  180. "anomalyPowerqualityPO",
  181. );
  182. assert(qualityPanels.length === 2, "电能质量单机图应按 panel 全部出图");
  183. assert(
  184. qualityPanels[1].yAxis.name === "电流不平衡度",
  185. "第二张单机图应使用对应 panel 的 Y 轴",
  186. );
  187. assert(
  188. (qualityPanels[1].series || []).some(
  189. (item) => Array.isArray(item.data) && item.data.length > 0,
  190. ),
  191. "单机图系列不能为空",
  192. );
  193. const pitchFarm = buildFarmPlotJsons(
  194. [
  195. {
  196. engineName: "101",
  197. plotJson: {
  198. panels: [
  199. {
  200. panelTitle: "桨距角时序",
  201. xaxis: "时间",
  202. yaxis: "桨距角",
  203. data: [
  204. {
  205. label: "桨叶 1",
  206. mode: "markers",
  207. timeData: ["2026-01-01 00:00"],
  208. yData: [1],
  209. },
  210. {
  211. label: "桨叶 2",
  212. mode: "lines",
  213. timeData: ["2026-01-01 00:00"],
  214. yData: [2],
  215. },
  216. {
  217. label: "桨叶 3",
  218. mode: "lines",
  219. timeData: ["2026-01-01 00:00"],
  220. yData: [3],
  221. },
  222. {
  223. label: "阈值参考",
  224. mode: "lines",
  225. timeData: ["2026-01-01 00:00"],
  226. yData: [4],
  227. },
  228. ],
  229. },
  230. {
  231. panelTitle: "桨距角差",
  232. xaxis: "时间",
  233. yaxis: "差值",
  234. data: [
  235. {
  236. label: "桨叶 1",
  237. mode: "markers",
  238. timeData: ["2026-01-01 00:00"],
  239. yData: [1],
  240. },
  241. ],
  242. },
  243. ],
  244. },
  245. },
  246. ],
  247. "pitch_regulation",
  248. "全场变桨一致性",
  249. );
  250. assert(pitchFarm.length === 2, "变桨一致性总图应为 2 张");
  251. assert(
  252. /各桨叶分风速段/.test(pitchFarm[0].title || ""),
  253. "变桨一致性第一张应为各桨叶分风速段中位数趋势",
  254. );
  255. assert(
  256. /极差/.test(pitchFarm[1].title || ""),
  257. "变桨一致性第二张应为桨叶极差时序及异常识别",
  258. );
  259. assert(
  260. !pitchFarm[0].data.some((row) => /阈值/.test(row.originalLabel || "")),
  261. "变桨一致性不应展示阈值参考",
  262. );
  263. const pitchFarmReportOptions = listFarmChartOptions(
  264. [
  265. {
  266. engineName: "101",
  267. plotJson: {
  268. panels: [
  269. {
  270. panelTitle: "桨距角时序",
  271. xaxis: "时间",
  272. yaxis: "桨距角",
  273. data: [
  274. { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1] },
  275. { label: "桨叶 2", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [2] },
  276. { label: "桨叶 3", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [3] },
  277. { label: "阈值参考", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [4] },
  278. ],
  279. },
  280. {
  281. panelTitle: "桨距角差",
  282. xaxis: "时间",
  283. yaxis: "差值",
  284. data: [
  285. { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1] },
  286. ],
  287. },
  288. ],
  289. },
  290. },
  291. ],
  292. "全场变桨一致性",
  293. "anomalyPitchregulationPO",
  294. { templateKey: "pitch_regulation", farmMode: true },
  295. );
  296. assert(pitchFarmReportOptions.length === 2, "报告变桨一致性应出 2 张图");
  297. const deloadFarm = buildFarmPlotJsons(
  298. [
  299. {
  300. engineName: "101",
  301. plotJson: {
  302. panels: [
  303. {
  304. panelTitle: "风速",
  305. xaxis: "时间",
  306. yaxis: "风速",
  307. data: [{ label: "风速", timeData: ["t"], yData: [1] }],
  308. },
  309. {
  310. panelTitle: "有功功率时序",
  311. xaxis: "时间",
  312. yaxis: "有功功率",
  313. data: [
  314. { label: "正常", mode: "markers", timeData: ["t"], yData: [1] },
  315. { label: "异常", mode: "markers", timeData: ["t"], yData: [2] },
  316. ],
  317. },
  318. ],
  319. },
  320. },
  321. ],
  322. "ctrl_deload",
  323. "全场降载",
  324. );
  325. assert(deloadFarm.length === 1, "降载总图只出有功功率时序");
  326. assert(
  327. /有功功率/.test(deloadFarm[0].title + deloadFarm[0].yaxis),
  328. "降载总图应为有功功率",
  329. );
  330. assert(
  331. diagnosticFarmSeriesColor(
  332. { originalLabel: "正常", turbineIndex: 1 },
  333. 0,
  334. "ctrl_deload",
  335. ) ===
  336. diagnosticFarmSeriesColor(
  337. { originalLabel: "异常", turbineIndex: 1 },
  338. 1,
  339. "ctrl_deload",
  340. ),
  341. "降载图同一风机的正常和异常点应使用相同颜色",
  342. );
  343. assert(
  344. FARM_NORMAL_COOL_COLORS.includes(
  345. diagnosticFarmSeriesColor(
  346. { originalLabel: "正常", turbineIndex: 0 },
  347. 0,
  348. "pitch_coord",
  349. ),
  350. ),
  351. "总图正常风机应使用冷色",
  352. );
  353. assert(
  354. FARM_ANOMALY_WARM_COLORS.includes(
  355. diagnosticFarmSeriesColor(
  356. { originalLabel: "异常", turbineIndex: 0 },
  357. 0,
  358. "pitch_coord",
  359. ),
  360. ),
  361. "总图异常风机应使用红橙暖色",
  362. );
  363. assert(
  364. diagnosticFarmSeriesColor(
  365. { extra: { anomaly: true }, originalLabel: "正常", turbineIndex: 3 },
  366. 0,
  367. "ctrl_deload",
  368. ) ===
  369. diagnosticFarmSeriesColor(
  370. { extra: { anomaly: true }, originalLabel: "异常", turbineIndex: 3 },
  371. 1,
  372. "ctrl_deload",
  373. ) &&
  374. FARM_ANOMALY_WARM_COLORS.includes(
  375. diagnosticFarmSeriesColor(
  376. { extra: { anomaly: true }, turbineIndex: 3 },
  377. 0,
  378. "ctrl_deload",
  379. ),
  380. ),
  381. "异常降载风机应整机暖色且正常/异常点同色",
  382. );
  383. assert(
  384. diagnosticSeriesSymbol({ originalLabel: "正常" }) !==
  385. diagnosticSeriesSymbol({ originalLabel: "异常" }),
  386. "降载图应使用不同点形区分正常和异常",
  387. );
  388. const deloadRateOption = listDetectorPlotOptions(
  389. {
  390. panels: [
  391. {
  392. panelTitle: "有功功率时序",
  393. xaxis: "时间",
  394. yaxis: "有功功率",
  395. data: [
  396. {
  397. label: "正常运行",
  398. mode: "markers",
  399. timeData: ["2026-08-22 00:00:00", "2026-08-22 04:00:00"],
  400. yData: [1000, 1100],
  401. },
  402. ],
  403. },
  404. {
  405. panelTitle: "累计降载率随时间变化",
  406. xaxis: "时间",
  407. yaxis: "累计降载率 (%)",
  408. data: [
  409. {
  410. label: "累计降载率",
  411. mode: "markers",
  412. timeData: [
  413. "2026-08-22 00:00:00",
  414. "2026-08-22 04:00:00",
  415. "2026-08-22 08:00:00",
  416. ],
  417. yData: [14, 2, 3],
  418. },
  419. {
  420. label: "正常上限 5%",
  421. mode: "markers",
  422. timeData: ["2026-08-22 00:00:00", "2026-08-22 08:00:00"],
  423. yData: [5, 5],
  424. },
  425. {
  426. label: "频繁阈值 20%",
  427. mode: "markers",
  428. timeData: ["2026-08-22 00:00:00"],
  429. yData: [20],
  430. },
  431. ],
  432. },
  433. ],
  434. },
  435. "002 降载判定分析",
  436. "anomalyDeloadPO",
  437. { templateKey: "ctrl_deload" },
  438. );
  439. assert(deloadRateOption.length === 2, "单机降载应按 panel 出图");
  440. const rateChart = deloadRateOption[1];
  441. assert(
  442. rateChart.series.every((item) => item.type === "line"),
  443. "累计降载率应为折线图",
  444. );
  445. assert(
  446. rateChart.series.find((item) => item.name === "累计降载率")?.lineStyle
  447. .type === "solid",
  448. "累计降载率应为实线",
  449. );
  450. assert(
  451. rateChart.series.find((item) => /正常上限/.test(item.name))?.lineStyle
  452. .type === "dashed",
  453. "正常上限应为虚线",
  454. );
  455. assert(
  456. rateChart.series.find((item) => /频繁阈值/.test(item.name))?.lineStyle
  457. .type === "dashed",
  458. "频繁阈值应为虚线",
  459. );
  460. assert(
  461. (rateChart.series.find((item) => /频繁阈值/.test(item.name))?.data || [])
  462. .length >= 2,
  463. "阈值线应拉满时间轴",
  464. );
  465. const opFarm = buildFarmPlotJsons(
  466. [
  467. {
  468. engineName: "101",
  469. plotJson: {
  470. panels: [
  471. {
  472. panelTitle: "投影",
  473. xaxis: "x",
  474. yaxis: "y",
  475. data: [{ label: "簇", xData: [1], yData: [1] }],
  476. },
  477. {
  478. panelTitle: "转速-功率散点",
  479. xaxis: "转速",
  480. yaxis: "功率",
  481. data: [{ label: "散点", xData: [1], yData: [2] }],
  482. },
  483. ],
  484. },
  485. },
  486. ],
  487. "ctrl_op_state",
  488. "全场运行状态",
  489. );
  490. assert(opFarm.length === 1, "运行状态总图只出一张");
  491. assert(
  492. /转速/.test(opFarm[0].title + opFarm[0].xaxis),
  493. "运行状态总图应为转速-功率",
  494. );
  495. const pqFarm = buildFarmPlotJsons(
  496. [
  497. {
  498. engineName: "101",
  499. plotJson: {
  500. panels: [
  501. {
  502. panelTitle: "三相电流不平衡度 | 异常点: 0",
  503. xaxis: "时间",
  504. yaxis: "电流不平衡度",
  505. data: [{ label: "点", xData: [1], yData: [1] }],
  506. },
  507. {
  508. panelTitle: "三相电压不平衡度 | 异常点: 0",
  509. xaxis: "时间",
  510. yaxis: "电压不平衡度",
  511. data: [{ label: "点", xData: [1], yData: [1] }],
  512. },
  513. {
  514. panelTitle: "功率因数 | 异常点: 0",
  515. xaxis: "时间",
  516. yaxis: "功率因数",
  517. data: [{ label: "点", xData: [1], yData: [1] }],
  518. },
  519. ],
  520. },
  521. },
  522. ],
  523. "ctrl_power_quality",
  524. "全场电能质量",
  525. );
  526. assert(pqFarm.length === 3, "电能质量总图应出三张");
  527. assert(
  528. pqFarm.length === getFarmPanelRule("ctrl_power_quality").count,
  529. "电能质量图数必须与共享报告模板规则一致",
  530. );
  531. assert(
  532. /电流不平衡/.test(pqFarm[0].title + pqFarm[0].yaxis),
  533. "第一张应为三相电流不平衡度",
  534. );
  535. assert(
  536. /电压不平衡/.test(pqFarm[1].title + pqFarm[1].yaxis),
  537. "第二张应为三相电压不平衡度",
  538. );
  539. assert(
  540. /功率因数/.test(pqFarm[2].title + pqFarm[2].yaxis),
  541. "第三张应为功率因数",
  542. );
  543. function loadLocalFarmJson(name) {
  544. const filePath = path.resolve(__dirname, `../../SVLgVZfi/${name}`);
  545. if (!fs.existsSync(filePath)) return null;
  546. return JSON.parse(fs.readFileSync(filePath, "utf8"));
  547. }
  548. function dummyFarmPanel(title, yaxis) {
  549. return {
  550. title,
  551. xaxis: "x",
  552. yaxis,
  553. data: [{ label: "占位", xData: [1], yData: [1] }],
  554. };
  555. }
  556. const localPitchFarm = loadLocalFarmJson("farm_pitch_regulation.json");
  557. if (localPitchFarm) {
  558. const overlay = farmAggregateToPanels(localPitchFarm, {
  559. templateKey: "pitch_regulation",
  560. });
  561. const filled = fillFarmPanelsToRule(
  562. overlay,
  563. [
  564. dummyFarmPanel("各桨叶分风速段中位数趋势", "桨距角 (°)"),
  565. dummyFarmPanel("桨叶极差时序及异常识别", "桨叶极差 (°)"),
  566. ],
  567. "pitch_regulation",
  568. );
  569. assert(filled.length === 2, "变桨一致性页面总图应为 2 面");
  570. assert(/各桨叶分风速段/.test(filled[0].title), "第一面应为各桨叶分风速段中位数趋势");
  571. assert(/极差/.test(filled[1].title), "第二面应为桨叶极差时序及异常识别");
  572. assert(filled[1].data.length > 1, "极差面应保留 farm JSON 真实叠加");
  573. }
  574. const localCoordFarm = loadLocalFarmJson("farm_pitch_coord.json");
  575. if (localCoordFarm) {
  576. const overlay = farmAggregateToPanels(localCoordFarm, {
  577. templateKey: "pitch_coord",
  578. });
  579. const filled = fillFarmPanelsToRule(
  580. overlay,
  581. [
  582. dummyFarmPanel("功率-转速散点(桨距角着色)", "有功功率 (kW)"),
  583. dummyFarmPanel("桨距角-转速散点", "桨距角 (°)"),
  584. ],
  585. "pitch_coord",
  586. );
  587. assert(filled.length === 2, "变桨协调页面总图应为 2 面");
  588. assert(/着色/.test(filled[0].title), "变桨协调第一面应为桨距角着色");
  589. assert(/桨距角-转速/.test(filled[1].title), "变桨协调第二面应为桨距角-转速散点");
  590. }
  591. const localPqFarm = loadLocalFarmJson("farm_ctrl_power_quality.json");
  592. if (localPqFarm) {
  593. const overlay = farmAggregateToPanels(localPqFarm, {
  594. templateKey: "ctrl_power_quality",
  595. });
  596. const filled = fillFarmPanelsToRule(
  597. overlay,
  598. [
  599. dummyFarmPanel("三相电流不平衡度", "电流不平衡度"),
  600. dummyFarmPanel("三相电压不平衡度", "电压不平衡度"),
  601. dummyFarmPanel("功率因数", "功率因数"),
  602. ],
  603. "ctrl_power_quality",
  604. );
  605. assert(filled.length === 3, "电能质量页面总图应为 3 面");
  606. assert(/电流不平衡/.test(filled[0].title), "电能质量第一面应为电流不平衡");
  607. assert(/电压不平衡/.test(filled[1].title), "电能质量第二面应为电压不平衡");
  608. assert(/功率因数/.test(filled[2].title), "电能质量第三面应为功率因数");
  609. assert(filled[2].data.length > 1, "功率因数面应保留 farm JSON 真实叠加");
  610. }
  611. const rawPointCount = 9001;
  612. const rawX = Array.from({ length: rawPointCount }, (_, index) => index);
  613. const rawY = rawX.map((value) => value * 2);
  614. const rawPointFarm = buildFarmPlotJsons(
  615. [
  616. {
  617. engineName: "101",
  618. plotJson: {
  619. panels: [
  620. {
  621. panelTitle: "转速-功率散点",
  622. xaxis: "转速",
  623. yaxis: "功率",
  624. data: [
  625. { label: "散点", mode: "markers", xData: rawX, yData: rawY },
  626. ],
  627. },
  628. ],
  629. },
  630. },
  631. ],
  632. "ctrl_op_state",
  633. "全场运行状态",
  634. );
  635. assert(
  636. rawPointFarm[0].data[0].xData.length === rawPointCount,
  637. "全场图不得抽样原始数据点",
  638. );
  639. const rawPointOption = buildDetectorPlotOption(
  640. rawPointFarm[0],
  641. rawPointFarm[0].title,
  642. "anomalyOperationPO",
  643. );
  644. assert(
  645. rawPointOption.series[0].data.length === rawPointCount,
  646. "报告图渲染不得抽样原始数据点",
  647. );
  648. console.log("farm chart rules ok");
  649. const curveFarm = buildFarmPlotJsons(
  650. [
  651. {
  652. engineName: "DT01",
  653. plotJson: {
  654. xaxis: "风速 (m/s)",
  655. yaxis: "有功功率 (kW)",
  656. data: [
  657. {
  658. label: "30天功率曲线",
  659. mode: "lines",
  660. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  661. xData: [4, 12],
  662. yData: [200, 2000],
  663. },
  664. {
  665. label: "合同功率曲线",
  666. mode: "lines",
  667. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  668. xData: [4, 12],
  669. yData: [180, 2100],
  670. },
  671. ],
  672. },
  673. },
  674. {
  675. engineName: "DT02",
  676. plotJson: {
  677. xaxis: "风速 (m/s)",
  678. yaxis: "有功功率 (kW)",
  679. data: [
  680. {
  681. label: "30天功率曲线",
  682. mode: "lines",
  683. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  684. xData: [5, 11],
  685. yData: [300, 1900],
  686. },
  687. {
  688. label: "合同功率曲线",
  689. mode: "lines",
  690. xData: [4, 12],
  691. yData: [180, 2100],
  692. },
  693. ],
  694. },
  695. },
  696. ],
  697. "wind_power_curve",
  698. "全场功率曲线",
  699. );
  700. assert(curveFarm.length === 1, "功率曲线总图应 1 张");
  701. assert(
  702. curveFarm[0].data.filter((row) => row.__shared).length === 1,
  703. "合同功率曲线全场只保留一条",
  704. );
  705. assert(
  706. curveFarm[0].data[curveFarm[0].data.length - 1].label === "合同功率曲线",
  707. "合同功率曲线应在图例末尾",
  708. );
  709. const curveOption = buildDetectorPlotOption(
  710. curveFarm[0],
  711. curveFarm[0].title,
  712. "anomalyPowercurvePO",
  713. );
  714. assert(curveOption.grid3D, "功率曲线应输出 3D 图表");
  715. assert(curveOption.xAxis3D?.name === "风机机位", "3D X 轴应为风机机位");
  716. assert(/风速/.test(String(curveOption.yAxis3D?.name || "")), "3D Y 轴应为风速");
  717. assert(/功率/.test(String(curveOption.zAxis3D?.name || "")), "3D Z 轴应为功率");
  718. assert(
  719. curveOption.series.some(
  720. (item) =>
  721. (item.type === "scatter3D" || item.type === "line3D") &&
  722. (item.data?.[0]?.[1] === 4 || item.data?.[0]?.[1] === 5),
  723. ),
  724. "功率曲线 3D Y 轴应使用风速",
  725. );
  726. assert(
  727. curveOption.series.some(
  728. (item) => item.type === "line3D" && item.data?.[0]?.[0] > 0,
  729. ),
  730. "功率曲线各风机系列 X 轴应为机组序号",
  731. );
  732. assert(
  733. !curveOption.series.some(
  734. (item) =>
  735. item.type === "scatter3D" && item.name && !/参考|合同/.test(item.name),
  736. ),
  737. "功率曲线风机数据不应为 scatter3D",
  738. );
  739. const misplacedX = buildDetectorPlotOption(
  740. {
  741. title: "DT01 功率曲线分析",
  742. xaxis: "风速 (m/s)",
  743. yaxis: "有功功率 (kW)",
  744. data: [
  745. {
  746. label: "30天功率曲线",
  747. mode: "lines",
  748. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  749. xData: [6, 10],
  750. yData: [400, 1800],
  751. },
  752. ],
  753. },
  754. "DT01 功率曲线分析",
  755. "anomalyPowercurvePO",
  756. );
  757. assert(misplacedX.series[0].data[0][0] === 6, "单机功率曲线应取 xData 风速");
  758. assert(!misplacedX.grid3D, "单机功率曲线应保持 2D");
  759. assert(misplacedX.xAxis?.type === "value", "单机功率曲线风速轴应为数值轴");
  760. const pitchCoordFarm = buildFarmPlotJsons(
  761. [
  762. {
  763. engineName: "DT40",
  764. plotJson: {
  765. xaxis: "转速 (r/min)",
  766. yaxis: "有功功率 (kW)",
  767. data: [
  768. {
  769. label: "散点",
  770. mode: "lines",
  771. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  772. xData: [8, 12],
  773. yData: [400, 1800],
  774. },
  775. ],
  776. },
  777. },
  778. ],
  779. "pitch_coord",
  780. "全场变桨协调",
  781. );
  782. const pitchCoordOption = buildDetectorPlotOption(
  783. pitchCoordFarm[0],
  784. pitchCoordFarm[0].title,
  785. "anomalyPitchcoordPO",
  786. );
  787. assert(pitchCoordOption.series[0].type === "scatter", "变桨协调应输出散点");
  788. assert(pitchCoordOption.series[0].data[0][0] === 8, "变桨协调 X 轴应为转速");
  789. assert(
  790. Number(pitchCoordOption.series[0].symbolSize) >= 12,
  791. "全场变桨协调散点应加大",
  792. );
  793. const pitchCoordFarmTwo = buildFarmPlotJsons(
  794. [
  795. {
  796. engineName: "001",
  797. plotJson: {
  798. panels: [
  799. {
  800. panelTitle: "功率-转速散点(桨距角着色)",
  801. xaxis: "转速 (r/min)",
  802. yaxis: "有功功率 (kW)",
  803. data: [
  804. {
  805. label: "正常数据",
  806. mode: "markers",
  807. xData: [8, 12],
  808. yData: [400, 1800],
  809. colorbar: [1, 8],
  810. colorbarTitle: "桨距角 (°)",
  811. },
  812. {
  813. label: "异常点",
  814. mode: "markers",
  815. xData: [10],
  816. yData: [900],
  817. colorbar: [12],
  818. },
  819. ],
  820. },
  821. {
  822. panelTitle: "桨距角-转速散点",
  823. xaxis: "转速 (r/min)",
  824. yaxis: "桨距角 (°)",
  825. data: [
  826. {
  827. label: "正常数据",
  828. mode: "markers",
  829. xData: [8, 12],
  830. yData: [1, 8],
  831. },
  832. ],
  833. },
  834. ],
  835. },
  836. },
  837. {
  838. engineName: "005",
  839. plotJson: {
  840. panels: [
  841. {
  842. panelTitle: "功率-转速散点(桨距角着色)",
  843. xaxis: "转速 (r/min)",
  844. yaxis: "有功功率 (kW)",
  845. data: [
  846. {
  847. label: "正常数据",
  848. mode: "markers",
  849. xData: [9, 11],
  850. yData: [500, 1600],
  851. colorbar: [2, 7],
  852. colorbarTitle: "桨距角 (°)",
  853. },
  854. ],
  855. },
  856. {
  857. panelTitle: "桨距角-转速散点",
  858. xaxis: "转速 (r/min)",
  859. yaxis: "桨距角 (°)",
  860. data: [
  861. {
  862. label: "正常数据",
  863. mode: "markers",
  864. xData: [9, 11],
  865. yData: [2, 7],
  866. },
  867. ],
  868. },
  869. ],
  870. },
  871. },
  872. ],
  873. "pitch_coord",
  874. "全场变桨协调分析检测汇总",
  875. );
  876. assert(pitchCoordFarmTwo.length === 2, "变桨协调总图应为 2 张");
  877. assert(
  878. /功率-转速散点(桨距角着色)/.test(pitchCoordFarmTwo[0].title),
  879. "第一张应为功率-转速散点(桨距角着色)",
  880. );
  881. assert(
  882. /桨距角-转速散点/.test(pitchCoordFarmTwo[1].title),
  883. "第二张应为桨距角-转速散点",
  884. );
  885. const pitchColorOption = buildDetectorPlotOption(
  886. pitchCoordFarmTwo[0],
  887. pitchCoordFarmTwo[0].title,
  888. "anomalyPitchcoordPO",
  889. );
  890. assert(Array.isArray(pitchColorOption.visualMap), "功率-转速图应按桨距角着色");
  891. assert(
  892. pitchColorOption.series.some((item) => item.name === "正常数据"),
  893. "桨距角着色图应按正常/异常分系列而非每台风机",
  894. );
  895. assert(
  896. pitchColorOption.series.find((item) => item.name === "正常数据")?.data
  897. .length === 4 &&
  898. pitchColorOption.series.find((item) => item.name === "异常点")?.data
  899. .length === 1,
  900. "桨距角着色图合并系列时不得丢失原始点",
  901. );
  902. const pitchSpeedOption = buildDetectorPlotOption(
  903. pitchCoordFarmTwo[1],
  904. pitchCoordFarmTwo[1].title,
  905. "anomalyPitchcoordPO",
  906. );
  907. assert(
  908. pitchSpeedOption.series.some((item) => item.name === "001") &&
  909. pitchSpeedOption.series.some((item) => item.name === "005"),
  910. "桨距角-转速图应按风机分色",
  911. );
  912. const pitchStyleOption = listDetectorPlotOptions(
  913. {
  914. xaxis: "风速分箱中心值 (m/s)",
  915. yaxis: "桨距角中位数 (°)",
  916. data: [
  917. {
  918. label: "桨叶 1",
  919. mode: "lines",
  920. xData: [2, 6],
  921. yData: [1, 2],
  922. timeData: ["t1", "t2"],
  923. },
  924. {
  925. label: "桨叶 2",
  926. mode: "lines",
  927. xData: [2, 6],
  928. yData: [1.1, 2.1],
  929. },
  930. {
  931. label: "桨叶 3",
  932. mode: "lines",
  933. xData: [2, 6],
  934. yData: [1.2, 2.2],
  935. },
  936. ],
  937. },
  938. "DT01 变桨一致性分析",
  939. "anomalyPitchregulationPO",
  940. { templateKey: "pitch_regulation" },
  941. )[0];
  942. assert(pitchStyleOption.series.length === 3, "单机变桨一致性应绘出三支桨叶");
  943. assert(pitchStyleOption.series[0].symbol === "rect", "桨叶1应为方形");
  944. assert(pitchStyleOption.series[0].lineStyle.type === "dashed", "桨叶1应为虚线");
  945. assert(pitchStyleOption.series[1].symbol === "circle", "桨叶2应为圆形");
  946. assert(pitchStyleOption.series[1].lineStyle.type === "solid", "桨叶2应为实线");
  947. assert(pitchStyleOption.series[2].symbol === "triangle", "桨叶3应为三角");
  948. assert(pitchStyleOption.series[2].lineStyle.type === "dotted", "桨叶3应为点线");
  949. assert(
  950. pitchStyleOption.series[0].data[0][0] === 2,
  951. "变桨一致性 X 轴应使用分箱风速",
  952. );
  953. const yawErrorOptions = listDetectorPlotOptions(
  954. {
  955. title: "静态偏航误差分析",
  956. xaxis: "对风角度/偏航误差 (°)",
  957. yaxis: "平均有功功率 (kW)",
  958. data: [
  959. {
  960. label: "4.5-5.0 m/s",
  961. mode: "lines+markers",
  962. xData: [-2, 0, 4],
  963. yData: [300, 420, 380],
  964. },
  965. {
  966. label: "5.0-5.5 m/s",
  967. mode: "lines+markers",
  968. xData: [-1, 2, 5],
  969. yData: [410, 500, 430],
  970. },
  971. ],
  972. },
  973. "01 静态偏航误差分析",
  974. "anomalyYawErrorPO",
  975. { templateKey: "yaw_error" },
  976. );
  977. assert(yawErrorOptions.length === 1, "偏航误差单机图应按 data[] 出一张");
  978. assert(yawErrorOptions[0].series.length === 2, "偏航误差应按风速箱分系列");
  979. assert(
  980. yawErrorOptions[0].series[0].markPoint?.data?.[0]?.coord?.[0] === 0,
  981. "偏航误差应标注平均功率最大的对风角度",
  982. );
  983. const yawCountOptions = listDetectorPlotOptions(
  984. {
  985. title: "偏航次数检测(CW/CCW 跳变)",
  986. xaxis: "时间",
  987. yaxis: "状态 (0/1)",
  988. data: [
  989. {
  990. label: "CW 偏航状态",
  991. mode: "lines",
  992. xData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  993. yData: [0, 1],
  994. timeData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  995. },
  996. {
  997. label: "CCW 偏航状态",
  998. mode: "lines",
  999. xData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  1000. yData: [0, 0],
  1001. timeData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  1002. },
  1003. ],
  1004. },
  1005. "01 偏航次数分析",
  1006. "anomalyYawCountPO",
  1007. { templateKey: "yaw_count" },
  1008. );
  1009. assert(yawCountOptions[0].yAxis.min === -0.05, "偏航次数 Y 轴应为 0/1");
  1010. assert(yawCountOptions[0].series.length === 2, "偏航次数应绘制 CW/CCW");
  1011. assert(
  1012. yawCountOptions[0].xAxis.type === "time",
  1013. "偏航次数横轴应为时间",
  1014. );
  1015. const pitchMinOptions = listDetectorPlotOptions(
  1016. {
  1017. title: "最小桨距角分布",
  1018. xaxis: "时间",
  1019. yaxis: "桨距角 (°)",
  1020. xrange: ["2026-09-05", "2026-09-11"],
  1021. yrange: [-1.0, 4.5],
  1022. data: [
  1023. {
  1024. label: "最小桨距角分布",
  1025. mode: "markers",
  1026. xData: ["2026-09-05", "2026-09-06"],
  1027. yData: [-0.5, 0.2],
  1028. colorbar: [120, 40],
  1029. colorbarTitle: "点数",
  1030. },
  1031. ],
  1032. },
  1033. "01 最小桨距角分析",
  1034. "anomalyMinpitchPO",
  1035. { templateKey: "pitch_min" },
  1036. );
  1037. assert(pitchMinOptions[0].series[0].type === "scatter", "最小桨距角应为散点");
  1038. assert(
  1039. typeof pitchMinOptions[0].series[0].symbolSize === "function",
  1040. "最小桨距角气泡大小应跟随点数",
  1041. );
  1042. const opStateOptions = listDetectorPlotOptions(
  1043. {
  1044. detector_name: "ctrl_op_state",
  1045. title: "运行状态综合异常检测",
  1046. panels: [
  1047. {
  1048. panelTitle: "转速-功率散点图",
  1049. xaxis: "转速(r/min)",
  1050. yaxis: "有功功率(kW)",
  1051. xrange: [0, 30],
  1052. yrange: [0, 800],
  1053. data: [
  1054. {
  1055. label: "正常数据",
  1056. mode: "markers",
  1057. xData: [12, 18],
  1058. yData: [320, 500],
  1059. },
  1060. ],
  1061. },
  1062. {
  1063. panelTitle: "PCA 降维投影",
  1064. xaxis: "PC1",
  1065. yaxis: "PC2",
  1066. componentDefinition: [
  1067. {
  1068. name: "PC1",
  1069. explainedVarianceRatio: 0.63,
  1070. loadings: [
  1071. {
  1072. feature: "p_active",
  1073. displayName: "z(功率)",
  1074. coefficient: 0.81,
  1075. standardized: true,
  1076. },
  1077. ],
  1078. },
  1079. ],
  1080. data: [
  1081. {
  1082. label: "投影点",
  1083. mode: "markers",
  1084. xData: [0.1],
  1085. yData: [0.2],
  1086. },
  1087. ],
  1088. },
  1089. ],
  1090. },
  1091. "01 运行状态分析",
  1092. "anomalyOperationPO",
  1093. { templateKey: "ctrl_op_state" },
  1094. );
  1095. assert(opStateOptions.length === 2, "运行状态单机图应按 panels[] 全部出图");
  1096. assert(
  1097. String(opStateOptions[1].title?.subtext || "").includes("PC1"),
  1098. "运行状态 PCA 面板应展示主成分定义",
  1099. );
  1100. const farmCurveOptions = listDetectorPlotOptions(
  1101. {
  1102. detector: "wind_power_curve",
  1103. farm: "XX风电场",
  1104. chartType: "series",
  1105. xaxis: "风速 (m/s)",
  1106. yaxis: "有功功率 (kW)",
  1107. xrange: [3.25, 25.25],
  1108. turbines: [
  1109. {
  1110. turbine: "a1",
  1111. anomaly: false,
  1112. xType: "number",
  1113. xData: [3.25, 4],
  1114. yData: [120, 200],
  1115. ratio: 1.02,
  1116. status: "正常",
  1117. },
  1118. {
  1119. turbine: "a2",
  1120. anomaly: true,
  1121. xType: "number",
  1122. xData: [3.25, 4],
  1123. yData: [80, 150],
  1124. ratio: 0.7,
  1125. status: "欠发",
  1126. },
  1127. ],
  1128. },
  1129. "全场功率曲线分析检测汇总",
  1130. "anomalyPowercurvePO",
  1131. {
  1132. templateKey: "wind_power_curve",
  1133. farmMode: true,
  1134. nameMap: { a1: "01", a2: "02" },
  1135. },
  1136. );
  1137. assert(farmCurveOptions.length === 1, "风场功率曲线总图应为一张叠加图");
  1138. assert(farmCurveOptions[0].grid3D, "风场功率曲线总图应按全场 3D 渲染");
  1139. assert(
  1140. farmCurveOptions[0].series.some((item) => /01号/.test(item.name)),
  1141. "风场总图应把 engineId 映射成风机名",
  1142. );
  1143. const farmYawErrorOptions = listDetectorPlotOptions(
  1144. {
  1145. detector: "yaw_error",
  1146. chartType: "value",
  1147. turbines: [
  1148. { turbine: "a1", anomaly: true, value: 15, powerloss: 2.1, coverage: 0.9 },
  1149. { turbine: "a2", anomaly: false, value: 3, powerloss: 0.1, coverage: 0.95 },
  1150. ],
  1151. },
  1152. "全场静态偏航误差分析检测汇总",
  1153. "anomalyYawErrorPO",
  1154. {
  1155. templateKey: "yaw_error",
  1156. farmMode: true,
  1157. nameMap: { a1: "01", a2: "02" },
  1158. },
  1159. );
  1160. assert(farmYawErrorOptions[0].series[0].type === "bar", "风场偏航误差总图应为柱状对比");
  1161. assert(
  1162. farmYawErrorOptions[0].series.some((item) => item.name === "风场均值"),
  1163. "风场 value 型总图应绘制均值线",
  1164. );
  1165. const farmYawStaticOptions = listDetectorPlotOptions(
  1166. {
  1167. detector: "yaw_static",
  1168. chartType: "series",
  1169. xaxis: "时间",
  1170. yaxis: "偏航角 (°)",
  1171. turbines: [
  1172. {
  1173. turbine: "t1",
  1174. anomaly: true,
  1175. xType: "time",
  1176. xData: [
  1177. "2026-09-11 00:00:00",
  1178. "2026-09-11 00:01:00",
  1179. "2026-09-11 00:02:00",
  1180. ],
  1181. yData: [10, 80, 12],
  1182. pointAnomaly: [false, true, false],
  1183. },
  1184. ],
  1185. },
  1186. "全场静态偏航分析检测汇总",
  1187. "anomalyStaticyawPO",
  1188. { templateKey: "yaw_static", farmMode: true },
  1189. );
  1190. assert(
  1191. farmYawStaticOptions[0].series.length === 2,
  1192. "风场静态偏航总图应按 pointAnomaly 拆成正常/异常点",
  1193. );
  1194. const stitchedFarmJson = turbinePlotsToFarmJson(
  1195. [
  1196. {
  1197. engineId: "a1",
  1198. engineName: "01",
  1199. plotJson: {
  1200. title: "功率曲线",
  1201. xaxis: "风速 (m/s)",
  1202. yaxis: "有功功率 (kW)",
  1203. data: [
  1204. {
  1205. label: "实测功率",
  1206. mode: "lines",
  1207. xData: [3.25, 4],
  1208. yData: [120, 200],
  1209. ratio: 1.02,
  1210. status: "正常",
  1211. },
  1212. ],
  1213. },
  1214. },
  1215. {
  1216. engineId: "a2",
  1217. engineName: "02",
  1218. po: { detectorIsAnomaly: 1 },
  1219. plotJson: {
  1220. title: "功率曲线",
  1221. xaxis: "风速 (m/s)",
  1222. yaxis: "有功功率 (kW)",
  1223. data: [
  1224. {
  1225. label: "实测功率",
  1226. mode: "lines",
  1227. xData: [3.25, 4],
  1228. yData: [80, 150],
  1229. ratio: 0.7,
  1230. status: "欠发",
  1231. },
  1232. ],
  1233. },
  1234. },
  1235. ],
  1236. "wind_power_curve",
  1237. );
  1238. assert(stitchedFarmJson?.chartType === "series", "单机 JSON 拼总图应为 series");
  1239. assert(stitchedFarmJson.turbines.length === 2, "单机 JSON 拼总图应包含全部风机");
  1240. const stitchedFarmOptions = listFarmPlotOptionsFromTurbines(
  1241. [
  1242. {
  1243. engineId: "a1",
  1244. engineName: "01",
  1245. plotJson: {
  1246. title: "功率曲线",
  1247. xaxis: "风速 (m/s)",
  1248. yaxis: "有功功率 (kW)",
  1249. data: [{ label: "实测功率", xData: [3.25, 4], yData: [120, 200] }],
  1250. },
  1251. },
  1252. ],
  1253. "全场功率曲线分析检测汇总",
  1254. "anomalyPowercurvePO",
  1255. {
  1256. templateKey: "wind_power_curve",
  1257. nameMap: { a1: "01" },
  1258. },
  1259. );
  1260. assert(stitchedFarmOptions[0].grid3D, "报告总图应与页面一样走全场 3D");
  1261. const yawErrorFarmFromTurbines = listFarmPlotOptionsFromTurbines(
  1262. [
  1263. {
  1264. engineId: "a1",
  1265. engineName: "01",
  1266. po: { detectorIsAnomaly: 1, detectorAnomalyRate: 15 },
  1267. plotJson: {
  1268. xaxis: "对风角度/偏航误差 (°)",
  1269. yaxis: "平均有功功率 (kW)",
  1270. data: [
  1271. {
  1272. label: "4.5-5.0 m/s",
  1273. xData: [-2, 0, 4],
  1274. yData: [300, 420, 380],
  1275. },
  1276. {
  1277. label: "5.0-5.5 m/s",
  1278. xData: [-1, 2, 5],
  1279. yData: [410, 500, 430],
  1280. },
  1281. ],
  1282. },
  1283. },
  1284. {
  1285. engineId: "a2",
  1286. engineName: "02",
  1287. po: { detectorIsAnomaly: 0, detectorAnomalyRate: 3 },
  1288. plotJson: {
  1289. data: [
  1290. {
  1291. label: "4.5-5.0 m/s",
  1292. xData: [-1, 3],
  1293. yData: [200, 260],
  1294. },
  1295. ],
  1296. },
  1297. },
  1298. ],
  1299. "全场静态偏航误差分析检测汇总",
  1300. "anomalyYawErrorPO",
  1301. {
  1302. templateKey: "yaw_error",
  1303. nameMap: { a1: "01", a2: "02" },
  1304. },
  1305. );
  1306. assert(
  1307. yawErrorFarmFromTurbines[0].series[0].type === "bar",
  1308. "报告偏航误差总图应与页面一样为单值柱状",
  1309. );
  1310. assert(
  1311. yawErrorFarmFromTurbines[0].series.some((item) => item.name === "风场均值"),
  1312. "报告 value 型总图应绘制均值线",
  1313. );
  1314. const emptyOption = buildEmptyPlaceholderOption("全场检测");
  1315. assert(!optionHasData(emptyOption), "占位图不应视为有数据");
  1316. assert(
  1317. JSON.stringify(emptyOption.title).includes("暂无"),
  1318. "无 JSON 时应输出暂无占位",
  1319. );
  1320. const watchlist = pickKeyTurbines(
  1321. [
  1322. { engineId: "1", turbineName: "DT01", anomalyPoints: 99 },
  1323. { engineId: "2", turbineName: "DT02", anomalyPoints: 1 },
  1324. ],
  1325. [
  1326. { engineId: "2", model3PitchPitchcoordRatio: 0.6 },
  1327. { engineId: "1", model3PitchPitchcoordRatio: 0.1 },
  1328. ],
  1329. );
  1330. assert(
  1331. watchlist.length === 1 && watchlist[0].turbineName === "DT02",
  1332. "第6章应只取关注机组",
  1333. );
  1334. assert(
  1335. pickKeyTurbines([{ engineId: "1" }], []).length === 0,
  1336. "无关注机组时应为空",
  1337. );
  1338. const emptyRowsPayload = buildDetectorSectionPayload(
  1339. DETECTOR_TEMPLATE_CONFIG.find((cfg) => cfg.templateKey === "pitch_coord"),
  1340. { farmImages: [], turbineImages: [], rows: [] },
  1341. );
  1342. assert(
  1343. emptyRowsPayload.pitch_coordRows[0].turbine_name === "暂无",
  1344. "异常机组清单无数据时应写暂无",
  1345. );
  1346. const emptySensorRows = buildSensorAnomalyRows([]);
  1347. assert(
  1348. emptySensorRows[0].turbine_name === "暂无",
  1349. "数据感知统计无数据时应写暂无",
  1350. );
  1351. const sensorHeat = buildSensorHeatmapData([
  1352. {
  1353. engineName: "09",
  1354. sensorAnomalyPowerRatio: 0.12,
  1355. sensorAnomalyWindRatio: 0,
  1356. },
  1357. { engineName: "16", sensorAnomalyPowerRatio: 0, sensorAnomalyWindRatio: 0.2 },
  1358. ]);
  1359. assert(
  1360. sensorHeat.xLabels.join(",") === "09号,16号",
  1361. "数据感知热力图风机名应带号",
  1362. );
  1363. assert(sensorHeat.yLabels[0] === "功率异常", "数据感知热力图首行应为功率异常");
  1364. assert(
  1365. sensorHeat.yLabels.includes("偏航角、扭缆角、偏航误差等偏航"),
  1366. "数据感知热力图应含偏航角、扭缆角、偏航误差等偏航",
  1367. );
  1368. assert(sensorHeat.yLabels.length === 8, "数据感知热力图应为8行");
  1369. assert(sensorHeat.matrix[0][0] === 12, "数据感知热力图应按占比×100");
  1370. assert(chunkHeatmapData(sensorHeat, 1).length === 2, "热力图应按风机分片");
  1371. const detectorHeat = buildDetectorHeatmapData([
  1372. {
  1373. engineName: "01",
  1374. model2YawYawerrorRatio: 0.9247,
  1375. model4CtrlparamDeloadRatio: 0,
  1376. },
  1377. ]);
  1378. assert(
  1379. detectorHeat.yLabels.includes("静态偏航误差"),
  1380. "功能诊断热力图应含静态偏航误差",
  1381. );
  1382. assert(
  1383. detectorHeat.matrix[detectorHeat.yLabels.indexOf("静态偏航误差")][0] ===
  1384. 92.47,
  1385. "功能诊断热力图占比换算错误",
  1386. );
  1387. const heatOption = buildReportHeatmapOption(
  1388. sensorHeat,
  1389. "各机组数据感知异常分布",
  1390. );
  1391. assert(heatOption.series[0].type === "heatmap", "图3-1应为热力图");
  1392. assert(
  1393. heatOption.series[0].data[0]?.name?.includes("%"),
  1394. "报告热力图单元格应预置占比文本",
  1395. );
  1396. assert(optionHasData(heatOption), "数据感知热力图应视为有数据");
  1397. const sensorRadar = buildSensorRadarData(
  1398. { sensorAnomalyPowerCount: 2, sensorAnomalyWindCount: 1 },
  1399. 20,
  1400. );
  1401. assert(sensorRadar.values[0] === 2, "数据感知雷达图应映射异常台数");
  1402. const detectorRadar = buildDetectorRadarData(
  1403. { model1Count: 3, model2Count: 1 },
  1404. 20,
  1405. );
  1406. const sensorRadarOption = buildAnomalyRadarOption(
  1407. sensorRadar,
  1408. "数据感知异常台数",
  1409. );
  1410. assert(sensorRadarOption.series[0].type === "radar", "概览区应输出雷达图");
  1411. assert(
  1412. Number(sensorRadarOption.series[0].lineStyle?.width) >= 2,
  1413. "雷达图 series 应配置连线宽度",
  1414. );
  1415. assert(
  1416. sensorRadarOption.radar.indicator[0].name.includes("2台"),
  1417. "雷达图维度应标注台数",
  1418. );
  1419. console.log("axis/watchlist/placeholder rules ok");
  1420. await initChartService();
  1421. const imageBufferMap = {};
  1422. const sensorRadarBuf = await renderEchartsOption(sensorRadarOption, {
  1423. width: 520,
  1424. height: 360,
  1425. });
  1426. registerImageBuffer(imageBufferMap, "anomaly_sensor_radar", sensorRadarBuf);
  1427. const detectorRadarBuf = await renderEchartsOption(
  1428. buildAnomalyRadarOption(detectorRadar, "功能诊断异常台数"),
  1429. { width: 520, height: 360 },
  1430. );
  1431. registerImageBuffer(imageBufferMap, "anomaly_detector_radar", detectorRadarBuf);
  1432. const sensorHeatBuf = await renderEchartsOption(heatOption, {
  1433. width: 920,
  1434. height: 420,
  1435. });
  1436. registerImageBuffer(imageBufferMap, "anomaly_sensor_dist", sensorHeatBuf);
  1437. const detectorHeatBuf = await renderEchartsOption(
  1438. buildReportHeatmapOption(detectorHeat, "各机组功能诊断异常分布"),
  1439. { width: 920, height: 560 },
  1440. );
  1441. registerImageBuffer(imageBufferMap, "anomaly_detector_dist", detectorHeatBuf);
  1442. const farmPlots = buildFarmPlotJsons(
  1443. [
  1444. { engineName: "111", plotJson: samplePlot("111") },
  1445. { engineName: "112", plotJson: samplePlot("112") },
  1446. ],
  1447. "yaw_static",
  1448. "全场静态偏航分析检测汇总",
  1449. );
  1450. const farmLabels = (farmPlots[0]?.data || []).map((row) => row.label);
  1451. if (farmLabels.join(",") !== "111,112") {
  1452. throw new Error(`全场偏航分色失败: ${farmLabels.join(",")}`);
  1453. }
  1454. if (
  1455. (farmPlots[0]?.data || []).some((row) => /均值/.test(row.originalLabel || ""))
  1456. ) {
  1457. throw new Error("全场偏航仍包含 2h/12h 均值");
  1458. }
  1459. const farmBuf = await renderEchartsOption(
  1460. buildDetectorPlotOption(
  1461. farmPlots[0],
  1462. farmPlots[0].title,
  1463. "anomalyStaticyawPO",
  1464. ),
  1465. { width: 820, height: 420 },
  1466. );
  1467. registerImageBuffer(imageBufferMap, "anomaly_farm_yaw_static", farmBuf);
  1468. const turbineBuf = await renderEchartsOption(
  1469. buildDetectorPlotOption(samplePlot("111"), "111 静态偏航分析"),
  1470. { width: 760, height: 400 },
  1471. );
  1472. registerImageBuffer(imageBufferMap, "anomaly_111_yaw_static", turbineBuf);
  1473. const denseCount = 4500;
  1474. const denseTimes = Array.from({ length: denseCount }, (_, i) => {
  1475. const t = new Date("2026-08-01T00:00:00").getTime() + i * 60000;
  1476. const pad = (n) => String(n).padStart(2, "0");
  1477. const d = new Date(t);
  1478. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
  1479. d.getHours(),
  1480. )}:${pad(d.getMinutes())}:00`;
  1481. });
  1482. const denseOption = buildDetectorPlotOption(
  1483. {
  1484. title: "DT01 电能质量分析",
  1485. xaxis: "时间",
  1486. yaxis: "电流不平衡度",
  1487. data: [
  1488. {
  1489. label: "电流不平衡度",
  1490. mode: "markers",
  1491. timeData: denseTimes,
  1492. yData: Array.from(
  1493. { length: denseCount },
  1494. (_, i) => 0.08 + (i % 20) / 200,
  1495. ),
  1496. },
  1497. ],
  1498. },
  1499. "DT01 电能质量分析",
  1500. "anomalyPowerqualityPO",
  1501. );
  1502. assert(denseOption.series[0].large === false, "密点单机图不应使用 large");
  1503. assert(
  1504. Number(denseOption.series[0].symbolSize) >= 8,
  1505. "密点单机图 symbolSize 过小",
  1506. );
  1507. const denseBuf = await renderEchartsOption(denseOption, {
  1508. width: 760,
  1509. height: 420,
  1510. });
  1511. assert(denseBuf.length > 10000, "密点单机图截图过小,可能未画出数据");
  1512. const renderData = {
  1513. reportNo: "AD-SMOKE-20260824",
  1514. Province: "测试省",
  1515. Wind_farm: "烟雾风场",
  1516. Year_now: "2026",
  1517. Month_now: "08",
  1518. machineTypeCode: "WD",
  1519. turbine_count: "2",
  1520. anomaly_turbine_count: "1",
  1521. total_anomaly_points: "3",
  1522. anomaly_rate: "50.00",
  1523. Overview_of_the_Wind_Farm: "烟雾风场用于模板渲染校验。",
  1524. target_date: "2026-08-24",
  1525. anomaly_module_count: "1",
  1526. main_problem_modules: "偏航与扭缆",
  1527. top_problem_description: "静态偏航分析异常点数最多(3)",
  1528. sensorAnomalyRows: [
  1529. {
  1530. turbine_name: "111",
  1531. sensor_anomaly_type: "风速异常",
  1532. anomaly_points: "1",
  1533. ratio: "—",
  1534. },
  1535. ],
  1536. detectorSummaryRows: [
  1537. {
  1538. detector_name: "静态偏航分析",
  1539. module_name: "偏航与扭缆",
  1540. data_granularity: "秒级",
  1541. anomaly_turbines: "1",
  1542. anomaly_points: "3",
  1543. avg_anomaly_rate: "12.00%",
  1544. },
  1545. ],
  1546. anomalySummaryRows: [
  1547. {
  1548. turbine_name: "111",
  1549. anomaly_detector_count: "1",
  1550. anomaly_points: "3",
  1551. anomaly_rate: "12.00%",
  1552. main_anomaly_type: "静态偏航分析",
  1553. },
  1554. ],
  1555. priorityList: "111",
  1556. keyTurbineLoop: [
  1557. {
  1558. turbine_name: "111",
  1559. turbineDetailRows: [
  1560. {
  1561. detector_name: "静态偏航分析",
  1562. anomaly_points: "3",
  1563. anomaly_rate: "12.00%",
  1564. conclusion: "建议结合现场复核",
  1565. },
  1566. ],
  1567. "zn-techcn-replace-tags-key_turbine-generalFiles": [
  1568. { image: "anomaly_111_yaw_static" },
  1569. ],
  1570. },
  1571. ],
  1572. conclusionRows: [
  1573. {
  1574. index: "1",
  1575. problem_type: "静态偏航分析",
  1576. turbine_names: "111",
  1577. anomaly_points: "3",
  1578. risk_level: "P2",
  1579. suggestion: "核对偏航角越限情况。",
  1580. },
  1581. ],
  1582. "zn-techcn-replace-tags-anomaly_overview-generalFiles": [
  1583. { image: "anomaly_sensor_radar" },
  1584. { image: "anomaly_detector_radar" },
  1585. ],
  1586. "zn-techcn-replace-tags-data_sensor_anomaly-generalFiles": [
  1587. { image: "anomaly_sensor_dist" },
  1588. { image: "anomaly_detector_dist" },
  1589. ],
  1590. "zn-techcn-replace-tags-key_turbine-generalFiles": [],
  1591. };
  1592. DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => {
  1593. if (cfg.templateKey === "yaw_static") {
  1594. Object.assign(
  1595. renderData,
  1596. buildDetectorSectionPayload(cfg, {
  1597. farmImages: [{ image: "anomaly_farm_yaw_static" }],
  1598. turbineImages: [{ image: "anomaly_111_yaw_static" }],
  1599. rows: [
  1600. {
  1601. turbine_name: "111",
  1602. anomaly_points: "3",
  1603. anomaly_rate: "12.00%",
  1604. comment: "功能诊断异常",
  1605. },
  1606. ],
  1607. anomalyPoints: 3,
  1608. anomalyRate: 0.12,
  1609. anomalyTurbines: 1,
  1610. }),
  1611. );
  1612. return;
  1613. }
  1614. const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
  1615. const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
  1616. renderData[`show-${fileTag}`] = [];
  1617. renderData[farmTag] = [];
  1618. renderData[fileTag] = [];
  1619. renderData[`${cfg.templateKey}Rows`] = [];
  1620. });
  1621. const buffer = await renderDocxReport({
  1622. templateName: "异常检测数据分析报告模板(大唐版).docx",
  1623. renderData,
  1624. imageBufferMap,
  1625. });
  1626. const outDir = path.join(__dirname, "../templates");
  1627. fs.mkdirSync(outDir, { recursive: true });
  1628. const outPath = path.join(outDir, "_smoke_anomaly_report.docx");
  1629. fs.writeFileSync(outPath, buffer);
  1630. console.log("smoke anomaly report written:", outPath, "bytes=", buffer.length);
  1631. await shutdownChartService();