/** * 全场异常检测图表的报告模板规则。 * 前端页面和报告服务必须共同引用,避免面板数量与标题漂移。 */ export const FARM_DETECTOR_PANEL_RULES = Object.freeze({ pitch_regulation: Object.freeze({ count: 2, titles: Object.freeze([ "各桨叶分风速段中位数趋势", "桨叶极差时序及异常识别", ]), }), pitch_coord: Object.freeze({ count: 2, titles: Object.freeze([ "功率-转速散点(桨距角着色)", "桨距角-转速散点", ]), }), ctrl_deload: Object.freeze({ count: 1, titles: Object.freeze(["有功功率时序"]), }), ctrl_power_quality: Object.freeze({ count: 3, titles: Object.freeze([ "三相电流不平衡度", "三相电压不平衡度", "功率因数", ]), }), ctrl_op_state: Object.freeze({ count: 1, titles: Object.freeze(["转速-功率散点"]), }), }); export function getFarmPanelRule(templateKey) { return ( FARM_DETECTOR_PANEL_RULES[templateKey] || Object.freeze({ count: 1, titles: Object.freeze([]) }) ); } export function usesStitchedFarmPanels(templateKey) { return Boolean(FARM_DETECTOR_PANEL_RULES[templateKey]); } /** 与算法 farm_{detector}.json / 前端总图 Tab 一致的图种 */ export const FARM_VALUE_DETECTORS = new Set([ "yaw_error", "yaw_count", "pitch_min", ]); /** * 算法 farm JSON 只有 1 条叠加时,对应规则里的哪一面。 * 其余分面由单机 JSON 拼接或页面占位补齐。 */ export const FARM_PAGE_PANEL_HINTS = Object.freeze({ pitch_regulation: "极差", pitch_coord: "桨距", ctrl_deload: "有功功率", ctrl_op_state: "转速", ctrl_power_quality: "功率因数", }); /** 这些检测器全场图用颜色区分风机,图例保留全部系列 */ export const FARM_TURBINE_COLOR_DETECTORS = new Set([ "pitch_regulation", "pitch_coord", "ctrl_deload", "ctrl_power_quality", "ctrl_op_state", ]); export function shouldKeepFullFarmLegend(templateKey) { return FARM_TURBINE_COLOR_DETECTORS.has(templateKey); } export function shouldUseFarmTurbineColor(templateKey) { return FARM_TURBINE_COLOR_DETECTORS.has(templateKey); } export function shouldExcludeFarmLabel(label, templateKey) { const text = String(label || ""); if (templateKey === "wind_power_scatter") return /上限|下限/.test(text); if (templateKey === "yaw_twist") return /平均|均值/.test(text); if (templateKey === "yaw_static") { return /(?:2|12)\s*(?:h|H|小时)?\s*均值/.test(text); } if (templateKey === "pitch_regulation") return /阈值/.test(text); return false; } function panelText(panel) { const key = panel?.panelKey || panel?.key || ""; return `${key} ${panel?.title || ""} ${panel?.xaxis || ""} ${ panel?.yaxis || "" }`; } function panelLooksTimed(panel) { if (/时间|时序/.test(panelText(panel))) return true; const row = panel?.data?.[0]; const sample = (Array.isArray(row?.timeData) && row.timeData[0]) || (Array.isArray(row?.xData) && row.xData[0]) || (Array.isArray(row?.x) && row.x[0]); if (typeof sample === "string" && /\d{4}-\d{2}-\d{2}/.test(sample)) return true; const n = Number(sample); return Number.isFinite(n) && n > 1e12; } /** 电能质量三分面:兼容中文标题与算法 panels[].key */ const POWER_QUALITY_SLOT_PATTERNS = [ /电流不平衡|current_imbalance/, /电压不平衡|voltage_imbalance/, /功率因数|power_factor/, ]; export function matchFarmPanelSlot(panel, templateKey) { const text = panelText(panel); if (templateKey === "pitch_regulation") { if (/中位数|分风速段/.test(text) && !/极差/.test(text)) return 0; if (/极差/.test(text)) return 1; return 1; } if (templateKey === "pitch_coord") { if (/着色/.test(text) || (/功率/.test(text) && /转速/.test(text))) return 0; if (/桨距/.test(text)) return 1; return 1; } if (templateKey === "ctrl_power_quality") { if (POWER_QUALITY_SLOT_PATTERNS[0].test(text)) return 0; if (POWER_QUALITY_SLOT_PATTERNS[1].test(text)) return 1; if (POWER_QUALITY_SLOT_PATTERNS[2].test(text)) return 2; return 2; } return 0; } export function selectFarmPanels(panels, templateKey) { const list = Array.isArray(panels) ? panels : []; const panelLimit = getFarmPanelRule(templateKey).count; if (templateKey === "ctrl_deload") { const power = list.filter((panel) => /有功功率/.test(panelText(panel))); const timed = power.filter((panel) => panelLooksTimed(panel)); return (timed.length ? timed : power.length ? power : list).slice( 0, panelLimit, ); } if (templateKey === "ctrl_op_state") { const matched = list.filter( (panel) => /转速/.test(panelText(panel)) && /功率/.test(panelText(panel)), ); return (matched.length ? matched : list).slice(0, panelLimit); } if (templateKey === "pitch_regulation") { const medianTrend = list.find((panel) => { const text = panelText(panel); return /桨叶|中位数|分风速段/.test(text) && !/极差/.test(text); }); const rangeTimeline = list.find((panel) => /极差/.test(panelText(panel))); const picked = [medianTrend, rangeTimeline].filter(Boolean); list.forEach((panel) => { if (picked.length < panelLimit && !picked.includes(panel)) { picked.push(panel); } }); return picked.slice(0, panelLimit); } if (templateKey === "pitch_coord") { const powerSpeed = list.filter( (panel) => /转速/.test(panelText(panel)) && /功率/.test(panelText(panel)), ); const pitchSpeed = list.filter((panel) => { const text = panelText(panel); return ( /转速/.test(text) && /桨距/.test(text) && !/功率/.test(String(panel.yaxis || "")) ); }); const first = powerSpeed.find((panel) => /着色/.test(panel.title || "")) || powerSpeed[0]; const second = pitchSpeed.find((panel) => panel !== first); const picked = [first, second].filter(Boolean); if (picked.length >= panelLimit) return picked.slice(0, panelLimit); if (picked.length === 1) { const rest = list.find((panel) => panel !== picked[0]); return rest ? [picked[0], rest] : picked; } return list.slice(0, panelLimit); } if (templateKey === "ctrl_power_quality") { const picked = []; const seen = new Set(); const pushUnique = (panel) => { const key = `${panel.yaxis || ""}|${panel.title || ""}`; if (seen.has(key)) return; seen.add(key); picked.push(panel); }; POWER_QUALITY_SLOT_PATTERNS.forEach((pattern) => { list.filter((panel) => pattern.test(panelText(panel))).forEach(pushUnique); }); list.forEach((panel) => { if (picked.length < panelLimit && !picked.includes(panel)) { pushUnique(panel); } }); return picked.slice(0, panelLimit); } return list.slice(0, panelLimit); } export function applyFarmPanelTitles(panels, templateKey) { const titles = getFarmPanelRule(templateKey).titles || []; if (!titles.length) return Array.isArray(panels) ? panels : []; const list = Array.isArray(panels) ? panels : []; if (list.length >= titles.length) { return list.map((panel, index) => ({ ...panel, title: titles[index] || panel.title, })); } return list.map((panel) => { const slot = matchFarmPanelSlot(panel, templateKey); return { ...panel, title: titles[slot] || panel.title, }; }); } export function fillFarmPanelsToRule(realPanels, fillerPanels, templateKey) { const rule = getFarmPanelRule(templateKey); const count = Math.max(rule.count, 1); const titles = rule.titles || []; const list = Array.isArray(realPanels) ? realPanels : []; // JSON 已给全分面时按原顺序落位,避免标题相近时被匹配到同一格 if (list.length >= count) { return applyFarmPanelTitles(list.slice(0, count), templateKey); } const slots = Array.from({ length: count }, (_, index) => { const filler = fillerPanels?.[index]; if (!filler) return null; return { ...filler, title: titles[index] || filler.title, }; }); (realPanels || []).forEach((panel) => { const slot = Math.min( Math.max(matchFarmPanelSlot(panel, templateKey), 0), count - 1, ); slots[slot] = { ...panel, title: titles[slot] || panel.title, }; }); return slots.filter(Boolean); } function readPointCoord(point) { if (Array.isArray(point)) return point; if (Array.isArray(point?.value)) return point.value; return null; } /** 系列数据在 x/y 上的最小值,供坐标轴从 0 起或向负数自适应 */ export function seriesAxisDataMin(series = []) { let xMin = Infinity; let yMin = Infinity; (series || []).forEach((item) => { (item.data || []).forEach((point) => { const coord = readPointCoord(point); if (!coord) return; const x = Number(coord[0]); const y = Number(coord[1]); if (Number.isFinite(x) && x < xMin) xMin = x; if (Number.isFinite(y) && y < yMin) yMin = y; }); }); return { xMin: Number.isFinite(xMin) ? xMin : 0, yMin: Number.isFinite(yMin) ? yMin : 0, }; } /** * 数据都非负时轴从 0 开始;出现负数时返回 undefined,不锁最小值。 */ export function axisMinStartingAtZero(dataMin) { const value = Number(dataMin); if (Number.isFinite(value) && value < 0) return undefined; return 0; } export function applyZeroStartAxis(axis, dataMin) { if (!axis) return axis; const min = axisMinStartingAtZero(dataMin); if (min === 0) axis.min = 0; else delete axis.min; return axis; }