smoke-anomaly-report.mjs 92 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184
  1. import fs from "fs";
  2. import path from "path";
  3. import { fileURLToPath } from "url";
  4. import PizZip from "pizzip";
  5. import {
  6. registerImageBuffer,
  7. renderDocxReport,
  8. patchAnomalyTemplateXml,
  9. chunkDetailImageRows,
  10. } from "../src/server/reportService/docxReportBuilder.js";
  11. import {
  12. buildDetectorSectionPayload,
  13. pickKeyTurbines,
  14. buildSensorHeatmapData,
  15. buildDataCompletenessHeatmapData,
  16. buildDetectorHeatmapData,
  17. buildSensorRadarData,
  18. buildDetectorRadarData,
  19. chunkHeatmapData,
  20. buildSensorAnomalyRows,
  21. mapAnomalyCoverFields,
  22. summarizeTurbineAnomalyRate,
  23. buildAnomalySummaryRows,
  24. buildConclusionRows,
  25. formatSensorTypeText,
  26. } from "../src/server/reportService/anomalyReportMapper.js";
  27. import {
  28. DETECTOR_TEMPLATE_CONFIG,
  29. buildAnomalyRadarOption,
  30. buildDetectorPlotOption,
  31. buildEmptyPlaceholderOption,
  32. buildFarmPlotJsons,
  33. buildCompletenessHeatmapOption,
  34. buildReportHeatmapOption,
  35. listDetectorPlotOptions,
  36. listFarmPlotOptionsFromTurbines,
  37. listFarmChartOptions,
  38. optionHasData,
  39. plotJsonHasData,
  40. turbinePlotsToFarmJson,
  41. } from "../src/server/reportService/anomalyChartBuilder.js";
  42. import { renderEchartsOption } from "../src/server/reportService/echartsRenderer.js";
  43. import {
  44. initChartService,
  45. shutdownChartService,
  46. } from "../src/server/utils/chartService/index.js";
  47. import {
  48. diagnosticFarmSeriesColor,
  49. diagnosticFarmStyle,
  50. diagnosticSeriesColor,
  51. diagnosticSeriesSymbol,
  52. diagnosticSingleSeriesZ,
  53. DIAGNOSTIC_COLORS,
  54. PITCH_MIN_REPORT_COUNT_COLORS,
  55. PITCH_MIN_REPORT_COLOR_STOPS,
  56. PAGE_SHUTDOWN_COLORS,
  57. } from "../shared/anomalyDiagnosticVisual.mjs";
  58. import {
  59. farmAggregateToPanels,
  60. formatReportValueAxisLabel,
  61. resolvePanelSubtitle,
  62. } from "../shared/anomalyDetectorPlotAdapter.mjs";
  63. import {
  64. fillFarmPanelsToRule,
  65. getFarmPanelRule,
  66. } from "../shared/anomalyFarmChartRules.mjs";
  67. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  68. function samplePlot(labelPrefix = "") {
  69. return {
  70. title: `${labelPrefix}偏航`,
  71. xaxis: "时间",
  72. yaxis: "偏移 (°)",
  73. data: [
  74. {
  75. label: "正常偏航角",
  76. mode: "markers",
  77. timeData: [
  78. "2026-07-14 04:00:00",
  79. "2026-07-14 08:00:00",
  80. "2026-07-14 12:00:00",
  81. ],
  82. yData: [180, 200, 190],
  83. },
  84. {
  85. label: "12h均值",
  86. mode: "lines",
  87. timeData: [
  88. "2026-07-14 04:00:00",
  89. "2026-07-14 08:00:00",
  90. "2026-07-14 12:00:00",
  91. ],
  92. yData: [185, 198, 120],
  93. },
  94. ],
  95. };
  96. }
  97. function assert(condition, message) {
  98. if (!condition) throw new Error(message);
  99. }
  100. /** 图例项可能是字符串或 { name, itemStyle } 对象,统一取名称 */
  101. function legendNames(option) {
  102. const data = option?.legend?.data || [];
  103. return data.map((item) =>
  104. item && typeof item === "object" ? item.name : item,
  105. );
  106. }
  107. const headerCover = mapAnomalyCoverFields({
  108. fieldCode: "SVLgVZfi",
  109. fieldName: "新疆鄂能风电场",
  110. fieldMeta: { provinceName: "大唐新疆公司" },
  111. modelList: [],
  112. });
  113. assert(
  114. headerCover.Province === "新疆",
  115. `页眉 Province 应去重为「新疆」,实际为「${headerCover.Province}」`,
  116. );
  117. assert(
  118. headerCover.Wind_farm === "鄂能风电场",
  119. `页眉 Wind_farm 应去掉省份前缀并保留风电场,实际为「${headerCover.Wind_farm}」`,
  120. );
  121. assert(
  122. headerCover.Overview_of_the_Wind_Farm ===
  123. "新疆鄂能风电场位于大唐新疆公司,机型—,共安装0台风机",
  124. `项目概要不应重复公司名或多余句号,实际为「${headerCover.Overview_of_the_Wind_Farm}」`,
  125. );
  126. const duplicateLocationCover = mapAnomalyCoverFields({
  127. fieldCode: "SVLgVZfi",
  128. fieldName: "新疆鄂能风电场",
  129. fieldMeta: {
  130. provinceName: "大唐新疆公司",
  131. cityName: "大唐新疆公司",
  132. },
  133. modelList: [{ machineTypeCode: "GW82-1500" }, { machineTypeCode: "GW82-1500" }],
  134. });
  135. assert(
  136. duplicateLocationCover.Overview_of_the_Wind_Farm ===
  137. "新疆鄂能风电场位于大唐新疆公司,机型GW82-1500,共安装2台风机",
  138. `省/市同名时应去重,实际为「${duplicateLocationCover.Overview_of_the_Wind_Farm}」`,
  139. );
  140. assert(
  141. !duplicateLocationCover.Overview_of_the_Wind_Farm.includes("。"),
  142. "项目概要正文不应自带句号(模板已有)",
  143. );
  144. const thirteenCover = mapAnomalyCoverFields({
  145. fieldCode: "FIELD13",
  146. fieldName: "新疆十三师风电场",
  147. fieldMeta: { provinceName: "大唐新疆能源有限公司" },
  148. overview: { fieldName: "鄂能风电场" },
  149. modelList: [],
  150. });
  151. assert(
  152. thirteenCover.Province === "新疆" &&
  153. thirteenCover.Wind_farm === "十三师风电场" &&
  154. thirteenCover.Company === "大唐新疆能源有限公司",
  155. `选中风场应优先用于页眉,实际 Province=${thirteenCover.Province} Wind_farm=${thirteenCover.Wind_farm}`,
  156. );
  157. assert(
  158. !`${headerCover.Province}${headerCover.Wind_farm}`.includes("大唐大唐"),
  159. "页眉不应出现「大唐大唐」重复前缀",
  160. );
  161. const conclusionSample = buildConclusionRows({
  162. yaw_error: {
  163. anomalyTurbines: 3,
  164. anomalyPoints: 12,
  165. turbineNames: ["25", "4", "10", "4号"],
  166. },
  167. yaw_static: {
  168. anomalyTurbines: 0,
  169. anomalyPoints: 0,
  170. turbineNames: [],
  171. },
  172. });
  173. assert(conclusionSample.length === 1, "无异常检测器不应进入检测结论表");
  174. assert(
  175. conclusionSample[0].problem_desc.startsWith("主要表现为") &&
  176. conclusionSample[0].problem_desc.includes("静态偏差"),
  177. `问题描述应从「主要表现为」截取,实际为「${conclusionSample[0].problem_desc}」`,
  178. );
  179. assert(
  180. conclusionSample[0].suggestion.startsWith("建议"),
  181. `建议意见应使用排查建议,实际为「${conclusionSample[0].suggestion}」`,
  182. );
  183. assert(
  184. conclusionSample[0].problem_nature === "偏航与扭缆系统异常",
  185. `问题性质应为模块大标题,实际为「${conclusionSample[0].problem_nature}」`,
  186. );
  187. assert(
  188. conclusionSample[0].turbine_names === "#04、#10、#25",
  189. `问题机组应统一罗列,实际为「${conclusionSample[0].turbine_names}」`,
  190. );
  191. const conclusionWithoutDeload = buildConclusionRows({
  192. ctrl_deload: {
  193. anomalyTurbines: 20,
  194. anomalyPoints: 80,
  195. turbineNames: ["01", "02", "03"],
  196. },
  197. yaw_error: {
  198. anomalyTurbines: 1,
  199. anomalyPoints: 2,
  200. turbineNames: ["05"],
  201. },
  202. });
  203. assert(
  204. conclusionWithoutDeload.length === 1 &&
  205. !conclusionWithoutDeload.some((row) =>
  206. `${row.problem_desc}${row.suggestion}`.includes("降载"),
  207. ),
  208. "表10-1 检测结论不应统计降载判定",
  209. );
  210. assert(
  211. !plotJsonHasData({ data: [] }) && !plotJsonHasData(null),
  212. "空 JSON 应判定为无图表数据",
  213. );
  214. assert(
  215. plotJsonHasData({
  216. data: [{ label: "散点", xData: [1], yData: [2] }],
  217. }),
  218. "含坐标点的 JSON 应判定为有数据",
  219. );
  220. const templateSource = path.join(
  221. __dirname,
  222. "../src/public/file/异常检测数据分析报告模板(大唐版)_修订版_人工对齐版_业务化.docx",
  223. );
  224. if (fs.existsSync(templateSource)) {
  225. const sourceXml = new PizZip(fs.readFileSync(templateSource))
  226. .file("word/document.xml")
  227. .asText();
  228. const patchedXml = patchAnomalyTemplateXml(sourceXml);
  229. const tocParas = patchedXml.match(/<w:p[\s\S]*?<\/w:p>/g) || [];
  230. let tocHas42 = false;
  231. let tocHasFigure = false;
  232. let tocHasModuleTag = false;
  233. tocParas.slice(0, 70).forEach((paragraph) => {
  234. const plain = paragraph.replace(/<[^>]+>/g, "").trim();
  235. const isToc = /w:pStyle w:val="(?:26|29|30|31)"/.test(paragraph);
  236. if (!isToc) return;
  237. if (/^4\.2 按功能诊断项统计/.test(plain)) tocHas42 = true;
  238. if (/^图[34]-[12]/.test(plain)) tocHasFigure = true;
  239. if (/show_module_/.test(plain)) tocHasModuleTag = true;
  240. });
  241. assert(tocHas42, "目录应保留 4.2 按功能诊断项统计 条目");
  242. assert(!tocHasFigure, "目录中不应出现图3/图4 图题");
  243. assert(!tocHasModuleTag, "目录中不应插入模块条件标签");
  244. const detectorLoopOpen =
  245. patchedXml.indexOf(
  246. "{#zn-techcn-replace-tags-data_detector_anomaly-generalFiles}",
  247. );
  248. const detectorCaption = patchedXml.indexOf(
  249. "{figure_caption}",
  250. detectorLoopOpen,
  251. );
  252. const detectorImage = patchedXml.indexOf("{%image}", detectorCaption);
  253. const detectorLoopClose = patchedXml.indexOf(
  254. "{/zn-techcn-replace-tags-data_detector_anomaly-generalFiles}",
  255. detectorImage,
  256. );
  257. assert(
  258. detectorLoopOpen >= 0 &&
  259. detectorLoopOpen < detectorCaption &&
  260. detectorCaption < detectorImage &&
  261. detectorImage < detectorLoopClose,
  262. "第4章概览应按图题、图片逐项循环",
  263. );
  264. assert(
  265. patchAnomalyTemplateXml(patchedXml) === patchedXml,
  266. "异常模板补丁应可重复执行",
  267. );
  268. const detailGrid = patchedXml.indexOf(
  269. "{#zn-techcn-replace-tags-wind_power_curve-generalFiles}",
  270. );
  271. const detailImage = patchedXml.indexOf("{%detail_image}", detailGrid);
  272. const detailClose = patchedXml.indexOf(
  273. "{/zn-techcn-replace-tags-wind_power_curve-generalFiles}",
  274. detailImage,
  275. );
  276. assert(
  277. detailGrid >= 0 && detailGrid < detailImage && detailImage < detailClose,
  278. "分图应排成一行三列",
  279. );
  280. assert(
  281. patchedXml.includes("{#detail_slot1}") &&
  282. patchedXml.includes("{#detail_slot2}") &&
  283. patchedXml.includes("{#detail_slot3}"),
  284. "分图应预留三列",
  285. );
  286. assert(
  287. patchedXml.includes(
  288. "{#zn-techcn-replace-tags-wind_power_curve-generalFiles}{#detail_slot1}",
  289. ) &&
  290. patchedXml.includes(
  291. "{/detail_slot3}{/zn-techcn-replace-tags-wind_power_curve-generalFiles}",
  292. ),
  293. "分图循环标签应与槽位标签同段,避免第一列下移",
  294. );
  295. const farmImage = patchedXml.indexOf(
  296. "{#zn-techcn-replace-tags-wind_power_curve-farmSummary}",
  297. );
  298. assert(
  299. farmImage >= 0 &&
  300. patchedXml.indexOf("{%image}", farmImage) > farmImage &&
  301. patchedXml.indexOf("{%image}", farmImage) <
  302. patchedXml.indexOf(
  303. "{/zn-techcn-replace-tags-wind_power_curve-farmSummary}",
  304. farmImage,
  305. ),
  306. "总图应保持一行一张",
  307. );
  308. const chunked = chunkDetailImageRows([
  309. { image: "a" },
  310. { image: "b" },
  311. { image: "c" },
  312. { image: "d" },
  313. ]);
  314. assert(
  315. chunked.length === 2 &&
  316. chunked[0].detail_slot3[0].detail_image === "c" &&
  317. chunked[1].detail_slot1[0].detail_image === "d" &&
  318. chunked[1].detail_slot2.length === 0,
  319. "分图应按 3 张一组,末行空位留白",
  320. );
  321. assert(
  322. patchedXml.includes("{#conclusionRows}") &&
  323. patchedXml.includes("{problem_desc}") &&
  324. patchedXml.includes("{problem_nature}") &&
  325. patchedXml.includes("{/detectorSummaryRows}"),
  326. "表10-1 检测结论应改为 conclusionRows 循环,表4-1 应闭合 detectorSummaryRows",
  327. );
  328. assert(
  329. (patchedXml.match(/\{sensor_anomaly_type\}/g) || []).length >= 16,
  330. "各检测器明细表应保留数据感知异常类型列",
  331. );
  332. assert(
  333. patchedXml.includes("{#show_module_wind}") &&
  334. patchedXml.includes("{/show_module_aero}"),
  335. "各检测模块章节应包一层 show_module 条件标签",
  336. );
  337. const patchedPlain = patchedXml.replace(/<[^>]+>/g, "");
  338. const patchedLoops = [
  339. ...new Set([...patchedPlain.matchAll(/\{#([^{}]+)\}/g)].map((m) => m[1])),
  340. ];
  341. const danglingLoops = patchedLoops.filter(
  342. (name) => !patchedPlain.includes(`{/${name}}`),
  343. );
  344. assert(
  345. danglingLoops.length === 0,
  346. `模板循环应全部闭合,未闭合:${danglingLoops.join("、")}`,
  347. );
  348. assert(
  349. patchedXml.includes("{/wind_power_curveRows}") &&
  350. patchedXml.includes("{/aero_tsr_windRows}"),
  351. "检测明细表循环应补齐结尾标签",
  352. );
  353. const patchedParas = patchedXml.match(/<w:p[\s\S]*?<\/w:p>/g) || [];
  354. patchedParas.slice(0, 70).forEach((paragraph) => {
  355. const plain = paragraph.replace(/<[^>]+>/g, "").trim();
  356. const isToc = /w:pStyle w:val="(?:26|29|30|31)"/.test(paragraph);
  357. if (isToc && /show_module_/.test(plain)) {
  358. throw new Error("目录 TOC 段落中仍含有 show_module 标签");
  359. }
  360. });
  361. }
  362. const scatterFarm = buildFarmPlotJsons(
  363. [
  364. {
  365. engineName: "A",
  366. plotJson: {
  367. xaxis: "风速",
  368. yaxis: "功率",
  369. data: [
  370. { label: "散点", mode: "markers", xData: [1, 2], yData: [3, 4] },
  371. { label: "上限", mode: "lines", xData: [1, 2], yData: [8, 9] },
  372. { label: "下限", mode: "lines", xData: [1, 2], yData: [1, 2] },
  373. {
  374. label: "参考功率曲线",
  375. mode: "lines",
  376. xData: [1, 2],
  377. yData: [5, 6],
  378. },
  379. ],
  380. },
  381. },
  382. {
  383. engineName: "B",
  384. plotJson: {
  385. xaxis: "风速",
  386. yaxis: "功率",
  387. data: [
  388. { label: "散点", mode: "markers", xData: [2, 3], yData: [4, 5] },
  389. {
  390. label: "参考功率曲线",
  391. mode: "lines",
  392. xData: [1, 2],
  393. yData: [5, 6],
  394. },
  395. ],
  396. },
  397. },
  398. ],
  399. "wind_power_scatter",
  400. "全场散点",
  401. );
  402. assert(
  403. scatterFarm[0].data.filter((row) => row.__shared).length === 1,
  404. "参考功率曲线应只保留一条",
  405. );
  406. assert(
  407. scatterFarm[0].data[scatterFarm[0].data.length - 1].__shared,
  408. "参考/合同功率曲线应放在图例最后",
  409. );
  410. assert(
  411. !scatterFarm[0].data.some((row) => /上限|下限/.test(row.label)),
  412. "散点总图不应含上下限",
  413. );
  414. assert(
  415. scatterFarm[0].data.some((row) => row.label === "A") &&
  416. scatterFarm[0].data.some((row) => row.label === "B"),
  417. "散点总图应按风机分系列",
  418. );
  419. const farmScatterOption = buildDetectorPlotOption(
  420. scatterFarm[0],
  421. scatterFarm[0].title,
  422. "anomalyScatterPO",
  423. );
  424. const farmScatterSeries = (farmScatterOption.series || []).filter(
  425. (item) => item.type === "scatter3D",
  426. );
  427. assert(farmScatterSeries.length > 0, "全场散点应输出 scatter3D 系列");
  428. assert(
  429. farmScatterSeries.every((item) => {
  430. const size = Number(item.symbolSize);
  431. return size >= 4 && size <= 6;
  432. }),
  433. "全场散点报告正常点应收小,避免糊成色块",
  434. );
  435. const emphasisScatter = buildDetectorPlotOption(
  436. {
  437. detector: "wind_power_scatter",
  438. chartType: "series",
  439. xaxis: "风速 (m/s)",
  440. yaxis: "有功功率 (kW)",
  441. turbines: Array.from({ length: 12 }, (_, index) => ({
  442. turbine: String(index + 1).padStart(2, "0"),
  443. xType: "number",
  444. xData: [4, 8, 12],
  445. yData: [200, 800, 1400],
  446. pointAnomaly: [false, true, false],
  447. })),
  448. },
  449. "全场风功率散点分析",
  450. "anomalyScatterPO",
  451. );
  452. const emphasisNormal = (emphasisScatter.series || []).find(
  453. (item) => item.type === "scatter3D" && /正常/.test(item.name),
  454. );
  455. const emphasisAnomaly = (emphasisScatter.series || []).find(
  456. (item) =>
  457. item.type === "scatter3D" &&
  458. /异常/.test(item.name) &&
  459. !/正常/.test(item.name),
  460. );
  461. assert(emphasisNormal && emphasisAnomaly, "全场散点应按点拆成正常/异常");
  462. assert(
  463. Number(emphasisAnomaly.symbolSize) - Number(emphasisNormal.symbolSize) === 2,
  464. "报告全场散点异常点只比正常点大一圈",
  465. );
  466. assert(
  467. Number(emphasisAnomaly.itemStyle?.opacity) >
  468. Number(emphasisNormal.itemStyle?.opacity),
  469. "报告全场散点异常点应比正常点更不透明",
  470. );
  471. assert(
  472. emphasisAnomaly.itemStyle?.color === "#FF1E1E",
  473. "报告全场散点异常点应为亮红",
  474. );
  475. assert(
  476. !emphasisAnomaly.itemStyle?.borderWidth,
  477. "报告全场散点异常点不描白边",
  478. );
  479. assert(farmScatterOption.grid3D, "风功率散点应输出 3D 图表");
  480. const singleScatterOption = buildDetectorPlotOption(
  481. {
  482. title: "07 风功率散点分析",
  483. xaxis: "风速 (m/s)",
  484. yaxis: "有功功率 (kW)",
  485. data: [
  486. {
  487. label: "正常运行 (271)",
  488. mode: "markers",
  489. xData: [6, 8, 10],
  490. yData: [400, 900, 1500],
  491. },
  492. {
  493. label: "近90日功率曲线",
  494. mode: "markers",
  495. xData: [3, 6, 9, 12, 15],
  496. yData: [0, 400, 1200, 1800, 1850],
  497. },
  498. ],
  499. },
  500. "07 风功率散点分析",
  501. "anomalyScatterPO",
  502. );
  503. const curveSeries = (singleScatterOption.series || []).find(
  504. (item) => item.name === "近90日功率曲线",
  505. );
  506. assert(curveSeries?.type === "line", "近90日功率曲线应渲染为折线而非散点");
  507. assert(curveSeries?.showSymbol === false, "近90日功率曲线折线不应显示散点符号");
  508. assert(
  509. diagnosticSeriesColor({ label: "正常运行 (271)" }) === DIAGNOSTIC_COLORS.normal &&
  510. diagnosticSeriesColor({ label: "异常点" }) === DIAGNOSTIC_COLORS.anomaly &&
  511. diagnosticSeriesColor({ label: "降载" }) === DIAGNOSTIC_COLORS.deload &&
  512. diagnosticSeriesColor({ label: "停机" }) === DIAGNOSTIC_COLORS.shutdown,
  513. "单机风功率散点应按正常蓝/异常红/降载黄/停机黑上色",
  514. );
  515. assert(
  516. diagnosticSeriesColor({ label: "累计降载率" }) !== DIAGNOSTIC_COLORS.deload,
  517. "累计降载率曲线不应被当成散点降载状态色",
  518. );
  519. // 页面深色底可换成浅灰;报告与浅色页必须保持 shutdown 深色
  520. assert(
  521. diagnosticSeriesColor({ label: "停机" }, 0, {
  522. pageTheme: true,
  523. darkTheme: true,
  524. }) === PAGE_SHUTDOWN_COLORS.dark &&
  525. diagnosticSeriesColor({ label: "停机" }, 0, {
  526. pageTheme: true,
  527. darkTheme: false,
  528. }) === DIAGNOSTIC_COLORS.shutdown &&
  529. diagnosticSeriesColor({ label: "停机" }) === DIAGNOSTIC_COLORS.shutdown,
  530. "停机点色仅在页面深色主题切换,报告保持 shutdown 深色",
  531. );
  532. assert(
  533. diagnosticSeriesColor({ label: "正常运行 (271)" }, 0, {
  534. pageTheme: true,
  535. darkTheme: true,
  536. }) === DIAGNOSTIC_COLORS.normal,
  537. "页面主题选项不应影响停机以外的状态色",
  538. );
  539. const singleCurveWithContract = buildDetectorPlotOption(
  540. {
  541. title: "07 功率曲线分析",
  542. xaxis: "风速 (m/s)",
  543. yaxis: "有功功率 (kW)",
  544. data: [
  545. {
  546. label: "近90天功率曲线",
  547. mode: "lines",
  548. xData: [3, 6, 9, 12],
  549. yData: [0, 400, 1200, 1500],
  550. },
  551. {
  552. label: "合同功率曲线",
  553. mode: "lines",
  554. xData: [3, 6, 9, 12],
  555. yData: [0, 500, 1300, 1500],
  556. },
  557. ],
  558. },
  559. "07 功率曲线分析",
  560. "anomalyPowercurvePO",
  561. );
  562. const singleContractSeries = (singleCurveWithContract.series || []).find(
  563. (item) => item.name === "合同功率曲线",
  564. );
  565. const singlePowerCurveSeries = (singleCurveWithContract.series || []).find(
  566. (item) => /近90/.test(item.name || ""),
  567. );
  568. assert(singleContractSeries, "单机功率曲线应含合同功率曲线");
  569. assert(
  570. Number(singleContractSeries.z) < Number(singlePowerCurveSeries?.z || 2),
  571. "单机合同功率曲线层级应低于近90天功率曲线",
  572. );
  573. assert(
  574. diagnosticSingleSeriesZ({ label: "合同功率曲线" }) <
  575. diagnosticSingleSeriesZ({ label: "正常运行" }),
  576. "单机合同功率曲线 z 应低于散点",
  577. );
  578. const singleStatusScatter = buildDetectorPlotOption(
  579. {
  580. title: "07 风功率散点分析",
  581. xaxis: "风速 (m/s)",
  582. yaxis: "有功功率 (kW)",
  583. data: [
  584. { label: "正常", mode: "markers", xData: [6], yData: [400] },
  585. { label: "异常", mode: "markers", xData: [8], yData: [500] },
  586. { label: "降载", mode: "markers", xData: [7], yData: [300] },
  587. { label: "停机", mode: "markers", xData: [5], yData: [0] },
  588. {
  589. label: "合同功率曲线",
  590. mode: "lines",
  591. xData: [3, 12],
  592. yData: [0, 1500],
  593. },
  594. ],
  595. },
  596. "07 风功率散点分析",
  597. "anomalyScatterPO",
  598. );
  599. const colorByName = Object.fromEntries(
  600. (singleStatusScatter.series || []).map((item) => [
  601. item.name,
  602. item.itemStyle?.color || item.lineStyle?.color,
  603. ]),
  604. );
  605. assert(
  606. String(colorByName["正常"] || "").startsWith(DIAGNOSTIC_COLORS.normal),
  607. "正常应为蓝色",
  608. );
  609. assert(
  610. String(colorByName["异常"] || "").startsWith("#FF1E1E"),
  611. "报告散点异常点应为亮红",
  612. );
  613. assert(
  614. String(colorByName["降载"] || "").startsWith(DIAGNOSTIC_COLORS.deload),
  615. "降载应为黄色",
  616. );
  617. assert(
  618. String(colorByName["停机"] || "").startsWith(DIAGNOSTIC_COLORS.shutdown),
  619. "停机应为黑色",
  620. );
  621. assert(
  622. Number(
  623. (singleStatusScatter.series || []).find((item) => item.name === "合同功率曲线")
  624. ?.z,
  625. ) <
  626. Number(
  627. (singleStatusScatter.series || []).find((item) => item.name === "正常")?.z,
  628. ),
  629. "单机散点图合同功率曲线应在散点下方",
  630. );
  631. const scatterFarmWith90d = buildFarmPlotJsons(
  632. [
  633. {
  634. engineName: "A",
  635. plotJson: {
  636. xaxis: "风速",
  637. yaxis: "功率",
  638. data: [
  639. { label: "散点", mode: "markers", xData: [1, 2], yData: [3, 4] },
  640. {
  641. label: "近90日功率曲线",
  642. mode: "markers",
  643. xData: [1, 2, 3],
  644. yData: [5, 6, 7],
  645. },
  646. ],
  647. },
  648. },
  649. {
  650. engineName: "B",
  651. plotJson: {
  652. xaxis: "风速",
  653. yaxis: "功率",
  654. data: [
  655. { label: "散点", mode: "markers", xData: [2, 3], yData: [4, 5] },
  656. {
  657. label: "近90日功率曲线",
  658. mode: "markers",
  659. xData: [1, 2, 3],
  660. yData: [5, 6, 7],
  661. },
  662. ],
  663. },
  664. },
  665. ],
  666. "wind_power_scatter",
  667. "全场散点",
  668. );
  669. assert(
  670. scatterFarmWith90d[0].data.filter((row) => row.__shared).length === 1,
  671. "近90日功率曲线全场应只保留一条",
  672. );
  673. assert(
  674. scatterFarmWith90d[0].data.some((row) => row.label === "近90日功率曲线"),
  675. "近90日功率曲线应出现在全场散点图例",
  676. );
  677. const qualityPanels = listDetectorPlotOptions(
  678. {
  679. panels: [
  680. {
  681. panelTitle: "功率因数 | 异常点: 0",
  682. xaxis: "时间",
  683. yaxis: "功率因数",
  684. data: [
  685. {
  686. label: "功率因数",
  687. mode: "markers",
  688. timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
  689. yData: [0.98, 0.97],
  690. },
  691. ],
  692. },
  693. {
  694. panelTitle: "电流不平衡度",
  695. xaxis: "时间",
  696. yaxis: "电流不平衡度",
  697. data: [
  698. {
  699. label: "电流不平衡度",
  700. mode: "markers",
  701. timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
  702. yData: [0.12, 0.15],
  703. },
  704. ],
  705. },
  706. ],
  707. },
  708. "DT01 电能质量分析",
  709. "anomalyPowerqualityPO",
  710. );
  711. assert(qualityPanels.length === 2, "电能质量单机图应按 panel 全部出图");
  712. assert(
  713. qualityPanels[1].yAxis.name === "电流不平衡度",
  714. "第二张单机图应使用对应 panel 的 Y 轴",
  715. );
  716. assert(
  717. (qualityPanels[1].series || []).some(
  718. (item) => Array.isArray(item.data) && item.data.length > 0,
  719. ),
  720. "单机图系列不能为空",
  721. );
  722. const pitchFarm = buildFarmPlotJsons(
  723. [
  724. {
  725. engineName: "101",
  726. plotJson: {
  727. panels: [
  728. {
  729. panelTitle: "桨距角时序",
  730. xaxis: "时间",
  731. yaxis: "桨距角",
  732. data: [
  733. {
  734. label: "桨叶 1",
  735. mode: "markers",
  736. timeData: ["2026-01-01 00:00"],
  737. yData: [1],
  738. },
  739. {
  740. label: "桨叶 2",
  741. mode: "lines",
  742. timeData: ["2026-01-01 00:00"],
  743. yData: [2],
  744. },
  745. {
  746. label: "桨叶 3",
  747. mode: "lines",
  748. timeData: ["2026-01-01 00:00"],
  749. yData: [3],
  750. },
  751. {
  752. label: "阈值参考",
  753. mode: "lines",
  754. timeData: ["2026-01-01 00:00"],
  755. yData: [4],
  756. },
  757. ],
  758. },
  759. {
  760. panelTitle: "桨距角差",
  761. xaxis: "时间",
  762. yaxis: "差值",
  763. data: [
  764. {
  765. label: "桨叶 1",
  766. mode: "markers",
  767. timeData: ["2026-01-01 00:00"],
  768. yData: [1],
  769. },
  770. ],
  771. },
  772. ],
  773. },
  774. },
  775. ],
  776. "pitch_regulation",
  777. "全场变桨一致性",
  778. );
  779. assert(pitchFarm.length === 2, "变桨一致性总图应为 2 张");
  780. assert(
  781. /各桨叶分风速段/.test(pitchFarm[0].title || ""),
  782. "变桨一致性第一张应为各桨叶分风速段中位数趋势",
  783. );
  784. assert(
  785. /极差/.test(pitchFarm[1].title || ""),
  786. "变桨一致性第二张应为桨叶极差时序及异常识别",
  787. );
  788. assert(
  789. !pitchFarm[0].data.some((row) => /阈值/.test(row.originalLabel || "")),
  790. "变桨一致性不应展示阈值参考",
  791. );
  792. const pitchFarmReportOptions = listFarmChartOptions(
  793. [
  794. {
  795. engineName: "101",
  796. plotJson: {
  797. panels: [
  798. {
  799. panelTitle: "桨距角时序",
  800. xaxis: "时间",
  801. yaxis: "桨距角",
  802. data: [
  803. {
  804. label: "桨叶 1",
  805. mode: "markers",
  806. timeData: ["2026-01-01 00:00"],
  807. yData: [1],
  808. },
  809. {
  810. label: "桨叶 2",
  811. mode: "lines",
  812. timeData: ["2026-01-01 00:00"],
  813. yData: [2],
  814. },
  815. {
  816. label: "桨叶 3",
  817. mode: "lines",
  818. timeData: ["2026-01-01 00:00"],
  819. yData: [3],
  820. },
  821. {
  822. label: "阈值参考",
  823. mode: "lines",
  824. timeData: ["2026-01-01 00:00"],
  825. yData: [4],
  826. },
  827. ],
  828. },
  829. {
  830. panelTitle: "桨距角差",
  831. xaxis: "时间",
  832. yaxis: "差值",
  833. data: [
  834. {
  835. label: "桨叶 1",
  836. mode: "markers",
  837. timeData: ["2026-01-01 00:00"],
  838. yData: [1],
  839. },
  840. ],
  841. },
  842. ],
  843. },
  844. },
  845. ],
  846. "全场变桨一致性",
  847. "anomalyPitchregulationPO",
  848. { templateKey: "pitch_regulation", farmMode: true },
  849. );
  850. assert(pitchFarmReportOptions.length === 2, "报告变桨一致性应出 2 张图");
  851. const deloadFarm = buildFarmPlotJsons(
  852. [
  853. {
  854. engineName: "101",
  855. plotJson: {
  856. panels: [
  857. {
  858. panelTitle: "风速",
  859. xaxis: "时间",
  860. yaxis: "风速",
  861. data: [{ label: "风速", timeData: ["t"], yData: [1] }],
  862. },
  863. {
  864. panelTitle: "有功功率时序",
  865. xaxis: "时间",
  866. yaxis: "有功功率",
  867. data: [
  868. { label: "正常", mode: "markers", timeData: ["t"], yData: [1] },
  869. { label: "异常", mode: "markers", timeData: ["t"], yData: [2] },
  870. ],
  871. },
  872. ],
  873. },
  874. },
  875. ],
  876. "ctrl_deload",
  877. "全场降载",
  878. );
  879. assert(deloadFarm.length === 1, "降载总图只出有功功率时序");
  880. assert(
  881. /有功功率/.test(deloadFarm[0].title + deloadFarm[0].yaxis),
  882. "降载总图应为有功功率",
  883. );
  884. assert(
  885. diagnosticFarmSeriesColor(
  886. { extra: { anomaly: false }, originalLabel: "正常", turbineIndex: 1 },
  887. 0,
  888. "ctrl_deload",
  889. ) === DIAGNOSTIC_COLORS.normal &&
  890. diagnosticFarmSeriesColor(
  891. { extra: { anomaly: false }, originalLabel: "异常", turbineIndex: 1 },
  892. 1,
  893. "ctrl_deload",
  894. ) === DIAGNOSTIC_COLORS.normal,
  895. "正常降载机组整机应统一蓝色",
  896. );
  897. assert(
  898. diagnosticFarmSeriesColor(
  899. { originalLabel: "正常", turbineIndex: 0 },
  900. 0,
  901. "pitch_coord",
  902. ) === DIAGNOSTIC_COLORS.normal,
  903. "变桨协调总图正常点应统一蓝色",
  904. );
  905. assert(
  906. diagnosticFarmSeriesColor(
  907. { originalLabel: "异常", turbineIndex: 0 },
  908. 0,
  909. "pitch_coord",
  910. ) === DIAGNOSTIC_COLORS.anomaly,
  911. "变桨协调总图异常点应统一红色",
  912. );
  913. assert(
  914. diagnosticFarmSeriesColor(
  915. { label: "07号 正常", originalLabel: "正常", turbineIndex: 0 },
  916. 0,
  917. "wind_power_scatter",
  918. ) === DIAGNOSTIC_COLORS.normal &&
  919. diagnosticFarmSeriesColor(
  920. { label: "07号 异常", originalLabel: "异常", turbineIndex: 0 },
  921. 0,
  922. "wind_power_scatter",
  923. ) === DIAGNOSTIC_COLORS.anomaly &&
  924. diagnosticFarmSeriesColor(
  925. { label: "08号 正常", originalLabel: "正常", turbineIndex: 1 },
  926. 1,
  927. "wind_power_curve",
  928. ) === DIAGNOSTIC_COLORS.normal &&
  929. diagnosticFarmSeriesColor(
  930. { label: "08号 异常", originalLabel: "异常", turbineIndex: 1 },
  931. 1,
  932. "wind_power_curve",
  933. ) === DIAGNOSTIC_COLORS.anomaly,
  934. "风功率曲线/散点 3D 总图应按正常蓝、异常红上色",
  935. );
  936. assert(
  937. diagnosticFarmSeriesColor(
  938. { extra: { anomaly: true }, originalLabel: "正常", turbineIndex: 3 },
  939. 0,
  940. "ctrl_deload",
  941. ) === DIAGNOSTIC_COLORS.anomaly &&
  942. diagnosticFarmSeriesColor(
  943. { extra: { anomaly: true }, originalLabel: "异常", turbineIndex: 3 },
  944. 1,
  945. "ctrl_deload",
  946. ) === DIAGNOSTIC_COLORS.anomaly,
  947. "异常降载机组整机应统一红色",
  948. );
  949. assert(
  950. diagnosticFarmSeriesColor(
  951. { originalLabel: "降载", engineName: "08号", turbineIndex: 0 },
  952. 0,
  953. "ctrl_deload",
  954. ) === DIAGNOSTIC_COLORS.anomaly &&
  955. diagnosticFarmSeriesColor(
  956. { originalLabel: "停机", engineName: "08号", turbineIndex: 0 },
  957. 0,
  958. "ctrl_deload",
  959. ) === DIAGNOSTIC_COLORS.anomaly,
  960. "全场降载总图残留降载/停机系列名应标红而非黄/黑",
  961. );
  962. assert(
  963. diagnosticFarmSeriesColor(
  964. { extra: { anomaly: true }, originalLabel: "桨叶 1", turbineIndex: 0 },
  965. 0,
  966. "pitch_regulation",
  967. ) === DIAGNOSTIC_COLORS.anomaly &&
  968. diagnosticFarmSeriesColor(
  969. { extra: { anomaly: false }, originalLabel: "桨叶 2", turbineIndex: 1 },
  970. 1,
  971. "pitch_regulation",
  972. ) === DIAGNOSTIC_COLORS.normal,
  973. "变桨一致性总图应按机组状态统一红/蓝",
  974. );
  975. assert(
  976. diagnosticFarmStyle(
  977. { extra: { anomaly: true }, originalLabel: "桨叶 1" },
  978. "pitch_regulation",
  979. ).z >
  980. diagnosticFarmStyle(
  981. { extra: { anomaly: false }, originalLabel: "桨叶 2" },
  982. "pitch_regulation",
  983. ).z,
  984. "变桨一致性异常机组应叠在正常机组之上",
  985. );
  986. assert(
  987. diagnosticFarmSeriesColor(
  988. { anomaly: true, originalLabel: "异常", turbineIndex: 0 },
  989. 0,
  990. "aero_cp",
  991. ) === DIAGNOSTIC_COLORS.anomaly &&
  992. diagnosticFarmSeriesColor(
  993. { anomaly: false, originalLabel: "正常", turbineIndex: 1 },
  994. 1,
  995. "aero_tsr",
  996. ) === DIAGNOSTIC_COLORS.normal &&
  997. diagnosticFarmSeriesColor(
  998. {
  999. anomaly: false,
  1000. extra: { anomaly: true },
  1001. originalLabel: "正常",
  1002. turbineIndex: 0,
  1003. },
  1004. 0,
  1005. "aero_cp",
  1006. ) === DIAGNOSTIC_COLORS.normal,
  1007. "气动总图应按 pointAnomaly 拆分后的点状态上色(行级优先于整机)",
  1008. );
  1009. assert(
  1010. diagnosticSeriesSymbol({ originalLabel: "正常" }) === "circle" &&
  1011. diagnosticSeriesSymbol({ originalLabel: "异常" }) === "circle" &&
  1012. diagnosticSeriesSymbol({ originalLabel: "降载" }) === "circle",
  1013. "降载总图正常/异常/降载点形应统一为圆形,靠颜色区分",
  1014. );
  1015. const deloadRateOption = listDetectorPlotOptions(
  1016. {
  1017. panels: [
  1018. {
  1019. panelTitle: "有功功率时序",
  1020. xaxis: "时间",
  1021. yaxis: "有功功率",
  1022. data: [
  1023. {
  1024. label: "正常运行",
  1025. mode: "markers",
  1026. timeData: ["2026-08-22 00:00:00", "2026-08-22 04:00:00"],
  1027. yData: [1000, 1100],
  1028. },
  1029. ],
  1030. },
  1031. {
  1032. panelTitle: "累计降载率随时间变化",
  1033. xaxis: "时间",
  1034. yaxis: "累计降载率 (%)",
  1035. data: [
  1036. {
  1037. label: "累计降载率",
  1038. mode: "markers",
  1039. timeData: [
  1040. "2026-08-22 00:00:00",
  1041. "2026-08-22 04:00:00",
  1042. "2026-08-22 08:00:00",
  1043. ],
  1044. yData: [14, 2, 3],
  1045. },
  1046. {
  1047. label: "正常上限 5%",
  1048. mode: "markers",
  1049. timeData: ["2026-08-22 00:00:00", "2026-08-22 08:00:00"],
  1050. yData: [5, 5],
  1051. },
  1052. {
  1053. label: "频繁阈值 20%",
  1054. mode: "markers",
  1055. timeData: ["2026-08-22 00:00:00"],
  1056. yData: [20],
  1057. },
  1058. ],
  1059. },
  1060. ],
  1061. },
  1062. "002 降载判定分析",
  1063. "anomalyDeloadPO",
  1064. { templateKey: "ctrl_deload" },
  1065. );
  1066. assert(deloadRateOption.length === 2, "单机降载应按 panel 出图");
  1067. const rateChart = deloadRateOption[1];
  1068. assert(
  1069. rateChart.series.every((item) => item.type === "line"),
  1070. "累计降载率应为折线图",
  1071. );
  1072. assert(
  1073. rateChart.series.find((item) => item.name === "累计降载率")?.lineStyle
  1074. .type === "solid",
  1075. "累计降载率应为实线",
  1076. );
  1077. assert(
  1078. rateChart.series.find((item) => /正常上限/.test(item.name))?.lineStyle
  1079. .type === "dashed",
  1080. "正常上限应为虚线",
  1081. );
  1082. assert(
  1083. rateChart.series.find((item) => /频繁阈值/.test(item.name))?.lineStyle
  1084. .type === "dashed",
  1085. "频繁阈值应为虚线",
  1086. );
  1087. assert(
  1088. (rateChart.series.find((item) => /频繁阈值/.test(item.name))?.data || [])
  1089. .length >= 2,
  1090. "阈值线应拉满时间轴",
  1091. );
  1092. const opFarm = buildFarmPlotJsons(
  1093. [
  1094. {
  1095. engineName: "101",
  1096. plotJson: {
  1097. panels: [
  1098. {
  1099. panelTitle: "投影",
  1100. xaxis: "x",
  1101. yaxis: "y",
  1102. data: [{ label: "簇", xData: [1], yData: [1] }],
  1103. },
  1104. {
  1105. panelTitle: "转速-功率散点",
  1106. xaxis: "转速",
  1107. yaxis: "功率",
  1108. data: [{ label: "散点", xData: [1], yData: [2] }],
  1109. },
  1110. ],
  1111. },
  1112. },
  1113. ],
  1114. "ctrl_op_state",
  1115. "全场运行状态",
  1116. );
  1117. assert(opFarm.length === 1, "运行状态总图只出一张");
  1118. assert(
  1119. /转速/.test(opFarm[0].title + opFarm[0].xaxis),
  1120. "运行状态总图应为转速-功率",
  1121. );
  1122. const pqFarm = buildFarmPlotJsons(
  1123. [
  1124. {
  1125. engineName: "101",
  1126. plotJson: {
  1127. panels: [
  1128. {
  1129. panelTitle: "三相电流不平衡度 | 异常点: 0",
  1130. xaxis: "时间",
  1131. yaxis: "电流不平衡度",
  1132. data: [{ label: "点", xData: [1], yData: [1] }],
  1133. },
  1134. {
  1135. panelTitle: "三相电压不平衡度 | 异常点: 0",
  1136. xaxis: "时间",
  1137. yaxis: "电压不平衡度",
  1138. data: [{ label: "点", xData: [1], yData: [1] }],
  1139. },
  1140. {
  1141. panelTitle: "功率因数 | 异常点: 0",
  1142. xaxis: "时间",
  1143. yaxis: "功率因数",
  1144. data: [{ label: "点", xData: [1], yData: [1] }],
  1145. },
  1146. ],
  1147. },
  1148. },
  1149. ],
  1150. "ctrl_power_quality",
  1151. "全场电能质量",
  1152. );
  1153. assert(pqFarm.length === 3, "电能质量总图应出三张");
  1154. assert(
  1155. pqFarm.length === getFarmPanelRule("ctrl_power_quality").count,
  1156. "电能质量图数必须与共享报告模板规则一致",
  1157. );
  1158. assert(
  1159. /电流不平衡/.test(pqFarm[0].title + pqFarm[0].yaxis),
  1160. "第一张应为三相电流不平衡度",
  1161. );
  1162. assert(
  1163. /电压不平衡/.test(pqFarm[1].title + pqFarm[1].yaxis),
  1164. "第二张应为三相电压不平衡度",
  1165. );
  1166. assert(
  1167. /功率因数/.test(pqFarm[2].title + pqFarm[2].yaxis),
  1168. "第三张应为功率因数",
  1169. );
  1170. function loadLocalFarmJson(name) {
  1171. const filePath = path.resolve(__dirname, `../../SVLgVZfi/${name}`);
  1172. if (!fs.existsSync(filePath)) return null;
  1173. return JSON.parse(fs.readFileSync(filePath, "utf8"));
  1174. }
  1175. function dummyFarmPanel(title, yaxis) {
  1176. return {
  1177. title,
  1178. xaxis: "x",
  1179. yaxis,
  1180. data: [{ label: "占位", xData: [1], yData: [1] }],
  1181. };
  1182. }
  1183. const localPitchFarm = loadLocalFarmJson("farm_pitch_regulation.json");
  1184. if (localPitchFarm) {
  1185. const overlay = farmAggregateToPanels(localPitchFarm, {
  1186. templateKey: "pitch_regulation",
  1187. });
  1188. const filled = fillFarmPanelsToRule(
  1189. overlay,
  1190. [
  1191. dummyFarmPanel("各桨叶分风速段中位数趋势", "桨距角 (°)"),
  1192. dummyFarmPanel("桨叶极差时序及异常识别", "桨叶极差 (°)"),
  1193. ],
  1194. "pitch_regulation",
  1195. );
  1196. assert(filled.length === 2, "变桨一致性页面总图应为 2 面");
  1197. assert(
  1198. /各桨叶分风速段/.test(filled[0].title),
  1199. "第一面应为各桨叶分风速段中位数趋势",
  1200. );
  1201. assert(/极差/.test(filled[1].title), "第二面应为桨叶极差时序及异常识别");
  1202. assert(filled[1].data.length > 1, "极差面应保留 farm JSON 真实叠加");
  1203. }
  1204. const localCoordFarm = loadLocalFarmJson("farm_pitch_coord.json");
  1205. if (localCoordFarm) {
  1206. const overlay = farmAggregateToPanels(localCoordFarm, {
  1207. templateKey: "pitch_coord",
  1208. });
  1209. const filled = fillFarmPanelsToRule(
  1210. overlay,
  1211. [
  1212. dummyFarmPanel("功率-转速散点(桨距角着色)", "有功功率 (kW)"),
  1213. dummyFarmPanel("桨距角-转速散点", "桨距角 (°)"),
  1214. ],
  1215. "pitch_coord",
  1216. );
  1217. assert(filled.length === 2, "变桨协调页面总图应为 2 面");
  1218. assert(/着色/.test(filled[0].title), "变桨协调第一面应为桨距角着色");
  1219. assert(
  1220. /桨距角-转速/.test(filled[1].title),
  1221. "变桨协调第二面应为桨距角-转速散点",
  1222. );
  1223. }
  1224. const localPqFarm = loadLocalFarmJson("farm_ctrl_power_quality.json");
  1225. if (localPqFarm) {
  1226. const overlay = farmAggregateToPanels(localPqFarm, {
  1227. templateKey: "ctrl_power_quality",
  1228. });
  1229. const filled = fillFarmPanelsToRule(
  1230. overlay,
  1231. [
  1232. dummyFarmPanel("三相电流不平衡度", "电流不平衡度"),
  1233. dummyFarmPanel("三相电压不平衡度", "电压不平衡度"),
  1234. dummyFarmPanel("功率因数", "功率因数"),
  1235. ],
  1236. "ctrl_power_quality",
  1237. );
  1238. assert(filled.length === 3, "电能质量页面总图应为 3 面");
  1239. assert(/电流不平衡/.test(filled[0].title), "电能质量第一面应为电流不平衡");
  1240. assert(/电压不平衡/.test(filled[1].title), "电能质量第二面应为电压不平衡");
  1241. assert(/功率因数/.test(filled[2].title), "电能质量第三面应为功率因数");
  1242. assert(filled[2].data.length > 1, "功率因数面应保留 farm JSON 真实叠加");
  1243. }
  1244. const rawPointCount = 9001;
  1245. const rawX = Array.from({ length: rawPointCount }, (_, index) => index);
  1246. const rawY = rawX.map((value) => value * 2);
  1247. const rawPointFarm = buildFarmPlotJsons(
  1248. [
  1249. {
  1250. engineName: "101",
  1251. plotJson: {
  1252. panels: [
  1253. {
  1254. panelTitle: "转速-功率散点",
  1255. xaxis: "转速",
  1256. yaxis: "功率",
  1257. data: [
  1258. { label: "散点", mode: "markers", xData: rawX, yData: rawY },
  1259. ],
  1260. },
  1261. ],
  1262. },
  1263. },
  1264. ],
  1265. "ctrl_op_state",
  1266. "全场运行状态",
  1267. );
  1268. assert(
  1269. rawPointFarm[0].data[0].xData.length === rawPointCount,
  1270. "全场图不得抽样原始数据点",
  1271. );
  1272. const rawPointOption = buildDetectorPlotOption(
  1273. rawPointFarm[0],
  1274. rawPointFarm[0].title,
  1275. "anomalyOperationPO",
  1276. );
  1277. assert(
  1278. rawPointOption.series[0].data.length === rawPointCount,
  1279. "报告图渲染不得抽样原始数据点",
  1280. );
  1281. console.log("farm chart rules ok");
  1282. const curveFarm = buildFarmPlotJsons(
  1283. [
  1284. {
  1285. engineName: "DT01",
  1286. plotJson: {
  1287. xaxis: "风速 (m/s)",
  1288. yaxis: "有功功率 (kW)",
  1289. data: [
  1290. {
  1291. label: "30天功率曲线",
  1292. mode: "lines",
  1293. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  1294. xData: [4, 12],
  1295. yData: [200, 2000],
  1296. },
  1297. {
  1298. label: "合同功率曲线",
  1299. mode: "lines",
  1300. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  1301. xData: [4, 12],
  1302. yData: [180, 2100],
  1303. },
  1304. ],
  1305. },
  1306. },
  1307. {
  1308. engineName: "DT02",
  1309. plotJson: {
  1310. xaxis: "风速 (m/s)",
  1311. yaxis: "有功功率 (kW)",
  1312. data: [
  1313. {
  1314. label: "30天功率曲线",
  1315. mode: "lines",
  1316. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  1317. xData: [5, 11],
  1318. yData: [300, 1900],
  1319. },
  1320. {
  1321. label: "合同功率曲线",
  1322. mode: "lines",
  1323. xData: [4, 12],
  1324. yData: [180, 2100],
  1325. },
  1326. ],
  1327. },
  1328. },
  1329. ],
  1330. "wind_power_curve",
  1331. "全场功率曲线",
  1332. );
  1333. assert(curveFarm.length === 1, "功率曲线总图应 1 张");
  1334. assert(
  1335. curveFarm[0].data.filter((row) => row.__shared).length === 1,
  1336. "合同功率曲线全场只保留一条",
  1337. );
  1338. assert(
  1339. curveFarm[0].data[curveFarm[0].data.length - 1].label === "合同功率曲线",
  1340. "合同功率曲线应在图例末尾",
  1341. );
  1342. const curveOption = buildDetectorPlotOption(
  1343. curveFarm[0],
  1344. curveFarm[0].title,
  1345. "anomalyPowercurvePO",
  1346. );
  1347. assert(curveOption.grid3D, "功率曲线应输出 3D 图表");
  1348. assert(curveOption.xAxis3D?.name === "风机名称", "3D X 轴应为风机名称");
  1349. assert(
  1350. curveOption.xAxis3D?._turbineLabelMeta?.labels &&
  1351. typeof curveOption.xAxis3D._turbineLabelMeta.labels === "object",
  1352. "功率曲线风机轴应带可序列化抽样标签,供报告渲染重建 formatter",
  1353. );
  1354. assert(/风速/.test(String(curveOption.yAxis3D?.name || "")), "3D Y 轴应为风速");
  1355. assert(/功率/.test(String(curveOption.zAxis3D?.name || "")), "3D Z 轴应为功率");
  1356. assert(
  1357. curveOption.series.some(
  1358. (item) =>
  1359. (item.type === "scatter3D" || item.type === "line3D") &&
  1360. (item.data?.[0]?.[1] === 4 || item.data?.[0]?.[1] === 5),
  1361. ),
  1362. "功率曲线 3D Y 轴应使用风速",
  1363. );
  1364. assert(
  1365. curveOption.series.some(
  1366. (item) => item.type === "line3D" && item.data?.[0]?.[0] > 0,
  1367. ),
  1368. "功率曲线各风机系列 X 轴应为机组序号",
  1369. );
  1370. assert(
  1371. !curveOption.series.some(
  1372. (item) =>
  1373. item.type === "scatter3D" && item.name && !/参考|合同/.test(item.name),
  1374. ),
  1375. "功率曲线风机数据不应为 scatter3D",
  1376. );
  1377. const farmWithContractJson = {
  1378. detector: "wind_power_scatter",
  1379. chartType: "series",
  1380. xaxis: "风速 (m/s)",
  1381. yaxis: "有功功率 (kW)",
  1382. turbines: [
  1383. {
  1384. turbine: "03",
  1385. anomaly: true,
  1386. xType: "number",
  1387. xData: [3.25, 8.25],
  1388. yData: [56.45, 243.89],
  1389. },
  1390. {
  1391. turbine: "06",
  1392. anomaly: false,
  1393. xType: "number",
  1394. xData: [3.25, 8.25],
  1395. yData: [61.4, 238.51],
  1396. },
  1397. ],
  1398. referenceCurve: {
  1399. label: "合同功率曲线",
  1400. xData: [0, 12, 25],
  1401. yData: [0, 595, 850],
  1402. mode: "lines",
  1403. },
  1404. };
  1405. const farmWithContract = farmAggregateToPanels(farmWithContractJson);
  1406. assert(
  1407. farmWithContract[0].data
  1408. .filter((row) => !row.__shared)
  1409. .every((row) => row.mode === "markers"),
  1410. "风功率散点总图风机数据应为散点",
  1411. );
  1412. assert(
  1413. farmWithContract[0].data.some(
  1414. (row) => row.__shared && row.label === "合同功率曲线" && row.mode === "lines",
  1415. ),
  1416. "总图应绘制 referenceCurve 合同功率曲线",
  1417. );
  1418. const contractFarmOption = buildDetectorPlotOption(
  1419. farmWithContractJson,
  1420. "全场风功率散点分析",
  1421. "anomalyScatterPO",
  1422. );
  1423. const contractSeries = (contractFarmOption.series || []).find(
  1424. (item) => item.name === "合同功率曲线",
  1425. );
  1426. const turbineScatter = (contractFarmOption.series || []).find(
  1427. (item) =>
  1428. item.name !== "合同功率曲线" &&
  1429. (item.type === "scatter3D" || item.type === "scatter"),
  1430. );
  1431. assert(contractSeries, "合同功率曲线应出现在总图中");
  1432. assert(
  1433. contractSeries.type === "line3D" || contractSeries.type === "line",
  1434. "合同功率曲线应为折线",
  1435. );
  1436. assert(turbineScatter, "风功率散点总图风机数据应为散点");
  1437. assert(
  1438. /#7C3AED|rgb\(124,\s*58,\s*237\)/i.test(
  1439. String(contractSeries.lineStyle?.color || ""),
  1440. ),
  1441. `合同功率曲线应与异常红区分,实际为 ${contractSeries.lineStyle?.color}`,
  1442. );
  1443. assert(
  1444. Number(contractSeries.lineStyle?.width) >= 3,
  1445. "合同功率曲线应比风机散点更醒目",
  1446. );
  1447. assert(contractSeries.lineStyle?.type === "solid", "合同功率曲线应为实线");
  1448. const misplacedX = buildDetectorPlotOption(
  1449. {
  1450. title: "DT01 功率曲线分析",
  1451. xaxis: "风速 (m/s)",
  1452. yaxis: "有功功率 (kW)",
  1453. data: [
  1454. {
  1455. label: "30天功率曲线",
  1456. mode: "lines",
  1457. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  1458. xData: [6, 10],
  1459. yData: [400, 1800],
  1460. },
  1461. ],
  1462. },
  1463. "DT01 功率曲线分析",
  1464. "anomalyPowercurvePO",
  1465. );
  1466. assert(misplacedX.series[0].data[0][0] === 6, "单机功率曲线应取 xData 风速");
  1467. assert(!misplacedX.grid3D, "单机功率曲线应保持 2D");
  1468. assert(misplacedX.xAxis?.type === "value", "单机功率曲线风速轴应为数值轴");
  1469. const pitchCoordFarm = buildFarmPlotJsons(
  1470. [
  1471. {
  1472. engineName: "DT40",
  1473. plotJson: {
  1474. xaxis: "转速 (r/min)",
  1475. yaxis: "有功功率 (kW)",
  1476. data: [
  1477. {
  1478. label: "散点",
  1479. mode: "lines",
  1480. timeData: ["2026-01-01 00:00:00", "2026-01-01 01:00:00"],
  1481. xData: [8, 12],
  1482. yData: [400, 1800],
  1483. },
  1484. ],
  1485. },
  1486. },
  1487. ],
  1488. "pitch_coord",
  1489. "全场变桨协调",
  1490. );
  1491. const pitchCoordOption = buildDetectorPlotOption(
  1492. pitchCoordFarm[0],
  1493. pitchCoordFarm[0].title,
  1494. "anomalyPitchcoordPO",
  1495. );
  1496. assert(pitchCoordOption.series[0].type === "scatter", "变桨协调应输出散点");
  1497. assert(pitchCoordOption.series[0].data[0][0] === 8, "变桨协调 X 轴应为转速");
  1498. assert(
  1499. Number(pitchCoordOption.series[0].symbolSize) >= 3 &&
  1500. Number(pitchCoordOption.series[0].symbolSize) <= 5,
  1501. "全场变桨协调报告散点应更小",
  1502. );
  1503. const rangedOpFarm = listFarmChartOptions(
  1504. [
  1505. {
  1506. engineName: "01",
  1507. plotJson: {
  1508. panels: [
  1509. {
  1510. panelTitle: "转速-功率散点图",
  1511. xaxis: "转速(r/min)",
  1512. yaxis: "有功功率(kW)",
  1513. xrange: [0, 20],
  1514. yrange: [0, 500],
  1515. data: [
  1516. {
  1517. label: "异常点",
  1518. mode: "markers",
  1519. xData: [12, 40],
  1520. yData: [200, 900],
  1521. },
  1522. ],
  1523. },
  1524. ],
  1525. },
  1526. },
  1527. ],
  1528. "全场运行状态分析",
  1529. "anomalyOperationPO",
  1530. { templateKey: "ctrl_op_state" },
  1531. );
  1532. assert(rangedOpFarm.length === 1, "运行状态总图应保留转速-功率散点");
  1533. assert(
  1534. rangedOpFarm[0].xAxis.min == null && rangedOpFarm[0].xAxis.max == null,
  1535. "运行状态总图横轴应自适应",
  1536. );
  1537. assert(
  1538. rangedOpFarm[0].yAxis.min == null && rangedOpFarm[0].yAxis.max == null,
  1539. "运行状态总图纵轴应自适应",
  1540. );
  1541. assert(
  1542. rangedOpFarm[0].xAxis.scale === true && rangedOpFarm[0].yAxis.scale === true,
  1543. "运行状态总图应按数据自适应缩放",
  1544. );
  1545. const rangedPitch = listDetectorPlotOptions(
  1546. {
  1547. detector_name: "pitch_coord",
  1548. panels: [
  1549. {
  1550. panelTitle: "功率-转速散点(桨距角着色)",
  1551. xaxis: "转速 (r/min)",
  1552. yaxis: "有功功率 (kW)",
  1553. xrange: [5, 18],
  1554. yrange: [0, 1500],
  1555. data: [
  1556. {
  1557. label: "异常点",
  1558. mode: "markers",
  1559. xData: [8, 30],
  1560. yData: [400, 2200],
  1561. },
  1562. ],
  1563. },
  1564. ],
  1565. },
  1566. "01 变桨-转速-功率协调分析",
  1567. "anomalyPitchcoordPO",
  1568. { templateKey: "pitch_coord" },
  1569. );
  1570. assert(
  1571. rangedPitch[0].xAxis.min === 1000 && rangedPitch[0].xAxis.max == null,
  1572. "变桨协调分图横轴应从 1000 起,上限自适应",
  1573. );
  1574. assert(
  1575. rangedPitch[0].yAxis.min === 0 && rangedPitch[0].yAxis.max === 1500,
  1576. "变桨协调分图纵轴仍按 yrange",
  1577. );
  1578. const pitchCoordFarmTwo = buildFarmPlotJsons(
  1579. [
  1580. {
  1581. engineName: "001",
  1582. plotJson: {
  1583. panels: [
  1584. {
  1585. panelTitle: "功率-转速散点(桨距角着色)",
  1586. xaxis: "转速 (r/min)",
  1587. yaxis: "有功功率 (kW)",
  1588. data: [
  1589. {
  1590. label: "正常数据",
  1591. mode: "markers",
  1592. xData: [8, 12],
  1593. yData: [400, 1800],
  1594. colorbar: [1, 8],
  1595. colorbarTitle: "桨距角 (°)",
  1596. },
  1597. {
  1598. label: "异常点",
  1599. mode: "markers",
  1600. xData: [10],
  1601. yData: [900],
  1602. colorbar: [12],
  1603. },
  1604. ],
  1605. },
  1606. {
  1607. panelTitle: "桨距角-转速散点",
  1608. xaxis: "转速 (r/min)",
  1609. yaxis: "桨距角 (°)",
  1610. data: [
  1611. {
  1612. label: "正常数据",
  1613. mode: "markers",
  1614. xData: [8, 12],
  1615. yData: [1, 8],
  1616. },
  1617. ],
  1618. },
  1619. ],
  1620. },
  1621. },
  1622. {
  1623. engineName: "005",
  1624. plotJson: {
  1625. panels: [
  1626. {
  1627. panelTitle: "功率-转速散点(桨距角着色)",
  1628. xaxis: "转速 (r/min)",
  1629. yaxis: "有功功率 (kW)",
  1630. data: [
  1631. {
  1632. label: "正常数据",
  1633. mode: "markers",
  1634. xData: [9, 11],
  1635. yData: [500, 1600],
  1636. colorbar: [2, 7],
  1637. colorbarTitle: "桨距角 (°)",
  1638. },
  1639. ],
  1640. },
  1641. {
  1642. panelTitle: "桨距角-转速散点",
  1643. xaxis: "转速 (r/min)",
  1644. yaxis: "桨距角 (°)",
  1645. data: [
  1646. {
  1647. label: "正常数据",
  1648. mode: "markers",
  1649. xData: [9, 11],
  1650. yData: [2, 7],
  1651. },
  1652. ],
  1653. },
  1654. ],
  1655. },
  1656. },
  1657. ],
  1658. "pitch_coord",
  1659. "全场变桨协调分析检测汇总",
  1660. );
  1661. assert(pitchCoordFarmTwo.length === 2, "变桨协调总图应为 2 张");
  1662. assert(
  1663. /功率-转速散点(桨距角着色)/.test(pitchCoordFarmTwo[0].title),
  1664. "第一张应为功率-转速散点(桨距角着色)",
  1665. );
  1666. assert(
  1667. /桨距角-转速散点/.test(pitchCoordFarmTwo[1].title),
  1668. "第二张应为桨距角-转速散点",
  1669. );
  1670. const pitchColorOption = buildDetectorPlotOption(
  1671. pitchCoordFarmTwo[0],
  1672. pitchCoordFarmTwo[0].title,
  1673. "anomalyPitchcoordPO",
  1674. );
  1675. assert(!pitchColorOption.visualMap, "功率-转速全场图应按状态色而非桨距角着色");
  1676. assert(
  1677. pitchColorOption.series.some((item) => /001/.test(item.name)) &&
  1678. pitchColorOption.series.some((item) => /005/.test(item.name)),
  1679. "功率-转速全场图应按机组保留系列",
  1680. );
  1681. assert(
  1682. pitchColorOption.series.reduce(
  1683. (sum, item) => sum + (Array.isArray(item.data) ? item.data.length : 0),
  1684. 0,
  1685. ) === 5,
  1686. "功率-转速全场图合并后不得丢失原始点",
  1687. );
  1688. const pitchSpeedOption = buildDetectorPlotOption(
  1689. pitchCoordFarmTwo[1],
  1690. pitchCoordFarmTwo[1].title,
  1691. "anomalyPitchcoordPO",
  1692. );
  1693. assert(
  1694. pitchSpeedOption.series.some((item) => /001/.test(item.name)) &&
  1695. pitchSpeedOption.series.some((item) => /005/.test(item.name)),
  1696. "桨距角-转速图应按机组保留系列",
  1697. );
  1698. const pitchStyleOption = listDetectorPlotOptions(
  1699. {
  1700. xaxis: "风速分箱中心值 (m/s)",
  1701. yaxis: "桨距角中位数 (°)",
  1702. data: [
  1703. {
  1704. label: "桨叶 1",
  1705. mode: "lines",
  1706. xData: [2, 6],
  1707. yData: [1, 2],
  1708. timeData: ["t1", "t2"],
  1709. },
  1710. {
  1711. label: "桨叶 2",
  1712. mode: "lines",
  1713. xData: [2, 6],
  1714. yData: [1.1, 2.1],
  1715. },
  1716. {
  1717. label: "桨叶 3",
  1718. mode: "lines",
  1719. xData: [2, 6],
  1720. yData: [1.2, 2.2],
  1721. },
  1722. ],
  1723. },
  1724. "DT01 变桨一致性分析",
  1725. "anomalyPitchregulationPO",
  1726. { templateKey: "pitch_regulation" },
  1727. )[0];
  1728. assert(pitchStyleOption.series.length === 3, "单机变桨一致性应绘出三支桨叶");
  1729. assert(pitchStyleOption.series[0].symbol === "rect", "桨叶1应为方形");
  1730. assert(pitchStyleOption.series[0].lineStyle.type === "dashed", "桨叶1应为虚线");
  1731. assert(pitchStyleOption.series[1].symbol === "circle", "桨叶2应为圆形");
  1732. assert(pitchStyleOption.series[1].lineStyle.type === "solid", "桨叶2应为实线");
  1733. assert(pitchStyleOption.series[2].symbol === "triangle", "桨叶3应为三角");
  1734. assert(pitchStyleOption.series[2].lineStyle.type === "dotted", "桨叶3应为点线");
  1735. assert(
  1736. pitchStyleOption.series[0].data[0][0] === 2,
  1737. "变桨一致性 X 轴应使用分箱风速",
  1738. );
  1739. const yawErrorOptions = listDetectorPlotOptions(
  1740. {
  1741. title: "静态偏航误差分析",
  1742. xaxis: "对风角度/偏航误差 (°)",
  1743. yaxis: "平均有功功率 (kW)",
  1744. data: [
  1745. {
  1746. label: "4.5-5.0 m/s",
  1747. mode: "lines+markers",
  1748. xData: [-2, 0, 4],
  1749. yData: [300, 420, 380],
  1750. },
  1751. {
  1752. label: "5.0-5.5 m/s",
  1753. mode: "lines+markers",
  1754. xData: [-1, 2, 5],
  1755. yData: [410, 500, 430],
  1756. },
  1757. ],
  1758. },
  1759. "01 静态偏航误差分析",
  1760. "anomalyYawErrorPO",
  1761. { templateKey: "yaw_error" },
  1762. );
  1763. assert(yawErrorOptions.length === 1, "偏航误差单机图应按 data[] 出一张");
  1764. assert(yawErrorOptions[0].series.length === 2, "偏航误差应按风速箱分系列");
  1765. assert(yawErrorOptions[0].xAxis.min === -20, "偏航误差单机图 X 轴最小应为 -20");
  1766. assert(yawErrorOptions[0].xAxis.max === 20, "偏航误差单机图 X 轴最大应为 20");
  1767. assert(
  1768. yawErrorOptions[0].series[0].markPoint?.data?.[0]?.coord?.[0] === 0,
  1769. "偏航误差应标注平均功率最大的对风角度",
  1770. );
  1771. const yawCountOptions = listDetectorPlotOptions(
  1772. {
  1773. title: "偏航次数检测(CW/CCW 跳变)",
  1774. xaxis: "时间",
  1775. yaxis: "状态 (0/1)",
  1776. data: [
  1777. {
  1778. label: "CW 偏航状态",
  1779. mode: "lines",
  1780. xData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  1781. yData: [0, 1],
  1782. timeData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  1783. },
  1784. {
  1785. label: "CCW 偏航状态",
  1786. mode: "lines",
  1787. xData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  1788. yData: [0, 0],
  1789. timeData: ["2026-09-11 00:00:00", "2026-09-11 00:01:00"],
  1790. },
  1791. ],
  1792. },
  1793. "01 偏航次数分析",
  1794. "anomalyYawCountPO",
  1795. { templateKey: "yaw_count" },
  1796. );
  1797. assert(yawCountOptions[0].yAxis.min === -0.05, "偏航次数 Y 轴应为 0/1");
  1798. assert(yawCountOptions[0].series.length === 2, "偏航次数应绘制 CW/CCW");
  1799. assert(yawCountOptions[0].xAxis.type === "time", "偏航次数横轴应为时间");
  1800. const pitchMinOptions = listDetectorPlotOptions(
  1801. {
  1802. title: "最小桨距角分布",
  1803. xaxis: "时间",
  1804. yaxis: "桨距角 (°)",
  1805. xrange: ["2026-09-05", "2026-09-11"],
  1806. yrange: [-1.0, 4.5],
  1807. data: [
  1808. {
  1809. label: "最小桨距角分布",
  1810. mode: "markers",
  1811. xData: ["2026-09-05", "2026-09-06"],
  1812. yData: [-0.5, 0.2],
  1813. colorbar: [120, 40],
  1814. colorbarTitle: "点数",
  1815. },
  1816. ],
  1817. },
  1818. "01 最小桨距角分析",
  1819. "anomalyMinpitchPO",
  1820. { templateKey: "pitch_min" },
  1821. );
  1822. assert(pitchMinOptions[0].series[0].type === "scatter", "最小桨距角应为散点");
  1823. assert(
  1824. typeof pitchMinOptions[0].series[0].symbolSize === "number",
  1825. "最小桨距角点径应统一,不再按点数缩放气泡",
  1826. );
  1827. {
  1828. const vm = pitchMinOptions[0].visualMap;
  1829. const colors = Array.isArray(vm) ? vm[0]?.inRange?.color : vm?.inRange?.color;
  1830. const step = (colors?.length || 1) - 1;
  1831. const stopsOk = PITCH_MIN_REPORT_COLOR_STOPS.every(([ratio, color]) => {
  1832. const index = Math.round(ratio * step);
  1833. return colors?.[index]?.toLowerCase() === color.toLowerCase();
  1834. });
  1835. assert(
  1836. stopsOk &&
  1837. colors?.[0]?.toLowerCase() ===
  1838. PITCH_MIN_REPORT_COLOR_STOPS[0][1].toLowerCase() &&
  1839. colors?.slice(-1)[0]?.toLowerCase() ===
  1840. PITCH_MIN_REPORT_COLOR_STOPS.at(-1)[1].toLowerCase(),
  1841. "最小桨距角报告色带应与 PITCH_MIN_REPORT_COLOR_STOPS 停靠一致",
  1842. );
  1843. }
  1844. const opStateOptions = listDetectorPlotOptions(
  1845. {
  1846. detector_name: "ctrl_op_state",
  1847. title: "运行状态综合异常检测",
  1848. panels: [
  1849. {
  1850. panelTitle: "转速-功率散点图",
  1851. xaxis: "转速(r/min)",
  1852. yaxis: "有功功率(kW)",
  1853. xrange: [0, 30],
  1854. yrange: [0, 800],
  1855. data: [
  1856. {
  1857. label: "正常数据",
  1858. mode: "markers",
  1859. xData: [12, 18],
  1860. yData: [320, 500],
  1861. },
  1862. ],
  1863. },
  1864. {
  1865. panelTitle: "PCA 降维投影",
  1866. xaxis: "PC1",
  1867. yaxis: "PC2",
  1868. componentDefinition: [
  1869. {
  1870. name: "PC1",
  1871. explainedVarianceRatio: 0.63,
  1872. loadings: [
  1873. {
  1874. feature: "p_active",
  1875. displayName: "z(功率)",
  1876. coefficient: 0.81,
  1877. standardized: true,
  1878. },
  1879. ],
  1880. },
  1881. ],
  1882. data: [
  1883. {
  1884. label: "投影点",
  1885. mode: "markers",
  1886. xData: [0.1],
  1887. yData: [0.2],
  1888. },
  1889. ],
  1890. },
  1891. ],
  1892. },
  1893. "01 运行状态分析",
  1894. "anomalyOperationPO",
  1895. { templateKey: "ctrl_op_state" },
  1896. );
  1897. assert(opStateOptions.length === 2, "运行状态单机图应按 panels[] 全部出图");
  1898. assert(
  1899. opStateOptions[0].xAxis.min == null && opStateOptions[0].xAxis.max == null,
  1900. "运行状态转速-功率分图横轴应自适应",
  1901. );
  1902. assert(
  1903. opStateOptions[0].yAxis.min == null && opStateOptions[0].yAxis.max == null,
  1904. "运行状态转速-功率分图纵轴应自适应",
  1905. );
  1906. assert(
  1907. opStateOptions[0].xAxis.scale === true && opStateOptions[0].yAxis.scale === true,
  1908. "运行状态分图应按数据自适应缩放",
  1909. );
  1910. assert(
  1911. opStateOptions[1].xAxis.min == null && opStateOptions[1].yAxis.min == null,
  1912. "运行状态没有 xrange/yrange 的分图应自适应",
  1913. );
  1914. assert(
  1915. opStateOptions[1].xAxis.scale === true && opStateOptions[1].yAxis.scale === true,
  1916. "运行状态缺省轴范围时应自适应缩放",
  1917. );
  1918. assert(
  1919. !String(opStateOptions[1].title?.subtext || "").includes("PC1"),
  1920. "componentDefinition 不应再被前端拼接为小标题",
  1921. );
  1922. assert(
  1923. resolvePanelSubtitle({ subtitle: "算法小标题" }, {}) === "算法小标题",
  1924. "小标题应仅透传 JSON subtitle 字段",
  1925. );
  1926. const farmCurveOptions = listDetectorPlotOptions(
  1927. {
  1928. detector: "wind_power_curve",
  1929. farm: "XX风电场",
  1930. chartType: "series",
  1931. xaxis: "风速 (m/s)",
  1932. yaxis: "有功功率 (kW)",
  1933. xrange: [3.25, 25.25],
  1934. turbines: [
  1935. {
  1936. turbine: "a1",
  1937. anomaly: false,
  1938. xType: "number",
  1939. xData: [3.25, 4],
  1940. yData: [120, 200],
  1941. ratio: 1.02,
  1942. status: "正常",
  1943. },
  1944. {
  1945. turbine: "a2",
  1946. anomaly: true,
  1947. xType: "number",
  1948. xData: [3.25, 4],
  1949. yData: [80, 150],
  1950. ratio: 0.7,
  1951. status: "欠发",
  1952. },
  1953. ],
  1954. },
  1955. "全场功率曲线分析检测汇总",
  1956. "anomalyPowercurvePO",
  1957. {
  1958. templateKey: "wind_power_curve",
  1959. farmMode: true,
  1960. nameMap: { a1: "01", a2: "02" },
  1961. },
  1962. );
  1963. assert(farmCurveOptions.length === 1, "风场功率曲线总图应为一张叠加图");
  1964. assert(farmCurveOptions[0].grid3D, "风场功率曲线总图应按全场 3D 渲染");
  1965. assert(
  1966. farmCurveOptions[0].series.some((item) => /01号/.test(item.name)),
  1967. "风场功率曲线总图应画出正常机组",
  1968. );
  1969. assert(
  1970. !(Array.isArray(farmCurveOptions[0].legend)
  1971. ? farmCurveOptions[0].legend
  1972. : [farmCurveOptions[0].legend]
  1973. ).some((item) => (item?.data || []).some((name) => /01号/.test(name))),
  1974. "风场功率曲线总图图例不展示正常机组",
  1975. );
  1976. assert(
  1977. farmCurveOptions[0].series.some((item) => /02号/.test(item.name)),
  1978. "风场功率曲线总图应展示异常机组",
  1979. );
  1980. assert(
  1981. farmCurveOptions[0].legend?.orient === "vertical" &&
  1982. farmCurveOptions[0].legend?.right != null &&
  1983. farmCurveOptions[0].grid3D?.right >= 140,
  1984. "功率曲线图例应在三维图右侧",
  1985. );
  1986. const manyAnomalyTurbines = Array.from({ length: 9 }, (_, index) => ({
  1987. turbine: `b${index + 1}`,
  1988. anomaly: true,
  1989. xType: "number",
  1990. xData: [3.25, 4],
  1991. yData: [80, 150],
  1992. status: "异常",
  1993. }));
  1994. const packedCurveOptions = listDetectorPlotOptions(
  1995. {
  1996. detector: "wind_power_curve",
  1997. chartType: "series",
  1998. xaxis: "风速 (m/s)",
  1999. yaxis: "有功功率 (kW)",
  2000. turbines: [
  2001. {
  2002. turbine: "ok",
  2003. anomaly: false,
  2004. xData: [3.25, 4],
  2005. yData: [120, 200],
  2006. status: "正常",
  2007. },
  2008. ...manyAnomalyTurbines,
  2009. ],
  2010. },
  2011. "全场功率曲线分析检测汇总",
  2012. "anomalyPowercurvePO",
  2013. { templateKey: "wind_power_curve", farmMode: true },
  2014. );
  2015. assert(
  2016. packedCurveOptions[0].series.length === 10,
  2017. "功率曲线总图应画出正常机组和全部异常机组",
  2018. );
  2019. assert(
  2020. !Array.isArray(packedCurveOptions[0].legend) &&
  2021. packedCurveOptions[0].legend?.orient === "vertical" &&
  2022. packedCurveOptions[0].legend?.data?.length === 9,
  2023. "异常机组较多时图例仍在右侧且不遗漏",
  2024. );
  2025. const farmYawErrorOptions = listDetectorPlotOptions(
  2026. {
  2027. detector: "yaw_error",
  2028. chartType: "value",
  2029. turbines: [
  2030. {
  2031. turbine: "a1",
  2032. anomaly: true,
  2033. value: 15,
  2034. powerloss: 2.1,
  2035. coverage: 0.9,
  2036. },
  2037. {
  2038. turbine: "a2",
  2039. anomaly: false,
  2040. value: 3,
  2041. powerloss: 0.1,
  2042. coverage: 0.95,
  2043. },
  2044. ],
  2045. },
  2046. "全场静态偏航误差分析检测汇总",
  2047. "anomalyYawErrorPO",
  2048. {
  2049. templateKey: "yaw_error",
  2050. farmMode: true,
  2051. nameMap: { a1: "01", a2: "02" },
  2052. },
  2053. );
  2054. assert(
  2055. farmYawErrorOptions.length === 1,
  2056. "风场静态偏航误差总图应为一张三档着色柱图",
  2057. );
  2058. assert(
  2059. farmYawErrorOptions[0].series[0].type === "bar",
  2060. "风场偏航误差总图应为柱状对比",
  2061. );
  2062. assert(
  2063. farmYawErrorOptions[0].legend?.data
  2064. ?.map((item) => item?.name ?? item)
  2065. .join(",") === "[0,3],(3,5],>5",
  2066. "风场偏航误差总图应按绝对值三档着色",
  2067. );
  2068. assert(
  2069. !farmYawErrorOptions[0].series.some((item) => item.name === "风场均值"),
  2070. "风场偏航误差总图不应再画均值线",
  2071. );
  2072. assert(
  2073. farmYawErrorOptions[0].yAxis.name === "静态偏航误差(度)",
  2074. "风场偏航误差纵轴应直接表示 JSON value",
  2075. );
  2076. const signedYaw = listDetectorPlotOptions(
  2077. {
  2078. detector: "yaw_error",
  2079. chartType: "value",
  2080. turbines: [{ turbine: "a1", anomaly: true, value: -8 }],
  2081. },
  2082. "全场静态偏航误差分析检测汇总",
  2083. "anomalyYawErrorPO",
  2084. { templateKey: "yaw_error", farmMode: true },
  2085. );
  2086. const signedBar = signedYaw[0].series
  2087. .flatMap((item) => item.data || [])
  2088. .find((item) => item && item.value != null);
  2089. assert(signedBar?.value === -8, "静态偏航误差应使用 JSON value,不取绝对值");
  2090. assert(
  2091. farmYawErrorOptions[0].series.find((item) => item.name === ">5")?.data?.[0]
  2092. ?.itemStyle?.color === "#F04438",
  2093. "大于5°的机组应标红",
  2094. );
  2095. assert(
  2096. farmYawErrorOptions[0].series.find((item) => item.name === "[0,3]")?.data?.[1]
  2097. ?.itemStyle?.color === "#12B76A",
  2098. "小于等于3°的机组应标绿",
  2099. );
  2100. const farmYawCountOptions = listDetectorPlotOptions(
  2101. {
  2102. detector: "yaw_count",
  2103. chartType: "value",
  2104. turbines: [
  2105. { turbine: "a1", anomaly: true, value: 18, cw: 10, ccw: 8 },
  2106. { turbine: "a2", anomaly: false, value: 6, cw: 3, ccw: 3 },
  2107. { turbine: "a3", anomaly: true, value: 22, cw: 12, ccw: 10 },
  2108. ],
  2109. },
  2110. "全场偏航次数分析检测汇总",
  2111. "anomalyYawCountPO",
  2112. {
  2113. templateKey: "yaw_count",
  2114. farmMode: true,
  2115. nameMap: { a1: "01", a2: "02", a3: "03" },
  2116. },
  2117. );
  2118. assert(
  2119. legendNames(farmYawCountOptions[0]).join(",") === "正常机组,01号,03号",
  2120. "偏航次数总图图例应列出正常色和全部异常机组",
  2121. );
  2122. assert(
  2123. farmYawCountOptions[0].series.find((item) => item.name === "正常机组")?.data?.[1]
  2124. ?.itemStyle?.color === DIAGNOSTIC_COLORS.normal,
  2125. "偏航次数正常机组应统一蓝色",
  2126. );
  2127. assert(
  2128. farmYawCountOptions[0].series.find((item) => item.name === "01号")?.data?.[0]
  2129. ?.itemStyle?.color === DIAGNOSTIC_COLORS.anomaly,
  2130. "偏航次数异常机组应标红",
  2131. );
  2132. assert(
  2133. farmYawCountOptions[0].series.some((item) => item.name === "03号"),
  2134. "偏航次数图例应包含全部异常机组",
  2135. );
  2136. const farmPitchMinOptions = listDetectorPlotOptions(
  2137. {
  2138. detector: "pitch_min",
  2139. chartType: "value",
  2140. turbines: [
  2141. { turbine: "a1", anomaly: true, value: 2.4, p10: 1.2, baseline: -0.2 },
  2142. { turbine: "a2", anomaly: false, value: 0.3, p10: 0.2, baseline: 0 },
  2143. ],
  2144. },
  2145. "全场最小桨距角分析检测汇总",
  2146. "anomalyMinpitchPO",
  2147. {
  2148. templateKey: "pitch_min",
  2149. farmMode: true,
  2150. nameMap: { a1: "01", a2: "02" },
  2151. },
  2152. );
  2153. assert(
  2154. legendNames(farmPitchMinOptions[0]).join(",") === "正常机组,01号",
  2155. "最小桨距角总图图例应列出正常色和异常机组",
  2156. );
  2157. assert(
  2158. farmPitchMinOptions[0].series.find((item) => item.name === "01号")?.data?.[0]
  2159. ?.itemStyle?.color === DIAGNOSTIC_COLORS.anomaly,
  2160. "最小桨距角异常机组应标红",
  2161. );
  2162. const farmAeroOptions = listDetectorPlotOptions(
  2163. {
  2164. detector: "aero_cp",
  2165. chartType: "series",
  2166. xaxis: "风速 (m/s)",
  2167. yaxis: "Cp",
  2168. turbines: [
  2169. {
  2170. turbine: "t1",
  2171. anomaly: true,
  2172. xData: [6, 8],
  2173. yData: [0.4, 0.45],
  2174. },
  2175. {
  2176. turbine: "t2",
  2177. anomaly: false,
  2178. xData: [6, 8],
  2179. yData: [0.42, 0.48],
  2180. },
  2181. ],
  2182. },
  2183. "全场Cp功率系数分析检测汇总",
  2184. "anomalyCpPO",
  2185. { templateKey: "aero_cp", farmMode: true },
  2186. );
  2187. assert(
  2188. legendNames(farmAeroOptions[0]).includes("正常机组"),
  2189. "Cp总图应标明正常机组为蓝色",
  2190. );
  2191. assert(
  2192. legendNames(farmAeroOptions[0]).includes("t1"),
  2193. "Cp总图图例应展示异常机组",
  2194. );
  2195. assert(
  2196. !legendNames(farmAeroOptions[0]).includes("t2"),
  2197. "Cp总图有异常时图例不列正常机组",
  2198. );
  2199. assert(
  2200. farmAeroOptions[0].series.find((item) => item.name === "t1")?.itemStyle
  2201. ?.color === DIAGNOSTIC_COLORS.anomaly ||
  2202. String(
  2203. farmAeroOptions[0].series.find((item) => item.name === "t1")?.itemStyle
  2204. ?.color || "",
  2205. ).startsWith(DIAGNOSTIC_COLORS.anomaly),
  2206. "Cp异常机组应标红",
  2207. );
  2208. const farmAeroPointOptions = listDetectorPlotOptions(
  2209. {
  2210. detector: "aero_cp",
  2211. chartType: "series",
  2212. xaxis: "功率 (kW)",
  2213. yaxis: "功率系数 Cp",
  2214. turbines: [
  2215. {
  2216. turbine: "26",
  2217. anomaly: true,
  2218. xData: [100, 200, 300],
  2219. yData: [0.2, 0.3, 0.25],
  2220. pointAnomaly: [false, true, false],
  2221. },
  2222. {
  2223. turbine: "27",
  2224. anomaly: false,
  2225. xData: [120, 220],
  2226. yData: [0.22, 0.28],
  2227. pointAnomaly: [false, false],
  2228. },
  2229. ],
  2230. },
  2231. "全场Cp功率系数分析检测汇总",
  2232. "anomalyCpPO",
  2233. { templateKey: "aero_cp", farmMode: true, nameMap: { 26: "26", 27: "27" } },
  2234. );
  2235. const aeroDataSeries = (farmAeroPointOptions[0].series || []).filter(
  2236. (item) => Array.isArray(item.data) && item.data.length > 0,
  2237. );
  2238. assert(
  2239. aeroDataSeries.length === 3,
  2240. "Cp总图应按 pointAnomaly 拆分:26/27号正常点各一条,26号异常点一条",
  2241. );
  2242. const aero26NormalSeries = aeroDataSeries.find(
  2243. (item) =>
  2244. String(item.name || "").includes("26") &&
  2245. /正常/.test(String(item.name || "")),
  2246. );
  2247. const aero26AnomalySeries = aeroDataSeries.find(
  2248. (item) =>
  2249. String(item.name || "").includes("26") &&
  2250. !/正常/.test(String(item.name || "")),
  2251. );
  2252. assert(
  2253. aero26NormalSeries &&
  2254. String(aero26NormalSeries.itemStyle?.color || "").startsWith(
  2255. DIAGNOSTIC_COLORS.normal,
  2256. ),
  2257. "Cp总图 pointAnomaly=false 的点应为蓝色",
  2258. );
  2259. assert(
  2260. aero26AnomalySeries &&
  2261. String(aero26AnomalySeries.itemStyle?.color || "").startsWith(
  2262. DIAGNOSTIC_COLORS.anomaly,
  2263. ),
  2264. "Cp总图 pointAnomaly=true 的点应为红色",
  2265. );
  2266. assert(
  2267. aero26NormalSeries?.data?.length === 2 &&
  2268. aero26AnomalySeries?.data?.length === 1,
  2269. "26号正常点保留 2 个(下标 0/2),异常点仅 1 个(下标 1)",
  2270. );
  2271. assert(
  2272. !(farmAeroPointOptions[0].series || []).some(
  2273. (item) => String(item.name || "") === "27号" && item.data?.length,
  2274. ),
  2275. "27号无异常点时不单独成异常系列",
  2276. );
  2277. const farmYawStaticOptions = listDetectorPlotOptions(
  2278. {
  2279. detector: "yaw_static",
  2280. chartType: "series",
  2281. xaxis: "时间",
  2282. yaxis: "偏航角 (°)",
  2283. turbines: [
  2284. {
  2285. turbine: "t1",
  2286. anomaly: true,
  2287. xType: "time",
  2288. xData: [
  2289. "2026-09-11 00:00:00",
  2290. "2026-09-11 00:01:00",
  2291. "2026-09-11 00:02:00",
  2292. ],
  2293. yData: [10, 80, 12],
  2294. pointAnomaly: [false, true, false],
  2295. },
  2296. ],
  2297. },
  2298. "全场偏航分析检测汇总",
  2299. "anomalyStaticyawPO",
  2300. { templateKey: "yaw_static", farmMode: true },
  2301. );
  2302. const yawStaticDataSeries = farmYawStaticOptions[0].series.filter(
  2303. (item) => item.data?.length,
  2304. );
  2305. assert(
  2306. yawStaticDataSeries.length === 2,
  2307. "风场静态偏航总图应按 pointAnomaly 拆成正常/异常点",
  2308. );
  2309. assert(
  2310. farmYawStaticOptions[0].series.every((item) => {
  2311. const size = Number(item.symbolSize) || 0;
  2312. if (!item.data?.length) return true;
  2313. return size > 0 && size <= 4;
  2314. }) &&
  2315. new Set(
  2316. farmYawStaticOptions[0].series
  2317. .filter((item) => item.data?.length)
  2318. .map((item) => Number(item.symbolSize)),
  2319. ).size === 1,
  2320. "全场静态偏航报告正常点与异常点大小应一致",
  2321. );
  2322. assert(
  2323. Number(yawStaticDataSeries[0].itemStyle?.opacity) <= 0.7,
  2324. "全场静态偏航报告散点透明度应更低",
  2325. );
  2326. assert(
  2327. legendNames(farmYawStaticOptions[0]).includes("正常机组"),
  2328. "偏航总图应标明正常点为蓝色",
  2329. );
  2330. assert(
  2331. legendNames(farmYawStaticOptions[0]).includes("t1"),
  2332. "偏航总图有异常时应在图例展示异常机组",
  2333. );
  2334. assert(
  2335. farmYawStaticOptions[0].series.some(
  2336. (item) =>
  2337. item.name === "t1" && item.itemStyle?.color?.startsWith("#F04438"),
  2338. ),
  2339. "偏航总图异常点应为统一红色",
  2340. );
  2341. assert(
  2342. farmYawStaticOptions[0].series.some(
  2343. (item) =>
  2344. /正常/.test(item.name) && item.itemStyle?.color?.startsWith("#2E90FA"),
  2345. ),
  2346. "偏航总图正常点应为统一蓝色",
  2347. );
  2348. assert(
  2349. typeof farmYawStaticOptions[0].yAxis?.axisLabel?.formatter === "function",
  2350. "报告 Y 轴应保留 2 位小数",
  2351. );
  2352. assert(
  2353. formatReportValueAxisLabel(-1416.9293302406602) === "-1,416.93",
  2354. "Y 轴刻度应格式化为 2 位小数",
  2355. );
  2356. const stitchedFarmJson = turbinePlotsToFarmJson(
  2357. [
  2358. {
  2359. engineId: "a1",
  2360. engineName: "01",
  2361. plotJson: {
  2362. title: "功率曲线",
  2363. xaxis: "风速 (m/s)",
  2364. yaxis: "有功功率 (kW)",
  2365. data: [
  2366. {
  2367. label: "实测功率",
  2368. mode: "lines",
  2369. xData: [3.25, 4],
  2370. yData: [120, 200],
  2371. ratio: 1.02,
  2372. status: "正常",
  2373. },
  2374. ],
  2375. },
  2376. },
  2377. {
  2378. engineId: "a2",
  2379. engineName: "02",
  2380. po: { detectorIsAnomaly: 1 },
  2381. plotJson: {
  2382. title: "功率曲线",
  2383. xaxis: "风速 (m/s)",
  2384. yaxis: "有功功率 (kW)",
  2385. data: [
  2386. {
  2387. label: "实测功率",
  2388. mode: "lines",
  2389. xData: [3.25, 4],
  2390. yData: [80, 150],
  2391. ratio: 0.7,
  2392. status: "欠发",
  2393. },
  2394. ],
  2395. },
  2396. },
  2397. ],
  2398. "wind_power_curve",
  2399. );
  2400. assert(stitchedFarmJson?.chartType === "series", "单机 JSON 拼总图应为 series");
  2401. assert(
  2402. stitchedFarmJson.turbines.length === 2,
  2403. "单机 JSON 拼总图应包含全部风机",
  2404. );
  2405. const stitchedFarmOptions = listFarmPlotOptionsFromTurbines(
  2406. [
  2407. {
  2408. engineId: "a1",
  2409. engineName: "01",
  2410. plotJson: {
  2411. title: "功率曲线",
  2412. xaxis: "风速 (m/s)",
  2413. yaxis: "有功功率 (kW)",
  2414. data: [{ label: "实测功率", xData: [3.25, 4], yData: [120, 200] }],
  2415. },
  2416. },
  2417. ],
  2418. "全场功率曲线分析检测汇总",
  2419. "anomalyPowercurvePO",
  2420. {
  2421. templateKey: "wind_power_curve",
  2422. nameMap: { a1: "01" },
  2423. },
  2424. );
  2425. assert(stitchedFarmOptions[0].grid3D, "报告总图应与页面一样走全场 3D");
  2426. const yawErrorFarmFromTurbines = listFarmPlotOptionsFromTurbines(
  2427. [
  2428. {
  2429. engineId: "a1",
  2430. engineName: "01",
  2431. po: { detectorIsAnomaly: 1, detectorAnomalyRate: 15 },
  2432. plotJson: {
  2433. xaxis: "对风角度/偏航误差 (°)",
  2434. yaxis: "平均有功功率 (kW)",
  2435. data: [
  2436. {
  2437. label: "4.5-5.0 m/s",
  2438. xData: [-2, 0, 4],
  2439. yData: [300, 420, 380],
  2440. },
  2441. {
  2442. label: "5.0-5.5 m/s",
  2443. xData: [-1, 2, 5],
  2444. yData: [410, 500, 430],
  2445. },
  2446. ],
  2447. },
  2448. },
  2449. {
  2450. engineId: "a2",
  2451. engineName: "02",
  2452. po: { detectorIsAnomaly: 0, detectorAnomalyRate: 3 },
  2453. plotJson: {
  2454. data: [
  2455. {
  2456. label: "4.5-5.0 m/s",
  2457. xData: [-1, 3],
  2458. yData: [200, 260],
  2459. },
  2460. ],
  2461. },
  2462. },
  2463. ],
  2464. "全场静态偏航误差分析检测汇总",
  2465. "anomalyYawErrorPO",
  2466. {
  2467. templateKey: "yaw_error",
  2468. nameMap: { a1: "01", a2: "02" },
  2469. },
  2470. );
  2471. assert(
  2472. yawErrorFarmFromTurbines.length === 1,
  2473. "报告偏航误差总图应为一张三档着色柱图",
  2474. );
  2475. assert(
  2476. yawErrorFarmFromTurbines[0].series[0].type === "bar",
  2477. "报告偏航误差总图应与页面一样为单值柱状",
  2478. );
  2479. assert(
  2480. legendNames(yawErrorFarmFromTurbines[0]).join(",") === "[0,3],(3,5],>5",
  2481. "报告偏航误差总图应按绝对值三档着色",
  2482. );
  2483. const emptyOption = buildEmptyPlaceholderOption("全场检测");
  2484. assert(!optionHasData(emptyOption), "占位图不应视为有数据");
  2485. assert(
  2486. JSON.stringify(emptyOption.title).includes("暂无"),
  2487. "无 JSON 时应输出暂无占位",
  2488. );
  2489. const watchlist = pickKeyTurbines(
  2490. [
  2491. { engineId: "1", turbineName: "DT01", anomalyPoints: 99 },
  2492. { engineId: "2", turbineName: "DT02", anomalyPoints: 1 },
  2493. ],
  2494. [
  2495. { engineId: "2", model3PitchPitchcoordRatio: 0.6 },
  2496. { engineId: "1", model3PitchPitchcoordRatio: 0.1 },
  2497. ],
  2498. );
  2499. assert(
  2500. watchlist.length === 1 && watchlist[0].turbineName === "DT02",
  2501. "第6章应只取关注机组",
  2502. );
  2503. assert(
  2504. pickKeyTurbines([{ engineId: "1" }], []).length === 0,
  2505. "无关注机组时应为空",
  2506. );
  2507. assert(
  2508. pickKeyTurbines(
  2509. [
  2510. { engineId: "1", turbineName: "DT01" },
  2511. { engineId: "2", turbineName: "DT02" },
  2512. { engineId: "3", turbineName: "DT03" },
  2513. { engineId: "4", turbineName: "DT04" },
  2514. ],
  2515. [
  2516. { engineId: "1", model2YawYawcountRatio: 0.9 },
  2517. { engineId: "2", model2YawYawerrorRatio: 0.9 },
  2518. { engineId: "3", model3PitchMinpitchRatio: 0.9 },
  2519. { engineId: "4", model4CtrlparamDeloadRatio: 0.9 },
  2520. { engineId: "5", model1WindpwrPowercurveRatio: 0.9 },
  2521. ],
  2522. ).length === 0,
  2523. "偏航次数/静态偏航误差/最小桨距角/降载/功率曲线不应参与关注机组判定",
  2524. );
  2525. const emptyRowsPayload = buildDetectorSectionPayload(
  2526. DETECTOR_TEMPLATE_CONFIG.find((cfg) => cfg.templateKey === "pitch_coord"),
  2527. {
  2528. farmImages: [{ image: "demo" }],
  2529. turbineImages: [{ image: "demo" }],
  2530. rows: [],
  2531. hasChartData: true,
  2532. },
  2533. );
  2534. assert(
  2535. emptyRowsPayload.pitch_coordRows[0].turbine_name === "暂无",
  2536. "异常机组清单无数据时应写暂无",
  2537. );
  2538. const emptySensorRows = buildSensorAnomalyRows([]);
  2539. assert(
  2540. emptySensorRows[0].turbine_name === "暂无",
  2541. "数据感知统计无数据时应写暂无",
  2542. );
  2543. const sensorTableRows = buildSensorAnomalyRows([
  2544. {
  2545. engineName: "10",
  2546. sensorAnomalyWindPwr: 1,
  2547. sensorAnomalyWindPwrRatio: 0.086,
  2548. },
  2549. ]);
  2550. assert(sensorTableRows.length === 1, "数据感知表应按异常类型分行");
  2551. assert(
  2552. sensorTableRows[0].ratio === "8.60%",
  2553. `数据感知占比应来自 ratio 字段,实际 ${sensorTableRows[0].ratio}`,
  2554. );
  2555. assert(
  2556. sensorTableRows[0].sensor_anomaly_type === "风速-功率逻辑异常",
  2557. "数据感知表异常类型应与 flag 字段对应",
  2558. );
  2559. const majorTempRows = buildSensorAnomalyRows([
  2560. {
  2561. engineName: "10",
  2562. sensorAnomalyMajorTemp: 1,
  2563. sensorAnomalyMajorTempRatio: 0.3,
  2564. },
  2565. ]);
  2566. assert(
  2567. majorTempRows.length === 1 &&
  2568. majorTempRows[0].sensor_anomaly_type === "大部件温度异常" &&
  2569. majorTempRows[0].ratio === "30.00%",
  2570. "数据感知表应支持大部件温度异常行",
  2571. );
  2572. assert(
  2573. formatSensorTypeText({ sensorAnomalyType: "9" }) === "大部件温度异常",
  2574. "数据感知类型 9 应翻译为大部件温度异常",
  2575. );
  2576. assert(
  2577. formatSensorTypeText({ sensorAnomalyType: "1,5" }) === "功率异常、扭矩异常",
  2578. "多个数据感知类型编码应按顿号拼接",
  2579. );
  2580. assert(
  2581. formatSensorTypeText({}) === "暂无异常",
  2582. "无数据感知异常时应写暂无异常",
  2583. );
  2584. const sensorHeat = buildSensorHeatmapData([
  2585. {
  2586. engineName: "09",
  2587. sensorAnomalyPowerRatio: 0.12,
  2588. sensorAnomalyWindRatio: 0,
  2589. },
  2590. {
  2591. engineName: "16",
  2592. sensorAnomalyPowerRatio: 0,
  2593. sensorAnomalyWindRatio: 0.2,
  2594. sensorAnomalyMajorTempRatio: 0.35,
  2595. },
  2596. ]);
  2597. assert(
  2598. sensorHeat.xLabels.join(",") === "09号,16号",
  2599. "数据感知热力图风机名应带号",
  2600. );
  2601. assert(sensorHeat.yLabels[0] === "功率异常", "数据感知热力图首行应为功率异常");
  2602. assert(
  2603. sensorHeat.yLabels.includes("偏航角、扭缆角、偏航误差等偏航"),
  2604. "数据感知热力图应含偏航角、扭缆角、偏航误差等偏航",
  2605. );
  2606. assert(
  2607. sensorHeat.yLabels.includes("大部件温度异常"),
  2608. "数据感知热力图应含大部件温度异常",
  2609. );
  2610. assert(sensorHeat.yLabels.length === 9, "数据感知热力图应为9行");
  2611. assert(
  2612. sensorHeat.matrix[8][1] === 35 && sensorHeat.matrix[8][0] === 0,
  2613. "大部件温度异常行应按 sensorAnomalyMajorTempRatio 取占比",
  2614. );
  2615. assert(sensorHeat.matrix[0][0] === 12, "数据感知热力图应按占比×100");
  2616. assert(chunkHeatmapData(sensorHeat, 1).length === 2, "热力图应按风机分片");
  2617. const detectorHeat = buildDetectorHeatmapData([
  2618. {
  2619. engineName: "01",
  2620. model2YawYawerrorRatio: 18,
  2621. model2YawYawcountRatio: 110,
  2622. model3PitchMinpitchRatio: 1,
  2623. model4CtrlparamDeloadRatio: 0.01,
  2624. },
  2625. ]);
  2626. assert(
  2627. detectorHeat.yLabels.includes("静态偏航误差"),
  2628. "功能诊断热力图应含静态偏航误差",
  2629. );
  2630. assert(
  2631. detectorHeat.matrix[detectorHeat.yLabels.indexOf("静态偏航误差")][0] === 18,
  2632. "静态偏航误差热力图应按角度原值",
  2633. );
  2634. assert(
  2635. detectorHeat.matrix[detectorHeat.yLabels.indexOf("偏航次数")][0] === 110,
  2636. "偏航次数热力图应按次数原值",
  2637. );
  2638. assert(
  2639. detectorHeat.matrix[detectorHeat.yLabels.indexOf("最小桨距角异常")][0] === 1,
  2640. "最小桨距角热力图应按角度原值",
  2641. );
  2642. assert(
  2643. detectorHeat.matrix[detectorHeat.yLabels.indexOf("降载情况")][0] === 1,
  2644. "功能诊断热力图占比换算错误",
  2645. );
  2646. const detectorHeatOption = buildReportHeatmapOption(
  2647. detectorHeat,
  2648. "各机组功能诊断异常分布",
  2649. );
  2650. const heatRamp = (label) => {
  2651. const y = detectorHeat.yLabels.indexOf(label);
  2652. const seriesIndex = detectorHeatOption.series.findIndex((item) =>
  2653. (item.data || []).some((cell) => cell.value[1] === y),
  2654. );
  2655. const maps = [].concat(detectorHeatOption.visualMap || []);
  2656. return (maps.find((item) => item.seriesIndex === seriesIndex)?.inRange?.color || []).join(",");
  2657. };
  2658. assert(
  2659. Array.isArray(detectorHeatOption.visualMap) &&
  2660. detectorHeatOption.visualMap.length > 1 &&
  2661. heatRamp("静态偏航误差") !== heatRamp("降载情况") &&
  2662. heatRamp("偏航次数") !== heatRamp("静态偏航误差") &&
  2663. heatRamp("风功率曲线情况") !== heatRamp("降载情况"),
  2664. "功能诊断热力图应按符合度、角度、次数、异常率分色",
  2665. );
  2666. assert(
  2667. summarizeTurbineAnomalyRate(
  2668. [
  2669. { title: "降载判定分析", points: 477, rate: 0.3286 },
  2670. { title: "变桨协调分析", points: 8, rate: 0.0052 },
  2671. ],
  2672. { model4CtrlparamDeloadRatio: 0.3286, model3PitchPitchcoordRatio: 0.0052 },
  2673. ).toFixed(4) === "0.3233",
  2674. "表4-2 机组异常率应按异常点数加权,并优先使用热力图单项占比",
  2675. );
  2676. const summaryWithoutDeload = buildAnomalySummaryRows([
  2677. {
  2678. turbineName: "07",
  2679. anomalyDetectorCount: 1,
  2680. anomalyPoints: 250,
  2681. anomalyRate: 0.0893,
  2682. mainAnomalyType: "降载判定分析",
  2683. detectorHits: [{ title: "降载判定分析", points: 250, rate: 0.0893 }],
  2684. },
  2685. {
  2686. turbineName: "08",
  2687. anomalyDetectorCount: 2,
  2688. anomalyPoints: 220,
  2689. anomalyRate: 0.1,
  2690. mainAnomalyType: "降载判定分析",
  2691. detectorHits: [
  2692. { title: "降载判定分析", points: 200, rate: 0.12 },
  2693. { title: "变桨协调分析", points: 20, rate: 0.01 },
  2694. ],
  2695. },
  2696. ]);
  2697. assert(
  2698. summaryWithoutDeload.length === 1 &&
  2699. summaryWithoutDeload[0].turbine_name === "08" &&
  2700. summaryWithoutDeload[0].anomaly_detector_count === "变桨协调分析" &&
  2701. summaryWithoutDeload[0].main_anomaly_type === "变桨协调异常率" &&
  2702. summaryWithoutDeload[0].anomaly_rate === "1.00%",
  2703. "表4-2 不应收录仅降载异常机组,其余检测项应分行给出名称、异常率和说明",
  2704. );
  2705. const summaryMetricRows = buildAnomalySummaryRows([
  2706. {
  2707. turbineName: "009",
  2708. detectorHits: [
  2709. { title: "静态偏航误差分析", templateKey: "yaw_error", points: 4, rate: 8 },
  2710. { title: "偏航次数分析", templateKey: "yaw_count", points: 2, rate: 110 },
  2711. { title: "最小桨距角分析", templateKey: "pitch_min", points: 1, rate: 1.5 },
  2712. { title: "变桨一致性分析", templateKey: "pitch_regulation", points: 3, rate: 0.021 },
  2713. ],
  2714. },
  2715. ]);
  2716. assert(
  2717. summaryMetricRows.map((row) => row.anomaly_detector_count).join(",") ===
  2718. "静态偏航误差分析,变桨一致性分析,偏航次数分析,最小桨距角分析",
  2719. "表4-2 功能诊断项应为检测器名称",
  2720. );
  2721. assert(
  2722. summaryMetricRows.map((row) => row.anomaly_rate).join(",") ===
  2723. "8°,2.10%,110次,1.5°",
  2724. "表4-2 数值应按度、次数或异常率分别格式化",
  2725. );
  2726. assert(
  2727. summaryMetricRows.map((row) => row.main_anomaly_type).join(",") ===
  2728. "静态偏航误差度,变桨一致性异常率,偏航次数,最小桨距角",
  2729. "表4-2 数值说明应写明指标含义",
  2730. );
  2731. const heatOption = buildReportHeatmapOption(
  2732. sensorHeat,
  2733. "各机组数据感知异常分布",
  2734. );
  2735. assert(heatOption.series[0].type === "heatmap", "图3-1应为热力图");
  2736. assert(
  2737. heatOption.series[0].data[0]?.name?.includes("%"),
  2738. "报告热力图单元格应预置占比文本",
  2739. );
  2740. assert(optionHasData(heatOption), "数据感知热力图应视为有数据");
  2741. const sensorRadar = buildSensorRadarData(
  2742. { sensorAnomalyPowerCount: 2, sensorAnomalyWindCount: 1 },
  2743. 20,
  2744. );
  2745. assert(sensorRadar.values[0] === 2, "数据感知雷达图应映射异常台数");
  2746. assert(sensorRadar.max === 3, "数据感知雷达图应按异常台数自适应上限");
  2747. assert(
  2748. sensorRadar.indicators.length === 9 &&
  2749. sensorRadar.indicators[8].name === "大部件温度异常" &&
  2750. sensorRadar.values[8] === 0,
  2751. "数据感知雷达图应含大部件温度异常轴",
  2752. );
  2753. const majorTempRadar = buildSensorRadarData(
  2754. { sensorAnomalyMajorTempCount: 5 },
  2755. 20,
  2756. );
  2757. assert(
  2758. majorTempRadar.values[8] === 5,
  2759. "大部件温度异常台数应取自 sensorAnomalyMajorTempCount",
  2760. );
  2761. const detectorRadar = buildDetectorRadarData(
  2762. { model1Count: 3, model2Count: 1 },
  2763. 20,
  2764. );
  2765. assert(detectorRadar.max === 4, "功能诊断雷达图应按异常台数自适应上限");
  2766. const sensorRadarOption = buildAnomalyRadarOption(
  2767. sensorRadar,
  2768. "数据感知异常台数",
  2769. );
  2770. assert(sensorRadarOption.series[0].type === "radar", "概览区应输出雷达图");
  2771. assert(
  2772. Number(sensorRadarOption.series[0].lineStyle?.width) >= 2,
  2773. "雷达图 series 应配置连线宽度",
  2774. );
  2775. assert(
  2776. sensorRadarOption.radar.indicator[0].name.includes("2台"),
  2777. "雷达图维度应标注台数",
  2778. );
  2779. assert(
  2780. sensorRadarOption.radar.indicator.every((item) => item.max === 3),
  2781. "雷达图外圈应按本图最大台数自适应",
  2782. );
  2783. assert(sensorRadarOption.radar.splitNumber === 3, "雷达图刻度应落到整数台数");
  2784. console.log("axis/watchlist/placeholder rules ok");
  2785. await initChartService();
  2786. const imageBufferMap = {};
  2787. const sensorRadarBuf = await renderEchartsOption(sensorRadarOption, {
  2788. width: 520,
  2789. height: 360,
  2790. });
  2791. registerImageBuffer(imageBufferMap, "anomaly_sensor_radar", sensorRadarBuf);
  2792. const detectorRadarBuf = await renderEchartsOption(
  2793. buildAnomalyRadarOption(detectorRadar, "功能诊断异常台数"),
  2794. { width: 520, height: 360 },
  2795. );
  2796. registerImageBuffer(imageBufferMap, "anomaly_detector_radar", detectorRadarBuf);
  2797. const sensorHeatBuf = await renderEchartsOption(heatOption, {
  2798. width: 920,
  2799. height: 420,
  2800. });
  2801. registerImageBuffer(imageBufferMap, "anomaly_sensor_dist", sensorHeatBuf);
  2802. const detectorHeatBuf = await renderEchartsOption(
  2803. buildReportHeatmapOption(detectorHeat, "各机组功能诊断异常分布"),
  2804. { width: 920, height: 560 },
  2805. );
  2806. registerImageBuffer(imageBufferMap, "anomaly_detector_dist", detectorHeatBuf);
  2807. const completenessHeatmap = buildDataCompletenessHeatmapData(
  2808. [
  2809. {
  2810. engineName: "111",
  2811. sourceDatetime: new Date("2026-08-01").getTime(),
  2812. minuteDataCompleteness: 96.5,
  2813. },
  2814. {
  2815. engineName: "112",
  2816. sourceDatetime: new Date("2026-08-01").getTime(),
  2817. minuteDataCompleteness: 88.2,
  2818. },
  2819. {
  2820. engineName: "111",
  2821. sourceDatetime: new Date("2026-08-02").getTime(),
  2822. minuteDataCompleteness: 91,
  2823. },
  2824. ],
  2825. "minuteDataCompleteness",
  2826. );
  2827. assert(
  2828. completenessHeatmap.yLabels.length === 2 &&
  2829. completenessHeatmap.xLabels.length === 2,
  2830. "数据完整度热力图应正确展开机组×日期",
  2831. );
  2832. assert(
  2833. completenessHeatmap.xLabels.join(",") === "08-01,08-02",
  2834. "数据完整度 X 轴应为日期",
  2835. );
  2836. assert(
  2837. completenessHeatmap.yLabels.length === 2 &&
  2838. completenessHeatmap.yLabels.every((name) => /111|112/.test(name)),
  2839. "数据完整度 Y 轴应为风机",
  2840. );
  2841. const completenessMinuteBuf = await renderEchartsOption(
  2842. buildCompletenessHeatmapOption(
  2843. completenessHeatmap,
  2844. "分钟级数据逐日完整度(近90天,%)",
  2845. ),
  2846. { width: 920, height: 420 },
  2847. );
  2848. const completenessOption = buildCompletenessHeatmapOption(
  2849. completenessHeatmap,
  2850. "分钟级数据逐日完整度(近90天,%)",
  2851. );
  2852. assert(
  2853. completenessOption.xAxis?.name === "日期" &&
  2854. completenessOption.yAxis?.name === "风机",
  2855. "数据完整度热力图轴名称应为日期/风机",
  2856. );
  2857. registerImageBuffer(
  2858. imageBufferMap,
  2859. "anomaly_data_completeness_minute",
  2860. completenessMinuteBuf,
  2861. );
  2862. registerImageBuffer(
  2863. imageBufferMap,
  2864. "anomaly_data_completeness_second",
  2865. completenessMinuteBuf,
  2866. );
  2867. const farmPlots = buildFarmPlotJsons(
  2868. [
  2869. { engineName: "111", plotJson: samplePlot("111") },
  2870. { engineName: "112", plotJson: samplePlot("112") },
  2871. ],
  2872. "yaw_static",
  2873. "全场偏航分析检测汇总",
  2874. );
  2875. const farmLabels = (farmPlots[0]?.data || []).map((row) => row.label);
  2876. if (farmLabels.join(",") !== "111,112") {
  2877. throw new Error(`全场偏航分色失败: ${farmLabels.join(",")}`);
  2878. }
  2879. if (
  2880. (farmPlots[0]?.data || []).some((row) => /均值/.test(row.originalLabel || ""))
  2881. ) {
  2882. throw new Error("全场偏航仍包含 2h/12h 均值");
  2883. }
  2884. const farmBuf = await renderEchartsOption(
  2885. buildDetectorPlotOption(
  2886. farmPlots[0],
  2887. farmPlots[0].title,
  2888. "anomalyStaticyawPO",
  2889. ),
  2890. { width: 820, height: 420 },
  2891. );
  2892. registerImageBuffer(imageBufferMap, "anomaly_farm_yaw_static", farmBuf);
  2893. const turbineBuf = await renderEchartsOption(
  2894. buildDetectorPlotOption(samplePlot("111"), "111 偏航分析"),
  2895. { width: 760, height: 400 },
  2896. );
  2897. registerImageBuffer(imageBufferMap, "anomaly_111_yaw_static", turbineBuf);
  2898. const denseCount = 4500;
  2899. const denseTimes = Array.from({ length: denseCount }, (_, i) => {
  2900. const t = new Date("2026-08-01T00:00:00").getTime() + i * 60000;
  2901. const pad = (n) => String(n).padStart(2, "0");
  2902. const d = new Date(t);
  2903. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
  2904. d.getHours(),
  2905. )}:${pad(d.getMinutes())}:00`;
  2906. });
  2907. const denseOption = buildDetectorPlotOption(
  2908. {
  2909. title: "DT01 电能质量分析",
  2910. xaxis: "时间",
  2911. yaxis: "电流不平衡度",
  2912. data: [
  2913. {
  2914. label: "电流不平衡度",
  2915. mode: "markers",
  2916. timeData: denseTimes,
  2917. yData: Array.from(
  2918. { length: denseCount },
  2919. (_, i) => 0.08 + (i % 20) / 200,
  2920. ),
  2921. },
  2922. ],
  2923. },
  2924. "DT01 电能质量分析",
  2925. "anomalyPowerqualityPO",
  2926. );
  2927. assert(denseOption.series[0].large === false, "密点单机图不应使用 large");
  2928. assert(
  2929. Number(denseOption.series[0].symbolSize) <= 6,
  2930. "报告散点 symbolSize 应缩小以提升清晰度",
  2931. );
  2932. assert(denseOption.backgroundColor === "#eef4fb", "报告图表应使用浅蓝灰底色");
  2933. const denseBuf = await renderEchartsOption(denseOption, {
  2934. width: 760,
  2935. height: 420,
  2936. });
  2937. assert(denseBuf.length > 10000, "密点单机图截图过小,可能未画出数据");
  2938. const renderData = {
  2939. reportNo: "AD-SMOKE-20260824",
  2940. Province: "测试省",
  2941. Wind_farm: "烟雾风场",
  2942. Year_now: "2026",
  2943. Month_now: "08",
  2944. machineTypeCode: "WD",
  2945. turbine_count: "2",
  2946. anomaly_turbine_count: "1",
  2947. total_anomaly_points: "3",
  2948. anomaly_rate: "50.00",
  2949. Overview_of_the_Wind_Farm: "烟雾风场用于模板渲染校验。",
  2950. target_date: "2026-08-24",
  2951. anomaly_module_count: "1",
  2952. main_problem_modules: "偏航与扭缆",
  2953. top_problem_description: "偏航分析异常点数最多(3)",
  2954. sensorAnomalyRows: buildSensorAnomalyRows([
  2955. {
  2956. engineName: "111",
  2957. sensorAnomalyWindPwr: 1,
  2958. sensorAnomalyWindPwrRatio: 0.125,
  2959. },
  2960. {
  2961. engineName: "16",
  2962. sensorAnomalyWind: 1,
  2963. sensorAnomalyWindRatio: 0.2,
  2964. sensorAnomalyMajorTemp: 1,
  2965. sensorAnomalyMajorTempRatio: 0.3,
  2966. },
  2967. ]),
  2968. detectorSummaryRows: [
  2969. {
  2970. detector_name: "偏航分析",
  2971. module_name: "偏航与扭缆",
  2972. data_granularity: "秒级",
  2973. anomaly_turbines: "1",
  2974. anomaly_points: "3",
  2975. avg_anomaly_rate: "12.00%",
  2976. },
  2977. ],
  2978. anomalySummaryRows: [
  2979. {
  2980. turbine_name: "111",
  2981. anomaly_detector_count: "偏航分析",
  2982. anomaly_points: "3",
  2983. anomaly_rate: "12.00%",
  2984. main_anomaly_type: "偏航异常率",
  2985. },
  2986. ],
  2987. priorityList: "111",
  2988. keyTurbineLoop: [
  2989. {
  2990. turbine_name: "111",
  2991. turbineDetailRows: [
  2992. {
  2993. detector_name: "偏航分析",
  2994. anomaly_points: "3",
  2995. anomaly_rate: "12.00%",
  2996. conclusion: "建议结合现场复核",
  2997. },
  2998. ],
  2999. "zn-techcn-replace-tags-key_turbine-generalFiles": [
  3000. { image: "anomaly_111_yaw_static" },
  3001. ],
  3002. },
  3003. ],
  3004. conclusionRows: [
  3005. {
  3006. index: "1",
  3007. problem_desc: "主要表现为偏航角越限、突变或长时间不动作。",
  3008. suggestion:
  3009. "建议检查偏航编码器、风向标、偏航制动器及对风控制参数,对异常机组进行现场复核。",
  3010. problem_nature: "偏航与扭缆系统异常",
  3011. turbine_names: "#111",
  3012. },
  3013. ],
  3014. "zn-techcn-replace-tags-data_sensor_anomaly-generalFiles": [
  3015. {
  3016. image: "anomaly_sensor_radar",
  3017. figure_caption: "图3-1 数据感知异常台数",
  3018. },
  3019. {
  3020. image: "anomaly_sensor_dist",
  3021. figure_caption: "图3-2 各机组数据感知异常分布",
  3022. },
  3023. ],
  3024. "zn-techcn-replace-tags-data_detector_anomaly-generalFiles": [
  3025. {
  3026. image: "anomaly_detector_radar",
  3027. figure_caption: "图4-1 功能诊断异常台数",
  3028. },
  3029. {
  3030. image: "anomaly_detector_dist",
  3031. figure_caption: "图4-2 各机组功能诊断异常分布",
  3032. },
  3033. ],
  3034. "zn-techcn-replace-tags-data_completeness_minute-farmSummary": [
  3035. { image: "anomaly_data_completeness_minute" },
  3036. ],
  3037. "zn-techcn-replace-tags-data_completeness_second-farmSummary": [
  3038. { image: "anomaly_data_completeness_second" },
  3039. ],
  3040. "zn-techcn-replace-tags-key_turbine-generalFiles": [],
  3041. show_module_wind: [],
  3042. show_module_yaw: [{}],
  3043. show_module_pitch: [],
  3044. show_module_run: [],
  3045. show_module_aero: [],
  3046. };
  3047. DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => {
  3048. if (cfg.templateKey === "yaw_static") {
  3049. Object.assign(
  3050. renderData,
  3051. buildDetectorSectionPayload(cfg, {
  3052. farmImages: [{ image: "anomaly_farm_yaw_static" }],
  3053. turbineImages: [{ image: "anomaly_111_yaw_static" }],
  3054. rows: [
  3055. {
  3056. turbine_name: "111",
  3057. anomaly_points: "3",
  3058. anomaly_rate: "12.00%",
  3059. sensor_anomaly_type: "大部件温度异常",
  3060. comment: "功能诊断异常",
  3061. },
  3062. ],
  3063. anomalyPoints: 3,
  3064. anomalyRate: 0.12,
  3065. anomalyTurbines: 1,
  3066. hasChartData: true,
  3067. }),
  3068. );
  3069. return;
  3070. }
  3071. const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
  3072. const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
  3073. renderData[`show-${fileTag}`] = [];
  3074. renderData[farmTag] = [];
  3075. renderData[fileTag] = [];
  3076. renderData[`${cfg.templateKey}Rows`] = [];
  3077. });
  3078. const buffer = await renderDocxReport({
  3079. templateName: "异常检测数据分析报告模板(大唐版)_修订版_人工对齐版.docx",
  3080. renderData,
  3081. imageBufferMap,
  3082. });
  3083. const outDir = path.join(__dirname, "../templates");
  3084. fs.mkdirSync(outDir, { recursive: true });
  3085. const outPath = path.join(outDir, "_smoke_anomaly_report.docx");
  3086. fs.writeFileSync(outPath, buffer);
  3087. console.log("smoke anomaly report written:", outPath, "bytes=", buffer.length);
  3088. const smokeZip = new PizZip(buffer);
  3089. Object.keys(smokeZip.files)
  3090. .filter((name) => /^word\/footer\d+\.xml$/.test(name))
  3091. .sort()
  3092. .forEach((name) => {
  3093. const xml = smokeZip.file(name).asText();
  3094. const plain = xml.replace(/<[^>]+>/g, "").trim();
  3095. assert(!/PAGE/.test(xml), `${name} 不应含页码域`);
  3096. assert(!plain, `${name} 页脚应为空,实际为「${plain}」`);
  3097. });
  3098. const smokeDocText = smokeZip
  3099. .file("word/document.xml")
  3100. .asText()
  3101. .replace(/<[^>]+>/g, "");
  3102. assert(
  3103. smokeDocText.includes("数据感知异常类型"),
  3104. "报告中检测器明细表应包含数据感知异常类型列",
  3105. );
  3106. assert(
  3107. smokeDocText.includes("大部件温度异常"),
  3108. "报告应输出大部件温度异常",
  3109. );
  3110. assert(
  3111. smokeDocText.includes("大部件温度(齿轮箱油温、发电机轴承"),
  3112. "表3-1 应包含大部件温度关联测点说明",
  3113. );
  3114. assert(
  3115. !/\{[A-Za-z_]/.test(smokeDocText),
  3116. "报告中不应残留模板占位符",
  3117. );
  3118. await shutdownChartService();