anomalyDetectorPlotAdapter.mjs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  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: panel.data || [],
  185. rawMeans: panel.rawMeans,
  186. detectorName:
  187. plotJson.detector_name ||
  188. plotJson.analysisType ||
  189. plotJson.detector ||
  190. "",
  191. };
  192. }
  193. export function unwrapFarmPlotJson(raw) {
  194. if (!raw || typeof raw !== "object") return null;
  195. if (raw.__imageUrl) return raw;
  196. if (isFarmPlotJson(raw)) return raw;
  197. if (raw.data && isFarmPlotJson(raw.data)) return raw.data;
  198. return raw;
  199. }
  200. /** panels[] 里自带 turbines 的分面,按全场叠加图规则展开 */
  201. function farmPanelToPanel(panel = {}, farmJson = {}, options = {}) {
  202. const title = panel.title || panel.panelTitle || "";
  203. const [built] = farmAggregateToPanels(
  204. {
  205. detector: farmJson.detector || options.templateKey || "",
  206. farm: farmJson.farm,
  207. chartType: panel.chartType || "series",
  208. xaxis: panel.xaxis,
  209. yaxis: panel.yaxis,
  210. xrange: panel.xrange,
  211. yrange: panel.yrange,
  212. turbines: panel.turbines,
  213. referenceCurve: panel.referenceCurve || farmJson.referenceCurve,
  214. referenceCurves: panel.referenceCurves || farmJson.referenceCurves,
  215. contractCurve: panel.contractCurve || farmJson.contractCurve,
  216. },
  217. { ...options, title: title || options.title },
  218. );
  219. if (!built) return null;
  220. return {
  221. ...built,
  222. panelKey: panel.key || "",
  223. title: title || built.title,
  224. xaxis: panel.xaxis || built.xaxis,
  225. yaxis: panel.yaxis || built.yaxis,
  226. };
  227. }
  228. export function extractDetectorPanels(plotJson, options = {}) {
  229. const json = unwrapFarmPlotJson(plotJson);
  230. if (!json) return [];
  231. if (Array.isArray(json.panels) && json.panels.length) {
  232. return json.panels
  233. .map((panel) =>
  234. Array.isArray(panel?.turbines)
  235. ? farmPanelToPanel(panel, json, options)
  236. : copyPanelMeta(panel, json),
  237. )
  238. .filter(Boolean);
  239. }
  240. if (isFarmAggregateJson(json)) {
  241. return farmAggregateToPanels(json, options);
  242. }
  243. return [copyPanelMeta(json, json)];
  244. }
  245. function formatTurbineLabel(name, nameMap = {}) {
  246. const raw = String(name || "");
  247. if (!raw) return "--";
  248. const mapped = nameMap[raw] || nameMap[String(raw)];
  249. const text = mapped ? String(mapped) : raw;
  250. if (/号$/.test(text)) return text;
  251. if (/^\d+$/.test(text)) return `${text}号`;
  252. return text;
  253. }
  254. function meanOf(values = []) {
  255. const nums = values.map(Number).filter((n) => Number.isFinite(n));
  256. if (!nums.length) return null;
  257. return nums.reduce((sum, n) => sum + n, 0) / nums.length;
  258. }
  259. function farmSeriesMode(detector, item = {}) {
  260. if (item.xType === "time") return "markers";
  261. if (detector === "wind_power_curve") return "lines";
  262. if (detector === "pitch_regulation") return "lines+markers";
  263. return "markers";
  264. }
  265. function referenceCurveDisplayLabel(item = {}) {
  266. const text = String(item.label || "合同功率曲线");
  267. if (/合同功率/.test(text)) return "合同功率曲线";
  268. if (/近90日/.test(text)) return "近90日功率曲线";
  269. if (/参考功率|理论功率|参考曲线/.test(text)) return "参考功率曲线";
  270. return text;
  271. }
  272. function buildReferenceCurveRow(item) {
  273. if (!item || typeof item !== "object") return null;
  274. const xData = Array.isArray(item.xData)
  275. ? item.xData
  276. : Array.isArray(item.x)
  277. ? item.x
  278. : [];
  279. const yData = Array.isArray(item.yData)
  280. ? item.yData
  281. : Array.isArray(item.y)
  282. ? item.y
  283. : [];
  284. if (!xData.length) return null;
  285. const label = referenceCurveDisplayLabel(item);
  286. return {
  287. label,
  288. originalLabel: item.label || label,
  289. mode: "lines",
  290. xData,
  291. yData,
  292. timeData: Array.isArray(item.timeData) ? item.timeData : [],
  293. __shared: true,
  294. __farm: true,
  295. extra: item,
  296. };
  297. }
  298. function collectFarmReferenceRows(farmJson = {}) {
  299. const list = [];
  300. if (farmJson.referenceCurve) list.push(farmJson.referenceCurve);
  301. if (Array.isArray(farmJson.referenceCurves)) {
  302. list.push(...farmJson.referenceCurves);
  303. }
  304. if (farmJson.contractCurve) list.push(farmJson.contractCurve);
  305. const rows = list.map(buildReferenceCurveRow).filter(Boolean);
  306. const seen = new Set();
  307. return rows.filter((row) => {
  308. if (seen.has(row.label)) return false;
  309. seen.add(row.label);
  310. return true;
  311. });
  312. }
  313. /** pointAnomaly[i]===true 才是异常点;字符串 "false" 不能当成 true */
  314. export function isPointAnomalyFlag(flag) {
  315. return flag === true || flag === 1 || flag === "1" || flag === "true";
  316. }
  317. export function readPointAnomalyFlags(item) {
  318. if (!item || typeof item !== "object") return null;
  319. const candidates = [item.pointAnomaly, item.point_anomaly];
  320. if (!Array.isArray(item.pointAnomaly) && item.extra && item.extra !== item) {
  321. candidates.push(item.extra.pointAnomaly, item.extra.point_anomaly);
  322. }
  323. for (let i = 0; i < candidates.length; i += 1) {
  324. const raw = candidates[i];
  325. if (Array.isArray(raw)) return raw;
  326. if (typeof raw === "string" && raw.trim().startsWith("[")) {
  327. try {
  328. const parsed = JSON.parse(raw);
  329. if (Array.isArray(parsed)) return parsed;
  330. } catch (_error) {
  331. /* 忽略无法解析的标记 */
  332. }
  333. }
  334. }
  335. return null;
  336. }
  337. /**
  338. * 按 pointAnomaly 把一条折线拆成首尾相接的红/蓝段。
  339. * 相邻点只要有一端是异常,这段线就是红线,正常点之间是蓝线。
  340. */
  341. export function splitConnectedLineByPointAnomaly(xData = [], yData = [], flags) {
  342. const len = Math.min(xData.length, yData.length);
  343. const hasFlags = Array.isArray(flags) && flags.length > 0;
  344. const flagged = (index) => hasFlags && isPointAnomalyFlag(flags[index]);
  345. if (!len) return [];
  346. if (!hasFlags || len === 1) {
  347. return [
  348. {
  349. anomaly: flagged(0),
  350. xData: xData.slice(0, len),
  351. yData: yData.slice(0, len),
  352. },
  353. ];
  354. }
  355. const segments = [];
  356. let start = 0;
  357. let anomaly = flagged(0) || flagged(1);
  358. for (let index = 1; index < len - 1; index += 1) {
  359. const next = flagged(index) || flagged(index + 1);
  360. if (next === anomaly) continue;
  361. segments.push({
  362. anomaly,
  363. xData: xData.slice(start, index + 1),
  364. yData: yData.slice(start, index + 1),
  365. });
  366. start = index;
  367. anomaly = next;
  368. }
  369. segments.push({
  370. anomaly,
  371. xData: xData.slice(start, len),
  372. yData: yData.slice(start, len),
  373. });
  374. return segments.filter((segment) => segment.xData.length > 0);
  375. }
  376. function farmSeriesKind(item = {}, detector = "") {
  377. // 风功率散点没有逐点 pointAnomaly 时,不按整机 anomaly 把全部点画红
  378. if (detector === "wind_power_scatter") return "正常";
  379. if (item.status) return String(item.status);
  380. if (item.anomaly) return "异常";
  381. return "正常";
  382. }
  383. function sliceByFlag(item, flags, keepAnomaly) {
  384. const xData = [];
  385. const yData = [];
  386. const timeData = [];
  387. const sourceX = item.xData || [];
  388. const sourceY = item.yData || [];
  389. const sourceTime = Array.isArray(item.timeData) ? item.timeData : [];
  390. const len = Math.min(sourceX.length, sourceY.length);
  391. for (let idx = 0; idx < len; idx += 1) {
  392. // pointAnomaly[i] 与 xData/yData 同下标;缺省按正常(false)
  393. const flagged = isPointAnomalyFlag(flags[idx]);
  394. if (flagged !== keepAnomaly) continue;
  395. xData.push(sourceX[idx]);
  396. yData.push(sourceY[idx]);
  397. if (sourceTime[idx] != null) timeData.push(sourceTime[idx]);
  398. }
  399. return { xData, yData, timeData };
  400. }
  401. /** 变桨一致性等检测器:一台风机内按桨叶分多条曲线 */
  402. function buildFarmBladeRows(item, turbineName, mode) {
  403. const turbineAnomaly = Boolean(item.anomaly);
  404. return (item.blades || [])
  405. .filter((blade) => blade && Array.isArray(blade.xData))
  406. .map((blade) => {
  407. const bladeLabel = blade.label || "";
  408. return {
  409. label: `${turbineName} ${bladeLabel}`.trim(),
  410. originalLabel: bladeLabel,
  411. mode,
  412. xData: blade.xData || [],
  413. yData: blade.yData || [],
  414. timeData: Array.isArray(blade.timeData)
  415. ? blade.timeData
  416. : item.xType === "time"
  417. ? blade.xData || []
  418. : [],
  419. __farm: true,
  420. anomaly: turbineAnomaly,
  421. pointAnomaly: Array.isArray(blade.pointAnomaly)
  422. ? blade.pointAnomaly
  423. : Array.isArray(blade.point_anomaly)
  424. ? blade.point_anomaly
  425. : Array.isArray(item.pointAnomaly)
  426. ? item.pointAnomaly
  427. : Array.isArray(item.point_anomaly)
  428. ? item.point_anomaly
  429. : undefined,
  430. extra: {
  431. ...item,
  432. anomaly: turbineAnomaly,
  433. bladeLabel,
  434. },
  435. };
  436. });
  437. }
  438. function isJsonAnomalyFlag(flag) {
  439. return flag === true || flag === 1 || flag === "1" || flag === "true";
  440. }
  441. function buildFarmSeriesRows(item, detector, nameMap) {
  442. const turbineName = formatTurbineLabel(item.turbine, nameMap);
  443. const mode = farmSeriesMode(detector, item);
  444. // 功率曲线整机是否异常只看 JSON 的 anomaly,不用 status / pointAnomaly
  445. if (detector === "wind_power_curve") {
  446. const turbineAnomaly = isJsonAnomalyFlag(item.anomaly);
  447. const kind = turbineAnomaly ? "异常" : "正常";
  448. return [
  449. {
  450. label: `${turbineName} ${kind}`,
  451. originalLabel: kind,
  452. mode,
  453. xData: item.xData || [],
  454. yData: item.yData || [],
  455. timeData: Array.isArray(item.timeData) ? item.timeData : [],
  456. __farm: true,
  457. anomaly: turbineAnomaly,
  458. extra: { ...item, anomaly: turbineAnomaly },
  459. },
  460. ];
  461. }
  462. if (Array.isArray(item.blades) && item.blades.length) {
  463. return buildFarmBladeRows(item, turbineName, mode);
  464. }
  465. // 桨叶极差:保留整条折线和 pointAnomaly,由图表按段接成红蓝线
  466. if (detector === "pitch_regulation") {
  467. const pitchFlags = readPointAnomalyFlags(item);
  468. return [
  469. {
  470. label: turbineName,
  471. originalLabel: turbineName,
  472. mode,
  473. xData: item.xData || [],
  474. yData: item.yData || [],
  475. timeData: Array.isArray(item.timeData)
  476. ? item.timeData
  477. : item.xType === "time"
  478. ? item.xData || []
  479. : [],
  480. pointAnomaly: pitchFlags || undefined,
  481. __farm: true,
  482. anomaly: Boolean(item.anomaly),
  483. extra: {
  484. ...item,
  485. anomaly: Boolean(item.anomaly),
  486. turbineName,
  487. },
  488. },
  489. ];
  490. }
  491. const flags = readPointAnomalyFlags(item) || [];
  492. // 有 pointAnomaly 时按同下标拆分:false=正常点、true=异常点(不以整机 anomaly 覆盖)
  493. const canSplit = flags.length > 0;
  494. const rows = canSplit
  495. ? [
  496. { kind: "正常", sliced: sliceByFlag(item, flags, false) },
  497. { kind: "异常", sliced: sliceByFlag(item, flags, true) },
  498. ].filter((row) => row.sliced.xData.length)
  499. : [{ kind: farmSeriesKind(item, detector), sliced: null }];
  500. return rows.map((row) => {
  501. const sliced = row.sliced;
  502. const pointAnomalyFlag = canSplit
  503. ? row.kind === "异常"
  504. : Boolean(item.anomaly);
  505. return {
  506. label: `${turbineName} ${row.kind}`,
  507. originalLabel: row.kind,
  508. mode,
  509. xData: sliced ? sliced.xData : item.xData || [],
  510. yData: sliced ? sliced.yData : item.yData || [],
  511. timeData: sliced
  512. ? sliced.timeData
  513. : item.xType === "time"
  514. ? item.xData || []
  515. : [],
  516. pointAnomaly: canSplit ? undefined : item.pointAnomaly,
  517. __farm: true,
  518. anomaly: pointAnomalyFlag,
  519. // 行级 anomaly 写入 extra,避免着色仍读到整机 anomaly
  520. extra: { ...item, anomaly: pointAnomalyFlag },
  521. };
  522. });
  523. }
  524. export function farmAggregateToPanels(farmJson = {}, options = {}) {
  525. const detector = farmJson.detector || options.templateKey || "";
  526. const turbines = Array.isArray(farmJson.turbines) ? farmJson.turbines : [];
  527. const nameMap = options.nameMap || {};
  528. const title =
  529. options.title ||
  530. FARM_VALUE_TITLES[detector] ||
  531. FARM_SERIES_TITLES[detector] ||
  532. farmJson.farm ||
  533. "全场检测汇总";
  534. if (farmJson.chartType === "value") {
  535. const values = turbines.map((item) => Number(item.value));
  536. const categories = turbines.map((item) =>
  537. formatTurbineLabel(item.turbine, nameMap),
  538. );
  539. const valuePanel = {
  540. chartKind: "valueBar",
  541. title,
  542. detectorName: detector,
  543. xaxis: "风机",
  544. yaxis: FARM_VALUE_AXIS[detector] || "检测值",
  545. categories,
  546. values,
  547. anomalyFlags: turbines.map((item) => Boolean(item.anomaly)),
  548. extras: turbines,
  549. mean: meanOf(values),
  550. };
  551. if (detector === "yaw_count" || detector === "pitch_min") {
  552. return [{ ...valuePanel, colorMode: "farmStatus", showMean: false }];
  553. }
  554. if (detector !== "yaw_error") return [valuePanel];
  555. return [
  556. {
  557. ...valuePanel,
  558. title,
  559. xaxis: "机组编号",
  560. yaxis: "静态偏航误差(度)",
  561. values,
  562. colorMode: "yawErrorAbs",
  563. showMean: false,
  564. },
  565. ];
  566. }
  567. return [
  568. {
  569. title,
  570. xaxis: farmJson.xaxis || "",
  571. yaxis: farmJson.yaxis || "",
  572. xrange: farmJson.xrange,
  573. yrange: farmJson.yrange,
  574. detectorName: detector,
  575. data: turbines
  576. .flatMap((item, turbineIndex) =>
  577. buildFarmSeriesRows(item, detector, nameMap).map((row) => ({
  578. ...row,
  579. turbineIndex,
  580. engineName: formatTurbineLabel(item.turbine, nameMap),
  581. })),
  582. )
  583. .concat(collectFarmReferenceRows(farmJson)),
  584. },
  585. ];
  586. }
  587. export function formatPointHover(point) {
  588. if (!point || typeof point !== "object") return "";
  589. const lines = [];
  590. if (point.dataTime) lines.push(`时间: ${point.dataTime}`);
  591. const appendMap = (obj, prefix) => {
  592. if (!obj || typeof obj !== "object") return;
  593. Object.keys(obj).forEach((key) => {
  594. const val = obj[key];
  595. if (val == null || val === "") return;
  596. lines.push(`${prefix}${key}: ${val}`);
  597. });
  598. };
  599. appendMap(point.raw, "");
  600. appendMap(point.deviations, "");
  601. appendMap(point.deviation, "偏差 ");
  602. appendMap(point.mean, "均值 ");
  603. appendMap(point.meanDeviation, "均值偏差 ");
  604. return lines.join("<br/>");
  605. }
  606. export function formatFarmSeriesHover(extra = {}, detector = "") {
  607. if (!extra || typeof extra !== "object") return "";
  608. const lines = [];
  609. const push = (label, val, suffix = "") => {
  610. if (val == null || val === "") return;
  611. lines.push(`${label}: ${val}${suffix}`);
  612. };
  613. if (extra.bladeLabel) {
  614. const turbine =
  615. extra.turbineName != null ? String(extra.turbineName) : "";
  616. const turbineText = turbine
  617. ? /号$/.test(turbine)
  618. ? turbine
  619. : `${turbine}号`
  620. : "";
  621. lines.push([turbineText, extra.bladeLabel].filter(Boolean).join(" "));
  622. }
  623. push("状态", extra.status);
  624. push("发电量比值", extra.ratio);
  625. push("异常点数", extra.anomalyPoints);
  626. push("降载占比", extra.deloadRatio);
  627. if (detector === "yaw_error") {
  628. push("发电量损失", extra.powerloss, "%");
  629. push("覆盖率", extra.coverage);
  630. }
  631. if (detector === "yaw_count") {
  632. push("CW", extra.cw, "次");
  633. push("CCW", extra.ccw, "次");
  634. }
  635. if (detector === "pitch_min") {
  636. push("P10", extra.p10, "°");
  637. push("基线", extra.baseline, "°");
  638. }
  639. return lines.join("<br/>");
  640. }
  641. export function formatFarmValueHover(extra = {}, detector = "") {
  642. if (!extra || typeof extra !== "object") return "";
  643. const lines = [];
  644. const push = (label, val, suffix = "") => {
  645. if (val == null || val === "") return;
  646. lines.push(`${label}: ${val}${suffix}`);
  647. };
  648. if (detector === "yaw_error") {
  649. push("发电量损失", extra.powerloss, "%");
  650. push(
  651. "覆盖率",
  652. extra.coverage != null ? Number(extra.coverage) : extra.coverage,
  653. );
  654. } else if (detector === "yaw_count") {
  655. push("CW", extra.cw, "次");
  656. push("CCW", extra.ccw, "次");
  657. } else if (detector === "pitch_min") {
  658. push("漂移", extra.value, "°");
  659. push("P10", extra.p10, "°");
  660. push("基线", extra.baseline, "°");
  661. } else {
  662. push("数值", extra.value);
  663. }
  664. return lines.join("<br/>");
  665. }
  666. export function scaleBubbleSize(
  667. value,
  668. min,
  669. max,
  670. { minSize = 8, maxSize = 28 } = {},
  671. ) {
  672. const num = Number(value);
  673. if (!Number.isFinite(num)) return minSize;
  674. if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return 14;
  675. const ratio = (num - min) / (max - min);
  676. return minSize + ratio * (maxSize - minSize);
  677. }
  678. /** 异常检测报告数值轴刻度:最多保留 2 位小数 */
  679. export function formatReportValueAxisLabel(value, fractionDigits = 2) {
  680. const n = Number(value);
  681. if (!Number.isFinite(n)) return String(value ?? "");
  682. const rounded = Number(n.toFixed(fractionDigits));
  683. return rounded.toLocaleString("zh-CN", {
  684. minimumFractionDigits: 0,
  685. maximumFractionDigits: fractionDigits,
  686. });
  687. }