/** * 算法检测器 JSON(单机图 / 风场总图)→ 前端与报告共用的面板结构。 * 单机:通用 data[],或多面板 panels[]。 * 风场:chartType=series 曲线叠加,chartType=value 单值柱状对比。 */ export const PO_TO_TEMPLATE_KEY = { anomalyPowercurvePO: "wind_power_curve", anomalyScatterPO: "wind_power_scatter", anomalyStaticyawPO: "yaw_static", anomalyCabletwistPO: "yaw_twist", anomalyYawErrorPO: "yaw_error", anomalyYawCountPO: "yaw_count", anomalyPitchcoordPO: "pitch_coord", anomalyPitchregulationPO: "pitch_regulation", anomalyMinpitchPO: "pitch_min", anomalyPowerqualityPO: "ctrl_power_quality", anomalyOperationPO: "ctrl_op_state", anomalyDeloadPO: "ctrl_deload", anomalyCpPO: "aero_cp", anomalyTsrPO: "aero_tsr", anomalyCpTsrPO: "aero_tsr_wind", }; export const FARM_VALUE_AXIS = { yaw_error: "静态偏航误差 (°)", yaw_count: "偏航次数", pitch_min: "最小桨距角漂移 (°)", }; export const FARM_VALUE_TITLES = { yaw_error: "全场静态偏航误差", yaw_count: "全场偏航次数", pitch_min: "全场最小桨距角漂移", }; export const FARM_SERIES_TITLES = { wind_power_curve: "全场功率曲线分析", wind_power_scatter: "全场风功率散点分析", yaw_static: "全场静态偏航异常分布", yaw_twist: "全场扭缆分析", pitch_regulation: "全场变桨一致性分析", pitch_coord: "全场变桨协调分析", ctrl_power_quality: "全场电能质量分析", ctrl_op_state: "全场运行状态分析", ctrl_deload: "全场降载判定分析", aero_cp: "全场Cp功率系数分析", aero_tsr: "全场TSR叶尖速比分析", aero_tsr_wind: "全场TSR-Cp联合分析", }; export function resolveTemplateKey(options = {}, detectorKey = "") { if (options.templateKey) return options.templateKey; const key = options.detectorKey || options.poKey || detectorKey || ""; return PO_TO_TEMPLATE_KEY[key] || options.detectorName || ""; } export function isYawErrorDetector(templateKey) { return templateKey === "yaw_error"; } export function isYawCountDetector(templateKey) { return templateKey === "yaw_count"; } export function isPitchMinDetector(templateKey) { return templateKey === "pitch_min"; } export function isFarmAggregateJson(plotJson) { return Boolean( plotJson && (plotJson.chartType === "series" || plotJson.chartType === "value") && Array.isArray(plotJson.turbines), ); } /** 多图检测器:顶层 chartType=panels,每个 panel 自带 turbines */ export function isFarmPanelsJson(plotJson) { return Boolean( plotJson && plotJson.chartType === "panels" && Array.isArray(plotJson.panels) && plotJson.panels.some((panel) => Array.isArray(panel?.turbines)), ); } export function isFarmPlotJson(plotJson) { return isFarmAggregateJson(plotJson) || isFarmPanelsJson(plotJson); } export function isValueBarPanel(panel) { return panel?.chartKind === "valueBar"; } export function isBubbleColorbar(row = {}, templateKey = "") { if (isPitchMinDetector(templateKey)) return true; return /点数/.test(String(row.colorbarTitle || row.colorbartitle || "")); } /** 最小桨距角仅用点数上色,点径统一,不再按点数缩放气泡 */ export function usesBubbleSymbolSize(row = {}, templateKey = "") { if (isPitchMinDetector(templateKey)) return false; return /点数/.test(String(row.colorbarTitle || row.colorbartitle || "")); } /** * 连续色映射取值:优先 colorbar,其次数值型 pointData(与 x/y 同下标)。 * 点数色带即用该序列控制每个点的颜色深浅。 */ export function resolveRowColorMetric(row = {}, length = 0) { const len = Number(length) > 0 ? Number(length) : Math.min( Array.isArray(row?.xData) ? row.xData.length : 0, Array.isArray(row?.yData) ? row.yData.length : 0, ); if (!len) return []; const colorbar = row.colorbar || row.colorBar; if (Array.isArray(colorbar) && colorbar.length >= len) { return colorbar.slice(0, len).map((v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }); } const pointData = row.pointData; if (!Array.isArray(pointData) || pointData.length < len) return []; // pointData 若为悬停对象数组则不可作色值 if (pointData.some((item) => item != null && typeof item === "object")) { return []; } return pointData.slice(0, len).map((v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }); } /** 点数大的点后绘制(盖在浅色点之上),并写入更高 z */ export function sortScatterPairsByColorMetricAsc(pairs = []) { if (!Array.isArray(pairs) || pairs.length < 2) { return attachCountRenderZ(pairs); } const metricOf = (item) => { const value = Array.isArray(item) ? item : item?.value; if (!Array.isArray(value) || value.length < 3) return 0; const n = Number(value[2]); return Number.isFinite(n) ? n : 0; }; return attachCountRenderZ( [...pairs].sort((a, b) => metricOf(a) - metricOf(b)), ); } function attachCountRenderZ(pairs = []) { if (!Array.isArray(pairs) || !pairs.length) return pairs; return pairs.map((item) => { const value = Array.isArray(item) ? item : item?.value; const count = Array.isArray(value) && value.length >= 3 ? Number(value[2]) : 0; const z = Number.isFinite(count) ? Math.max(1, Math.round(count)) : 1; if (Array.isArray(item)) return { value: item, z }; return { ...item, z }; }); } export function findPeakCoord(xArr = [], yArr = []) { const len = Math.min(xArr.length, yArr.length); if (!len) return null; let maxIdx = 0; let maxY = Number(yArr[0]); for (let i = 1; i < len; i += 1) { const y = Number(yArr[i]); if (Number.isFinite(y) && (!Number.isFinite(maxY) || y > maxY)) { maxY = y; maxIdx = i; } } if (!Number.isFinite(maxY)) return null; return { x: xArr[maxIdx], y: maxY, index: maxIdx }; } /** 仅透传 JSON 中的小标题字段,不做前端拼接 */ export function resolvePanelSubtitle(panel = {}, plotJson = {}) { const text = panel.subtitle ?? panel.subtext ?? panel.panelSubtitle ?? plotJson.subtitle ?? plotJson.subtext ?? ""; return String(text || "").trim(); } function copyPanelMeta(panel, plotJson = {}) { return { panelKey: panel.key || "", title: panel.panelTitle || panel.title || plotJson.title || "", subtitle: resolvePanelSubtitle(panel, plotJson), xaxis: panel.xaxis || plotJson.xaxis || "", yaxis: panel.yaxis || plotJson.yaxis || "", xrange: panel.xrange || plotJson.xrange, yrange: panel.yrange || plotJson.yrange, data: panel.data || [], rawMeans: panel.rawMeans, detectorName: plotJson.detector_name || plotJson.analysisType || plotJson.detector || "", }; } export function unwrapFarmPlotJson(raw) { if (!raw || typeof raw !== "object") return null; if (raw.__imageUrl) return raw; if (isFarmPlotJson(raw)) return raw; if (raw.data && isFarmPlotJson(raw.data)) return raw.data; return raw; } /** panels[] 里自带 turbines 的分面,按全场叠加图规则展开 */ function farmPanelToPanel(panel = {}, farmJson = {}, options = {}) { const title = panel.title || panel.panelTitle || ""; const [built] = farmAggregateToPanels( { detector: farmJson.detector || options.templateKey || "", farm: farmJson.farm, chartType: panel.chartType || "series", xaxis: panel.xaxis, yaxis: panel.yaxis, xrange: panel.xrange, yrange: panel.yrange, turbines: panel.turbines, referenceCurve: panel.referenceCurve || farmJson.referenceCurve, referenceCurves: panel.referenceCurves || farmJson.referenceCurves, contractCurve: panel.contractCurve || farmJson.contractCurve, }, { ...options, title: title || options.title }, ); if (!built) return null; return { ...built, panelKey: panel.key || "", title: title || built.title, xaxis: panel.xaxis || built.xaxis, yaxis: panel.yaxis || built.yaxis, }; } export function extractDetectorPanels(plotJson, options = {}) { const json = unwrapFarmPlotJson(plotJson); if (!json) return []; if (Array.isArray(json.panels) && json.panels.length) { return json.panels .map((panel) => Array.isArray(panel?.turbines) ? farmPanelToPanel(panel, json, options) : copyPanelMeta(panel, json), ) .filter(Boolean); } if (isFarmAggregateJson(json)) { return farmAggregateToPanels(json, options); } return [copyPanelMeta(json, json)]; } function formatTurbineLabel(name, nameMap = {}) { const raw = String(name || ""); if (!raw) return "--"; const mapped = nameMap[raw] || nameMap[String(raw)]; const text = mapped ? String(mapped) : raw; if (/号$/.test(text)) return text; if (/^\d+$/.test(text)) return `${text}号`; return text; } function meanOf(values = []) { const nums = values.map(Number).filter((n) => Number.isFinite(n)); if (!nums.length) return null; return nums.reduce((sum, n) => sum + n, 0) / nums.length; } function farmSeriesMode(detector, item = {}) { if (item.xType === "time") return "markers"; if (detector === "wind_power_curve") return "lines"; if (detector === "pitch_regulation") return "lines+markers"; return "markers"; } function referenceCurveDisplayLabel(item = {}) { const text = String(item.label || "合同功率曲线"); if (/合同功率/.test(text)) return "合同功率曲线"; if (/近90日/.test(text)) return "近90日功率曲线"; if (/参考功率|理论功率|参考曲线/.test(text)) return "参考功率曲线"; return text; } function buildReferenceCurveRow(item) { if (!item || typeof item !== "object") return null; const xData = Array.isArray(item.xData) ? item.xData : Array.isArray(item.x) ? item.x : []; const yData = Array.isArray(item.yData) ? item.yData : Array.isArray(item.y) ? item.y : []; if (!xData.length) return null; const label = referenceCurveDisplayLabel(item); return { label, originalLabel: item.label || label, mode: "lines", xData, yData, timeData: Array.isArray(item.timeData) ? item.timeData : [], __shared: true, __farm: true, extra: item, }; } function collectFarmReferenceRows(farmJson = {}) { const list = []; if (farmJson.referenceCurve) list.push(farmJson.referenceCurve); if (Array.isArray(farmJson.referenceCurves)) { list.push(...farmJson.referenceCurves); } if (farmJson.contractCurve) list.push(farmJson.contractCurve); const rows = list.map(buildReferenceCurveRow).filter(Boolean); const seen = new Set(); return rows.filter((row) => { if (seen.has(row.label)) return false; seen.add(row.label); return true; }); } /** pointAnomaly[i]===true 才是异常点;字符串 "false" 不能当成 true */ export function isPointAnomalyFlag(flag) { return flag === true || flag === 1 || flag === "1" || flag === "true"; } export function readPointAnomalyFlags(item) { if (!item || typeof item !== "object") return null; const candidates = [item.pointAnomaly, item.point_anomaly]; if (!Array.isArray(item.pointAnomaly) && item.extra && item.extra !== item) { candidates.push(item.extra.pointAnomaly, item.extra.point_anomaly); } for (let i = 0; i < candidates.length; i += 1) { const raw = candidates[i]; if (Array.isArray(raw)) return raw; if (typeof raw === "string" && raw.trim().startsWith("[")) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (_error) { /* 忽略无法解析的标记 */ } } } return null; } /** * 按 pointAnomaly 把一条折线拆成首尾相接的红/蓝段。 * 相邻点只要有一端是异常,这段线就是红线,正常点之间是蓝线。 */ export function splitConnectedLineByPointAnomaly(xData = [], yData = [], flags) { const len = Math.min(xData.length, yData.length); const hasFlags = Array.isArray(flags) && flags.length > 0; const flagged = (index) => hasFlags && isPointAnomalyFlag(flags[index]); if (!len) return []; if (!hasFlags || len === 1) { return [ { anomaly: flagged(0), xData: xData.slice(0, len), yData: yData.slice(0, len), }, ]; } const segments = []; let start = 0; let anomaly = flagged(0) || flagged(1); for (let index = 1; index < len - 1; index += 1) { const next = flagged(index) || flagged(index + 1); if (next === anomaly) continue; segments.push({ anomaly, xData: xData.slice(start, index + 1), yData: yData.slice(start, index + 1), }); start = index; anomaly = next; } segments.push({ anomaly, xData: xData.slice(start, len), yData: yData.slice(start, len), }); return segments.filter((segment) => segment.xData.length > 0); } function farmSeriesKind(item = {}, detector = "") { // 风功率散点没有逐点 pointAnomaly 时,不按整机 anomaly 把全部点画红 if (detector === "wind_power_scatter") return "正常"; if (item.status) return String(item.status); if (item.anomaly) return "异常"; return "正常"; } function sliceByFlag(item, flags, keepAnomaly) { const xData = []; const yData = []; const timeData = []; const sourceX = item.xData || []; const sourceY = item.yData || []; const sourceTime = Array.isArray(item.timeData) ? item.timeData : []; const len = Math.min(sourceX.length, sourceY.length); for (let idx = 0; idx < len; idx += 1) { // pointAnomaly[i] 与 xData/yData 同下标;缺省按正常(false) const flagged = isPointAnomalyFlag(flags[idx]); if (flagged !== keepAnomaly) continue; xData.push(sourceX[idx]); yData.push(sourceY[idx]); if (sourceTime[idx] != null) timeData.push(sourceTime[idx]); } return { xData, yData, timeData }; } /** 变桨一致性等检测器:一台风机内按桨叶分多条曲线 */ function buildFarmBladeRows(item, turbineName, mode) { const turbineAnomaly = Boolean(item.anomaly); return (item.blades || []) .filter((blade) => blade && Array.isArray(blade.xData)) .map((blade) => { const bladeLabel = blade.label || ""; return { label: `${turbineName} ${bladeLabel}`.trim(), originalLabel: bladeLabel, mode, xData: blade.xData || [], yData: blade.yData || [], timeData: Array.isArray(blade.timeData) ? blade.timeData : item.xType === "time" ? blade.xData || [] : [], __farm: true, anomaly: turbineAnomaly, pointAnomaly: Array.isArray(blade.pointAnomaly) ? blade.pointAnomaly : Array.isArray(blade.point_anomaly) ? blade.point_anomaly : Array.isArray(item.pointAnomaly) ? item.pointAnomaly : Array.isArray(item.point_anomaly) ? item.point_anomaly : undefined, extra: { ...item, anomaly: turbineAnomaly, bladeLabel, }, }; }); } function isJsonAnomalyFlag(flag) { return flag === true || flag === 1 || flag === "1" || flag === "true"; } function buildFarmSeriesRows(item, detector, nameMap) { const turbineName = formatTurbineLabel(item.turbine, nameMap); const mode = farmSeriesMode(detector, item); // 功率曲线整机是否异常只看 JSON 的 anomaly,不用 status / pointAnomaly if (detector === "wind_power_curve") { const turbineAnomaly = isJsonAnomalyFlag(item.anomaly); const kind = turbineAnomaly ? "异常" : "正常"; return [ { label: `${turbineName} ${kind}`, originalLabel: kind, mode, xData: item.xData || [], yData: item.yData || [], timeData: Array.isArray(item.timeData) ? item.timeData : [], __farm: true, anomaly: turbineAnomaly, extra: { ...item, anomaly: turbineAnomaly }, }, ]; } if (Array.isArray(item.blades) && item.blades.length) { return buildFarmBladeRows(item, turbineName, mode); } // 桨叶极差:保留整条折线和 pointAnomaly,由图表按段接成红蓝线 if (detector === "pitch_regulation") { const pitchFlags = readPointAnomalyFlags(item); return [ { label: turbineName, originalLabel: turbineName, mode, xData: item.xData || [], yData: item.yData || [], timeData: Array.isArray(item.timeData) ? item.timeData : item.xType === "time" ? item.xData || [] : [], pointAnomaly: pitchFlags || undefined, __farm: true, anomaly: Boolean(item.anomaly), extra: { ...item, anomaly: Boolean(item.anomaly), turbineName, }, }, ]; } const flags = readPointAnomalyFlags(item) || []; // 有 pointAnomaly 时按同下标拆分:false=正常点、true=异常点(不以整机 anomaly 覆盖) const canSplit = flags.length > 0; const rows = canSplit ? [ { kind: "正常", sliced: sliceByFlag(item, flags, false) }, { kind: "异常", sliced: sliceByFlag(item, flags, true) }, ].filter((row) => row.sliced.xData.length) : [{ kind: farmSeriesKind(item, detector), sliced: null }]; return rows.map((row) => { const sliced = row.sliced; const pointAnomalyFlag = canSplit ? row.kind === "异常" : Boolean(item.anomaly); return { label: `${turbineName} ${row.kind}`, originalLabel: row.kind, mode, xData: sliced ? sliced.xData : item.xData || [], yData: sliced ? sliced.yData : item.yData || [], timeData: sliced ? sliced.timeData : item.xType === "time" ? item.xData || [] : [], pointAnomaly: canSplit ? undefined : item.pointAnomaly, __farm: true, anomaly: pointAnomalyFlag, // 行级 anomaly 写入 extra,避免着色仍读到整机 anomaly extra: { ...item, anomaly: pointAnomalyFlag }, }; }); } export function farmAggregateToPanels(farmJson = {}, options = {}) { const detector = farmJson.detector || options.templateKey || ""; const turbines = Array.isArray(farmJson.turbines) ? farmJson.turbines : []; const nameMap = options.nameMap || {}; const title = options.title || FARM_VALUE_TITLES[detector] || FARM_SERIES_TITLES[detector] || farmJson.farm || "全场检测汇总"; if (farmJson.chartType === "value") { const values = turbines.map((item) => Number(item.value)); const categories = turbines.map((item) => formatTurbineLabel(item.turbine, nameMap), ); const valuePanel = { chartKind: "valueBar", title, detectorName: detector, xaxis: "风机", yaxis: FARM_VALUE_AXIS[detector] || "检测值", categories, values, anomalyFlags: turbines.map((item) => Boolean(item.anomaly)), extras: turbines, mean: meanOf(values), }; if (detector === "yaw_count" || detector === "pitch_min") { return [{ ...valuePanel, colorMode: "farmStatus", showMean: false }]; } if (detector !== "yaw_error") return [valuePanel]; return [ { ...valuePanel, title, xaxis: "机组编号", yaxis: "静态偏航误差(度)", values, colorMode: "yawErrorAbs", showMean: false, }, ]; } return [ { title, xaxis: farmJson.xaxis || "", yaxis: farmJson.yaxis || "", xrange: farmJson.xrange, yrange: farmJson.yrange, detectorName: detector, data: turbines .flatMap((item, turbineIndex) => buildFarmSeriesRows(item, detector, nameMap).map((row) => ({ ...row, turbineIndex, engineName: formatTurbineLabel(item.turbine, nameMap), })), ) .concat(collectFarmReferenceRows(farmJson)), }, ]; } export function formatPointHover(point) { if (!point || typeof point !== "object") return ""; const lines = []; if (point.dataTime) lines.push(`时间: ${point.dataTime}`); const appendMap = (obj, prefix) => { if (!obj || typeof obj !== "object") return; Object.keys(obj).forEach((key) => { const val = obj[key]; if (val == null || val === "") return; lines.push(`${prefix}${key}: ${val}`); }); }; appendMap(point.raw, ""); appendMap(point.deviations, ""); appendMap(point.deviation, "偏差 "); appendMap(point.mean, "均值 "); appendMap(point.meanDeviation, "均值偏差 "); return lines.join("
"); } export function formatFarmSeriesHover(extra = {}, detector = "") { if (!extra || typeof extra !== "object") return ""; const lines = []; const push = (label, val, suffix = "") => { if (val == null || val === "") return; lines.push(`${label}: ${val}${suffix}`); }; if (extra.bladeLabel) { const turbine = extra.turbineName != null ? String(extra.turbineName) : ""; const turbineText = turbine ? /号$/.test(turbine) ? turbine : `${turbine}号` : ""; lines.push([turbineText, extra.bladeLabel].filter(Boolean).join(" ")); } push("状态", extra.status); push("发电量比值", extra.ratio); push("异常点数", extra.anomalyPoints); push("降载占比", extra.deloadRatio); if (detector === "yaw_error") { push("发电量损失", extra.powerloss, "%"); push("覆盖率", extra.coverage); } if (detector === "yaw_count") { push("CW", extra.cw, "次"); push("CCW", extra.ccw, "次"); } if (detector === "pitch_min") { push("P10", extra.p10, "°"); push("基线", extra.baseline, "°"); } return lines.join("
"); } export function formatFarmValueHover(extra = {}, detector = "") { if (!extra || typeof extra !== "object") return ""; const lines = []; const push = (label, val, suffix = "") => { if (val == null || val === "") return; lines.push(`${label}: ${val}${suffix}`); }; if (detector === "yaw_error") { push("发电量损失", extra.powerloss, "%"); push( "覆盖率", extra.coverage != null ? Number(extra.coverage) : extra.coverage, ); } else if (detector === "yaw_count") { push("CW", extra.cw, "次"); push("CCW", extra.ccw, "次"); } else if (detector === "pitch_min") { push("漂移", extra.value, "°"); push("P10", extra.p10, "°"); push("基线", extra.baseline, "°"); } else { push("数值", extra.value); } return lines.join("
"); } export function scaleBubbleSize( value, min, max, { minSize = 8, maxSize = 28 } = {}, ) { const num = Number(value); if (!Number.isFinite(num)) return minSize; if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return 14; const ratio = (num - min) / (max - min); return minSize + ratio * (maxSize - minSize); } /** 异常检测报告数值轴刻度:最多保留 2 位小数 */ export function formatReportValueAxisLabel(value, fractionDigits = 2) { const n = Number(value); if (!Number.isFinite(n)) return String(value ?? ""); const rounded = Number(n.toFixed(fractionDigits)); return rounded.toLocaleString("zh-CN", { minimumFractionDigits: 0, maximumFractionDigits: fractionDigits, }); }