anomalyFarmChartRules.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. /**
  2. * 全场异常检测图表的报告模板规则。
  3. * 前端页面和报告服务必须共同引用,避免面板数量与标题漂移。
  4. */
  5. export const FARM_DETECTOR_PANEL_RULES = Object.freeze({
  6. pitch_regulation: Object.freeze({
  7. count: 2,
  8. titles: Object.freeze([
  9. "各桨叶分风速段中位数趋势",
  10. "桨叶极差时序及异常识别",
  11. ]),
  12. }),
  13. pitch_coord: Object.freeze({
  14. count: 2,
  15. titles: Object.freeze([
  16. "功率-转速散点(桨距角着色)",
  17. "桨距角-转速散点",
  18. ]),
  19. }),
  20. ctrl_deload: Object.freeze({
  21. count: 1,
  22. titles: Object.freeze(["有功功率时序"]),
  23. }),
  24. ctrl_power_quality: Object.freeze({
  25. count: 3,
  26. titles: Object.freeze([
  27. "三相电流不平衡度",
  28. "三相电压不平衡度",
  29. "功率因数",
  30. ]),
  31. }),
  32. ctrl_op_state: Object.freeze({
  33. count: 1,
  34. titles: Object.freeze(["转速-功率散点"]),
  35. }),
  36. });
  37. export function getFarmPanelRule(templateKey) {
  38. return (
  39. FARM_DETECTOR_PANEL_RULES[templateKey] ||
  40. Object.freeze({ count: 1, titles: Object.freeze([]) })
  41. );
  42. }
  43. export function usesStitchedFarmPanels(templateKey) {
  44. return Boolean(FARM_DETECTOR_PANEL_RULES[templateKey]);
  45. }
  46. /** 与算法 farm_{detector}.json / 前端总图 Tab 一致的图种 */
  47. export const FARM_VALUE_DETECTORS = new Set([
  48. "yaw_error",
  49. "yaw_count",
  50. "pitch_min",
  51. ]);
  52. /**
  53. * 算法 farm JSON 只有 1 条叠加时,对应规则里的哪一面。
  54. * 其余分面由单机 JSON 拼接或页面占位补齐。
  55. */
  56. export const FARM_PAGE_PANEL_HINTS = Object.freeze({
  57. pitch_regulation: "极差",
  58. pitch_coord: "桨距",
  59. ctrl_deload: "有功功率",
  60. ctrl_op_state: "转速",
  61. ctrl_power_quality: "功率因数",
  62. });
  63. /** 这些检测器全场图用颜色区分风机,图例保留全部系列 */
  64. export const FARM_TURBINE_COLOR_DETECTORS = new Set([
  65. "pitch_regulation",
  66. "pitch_coord",
  67. "ctrl_deload",
  68. "ctrl_power_quality",
  69. "ctrl_op_state",
  70. ]);
  71. export function shouldKeepFullFarmLegend(templateKey) {
  72. return FARM_TURBINE_COLOR_DETECTORS.has(templateKey);
  73. }
  74. export function shouldUseFarmTurbineColor(templateKey) {
  75. return FARM_TURBINE_COLOR_DETECTORS.has(templateKey);
  76. }
  77. export function shouldExcludeFarmLabel(label, templateKey) {
  78. const text = String(label || "");
  79. if (templateKey === "wind_power_scatter") return /上限|下限/.test(text);
  80. if (templateKey === "yaw_twist") return /平均|均值/.test(text);
  81. if (templateKey === "yaw_static") {
  82. return /(?:2|12)\s*(?:h|H|小时)?\s*均值/.test(text);
  83. }
  84. if (templateKey === "pitch_regulation") return /阈值/.test(text);
  85. return false;
  86. }
  87. function panelText(panel) {
  88. const key = panel?.panelKey || panel?.key || "";
  89. return `${key} ${panel?.title || ""} ${panel?.xaxis || ""} ${
  90. panel?.yaxis || ""
  91. }`;
  92. }
  93. function panelLooksTimed(panel) {
  94. if (/时间|时序/.test(panelText(panel))) return true;
  95. const row = panel?.data?.[0];
  96. const sample =
  97. (Array.isArray(row?.timeData) && row.timeData[0]) ||
  98. (Array.isArray(row?.xData) && row.xData[0]) ||
  99. (Array.isArray(row?.x) && row.x[0]);
  100. if (typeof sample === "string" && /\d{4}-\d{2}-\d{2}/.test(sample)) return true;
  101. const n = Number(sample);
  102. return Number.isFinite(n) && n > 1e12;
  103. }
  104. /** 电能质量三分面:兼容中文标题与算法 panels[].key */
  105. const POWER_QUALITY_SLOT_PATTERNS = [
  106. /电流不平衡|current_imbalance/,
  107. /电压不平衡|voltage_imbalance/,
  108. /功率因数|power_factor/,
  109. ];
  110. export function matchFarmPanelSlot(panel, templateKey) {
  111. const text = panelText(panel);
  112. if (templateKey === "pitch_regulation") {
  113. if (/中位数|分风速段/.test(text) && !/极差/.test(text)) return 0;
  114. if (/极差/.test(text)) return 1;
  115. return 1;
  116. }
  117. if (templateKey === "pitch_coord") {
  118. if (/着色/.test(text) || (/功率/.test(text) && /转速/.test(text))) return 0;
  119. if (/桨距/.test(text)) return 1;
  120. return 1;
  121. }
  122. if (templateKey === "ctrl_power_quality") {
  123. if (POWER_QUALITY_SLOT_PATTERNS[0].test(text)) return 0;
  124. if (POWER_QUALITY_SLOT_PATTERNS[1].test(text)) return 1;
  125. if (POWER_QUALITY_SLOT_PATTERNS[2].test(text)) return 2;
  126. return 2;
  127. }
  128. return 0;
  129. }
  130. export function selectFarmPanels(panels, templateKey) {
  131. const list = Array.isArray(panels) ? panels : [];
  132. const panelLimit = getFarmPanelRule(templateKey).count;
  133. if (templateKey === "ctrl_deload") {
  134. const power = list.filter((panel) => /有功功率/.test(panelText(panel)));
  135. const timed = power.filter((panel) => panelLooksTimed(panel));
  136. return (timed.length ? timed : power.length ? power : list).slice(
  137. 0,
  138. panelLimit,
  139. );
  140. }
  141. if (templateKey === "ctrl_op_state") {
  142. const matched = list.filter(
  143. (panel) => /转速/.test(panelText(panel)) && /功率/.test(panelText(panel)),
  144. );
  145. return (matched.length ? matched : list).slice(0, panelLimit);
  146. }
  147. if (templateKey === "pitch_regulation") {
  148. const medianTrend = list.find((panel) => {
  149. const text = panelText(panel);
  150. return /桨叶|中位数|分风速段/.test(text) && !/极差/.test(text);
  151. });
  152. const rangeTimeline = list.find((panel) => /极差/.test(panelText(panel)));
  153. const picked = [medianTrend, rangeTimeline].filter(Boolean);
  154. list.forEach((panel) => {
  155. if (picked.length < panelLimit && !picked.includes(panel)) {
  156. picked.push(panel);
  157. }
  158. });
  159. return picked.slice(0, panelLimit);
  160. }
  161. if (templateKey === "pitch_coord") {
  162. const powerSpeed = list.filter(
  163. (panel) => /转速/.test(panelText(panel)) && /功率/.test(panelText(panel)),
  164. );
  165. const pitchSpeed = list.filter((panel) => {
  166. const text = panelText(panel);
  167. return (
  168. /转速/.test(text) &&
  169. /桨距/.test(text) &&
  170. !/功率/.test(String(panel.yaxis || ""))
  171. );
  172. });
  173. const first =
  174. powerSpeed.find((panel) => /着色/.test(panel.title || "")) ||
  175. powerSpeed[0];
  176. const second = pitchSpeed.find((panel) => panel !== first);
  177. const picked = [first, second].filter(Boolean);
  178. if (picked.length >= panelLimit) return picked.slice(0, panelLimit);
  179. if (picked.length === 1) {
  180. const rest = list.find((panel) => panel !== picked[0]);
  181. return rest ? [picked[0], rest] : picked;
  182. }
  183. return list.slice(0, panelLimit);
  184. }
  185. if (templateKey === "ctrl_power_quality") {
  186. const picked = [];
  187. const seen = new Set();
  188. const pushUnique = (panel) => {
  189. const key = `${panel.yaxis || ""}|${panel.title || ""}`;
  190. if (seen.has(key)) return;
  191. seen.add(key);
  192. picked.push(panel);
  193. };
  194. POWER_QUALITY_SLOT_PATTERNS.forEach((pattern) => {
  195. list.filter((panel) => pattern.test(panelText(panel))).forEach(pushUnique);
  196. });
  197. list.forEach((panel) => {
  198. if (picked.length < panelLimit && !picked.includes(panel)) {
  199. pushUnique(panel);
  200. }
  201. });
  202. return picked.slice(0, panelLimit);
  203. }
  204. return list.slice(0, panelLimit);
  205. }
  206. export function applyFarmPanelTitles(panels, templateKey) {
  207. const titles = getFarmPanelRule(templateKey).titles || [];
  208. if (!titles.length) return Array.isArray(panels) ? panels : [];
  209. const list = Array.isArray(panels) ? panels : [];
  210. if (list.length >= titles.length) {
  211. return list.map((panel, index) => ({
  212. ...panel,
  213. title: titles[index] || panel.title,
  214. }));
  215. }
  216. return list.map((panel) => {
  217. const slot = matchFarmPanelSlot(panel, templateKey);
  218. return {
  219. ...panel,
  220. title: titles[slot] || panel.title,
  221. };
  222. });
  223. }
  224. export function fillFarmPanelsToRule(realPanels, fillerPanels, templateKey) {
  225. const rule = getFarmPanelRule(templateKey);
  226. const count = Math.max(rule.count, 1);
  227. const titles = rule.titles || [];
  228. const list = Array.isArray(realPanels) ? realPanels : [];
  229. // JSON 已给全分面时按原顺序落位,避免标题相近时被匹配到同一格
  230. if (list.length >= count) {
  231. return applyFarmPanelTitles(list.slice(0, count), templateKey);
  232. }
  233. const slots = Array.from({ length: count }, (_, index) => {
  234. const filler = fillerPanels?.[index];
  235. if (!filler) return null;
  236. return {
  237. ...filler,
  238. title: titles[index] || filler.title,
  239. };
  240. });
  241. (realPanels || []).forEach((panel) => {
  242. const slot = Math.min(
  243. Math.max(matchFarmPanelSlot(panel, templateKey), 0),
  244. count - 1,
  245. );
  246. slots[slot] = {
  247. ...panel,
  248. title: titles[slot] || panel.title,
  249. };
  250. });
  251. return slots.filter(Boolean);
  252. }
  253. function readPointCoord(point) {
  254. if (Array.isArray(point)) return point;
  255. if (Array.isArray(point?.value)) return point.value;
  256. return null;
  257. }
  258. /** 系列数据在 x/y 上的最小值,供坐标轴从 0 起或向负数自适应 */
  259. export function seriesAxisDataMin(series = []) {
  260. let xMin = Infinity;
  261. let yMin = Infinity;
  262. (series || []).forEach((item) => {
  263. (item.data || []).forEach((point) => {
  264. const coord = readPointCoord(point);
  265. if (!coord) return;
  266. const x = Number(coord[0]);
  267. const y = Number(coord[1]);
  268. if (Number.isFinite(x) && x < xMin) xMin = x;
  269. if (Number.isFinite(y) && y < yMin) yMin = y;
  270. });
  271. });
  272. return {
  273. xMin: Number.isFinite(xMin) ? xMin : 0,
  274. yMin: Number.isFinite(yMin) ? yMin : 0,
  275. };
  276. }
  277. /**
  278. * 数据都非负时轴从 0 开始;出现负数时返回 undefined,不锁最小值。
  279. */
  280. export function axisMinStartingAtZero(dataMin) {
  281. const value = Number(dataMin);
  282. if (Number.isFinite(value) && value < 0) return undefined;
  283. return 0;
  284. }
  285. export function applyZeroStartAxis(axis, dataMin) {
  286. if (!axis) return axis;
  287. const min = axisMinStartingAtZero(dataMin);
  288. if (min === 0) axis.min = 0;
  289. else delete axis.min;
  290. return axis;
  291. }