smoke-anomaly-report.mjs 96 KB

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