anomalyDetectorPlotAdapter.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. /**
  2. * 算法检测器 JSON(单机图 / 风场总图)→ 前端与报告共用的面板结构。
  3. * 单机:通用 data[],或多面板 panels[]。
  4. * 风场:chartType=series 曲线叠加,chartType=value 单值柱状对比。
  5. */
  6. export const PO_TO_TEMPLATE_KEY = {
  7. anomalyPowercurvePO: "wind_power_curve",
  8. anomalyScatterPO: "wind_power_scatter",
  9. anomalyStaticyawPO: "yaw_static",
  10. anomalyCabletwistPO: "yaw_twist",
  11. anomalyYawErrorPO: "yaw_error",
  12. anomalyYawCountPO: "yaw_count",
  13. anomalyPitchcoordPO: "pitch_coord",
  14. anomalyPitchregulationPO: "pitch_regulation",
  15. anomalyMinpitchPO: "pitch_min",
  16. anomalyPowerqualityPO: "ctrl_power_quality",
  17. anomalyOperationPO: "ctrl_op_state",
  18. anomalyDeloadPO: "ctrl_deload",
  19. anomalyCpPO: "aero_cp",
  20. anomalyTsrPO: "aero_tsr",
  21. anomalyCpTsrPO: "aero_tsr_wind",
  22. };
  23. export const FARM_VALUE_AXIS = {
  24. yaw_error: "静态偏航误差 (°)",
  25. yaw_count: "偏航次数",
  26. pitch_min: "最小桨距角漂移 (°)",
  27. };
  28. export const FARM_VALUE_TITLES = {
  29. yaw_error: "全场静态偏航误差",
  30. yaw_count: "全场偏航次数",
  31. pitch_min: "全场最小桨距角漂移",
  32. };
  33. export const FARM_SERIES_TITLES = {
  34. wind_power_curve: "全场功率曲线分析",
  35. wind_power_scatter: "全场风功率散点分析",
  36. yaw_static: "全场静态偏航异常分布",
  37. yaw_twist: "全场扭缆分析",
  38. pitch_regulation: "全场变桨一致性分析",
  39. pitch_coord: "全场变桨协调分析",
  40. ctrl_power_quality: "全场电能质量分析",
  41. ctrl_op_state: "全场运行状态分析",
  42. ctrl_deload: "全场降载判定分析",
  43. aero_cp: "全场Cp功率系数分析",
  44. aero_tsr: "全场TSR叶尖速比分析",
  45. aero_tsr_wind: "全场TSR-Cp联合分析",
  46. };
  47. export function resolveTemplateKey(options = {}, detectorKey = "") {
  48. if (options.templateKey) return options.templateKey;
  49. const key = options.detectorKey || options.poKey || detectorKey || "";
  50. return PO_TO_TEMPLATE_KEY[key] || options.detectorName || "";
  51. }
  52. export function isYawErrorDetector(templateKey) {
  53. return templateKey === "yaw_error";
  54. }
  55. export function isYawCountDetector(templateKey) {
  56. return templateKey === "yaw_count";
  57. }
  58. export function isPitchMinDetector(templateKey) {
  59. return templateKey === "pitch_min";
  60. }
  61. export function isFarmAggregateJson(plotJson) {
  62. return Boolean(
  63. plotJson &&
  64. (plotJson.chartType === "series" || plotJson.chartType === "value") &&
  65. Array.isArray(plotJson.turbines),
  66. );
  67. }
  68. /** 多图检测器:顶层 chartType=panels,每个 panel 自带 turbines */
  69. export function isFarmPanelsJson(plotJson) {
  70. return Boolean(
  71. plotJson &&
  72. plotJson.chartType === "panels" &&
  73. Array.isArray(plotJson.panels) &&
  74. plotJson.panels.some((panel) => Array.isArray(panel?.turbines)),
  75. );
  76. }
  77. export function isFarmPlotJson(plotJson) {
  78. return isFarmAggregateJson(plotJson) || isFarmPanelsJson(plotJson);
  79. }
  80. export function isValueBarPanel(panel) {
  81. return panel?.chartKind === "valueBar";
  82. }
  83. export function isBubbleColorbar(row = {}, templateKey = "") {
  84. if (isPitchMinDetector(templateKey)) return true;
  85. return /点数/.test(String(row.colorbarTitle || row.colorbartitle || ""));
  86. }
  87. /** 最小桨距角仅用点数上色,点径统一,不再按点数缩放气泡 */
  88. export function usesBubbleSymbolSize(row = {}, templateKey = "") {
  89. if (isPitchMinDetector(templateKey)) return false;
  90. return /点数/.test(String(row.colorbarTitle || row.colorbartitle || ""));
  91. }
  92. /**
  93. * 连续色映射取值:优先 colorbar,其次数值型 pointData(与 x/y 同下标)。
  94. * 点数色带即用该序列控制每个点的颜色深浅。
  95. */
  96. export function resolveRowColorMetric(row = {}, length = 0) {
  97. const len =
  98. Number(length) > 0
  99. ? Number(length)
  100. : Math.min(
  101. Array.isArray(row?.xData) ? row.xData.length : 0,
  102. Array.isArray(row?.yData) ? row.yData.length : 0,
  103. );
  104. if (!len) return [];
  105. const colorbar = row.colorbar || row.colorBar;
  106. if (Array.isArray(colorbar) && colorbar.length >= len) {
  107. return colorbar.slice(0, len).map((v) => {
  108. const n = Number(v);
  109. return Number.isFinite(n) ? n : 0;
  110. });
  111. }
  112. const pointData = row.pointData;
  113. if (!Array.isArray(pointData) || pointData.length < len) return [];
  114. // pointData 若为悬停对象数组则不可作色值
  115. if (pointData.some((item) => item != null && typeof item === "object")) {
  116. return [];
  117. }
  118. return pointData.slice(0, len).map((v) => {
  119. const n = Number(v);
  120. return Number.isFinite(n) ? n : 0;
  121. });
  122. }
  123. /** 点数大的点后绘制(盖在浅色点之上),并写入更高 z */
  124. export function sortScatterPairsByColorMetricAsc(pairs = []) {
  125. if (!Array.isArray(pairs) || pairs.length < 2) {
  126. return attachCountRenderZ(pairs);
  127. }
  128. const metricOf = (item) => {
  129. const value = Array.isArray(item) ? item : item?.value;
  130. if (!Array.isArray(value) || value.length < 3) return 0;
  131. const n = Number(value[2]);
  132. return Number.isFinite(n) ? n : 0;
  133. };
  134. return attachCountRenderZ(
  135. [...pairs].sort((a, b) => metricOf(a) - metricOf(b)),
  136. );
  137. }
  138. function attachCountRenderZ(pairs = []) {
  139. if (!Array.isArray(pairs) || !pairs.length) return pairs;
  140. return pairs.map((item) => {
  141. const value = Array.isArray(item) ? item : item?.value;
  142. const count =
  143. Array.isArray(value) && value.length >= 3 ? Number(value[2]) : 0;
  144. const z = Number.isFinite(count) ? Math.max(1, Math.round(count)) : 1;
  145. if (Array.isArray(item)) return { value: item, z };
  146. return { ...item, z };
  147. });
  148. }
  149. export function findPeakCoord(xArr = [], yArr = []) {
  150. const len = Math.min(xArr.length, yArr.length);
  151. if (!len) return null;
  152. let maxIdx = 0;
  153. let maxY = Number(yArr[0]);
  154. for (let i = 1; i < len; i += 1) {
  155. const y = Number(yArr[i]);
  156. if (Number.isFinite(y) && (!Number.isFinite(maxY) || y > maxY)) {
  157. maxY = y;
  158. maxIdx = i;
  159. }
  160. }
  161. if (!Number.isFinite(maxY)) return null;
  162. return { x: xArr[maxIdx], y: maxY, index: maxIdx };
  163. }
  164. /** 仅透传 JSON 中的小标题字段,不做前端拼接 */
  165. export function resolvePanelSubtitle(panel = {}, plotJson = {}) {
  166. const text =
  167. panel.subtitle ??
  168. panel.subtext ??
  169. panel.panelSubtitle ??
  170. plotJson.subtitle ??
  171. plotJson.subtext ??
  172. "";
  173. return String(text || "").trim();
  174. }
  175. function copyPanelMeta(panel, plotJson = {}) {
  176. return {
  177. panelKey: panel.key || "",
  178. title: panel.panelTitle || panel.title || plotJson.title || "",
  179. subtitle: resolvePanelSubtitle(panel, plotJson),
  180. xaxis: panel.xaxis || plotJson.xaxis || "",
  181. yaxis: panel.yaxis || plotJson.yaxis || "",
  182. xrange: panel.xrange || plotJson.xrange,
  183. yrange: panel.yrange || plotJson.yrange,
  184. data: appendReferenceCurveRows(panel.data || [], {
  185. referenceCurve: panel.referenceCurve || plotJson.referenceCurve,
  186. referenceCurves: panel.referenceCurves || plotJson.referenceCurves,
  187. contractCurve: panel.contractCurve || plotJson.contractCurve,
  188. }),
  189. rawMeans: panel.rawMeans,
  190. detectorName:
  191. plotJson.detector_name ||
  192. plotJson.analysisType ||
  193. plotJson.detector ||
  194. "",
  195. };
  196. }
  197. export function unwrapFarmPlotJson(raw) {
  198. if (!raw || typeof raw !== "object") return null;
  199. if (raw.__imageUrl) return raw;
  200. if (isFarmPlotJson(raw)) return raw;
  201. if (raw.data && isFarmPlotJson(raw.data)) return raw.data;
  202. return raw;
  203. }
  204. /** panels[] 里自带 turbines 的分面,按全场叠加图规则展开 */
  205. function farmPanelToPanel(panel = {}, farmJson = {}, options = {}) {
  206. const title = panel.title || panel.panelTitle || "";
  207. const [built] = farmAggregateToPanels(
  208. {
  209. detector: farmJson.detector || options.templateKey || "",
  210. farm: farmJson.farm,
  211. chartType: panel.chartType || "series",
  212. xaxis: panel.xaxis,
  213. yaxis: panel.yaxis,
  214. xrange: panel.xrange,
  215. yrange: panel.yrange,
  216. turbines: panel.turbines,
  217. referenceCurve: panel.referenceCurve || farmJson.referenceCurve,
  218. referenceCurves: panel.referenceCurves || farmJson.referenceCurves,
  219. contractCurve: panel.contractCurve || farmJson.contractCurve,
  220. },
  221. { ...options, title: title || options.title },
  222. );
  223. if (!built) return null;
  224. return {
  225. ...built,
  226. panelKey: panel.key || "",
  227. title: title || built.title,
  228. xaxis: panel.xaxis || built.xaxis,
  229. yaxis: panel.yaxis || built.yaxis,
  230. };
  231. }
  232. export function extractDetectorPanels(plotJson, options = {}) {
  233. const json = unwrapFarmPlotJson(plotJson);
  234. if (!json) return [];
  235. if (Array.isArray(json.panels) && json.panels.length) {
  236. return json.panels
  237. .map((panel) =>
  238. Array.isArray(panel?.turbines)
  239. ? farmPanelToPanel(panel, json, options)
  240. : copyPanelMeta(panel, json),
  241. )
  242. .filter(Boolean);
  243. }
  244. if (isFarmAggregateJson(json)) {
  245. return farmAggregateToPanels(json, options);
  246. }
  247. return [copyPanelMeta(json, json)];
  248. }
  249. function formatTurbineLabel(name, nameMap = {}) {
  250. const raw = String(name || "");
  251. if (!raw) return "--";
  252. const mapped = nameMap[raw] || nameMap[String(raw)];
  253. const text = mapped ? String(mapped) : raw;
  254. if (/号$/.test(text)) return text;
  255. if (/^\d+$/.test(text)) return `${text}号`;
  256. return text;
  257. }
  258. function meanOf(values = []) {
  259. const nums = values.map(Number).filter((n) => Number.isFinite(n));
  260. if (!nums.length) return null;
  261. return nums.reduce((sum, n) => sum + n, 0) / nums.length;
  262. }
  263. function farmSeriesMode(detector, item = {}) {
  264. if (item.xType === "time") return "markers";
  265. if (detector === "wind_power_curve") return "lines";
  266. if (detector === "pitch_regulation") return "lines+markers";
  267. return "markers";
  268. }
  269. function referenceCurveDisplayLabel(item = {}) {
  270. const text = String(item.label || "合同功率曲线");
  271. if (/合同功率/.test(text)) return "合同功率曲线";
  272. if (/近90日/.test(text)) return "近90日功率曲线";
  273. if (/参考功率|理论功率|参考曲线/.test(text)) return "参考功率曲线";
  274. return text;
  275. }
  276. function buildReferenceCurveRow(item) {
  277. if (!item || typeof item !== "object") return null;
  278. const xData = Array.isArray(item.xData)
  279. ? item.xData
  280. : Array.isArray(item.x)
  281. ? item.x
  282. : [];
  283. const yData = Array.isArray(item.yData)
  284. ? item.yData
  285. : Array.isArray(item.y)
  286. ? item.y
  287. : [];
  288. if (!xData.length) return null;
  289. const label = referenceCurveDisplayLabel(item);
  290. const model = item.model == null ? "" : String(item.model).trim();
  291. return {
  292. label,
  293. originalLabel: item.label || label,
  294. model,
  295. mode: "lines",
  296. xData,
  297. yData,
  298. timeData: Array.isArray(item.timeData) ? item.timeData : [],
  299. __shared: true,
  300. __farm: true,
  301. extra: item,
  302. };
  303. }
  304. function collectFarmReferenceRows(farmJson = {}) {
  305. const list = [];
  306. if (farmJson.referenceCurve) list.push(farmJson.referenceCurve);
  307. if (Array.isArray(farmJson.referenceCurves)) {
  308. list.push(...farmJson.referenceCurves);
  309. }
  310. if (farmJson.contractCurve) list.push(farmJson.contractCurve);
  311. const rows = list.map(buildReferenceCurveRow).filter(Boolean);
  312. const seen = new Set();
  313. return rows.filter((row) => {
  314. // 同一条合同曲线仍只留一条;不同机型的 referenceCurves 都要保留
  315. const key = `${row.label}\0${row.model || ""}`;
  316. if (seen.has(key)) return false;
  317. seen.add(key);
  318. return true;
  319. });
  320. }
  321. function referenceRowKey(row = {}) {
  322. const label = referenceCurveDisplayLabel(row);
  323. const model = String(row.model || row.extra?.model || "").trim();
  324. return `${label}\0${model}`;
  325. }
  326. /** 单机 data 里没有的合同/参考曲线,从 referenceCurves 补上 */
  327. function appendReferenceCurveRows(data = [], source = {}) {
  328. const rows = collectFarmReferenceRows(source);
  329. if (!rows.length) return data;
  330. const seen = new Set((data || []).map((row) => referenceRowKey(row)));
  331. const extra = rows.filter((row) => {
  332. const key = referenceRowKey(row);
  333. if (seen.has(key)) return false;
  334. seen.add(key);
  335. return true;
  336. });
  337. return (data || []).concat(extra);
  338. }
  339. /** pointAnomaly[i]===true 才是异常点;字符串 "false" 不能当成 true */
  340. export function isPointAnomalyFlag(flag) {
  341. return flag === true || flag === 1 || flag === "1" || flag === "true";
  342. }
  343. export function readPointAnomalyFlags(item) {
  344. if (!item || typeof item !== "object") return null;
  345. const candidates = [item.pointAnomaly, item.point_anomaly];
  346. if (!Array.isArray(item.pointAnomaly) && item.extra && item.extra !== item) {
  347. candidates.push(item.extra.pointAnomaly, item.extra.point_anomaly);
  348. }
  349. for (let i = 0; i < candidates.length; i += 1) {
  350. const raw = candidates[i];
  351. if (Array.isArray(raw)) return raw;
  352. if (typeof raw === "string" && raw.trim().startsWith("[")) {
  353. try {
  354. const parsed = JSON.parse(raw);
  355. if (Array.isArray(parsed)) return parsed;
  356. } catch (_error) {
  357. /* 忽略无法解析的标记 */
  358. }
  359. }
  360. }
  361. return null;
  362. }
  363. /**
  364. * 按 pointAnomaly 把一条折线拆成首尾相接的红/蓝段。
  365. * 相邻点只要有一端是异常,这段线就是红线,正常点之间是蓝线。
  366. */
  367. export function splitConnectedLineByPointAnomaly(xData = [], yData = [], flags) {
  368. const len = Math.min(xData.length, yData.length);
  369. const hasFlags = Array.isArray(flags) && flags.length > 0;
  370. const flagged = (index) => hasFlags && isPointAnomalyFlag(flags[index]);
  371. if (!len) return [];
  372. if (!hasFlags || len === 1) {
  373. return [
  374. {
  375. anomaly: flagged(0),
  376. xData: xData.slice(0, len),
  377. yData: yData.slice(0, len),
  378. },
  379. ];
  380. }
  381. const segments = [];
  382. let start = 0;
  383. let anomaly = flagged(0) || flagged(1);
  384. for (let index = 1; index < len - 1; index += 1) {
  385. const next = flagged(index) || flagged(index + 1);
  386. if (next === anomaly) continue;
  387. segments.push({
  388. anomaly,
  389. xData: xData.slice(start, index + 1),
  390. yData: yData.slice(start, index + 1),
  391. });
  392. start = index;
  393. anomaly = next;
  394. }
  395. segments.push({
  396. anomaly,
  397. xData: xData.slice(start, len),
  398. yData: yData.slice(start, len),
  399. });
  400. return segments.filter((segment) => segment.xData.length > 0);
  401. }
  402. function farmSeriesKind(item = {}, detector = "") {
  403. // 风功率散点没有逐点 pointAnomaly 时,不按整机 anomaly 把全部点画红
  404. if (detector === "wind_power_scatter") return "正常";
  405. if (item.status) return String(item.status);
  406. if (item.anomaly) return "异常";
  407. return "正常";
  408. }
  409. function sliceByFlag(item, flags, keepAnomaly) {
  410. const xData = [];
  411. const yData = [];
  412. const timeData = [];
  413. const sourceX = item.xData || [];
  414. const sourceY = item.yData || [];
  415. const sourceTime = Array.isArray(item.timeData) ? item.timeData : [];
  416. const len = Math.min(sourceX.length, sourceY.length);
  417. for (let idx = 0; idx < len; idx += 1) {
  418. // pointAnomaly[i] 与 xData/yData 同下标;缺省按正常(false)
  419. const flagged = isPointAnomalyFlag(flags[idx]);
  420. if (flagged !== keepAnomaly) continue;
  421. xData.push(sourceX[idx]);
  422. yData.push(sourceY[idx]);
  423. if (sourceTime[idx] != null) timeData.push(sourceTime[idx]);
  424. }
  425. return { xData, yData, timeData };
  426. }
  427. /** 变桨一致性等检测器:一台风机内按桨叶分多条曲线 */
  428. function buildFarmBladeRows(item, turbineName, mode) {
  429. const turbineAnomaly = Boolean(item.anomaly);
  430. return (item.blades || [])
  431. .filter((blade) => blade && Array.isArray(blade.xData))
  432. .map((blade) => {
  433. const bladeLabel = blade.label || "";
  434. return {
  435. label: `${turbineName} ${bladeLabel}`.trim(),
  436. originalLabel: bladeLabel,
  437. mode,
  438. xData: blade.xData || [],
  439. yData: blade.yData || [],
  440. timeData: Array.isArray(blade.timeData)
  441. ? blade.timeData
  442. : item.xType === "time"
  443. ? blade.xData || []
  444. : [],
  445. __farm: true,
  446. anomaly: turbineAnomaly,
  447. pointAnomaly: Array.isArray(blade.pointAnomaly)
  448. ? blade.pointAnomaly
  449. : Array.isArray(blade.point_anomaly)
  450. ? blade.point_anomaly
  451. : Array.isArray(item.pointAnomaly)
  452. ? item.pointAnomaly
  453. : Array.isArray(item.point_anomaly)
  454. ? item.point_anomaly
  455. : undefined,
  456. extra: {
  457. ...item,
  458. anomaly: turbineAnomaly,
  459. bladeLabel,
  460. },
  461. };
  462. });
  463. }
  464. function isJsonAnomalyFlag(flag) {
  465. return flag === true || flag === 1 || flag === "1" || flag === "true";
  466. }
  467. function buildFarmSeriesRows(item, detector, nameMap) {
  468. const turbineName = formatTurbineLabel(item.turbine, nameMap);
  469. const mode = farmSeriesMode(detector, item);
  470. // 功率曲线整机是否异常只看 JSON 的 anomaly,不用 status / pointAnomaly
  471. if (detector === "wind_power_curve") {
  472. const turbineAnomaly = isJsonAnomalyFlag(item.anomaly);
  473. const kind = turbineAnomaly ? "异常" : "正常";
  474. return [
  475. {
  476. label: `${turbineName} ${kind}`,
  477. originalLabel: kind,
  478. mode,
  479. xData: item.xData || [],
  480. yData: item.yData || [],
  481. timeData: Array.isArray(item.timeData) ? item.timeData : [],
  482. __farm: true,
  483. anomaly: turbineAnomaly,
  484. extra: { ...item, anomaly: turbineAnomaly },
  485. },
  486. ];
  487. }
  488. if (Array.isArray(item.blades) && item.blades.length) {
  489. return buildFarmBladeRows(item, turbineName, mode);
  490. }
  491. // 桨叶极差:保留整条折线和 pointAnomaly,由图表按段接成红蓝线
  492. if (detector === "pitch_regulation") {
  493. const pitchFlags = readPointAnomalyFlags(item);
  494. return [
  495. {
  496. label: turbineName,
  497. originalLabel: turbineName,
  498. mode,
  499. xData: item.xData || [],
  500. yData: item.yData || [],
  501. timeData: Array.isArray(item.timeData)
  502. ? item.timeData
  503. : item.xType === "time"
  504. ? item.xData || []
  505. : [],
  506. pointAnomaly: pitchFlags || undefined,
  507. __farm: true,
  508. anomaly: Boolean(item.anomaly),
  509. extra: {
  510. ...item,
  511. anomaly: Boolean(item.anomaly),
  512. turbineName,
  513. },
  514. },
  515. ];
  516. }
  517. const flags = readPointAnomalyFlags(item) || [];
  518. // 有 pointAnomaly 时按同下标拆分:false=正常点、true=异常点(不以整机 anomaly 覆盖)
  519. const canSplit = flags.length > 0;
  520. const rows = canSplit
  521. ? [
  522. { kind: "正常", sliced: sliceByFlag(item, flags, false) },
  523. { kind: "异常", sliced: sliceByFlag(item, flags, true) },
  524. ].filter((row) => row.sliced.xData.length)
  525. : [{ kind: farmSeriesKind(item, detector), sliced: null }];
  526. return rows.map((row) => {
  527. const sliced = row.sliced;
  528. const pointAnomalyFlag = canSplit
  529. ? row.kind === "异常"
  530. : Boolean(item.anomaly);
  531. return {
  532. label: `${turbineName} ${row.kind}`,
  533. originalLabel: row.kind,
  534. mode,
  535. xData: sliced ? sliced.xData : item.xData || [],
  536. yData: sliced ? sliced.yData : item.yData || [],
  537. timeData: sliced
  538. ? sliced.timeData
  539. : item.xType === "time"
  540. ? item.xData || []
  541. : [],
  542. pointAnomaly: canSplit ? undefined : item.pointAnomaly,
  543. __farm: true,
  544. anomaly: pointAnomalyFlag,
  545. // 行级 anomaly 写入 extra,避免着色仍读到整机 anomaly
  546. extra: { ...item, anomaly: pointAnomalyFlag },
  547. };
  548. });
  549. }
  550. export function farmAggregateToPanels(farmJson = {}, options = {}) {
  551. const detector = farmJson.detector || options.templateKey || "";
  552. const turbines = Array.isArray(farmJson.turbines) ? farmJson.turbines : [];
  553. const nameMap = options.nameMap || {};
  554. const title =
  555. options.title ||
  556. FARM_VALUE_TITLES[detector] ||
  557. FARM_SERIES_TITLES[detector] ||
  558. farmJson.farm ||
  559. "全场检测汇总";
  560. if (farmJson.chartType === "value") {
  561. const values = turbines.map((item) => Number(item.value));
  562. const categories = turbines.map((item) =>
  563. formatTurbineLabel(item.turbine, nameMap),
  564. );
  565. const valuePanel = {
  566. chartKind: "valueBar",
  567. title,
  568. detectorName: detector,
  569. xaxis: "风机",
  570. yaxis: FARM_VALUE_AXIS[detector] || "检测值",
  571. categories,
  572. values,
  573. anomalyFlags: turbines.map((item) => Boolean(item.anomaly)),
  574. extras: turbines,
  575. mean: meanOf(values),
  576. };
  577. if (detector === "yaw_count" || detector === "pitch_min") {
  578. return [{ ...valuePanel, colorMode: "farmStatus", showMean: false }];
  579. }
  580. if (detector !== "yaw_error") return [valuePanel];
  581. return [
  582. {
  583. ...valuePanel,
  584. title,
  585. xaxis: "机组编号",
  586. yaxis: "静态偏航误差(度)",
  587. values,
  588. colorMode: "yawErrorAbs",
  589. showMean: false,
  590. },
  591. ];
  592. }
  593. return [
  594. {
  595. title,
  596. xaxis: farmJson.xaxis || "",
  597. yaxis: farmJson.yaxis || "",
  598. xrange: farmJson.xrange,
  599. yrange: farmJson.yrange,
  600. detectorName: detector,
  601. data: turbines
  602. .flatMap((item, turbineIndex) =>
  603. buildFarmSeriesRows(item, detector, nameMap).map((row) => ({
  604. ...row,
  605. turbineIndex,
  606. engineName: formatTurbineLabel(item.turbine, nameMap),
  607. })),
  608. )
  609. .concat(collectFarmReferenceRows(farmJson)),
  610. },
  611. ];
  612. }
  613. export function formatPointHover(point) {
  614. if (!point || typeof point !== "object") return "";
  615. const lines = [];
  616. if (point.dataTime) lines.push(`时间: ${point.dataTime}`);
  617. const appendMap = (obj, prefix) => {
  618. if (!obj || typeof obj !== "object") return;
  619. Object.keys(obj).forEach((key) => {
  620. const val = obj[key];
  621. if (val == null || val === "") return;
  622. lines.push(`${prefix}${key}: ${val}`);
  623. });
  624. };
  625. appendMap(point.raw, "");
  626. appendMap(point.deviations, "");
  627. appendMap(point.deviation, "偏差 ");
  628. appendMap(point.mean, "均值 ");
  629. appendMap(point.meanDeviation, "均值偏差 ");
  630. return lines.join("<br/>");
  631. }
  632. export function formatFarmSeriesHover(extra = {}, detector = "") {
  633. if (!extra || typeof extra !== "object") return "";
  634. const lines = [];
  635. const push = (label, val, suffix = "") => {
  636. if (val == null || val === "") return;
  637. lines.push(`${label}: ${val}${suffix}`);
  638. };
  639. if (extra.bladeLabel) {
  640. const turbine =
  641. extra.turbineName != null ? String(extra.turbineName) : "";
  642. const turbineText = turbine
  643. ? /号$/.test(turbine)
  644. ? turbine
  645. : `${turbine}号`
  646. : "";
  647. lines.push([turbineText, extra.bladeLabel].filter(Boolean).join(" "));
  648. }
  649. push("机型", extra.model);
  650. push("状态", extra.status);
  651. push("发电量比值", extra.ratio);
  652. push("异常点数", extra.anomalyPoints);
  653. push("降载占比", extra.deloadRatio);
  654. if (detector === "yaw_error") {
  655. push("发电量损失", extra.powerloss, "%");
  656. push("覆盖率", extra.coverage);
  657. }
  658. if (detector === "yaw_count") {
  659. push("CW", extra.cw, "次");
  660. push("CCW", extra.ccw, "次");
  661. }
  662. if (detector === "pitch_min") {
  663. push("P10", extra.p10, "°");
  664. push("基线", extra.baseline, "°");
  665. }
  666. return lines.join("<br/>");
  667. }
  668. export function formatFarmValueHover(extra = {}, detector = "") {
  669. if (!extra || typeof extra !== "object") return "";
  670. const lines = [];
  671. const push = (label, val, suffix = "") => {
  672. if (val == null || val === "") return;
  673. lines.push(`${label}: ${val}${suffix}`);
  674. };
  675. if (detector === "yaw_error") {
  676. push("发电量损失", extra.powerloss, "%");
  677. push(
  678. "覆盖率",
  679. extra.coverage != null ? Number(extra.coverage) : extra.coverage,
  680. );
  681. } else if (detector === "yaw_count") {
  682. push("CW", extra.cw, "次");
  683. push("CCW", extra.ccw, "次");
  684. } else if (detector === "pitch_min") {
  685. push("漂移", extra.value, "°");
  686. push("P10", extra.p10, "°");
  687. push("基线", extra.baseline, "°");
  688. } else {
  689. push("数值", extra.value);
  690. }
  691. return lines.join("<br/>");
  692. }
  693. export function scaleBubbleSize(
  694. value,
  695. min,
  696. max,
  697. { minSize = 8, maxSize = 28 } = {},
  698. ) {
  699. const num = Number(value);
  700. if (!Number.isFinite(num)) return minSize;
  701. if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return 14;
  702. const ratio = (num - min) / (max - min);
  703. return minSize + ratio * (maxSize - minSize);
  704. }
  705. /** 异常检测报告数值轴刻度:最多保留 2 位小数 */
  706. export function formatReportValueAxisLabel(value, fractionDigits = 2) {
  707. const n = Number(value);
  708. if (!Number.isFinite(n)) return String(value ?? "");
  709. const rounded = Number(n.toFixed(fractionDigits));
  710. return rounded.toLocaleString("zh-CN", {
  711. minimumFractionDigits: 0,
  712. maximumFractionDigits: fractionDigits,
  713. });
  714. }