anomalyReportMapper.js 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102
  1. import { DETECTOR_TEMPLATE_CONFIG, adaptiveRadarMax } from "./anomalyChartBuilder.js";
  2. const SENSOR_TYPE_LABELS = {
  3. 1: "功率异常",
  4. 2: "风速异常",
  5. 3: "变桨角度异常",
  6. 4: "转速异常",
  7. 5: "扭矩异常",
  8. 6: "风速-功率逻辑异常",
  9. 7: "转速-扭矩逻辑异常",
  10. 8: "偏航角、扭缆角、偏航误差等偏航",
  11. 9: "大部件温度异常",
  12. };
  13. const MODEL_SENSOR_FIELDS = [
  14. { field: "sensorAnomalyPower", label: "功率异常" },
  15. { field: "sensorAnomalyWind", label: "风速异常" },
  16. { field: "sensorAnomalyPitch", label: "变桨角度异常" },
  17. { field: "sensorAnomalySpeed", label: "转速异常" },
  18. { field: "sensorAnomalyTorque", label: "扭矩异常" },
  19. { field: "sensorAnomalyWindPwr", label: "风速-功率逻辑异常" },
  20. { field: "sensorAnomalySpdTrq", label: "转速-扭矩逻辑异常" },
  21. {
  22. field: "sensorAnomalyFreeze",
  23. label: "偏航角、扭缆角、偏航误差等偏航",
  24. },
  25. { field: "sensorAnomalyMajorTemp", label: "大部件温度异常" },
  26. ];
  27. const SENSOR_HEATMAP_FIELDS = [
  28. { ratioFields: ["sensorAnomalyPowerRatio"], label: "功率异常" },
  29. { ratioFields: ["sensorAnomalyWindRatio"], label: "风速异常" },
  30. { ratioFields: ["sensorAnomalyPitchRatio"], label: "变桨角度异常" },
  31. { ratioFields: ["sensorAnomalySpeedRatio"], label: "转速异常" },
  32. { ratioFields: ["sensorAnomalyTorqueRatio"], label: "扭矩异常" },
  33. { ratioFields: ["sensorAnomalyWindPwrRatio"], label: "风速-功率逻辑异常" },
  34. { ratioFields: ["sensorAnomalySpdTrqRatio"], label: "转速-扭矩逻辑异常" },
  35. {
  36. ratioFields: ["sensorAnomalyFreezeRatio"],
  37. label: "偏航角、扭缆角、偏航误差等偏航",
  38. },
  39. { ratioFields: ["sensorAnomalyMajorTempRatio"], label: "大部件温度异常" },
  40. ];
  41. /** 表3-2:与热力图共用 ratio 字段,按「风机 × 异常类型」展开行 */
  42. const SENSOR_TABLE_FIELDS = MODEL_SENSOR_FIELDS.map((cfg, index) => ({
  43. flagField: cfg.field,
  44. label: cfg.label,
  45. ratioFields: SENSOR_HEATMAP_FIELDS[index]?.ratioFields || [],
  46. }));
  47. const DETECTOR_HEATMAP_FIELDS = [
  48. { ratioFields: ["model1WindpwrPowercurveRatio"], label: "风功率曲线情况" },
  49. { ratioFields: ["model1WindpwrScatterRatio"], label: "风功率散点异常" },
  50. { ratioFields: ["model2YawStaticyawRatio"], label: "偏航异常" },
  51. { ratioFields: ["model2YawCabletwistRatio"], label: "扭缆异常" },
  52. {
  53. ratioFields: [
  54. "model2YawYawcountRatio",
  55. "model2YawCountRatio",
  56. "model2YawYawCountRatio",
  57. ],
  58. label: "偏航次数",
  59. valueKind: "count",
  60. },
  61. {
  62. ratioFields: [
  63. "model2YawYawerrorRatio",
  64. "model2YawErrorRatio",
  65. "model2YawYawErrorRatio",
  66. ],
  67. label: "静态偏航误差",
  68. valueKind: "degree",
  69. },
  70. { ratioFields: ["model3PitchPitchregulationRatio"], label: "变桨一致性异常" },
  71. { ratioFields: ["model3PitchPitchcoordRatio"], label: "变桨协调异常" },
  72. {
  73. ratioFields: ["model3PitchMinpitchRatio", "model3PitchMinPitchRatio"],
  74. label: "最小桨距角异常",
  75. valueKind: "degree",
  76. },
  77. { ratioFields: ["model4CtrlparamDeloadRatio"], label: "降载情况" },
  78. {
  79. ratioFields: ["model4CtrlparamOperationstateRatio"],
  80. label: "运行状态异常",
  81. },
  82. { ratioFields: ["model4CtrlparamPowerqualityRatio"], label: "电能质量异常" },
  83. { ratioFields: ["model5AerodynamicsTsrRatio"], label: "TSR异常" },
  84. { ratioFields: ["model5AerodynamicsCpRatio"], label: "CP异常" },
  85. {
  86. ratioFields: ["model5AerodynamicsCpTsrRatio"],
  87. label: "TSR-CP联合分布异常",
  88. },
  89. ];
  90. const HEATMAP_CHUNK_SIZE = 12;
  91. /** 报告数据完整度热力图:每张图最多的风机数 */
  92. const COMPLETENESS_TURBINE_CHUNK = 10;
  93. const SENSOR_RADAR_FIELDS = [
  94. { key: "sensorAnomalyPowerCount", label: "功率异常" },
  95. { key: "sensorAnomalyWindCount", label: "风速异常" },
  96. { key: "sensorAnomalyPitchCount", label: "变桨角度异常" },
  97. { key: "sensorAnomalyApeedCount", label: "转速异常" },
  98. { key: "sensorAnomalyTorqueCount", label: "扭矩异常" },
  99. { key: "sensorAnomalyWindPwrCount", label: "风速-功率\n逻辑异常" },
  100. { key: "sensorAnomalySpdTrqCount", label: "转速-扭矩\n逻辑异常" },
  101. {
  102. key: "sensorAnomalyFreezeCount",
  103. label: "偏航角、扭缆角、\n偏航误差等偏航",
  104. },
  105. { key: "sensorAnomalyMajorTempCount", label: "大部件温度异常" },
  106. ];
  107. const MODEL_RADAR_FIELDS = [
  108. { key: "model1Count", label: "风功率异常" },
  109. { key: "model2Count", label: "偏航系统\n异常" },
  110. { key: "model3Count", label: "变桨系统\n异常" },
  111. { key: "model4Count", label: "运行状态\n异常" },
  112. { key: "model5Count", label: "气动性能\n异常" },
  113. ];
  114. /**
  115. * 第6章重点机组判定字段。
  116. * 偏航次数、最小桨距角、静态偏航误差与降载判定、功率曲线一样不参与判定。
  117. */
  118. const WATCHLIST_RATIO_FIELDS = [
  119. "model1WindpwrScatterRatio",
  120. "model2YawStaticyawRatio",
  121. "model2YawCabletwistRatio",
  122. "model3PitchPitchregulationRatio",
  123. "model3PitchPitchcoordRatio",
  124. "model4CtrlparamPowerqualityRatio",
  125. "model4CtrlparamOperationstateRatio",
  126. "model5AerodynamicsCpRatio",
  127. "model5AerodynamicsTsrRatio",
  128. "model5AerodynamicsCpTsrRatio",
  129. ];
  130. export function displayTurbineName(item = {}) {
  131. return item.engineName || item.engineId || "";
  132. }
  133. export function formatHeatmapEngineLabel(engineName) {
  134. if (engineName == null || engineName === "") return "--";
  135. const text = String(engineName);
  136. if (/号$/.test(text)) return text;
  137. if (/^\d+$/.test(text)) return `${text}号`;
  138. return text;
  139. }
  140. function readRatioPercent(item, ratioFields = []) {
  141. for (const field of ratioFields) {
  142. if (item?.[field] == null || item[field] === "") continue;
  143. return toPercentNumber(item[field]);
  144. }
  145. return 0;
  146. }
  147. function readHeatmapRaw(item, ratioFields = []) {
  148. for (const field of ratioFields) {
  149. if (item?.[field] == null || item[field] === "") continue;
  150. const num = Number(item[field]);
  151. return Number.isFinite(num) ? num : 0;
  152. }
  153. return 0;
  154. }
  155. function buildHeatmapFromFields(modelList = [], fields = []) {
  156. const list = sortTurbines(modelList);
  157. return {
  158. xLabels: list.map((item) =>
  159. formatHeatmapEngineLabel(displayTurbineName(item)),
  160. ),
  161. yLabels: fields.map((item) => item.label),
  162. valueKinds: fields.map((item) => item.valueKind || "percent"),
  163. matrix: fields.map((field) =>
  164. list.map((item) =>
  165. field.valueKind === "count" || field.valueKind === "degree"
  166. ? readHeatmapRaw(item, field.ratioFields)
  167. : readRatioPercent(item, field.ratioFields),
  168. ),
  169. ),
  170. };
  171. }
  172. export function buildSensorHeatmapData(modelList = []) {
  173. return buildHeatmapFromFields(modelList, SENSOR_HEATMAP_FIELDS);
  174. }
  175. export function buildDetectorHeatmapData(modelList = []) {
  176. return buildHeatmapFromFields(modelList, DETECTOR_HEATMAP_FIELDS);
  177. }
  178. function formatCompletenessDate(sourceDatetime) {
  179. const ms = Number(sourceDatetime);
  180. if (!Number.isFinite(ms) || ms <= 0) return "--";
  181. const d = new Date(ms);
  182. const pad = (n) => String(n).padStart(2, "0");
  183. return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
  184. }
  185. function parseCompletenessValue(raw) {
  186. if (raw == null || raw === "") return null;
  187. if (typeof raw === "number") {
  188. return Number.isFinite(raw) ? raw : null;
  189. }
  190. const text = String(raw).trim();
  191. if (!text) return null;
  192. const direct = Number(text);
  193. if (Number.isFinite(direct)) return direct;
  194. try {
  195. const parsed = JSON.parse(text);
  196. if (typeof parsed === "number") return parsed;
  197. if (parsed && typeof parsed === "object") {
  198. const nested =
  199. parsed.value ??
  200. parsed.ratio ??
  201. parsed.completeness ??
  202. parsed.percent;
  203. const num = Number(nested);
  204. if (Number.isFinite(num)) return num;
  205. }
  206. } catch (_error) {
  207. // ignore
  208. }
  209. return null;
  210. }
  211. /**
  212. * 数据完整度热力图:X=日期,Y=机组(与功能诊断热力图轴向不同,但复用同一 ECharts 渲染器)。
  213. */
  214. export function buildDataCompletenessHeatmapData(
  215. rows = [],
  216. valueKey = "minuteDataCompleteness",
  217. ) {
  218. const list = Array.isArray(rows) ? rows : [];
  219. if (!list.length) {
  220. return { xLabels: [], yLabels: [], matrix: [] };
  221. }
  222. const dateKeys = [
  223. ...new Set(
  224. list
  225. .map((row) => Number(row?.sourceDatetime))
  226. .filter((ms) => Number.isFinite(ms) && ms > 0),
  227. ),
  228. ].sort((a, b) => a - b);
  229. const xLabels = dateKeys.map(formatCompletenessDate);
  230. const engineNames = [
  231. ...new Set(
  232. list.map((row) =>
  233. formatHeatmapEngineLabel(displayTurbineName(row)),
  234. ),
  235. ),
  236. ].sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true }));
  237. const yLabels = engineNames;
  238. const cellMap = new Map();
  239. list.forEach((row) => {
  240. const engine = formatHeatmapEngineLabel(displayTurbineName(row));
  241. const ms = Number(row?.sourceDatetime);
  242. if (!engine || !Number.isFinite(ms)) return;
  243. const value = parseCompletenessValue(row?.[valueKey]);
  244. if (value == null) return;
  245. cellMap.set(`${engine}|${ms}`, value);
  246. });
  247. const matrix = yLabels.map((engine) =>
  248. dateKeys.map((ms) => {
  249. const value = cellMap.get(`${engine}|${ms}`);
  250. return value == null ? 0 : value;
  251. }),
  252. );
  253. return { xLabels, yLabels, matrix };
  254. }
  255. function buildRadarData(fields, source = {}) {
  256. const values = fields.map((item) => Number(source[item.key]) || 0);
  257. const max = adaptiveRadarMax(values);
  258. return {
  259. indicators: fields.map((item) => ({ name: item.label, max })),
  260. values,
  261. max,
  262. };
  263. }
  264. export function buildSensorRadarData(sensorCount = {}) {
  265. return buildRadarData(SENSOR_RADAR_FIELDS, sensorCount);
  266. }
  267. export function buildDetectorRadarData(modelCount = {}) {
  268. return buildRadarData(MODEL_RADAR_FIELDS, modelCount);
  269. }
  270. export function chunkHeatmapData(heatmap, size = HEATMAP_CHUNK_SIZE) {
  271. const xLabels = heatmap?.xLabels || [];
  272. const yLabels = heatmap?.yLabels || [];
  273. const matrix = heatmap?.matrix || [];
  274. if (!xLabels.length || !yLabels.length) return [];
  275. const chunks = [];
  276. for (let i = 0; i < xLabels.length; i += size) {
  277. chunks.push({
  278. yLabels,
  279. valueKinds: heatmap?.valueKinds,
  280. xLabels: xLabels.slice(i, i + size),
  281. matrix: matrix.map((row) => (row || []).slice(i, i + size)),
  282. });
  283. }
  284. return chunks;
  285. }
  286. /**
  287. * 数据完整度:Y 轴按风机分页,每张最多 10 台;日期仍按列分页,避免 90 天挤在一张图上。
  288. */
  289. export function chunkCompletenessHeatmapData(
  290. heatmap,
  291. turbineSize = COMPLETENESS_TURBINE_CHUNK,
  292. dateSize = HEATMAP_CHUNK_SIZE,
  293. ) {
  294. const xLabels = heatmap?.xLabels || [];
  295. const yLabels = heatmap?.yLabels || [];
  296. const matrix = heatmap?.matrix || [];
  297. if (!xLabels.length || !yLabels.length) return [];
  298. const turbineStep = Math.max(1, Number(turbineSize) || COMPLETENESS_TURBINE_CHUNK);
  299. const dateStep = Math.max(1, Number(dateSize) || HEATMAP_CHUNK_SIZE);
  300. const chunks = [];
  301. for (let y = 0; y < yLabels.length; y += turbineStep) {
  302. const ySlice = yLabels.slice(y, y + turbineStep);
  303. const matrixSlice = matrix.slice(y, y + turbineStep);
  304. for (let x = 0; x < xLabels.length; x += dateStep) {
  305. chunks.push({
  306. yLabels: ySlice,
  307. xLabels: xLabels.slice(x, x + dateStep),
  308. matrix: matrixSlice.map((row) => (row || []).slice(x, x + dateStep)),
  309. });
  310. }
  311. }
  312. return chunks;
  313. }
  314. export function sortTurbines(list = []) {
  315. return [...list].sort((a, b) =>
  316. String(displayTurbineName(a)).localeCompare(
  317. String(displayTurbineName(b)),
  318. "zh-CN",
  319. {
  320. numeric: true,
  321. },
  322. ),
  323. );
  324. }
  325. export function toPercentNumber(value) {
  326. const num = Number(value);
  327. if (!Number.isFinite(num)) return 0;
  328. return Number.parseFloat((num * 100).toPrecision(12));
  329. }
  330. export function formatPercent(value, digits = 2) {
  331. return toPercentNumber(value).toFixed(digits);
  332. }
  333. /** 表4-2 数值说明:角度、次数按原值,其余检测项为异常率 */
  334. export function formatSummaryValueCaption(templateKey, title) {
  335. if (templateKey === "yaw_error") return "静态偏航误差度";
  336. if (templateKey === "pitch_min") return "最小桨距角";
  337. if (templateKey === "yaw_count") return "偏航次数";
  338. const name = String(title || "检测项").replace(/分析$/, "");
  339. return `${name}异常率`;
  340. }
  341. export function formatSummaryMetricValue(templateKey, rate) {
  342. const num = Number(rate);
  343. if (!Number.isFinite(num)) return "—";
  344. if (templateKey === "yaw_count") return `${Math.round(num)}次`;
  345. if (templateKey === "yaw_error" || templateKey === "pitch_min") {
  346. return `${Number.parseFloat(num.toPrecision(12))}°`;
  347. }
  348. return `${formatPercent(num)}%`;
  349. }
  350. function detectorConfigByHit(hit = {}) {
  351. if (hit.templateKey) {
  352. const matched = DETECTOR_TEMPLATE_CONFIG.find(
  353. (cfg) => cfg.templateKey === hit.templateKey,
  354. );
  355. if (matched) return matched;
  356. }
  357. return (
  358. DETECTOR_TEMPLATE_CONFIG.find((cfg) => cfg.title === hit.title) || null
  359. );
  360. }
  361. function summaryRowFromHit(turbineName, hit = {}) {
  362. const cfg = detectorConfigByHit(hit);
  363. const templateKey = cfg?.templateKey || hit.templateKey || "";
  364. const title = cfg?.title || hit.title || "—";
  365. return {
  366. turbine_name: turbineName,
  367. anomaly_detector_count: title,
  368. anomaly_rate: formatSummaryMetricValue(templateKey, hit.rate),
  369. main_anomaly_type: formatSummaryValueCaption(templateKey, title),
  370. anomaly_points: String(Number(hit.points) || 0),
  371. };
  372. }
  373. /** 偏航次数是次数,静态偏航误差和最小桨距角是度,都不按占比换算 */
  374. export function formatDetectorRowMetric(templateKey, rate) {
  375. const num = Number(rate);
  376. if (!Number.isFinite(num)) return "—";
  377. if (templateKey === "yaw_count") {
  378. return String(Math.round(num));
  379. }
  380. if (templateKey === "yaw_error" || templateKey === "pitch_min") {
  381. return Number.parseFloat(num.toPrecision(12)).toFixed(2);
  382. }
  383. return `${formatPercent(num)}%`;
  384. }
  385. /** 模板正文为「占检测机组的{anomaly_turbine_ratio}%」,这里只给百分数 */
  386. export function formatAnomalyTurbineRatio(anomalyTurbines, testedTurbines) {
  387. const total = Number(testedTurbines) || 0;
  388. const anomaly = Number(anomalyTurbines) || 0;
  389. if (total <= 0) return "0.00";
  390. return formatPercent(anomaly / total);
  391. }
  392. function pad2(n) {
  393. return String(n).padStart(2, "0");
  394. }
  395. function parseDateParts(raw) {
  396. const text = String(raw || "").trim();
  397. const match = text.match(/^(\d{4})[-/](\d{1,2})(?:[-/](\d{1,2}))?/);
  398. if (match) {
  399. return {
  400. year: match[1],
  401. month: pad2(match[2]),
  402. day: pad2(match[3] || "1"),
  403. };
  404. }
  405. const now = new Date();
  406. return {
  407. year: String(now.getFullYear()),
  408. month: pad2(now.getMonth() + 1),
  409. day: pad2(now.getDate()),
  410. };
  411. }
  412. /** PO 的 sensorAnomalyType 编码 / 各传感器 flag → 「数据感知异常类型」文案 */
  413. export function formatSensorTypeText(item = {}) {
  414. const fromFlags = MODEL_SENSOR_FIELDS.filter(
  415. (cfg) => Number(item[cfg.field]) > 0,
  416. ).map((cfg) => cfg.label);
  417. if (fromFlags.length) return fromFlags.join("、");
  418. const raw = item.sensorAnomalyType;
  419. if (raw == null || raw === "") return "暂无异常";
  420. const labels = String(raw)
  421. .split(/[,|、\s]+/)
  422. .map((code) => SENSOR_TYPE_LABELS[Number(code)] || "")
  423. .filter(Boolean);
  424. return labels.length ? labels.join("、") : "暂无异常";
  425. }
  426. function sensorAnomalyPoints(item = {}) {
  427. const count = Number(item.sensorAnomalyCount);
  428. if (Number.isFinite(count) && count > 0) return count;
  429. return MODEL_SENSOR_FIELDS.reduce(
  430. (sum, cfg) => sum + (Number(item[cfg.field]) > 0 ? 1 : 0),
  431. 0,
  432. );
  433. }
  434. export function pickPo(moduleBundle, poKey) {
  435. if (!poKey || !moduleBundle) return null;
  436. return (
  437. moduleBundle.wind?.[poKey] ||
  438. moduleBundle.yaw?.[poKey] ||
  439. moduleBundle.pitch?.[poKey] ||
  440. moduleBundle.run?.[poKey] ||
  441. moduleBundle.aero?.[poKey] ||
  442. null
  443. );
  444. }
  445. export function isPoAnomaly(po) {
  446. return Number(po?.detectorIsAnomaly) === 1;
  447. }
  448. export function poAnomalyPoints(po) {
  449. return Number(po?.detectorAnomalyCount) || 0;
  450. }
  451. export function poAnomalyRate(po) {
  452. const raw = Number(po?.detectorAnomalyRate);
  453. if (Number.isFinite(raw)) return raw;
  454. const anomaly = poAnomalyPoints(po);
  455. const normal = Number(po?.detectorNormallyCount) || 0;
  456. const total = anomaly + normal;
  457. return total ? anomaly / total : 0;
  458. }
  459. export function buildDetectorComment(po, templateKey) {
  460. if (!po) return "暂无数据";
  461. if (templateKey === "wind_power_curve") {
  462. const ratio = Number(po.detectorAnomalyRate);
  463. if (Number.isFinite(ratio) && ratio > 1.2) return "相对理论功率曲线超发";
  464. if (Number.isFinite(ratio) && ratio > 0 && ratio < 0.8) {
  465. return "相对理论功率曲线欠发";
  466. }
  467. }
  468. if (templateKey === "ctrl_deload") {
  469. return isPoAnomaly(po) ? "存在降载运行" : "未见明显降载";
  470. }
  471. return isPoAnomaly(po) ? "功能诊断异常" : "正常";
  472. }
  473. function isWatchlistUnit(item) {
  474. return WATCHLIST_RATIO_FIELDS.some((key) => {
  475. const num = Number(item?.[key]);
  476. return Number.isFinite(num) && num > 0.5;
  477. });
  478. }
  479. function detectorHeatmapRatioByTitle(title = "") {
  480. const text = String(title || "");
  481. const field = DETECTOR_HEATMAP_FIELDS.find((item) =>
  482. text.includes(item.label.replace(/异常$/, "")),
  483. );
  484. return field?.ratioFields || [];
  485. }
  486. export function summarizeTurbineAnomalyRate(detectorHits = [], modelItem = {}) {
  487. const hits = Array.isArray(detectorHits) ? detectorHits : [];
  488. if (!hits.length) return 0;
  489. const weighted = hits.reduce(
  490. (acc, hit) => {
  491. const points = Number(hit?.points) || 0;
  492. const heatmapFields = detectorHeatmapRatioByTitle(hit?.title);
  493. let rate = Number(hit?.rate) || 0;
  494. if (heatmapFields.length) {
  495. const raw = heatmapFields
  496. .map((field) => modelItem?.[field])
  497. .find((value) => value != null && value !== "");
  498. if (raw != null) {
  499. rate = toPercentNumber(raw) / 100;
  500. }
  501. }
  502. const weight = points > 0 ? points : 1;
  503. acc.weightedSum += rate * weight;
  504. acc.weight += weight;
  505. return acc;
  506. },
  507. { weightedSum: 0, weight: 0 },
  508. );
  509. return weighted.weight ? weighted.weightedSum / weighted.weight : 0;
  510. }
  511. function collectMachineTypes(modelList = []) {
  512. return [
  513. ...new Set(
  514. modelList
  515. .map(
  516. (item) =>
  517. item.machineTypeCode ||
  518. item.engineTypeName ||
  519. item.engineTypeCode ||
  520. item.modelName ||
  521. "",
  522. )
  523. .filter(Boolean),
  524. ),
  525. ];
  526. }
  527. function withWindFarmLabel(raw = "") {
  528. const text = String(raw || "").trim();
  529. if (!text) return "";
  530. if (/风电[场厂]$/u.test(text) || /风场$/u.test(text)) return text;
  531. return `${text}风电场`;
  532. }
  533. /** 合并省/市或公司归属,去掉完全重复或互相包含的片段 */
  534. function joinLocationParts(...parts) {
  535. const cleaned = [];
  536. parts.forEach((part) => {
  537. const text = String(part || "").trim();
  538. if (!text) return;
  539. const overlapIndex = cleaned.findIndex(
  540. (item) => item === text || item.includes(text) || text.includes(item),
  541. );
  542. if (overlapIndex >= 0) {
  543. if (text.length > cleaned[overlapIndex].length) {
  544. cleaned[overlapIndex] = text;
  545. }
  546. return;
  547. }
  548. cleaned.push(text);
  549. });
  550. return cleaned.join("");
  551. }
  552. function buildOverviewText(
  553. fieldMeta = {},
  554. fieldName,
  555. machineTypes,
  556. turbineCount,
  557. ) {
  558. const province = fieldMeta.provinceName || fieldMeta.province || "";
  559. const city = fieldMeta.cityName || fieldMeta.city || "";
  560. const location = joinLocationParts(province, city);
  561. const typeText = machineTypes.join("、") || "—";
  562. const farmLabel = withWindFarmLabel(fieldName);
  563. // 模板为「{Overview_of_the_Wind_Farm}。基于…」,此处不加句号,避免「。。」
  564. if (location) {
  565. return `${farmLabel}位于${location},机型${typeText},共安装${turbineCount}台风机`;
  566. }
  567. return `${farmLabel},机型${typeText},接入机组${turbineCount}台`;
  568. }
  569. function riskLevel(rate, turbines) {
  570. if (turbines >= 8 || rate >= 0.2) return "P1";
  571. if (turbines >= 3 || rate >= 0.05) return "P2";
  572. return "P3";
  573. }
  574. export function buildEmptyDetectorSection(cfg) {
  575. const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
  576. const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
  577. const rowsKey = `${cfg.templateKey}Rows`;
  578. return {
  579. [`show-${fileTag}`]: [],
  580. [farmTag]: [],
  581. [fileTag]: [],
  582. [rowsKey]: [],
  583. };
  584. }
  585. export function buildDetectorSectionPayload(
  586. cfg,
  587. {
  588. farmImages = [],
  589. turbineImages = [],
  590. rows = [],
  591. anomalyPoints = 0,
  592. anomalyRate = 0,
  593. anomalyTurbines = 0,
  594. testedTurbines = 0,
  595. hasChartData = false,
  596. },
  597. ) {
  598. const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
  599. const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
  600. const rowsKey = `${cfg.templateKey}Rows`;
  601. const tableRows =
  602. Array.isArray(rows) && rows.length ? rows : emptyDetectorRowsPlaceholder();
  603. const visible =
  604. hasChartData && (farmImages.length > 0 || turbineImages.length > 0);
  605. const section = {
  606. anomaly_points: String(anomalyPoints),
  607. anomaly_rate: `${formatPercent(anomalyRate)}%`,
  608. anomaly_turbines: String(anomalyTurbines),
  609. anomaly_turbine_ratio: formatAnomalyTurbineRatio(
  610. anomalyTurbines,
  611. testedTurbines,
  612. ),
  613. [farmTag]: farmImages,
  614. [fileTag]: turbineImages,
  615. [rowsKey]: tableRows,
  616. };
  617. return {
  618. [`show-${fileTag}`]: visible ? [section] : [],
  619. [farmTag]: visible ? farmImages : [],
  620. [fileTag]: visible ? turbineImages : [],
  621. [rowsKey]: visible ? tableRows : [],
  622. };
  623. }
  624. export function buildModuleVisibilityPayload(detectorState = {}) {
  625. const payload = {};
  626. const modules = new Set(
  627. DETECTOR_TEMPLATE_CONFIG.map((cfg) => cfg.module).filter(Boolean),
  628. );
  629. modules.forEach((moduleKey) => {
  630. const hasData = DETECTOR_TEMPLATE_CONFIG.some(
  631. (cfg) =>
  632. cfg.module === moduleKey && detectorState[cfg.templateKey]?.hasChartData,
  633. );
  634. payload[`show_module_${moduleKey}`] = hasData ? [{}] : [];
  635. });
  636. return payload;
  637. }
  638. /** 页眉模板:大唐{Province}能源有限公司{Wind_farm}风机异常检测数据分析报告 */
  639. const CHINA_PROVINCE_NAMES = [
  640. "新疆",
  641. "内蒙古",
  642. "西藏",
  643. "宁夏",
  644. "广西",
  645. "黑龙江",
  646. "吉林",
  647. "辽宁",
  648. "河北",
  649. "河南",
  650. "山东",
  651. "山西",
  652. "陕西",
  653. "甘肃",
  654. "青海",
  655. "江苏",
  656. "浙江",
  657. "安徽",
  658. "福建",
  659. "江西",
  660. "湖北",
  661. "湖南",
  662. "广东",
  663. "海南",
  664. "四川",
  665. "贵州",
  666. "云南",
  667. "北京",
  668. "天津",
  669. "上海",
  670. "重庆",
  671. ];
  672. function inferProvinceName(...texts) {
  673. const blob = texts.map((item) => String(item || "")).join(" ");
  674. return CHINA_PROVINCE_NAMES.find((name) => blob.includes(name)) || "";
  675. }
  676. function normalizeHeaderProvince(raw = "") {
  677. const inferred = inferProvinceName(raw);
  678. if (inferred) return inferred;
  679. let text = String(raw || "").trim();
  680. if (!text) return "";
  681. text = text.replace(/^大唐+/g, "").trim();
  682. text = text
  683. .replace(/能源?有限公司$/g, "")
  684. .replace(/有限责任公司$/g, "")
  685. .replace(/公司$/g, "")
  686. .trim();
  687. return CHINA_PROVINCE_NAMES.includes(text) ? text : "";
  688. }
  689. function isFarmDisplayName(raw = "") {
  690. const text = String(raw || "").trim();
  691. if (!text) return false;
  692. if (!/[\u4e00-\u9fff]/.test(text) && /^[\w-]+$/.test(text)) return false;
  693. return true;
  694. }
  695. function pickFarmDisplayName({
  696. fieldName,
  697. fieldMeta = {},
  698. overview = {},
  699. fieldCode,
  700. } = {}) {
  701. const candidates = [
  702. fieldName,
  703. fieldMeta.fieldName,
  704. fieldMeta.companyName,
  705. overview.fieldName,
  706. ]
  707. .map((item) => String(item || "").trim())
  708. .filter(isFarmDisplayName);
  709. if (candidates[0]) return candidates[0];
  710. return isFarmDisplayName(fieldCode) ? String(fieldCode).trim() : "";
  711. }
  712. function ensureWindFarmSuffix(raw = "") {
  713. let text = String(raw || "").trim();
  714. if (!text) return "";
  715. if (/风电场$/u.test(text)) return text;
  716. if (/风场$/u.test(text)) return text.replace(/风场$/u, "风电场");
  717. return `${text}风电场`;
  718. }
  719. function normalizeHeaderWindFarm(raw = "", provincePart = "") {
  720. let text = String(raw || "").trim();
  721. if (!text) return "";
  722. text = text.replace(/^大唐/g, "").trim();
  723. if (provincePart && text.startsWith(provincePart)) {
  724. text = text.slice(provincePart.length).trim();
  725. }
  726. text = text.replace(/^[\s·\-—]+/, "").trim();
  727. return ensureWindFarmSuffix(text || String(raw || "").trim());
  728. }
  729. function normalizeMachineTypeCode(raw) {
  730. if (Array.isArray(raw)) {
  731. return [
  732. ...new Set(raw.map((item) => String(item || "").trim()).filter(Boolean)),
  733. ].join("、");
  734. }
  735. return String(raw || "").trim();
  736. }
  737. export function mapAnomalyCoverFields({
  738. fieldCode,
  739. datatime,
  740. fieldName,
  741. fieldMeta = {},
  742. overview = {},
  743. modelList = [],
  744. machineTypeCode,
  745. }) {
  746. const parts = parseDateParts(datatime || overview.sourceDatetime);
  747. const turbines = sortTurbines(modelList);
  748. const machineTypes = collectMachineTypes(turbines);
  749. const typeText =
  750. normalizeMachineTypeCode(machineTypeCode) ||
  751. machineTypes.join("、") ||
  752. fieldMeta.machineTypeCode ||
  753. "—";
  754. const rawFarm = pickFarmDisplayName({
  755. fieldName,
  756. fieldMeta,
  757. overview,
  758. fieldCode,
  759. });
  760. const province = inferProvinceName(
  761. fieldMeta.provinceName,
  762. fieldMeta.province,
  763. fieldName,
  764. fieldMeta.fieldName,
  765. fieldMeta.companyName,
  766. rawFarm,
  767. ) || normalizeHeaderProvince(
  768. fieldMeta.provinceName || fieldMeta.province || "",
  769. );
  770. const farm = normalizeHeaderWindFarm(rawFarm, province);
  771. const turbineCount = turbines.length || Number(fieldMeta.engineCount) || 0;
  772. return {
  773. reportNo: `AD-${fieldCode || "FIELD"}-${parts.year}${parts.month}${
  774. parts.day
  775. }`,
  776. Province: province,
  777. Wind_farm: farm,
  778. Year_now: parts.year,
  779. Month_now: parts.month,
  780. machineTypeCode: typeText,
  781. Company: province ? `大唐${province}能源有限公司` : "大唐可再生能源试验研究院有限公司",
  782. turbine_count: String(turbineCount),
  783. target_date:
  784. datatime ||
  785. overview.sourceDatetime ||
  786. `${parts.year}-${parts.month}-${parts.day}`,
  787. Overview_of_the_Wind_Farm: String(
  788. buildOverviewText(
  789. fieldMeta,
  790. rawFarm,
  791. typeText === "—" ? [] : typeText.split("、"),
  792. turbineCount,
  793. ) || "",
  794. ).replace(/[。..]+$/u, ""),
  795. };
  796. }
  797. function isSensorTableRowActive(item = {}, cfg = {}) {
  798. if (Number(item[cfg.flagField]) > 0) return true;
  799. return readRatioPercent(item, cfg.ratioFields) > 0;
  800. }
  801. export function buildSensorAnomalyRows(modelList = []) {
  802. const rows = [];
  803. sortTurbines(modelList).forEach((item) => {
  804. SENSOR_TABLE_FIELDS.forEach((cfg) => {
  805. if (!isSensorTableRowActive(item, cfg)) return;
  806. const ratio = readRatioPercent(item, cfg.ratioFields);
  807. rows.push({
  808. turbine_name: displayTurbineName(item),
  809. sensor_anomaly_type: cfg.label,
  810. ratio: `${ratio.toFixed(2)}%`,
  811. });
  812. });
  813. });
  814. return rows.length ? rows : emptySensorAnomalyRowsPlaceholder();
  815. }
  816. export function emptySensorAnomalyRowsPlaceholder() {
  817. return [
  818. {
  819. turbine_name: "暂无",
  820. sensor_anomaly_type: "暂无",
  821. anomaly_points: "—",
  822. ratio: "—",
  823. },
  824. ];
  825. }
  826. export function buildDetectorSummaryRows(aggregates = {}) {
  827. return DETECTOR_TEMPLATE_CONFIG.filter((cfg) => cfg.poKey).map((cfg) => {
  828. const item = aggregates[cfg.templateKey] || {};
  829. return {
  830. detector_name: cfg.title,
  831. module_name: cfg.moduleName,
  832. data_granularity: cfg.granularity,
  833. anomaly_turbines: String(item.anomalyTurbines || 0),
  834. anomaly_points: String(item.anomalyPoints || 0),
  835. avg_anomaly_rate: `${formatPercent(item.avgRate || 0)}%`,
  836. };
  837. });
  838. }
  839. const DELOAD_MAIN_ANOMALY_TITLE = "降载判定分析";
  840. function isDeloadMainAnomalyType(title = "") {
  841. return String(title || "").includes(DELOAD_MAIN_ANOMALY_TITLE);
  842. }
  843. /** 表4-2:一行一个检测项。功能诊断项为检测器名称,数值为该项指标,数值说明为指标含义 */
  844. export function buildAnomalySummaryRows(turbineStats = []) {
  845. const rows = [];
  846. turbineStats.forEach((item) => {
  847. const rawHits = Array.isArray(item.detectorHits) ? item.detectorHits : [];
  848. const hits = rawHits
  849. .filter((hit) => !isDeloadMainAnomalyType(hit?.title))
  850. .sort((a, b) => (Number(b.points) || 0) - (Number(a.points) || 0));
  851. if (hits.length) {
  852. hits.forEach((hit) => rows.push(summaryRowFromHit(item.turbineName, hit)));
  853. return;
  854. }
  855. if (isDeloadMainAnomalyType(item.mainAnomalyType)) return;
  856. if (!(item.anomalyDetectorCount > 0 || item.anomalyPoints > 0)) return;
  857. rows.push(
  858. summaryRowFromHit(item.turbineName, {
  859. title: item.mainAnomalyType,
  860. rate: item.anomalyRate,
  861. points: item.anomalyPoints,
  862. }),
  863. );
  864. });
  865. return rows;
  866. }
  867. export function pickKeyTurbines(turbineStats = [], modelList = []) {
  868. const watchIds = new Set(
  869. modelList
  870. .filter(isWatchlistUnit)
  871. .map((item) => String(item.engineId || "")),
  872. );
  873. return turbineStats.filter((item) => watchIds.has(String(item.engineId)));
  874. }
  875. export function emptyDetectorRowsPlaceholder() {
  876. return [
  877. {
  878. turbine_name: "暂无",
  879. anomaly_points: "—",
  880. anomaly_rate: "—",
  881. sensor_anomaly_type: "—",
  882. comment: "暂无",
  883. },
  884. ];
  885. }
  886. export function emptyKeyTurbinePlaceholder() {
  887. return [
  888. {
  889. turbine_name: "暂无",
  890. turbineDetailRows: [
  891. {
  892. detector_name: "暂无",
  893. anomaly_points: "—",
  894. anomaly_rate: "—",
  895. conclusion: "暂无",
  896. },
  897. ],
  898. "zn-techcn-replace-tags-key_turbine-generalFiles": [],
  899. },
  900. ];
  901. }
  902. export function extractProblemDesc(raw = "") {
  903. const text = String(raw || "")
  904. .replace(/&gt;/g, ">")
  905. .replace(/&lt;/g, "<")
  906. .replace(/\s+/g, " ")
  907. .trim();
  908. if (!text) return "";
  909. const start = text.indexOf("主要表现为");
  910. let body =
  911. start >= 0
  912. ? text.slice(start)
  913. : text.replace(/^本次检测中,[^;;]*[;;]/, "").trim();
  914. body = body
  915. .replace(
  916. /[,,;;、]?(?:机组明细|相关机组[^。]{0,24})见(?:表|图)[^。]*。?/g,
  917. "",
  918. )
  919. .replace(/[,,;;]\s*$/g, "")
  920. .trim();
  921. if (body && !body.startsWith("主要表现为")) {
  922. body = `主要表现为${body}`;
  923. }
  924. if (body && !/[。!?]$/.test(body)) body += "。";
  925. return body;
  926. }
  927. export function formatConclusionTurbineName(name = "") {
  928. const text = String(name || "").trim();
  929. if (!text) return "";
  930. const stripped = text.replace(/^#+/, "").replace(/号$/u, "").trim();
  931. if (/^\d+$/.test(stripped)) {
  932. return `#${stripped.padStart(2, "0")}`;
  933. }
  934. return text;
  935. }
  936. export function formatConclusionTurbines(names = []) {
  937. const unique = [
  938. ...new Set(
  939. (Array.isArray(names) ? names : [])
  940. .map((item) => formatConclusionTurbineName(item))
  941. .filter(Boolean),
  942. ),
  943. ];
  944. unique.sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true }));
  945. return unique.join("、") || "—";
  946. }
  947. export function buildConclusionRows(aggregates = {}) {
  948. const rows = DETECTOR_TEMPLATE_CONFIG.filter(
  949. (cfg) => cfg.poKey && !cfg.skipConclusion,
  950. )
  951. .map((cfg) => {
  952. const item = aggregates[cfg.templateKey] || {};
  953. if (!item.anomalyTurbines && !item.anomalyPoints) return null;
  954. return {
  955. problem_desc: extractProblemDesc(cfg.problemDesc || cfg.title),
  956. suggestion: String(cfg.suggestion || "").trim() || "—",
  957. problem_nature: cfg.moduleTitle || cfg.moduleName || "—",
  958. turbine_names: formatConclusionTurbines(item.turbineNames),
  959. };
  960. })
  961. .filter(Boolean)
  962. .map((row, index) => ({ ...row, index: String(index + 1) }));
  963. if (rows.length) return rows;
  964. return [
  965. {
  966. index: "1",
  967. problem_desc: "本次检测未发现功能诊断异常。",
  968. suggestion: "—",
  969. problem_nature: "—",
  970. turbine_names: "—",
  971. },
  972. ];
  973. }
  974. export function summarizeFarmStats({
  975. overview = {},
  976. modelList = [],
  977. turbineStats = [],
  978. aggregates = {},
  979. }) {
  980. const turbineCount = modelList.length;
  981. const anomalyTurbines = turbineStats.filter(
  982. (item) => item.anomalyDetectorCount > 0,
  983. ).length;
  984. const totalPoints = turbineStats.reduce(
  985. (sum, item) => sum + (item.anomalyPoints || 0),
  986. 0,
  987. );
  988. const overviewPoints =
  989. Number(overview.detectorAnomalyCount) ||
  990. Number(overview.totalAnomalyCount) ||
  991. totalPoints;
  992. const rate = turbineCount ? anomalyTurbines / turbineCount : 0;
  993. const moduleKeys = new Set(
  994. DETECTOR_TEMPLATE_CONFIG.filter(
  995. (cfg) => (aggregates[cfg.templateKey]?.anomalyTurbines || 0) > 0,
  996. ).map((cfg) => cfg.moduleName),
  997. );
  998. const topDetector = Object.entries(aggregates)
  999. .map(([key, value]) => ({
  1000. key,
  1001. ...value,
  1002. title: DETECTOR_TEMPLATE_CONFIG.find((cfg) => cfg.templateKey === key)
  1003. ?.title,
  1004. }))
  1005. .sort((a, b) => (b.anomalyPoints || 0) - (a.anomalyPoints || 0))[0];
  1006. return {
  1007. turbine_count: String(turbineCount),
  1008. anomaly_turbine_count: String(
  1009. Number(overview.anomalyCount) || anomalyTurbines,
  1010. ),
  1011. total_anomaly_points: String(overviewPoints || totalPoints),
  1012. anomaly_rate: formatPercent(rate),
  1013. anomaly_module_count: String(moduleKeys.size),
  1014. main_problem_modules: [...moduleKeys].join("、") || "暂无突出模块",
  1015. top_problem_description: topDetector?.title
  1016. ? `${topDetector.title}异常点数最多(${topDetector.anomalyPoints || 0})`
  1017. : "各检测器未见集中异常",
  1018. };
  1019. }