|
|
@@ -0,0 +1,806 @@
|
|
|
+const SERIES_PALETTE = ["#1E7CF8", "#7C5CFF", "#16A34A", "#F59E0B", "#EF4444"];
|
|
|
+const MAX_STITCH_POINTS = 6000;
|
|
|
+
|
|
|
+export const DETECTOR_TEMPLATE_CONFIG = [
|
|
|
+ {
|
|
|
+ templateKey: "wind_power_curve",
|
|
|
+ poKey: "anomalyPowercurvePO",
|
|
|
+ module: "wind",
|
|
|
+ moduleName: "风速-功率",
|
|
|
+ title: "功率曲线分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "核对风速仪/功率计精度,结合叶片状态与控制参数复核发电性能。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "wind_power_scatter",
|
|
|
+ poKey: "anomalyScatterPO",
|
|
|
+ module: "wind",
|
|
|
+ moduleName: "风速-功率",
|
|
|
+ title: "风功率散点分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "排查风速-功率散点偏离原因,核对测点质量与限功率策略。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "yaw_static",
|
|
|
+ poKey: "anomalyStaticyawPO",
|
|
|
+ module: "yaw",
|
|
|
+ moduleName: "偏航与扭缆",
|
|
|
+ title: "静态偏航分析",
|
|
|
+ granularity: "秒级",
|
|
|
+ suggestion: "核对偏航角越限、长时间不动作等情况,结合对风测试复核。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "yaw_twist",
|
|
|
+ poKey: "anomalyCabletwistPO",
|
|
|
+ module: "yaw",
|
|
|
+ moduleName: "偏航与扭缆",
|
|
|
+ title: "扭缆分析",
|
|
|
+ granularity: "秒级",
|
|
|
+ suggestion: "检查扭缆角度与解缆策略,避免长期扭缆累积。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "yaw_error",
|
|
|
+ poKey: null,
|
|
|
+ module: "yaw",
|
|
|
+ moduleName: "偏航与扭缆",
|
|
|
+ title: "静态偏航误差分析",
|
|
|
+ granularity: "秒级(近7天)",
|
|
|
+ suggestion: "对静态偏航误差绝对值大于3°的机组进行偏航校正。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "yaw_count",
|
|
|
+ poKey: null,
|
|
|
+ module: "yaw",
|
|
|
+ moduleName: "偏航与扭缆",
|
|
|
+ title: "偏航次数分析",
|
|
|
+ granularity: "秒级",
|
|
|
+ suggestion: "推动偏航动作状态测点规范接入,提升偏航次数检测覆盖率。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "pitch_coord",
|
|
|
+ poKey: "anomalyPitchcoordPO",
|
|
|
+ module: "pitch",
|
|
|
+ moduleName: "变桨系统",
|
|
|
+ title: "变桨协调分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "检查三叶片变桨协同与传感器一致性。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "pitch_regulation",
|
|
|
+ poKey: "anomalyPitchregulationPO",
|
|
|
+ module: "pitch",
|
|
|
+ moduleName: "变桨系统",
|
|
|
+ title: "变桨一致性分析",
|
|
|
+ granularity: "秒级",
|
|
|
+ suggestion: "核对变桨执行机构与控制逻辑,排查桨距角不一致。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "pitch_min",
|
|
|
+ poKey: "anomalyMinpitchPO",
|
|
|
+ module: "pitch",
|
|
|
+ moduleName: "变桨系统",
|
|
|
+ title: "最小桨距角分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "对最小桨距角漂移超限机组开展开桨零位标定与传感器检查。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "ctrl_power_quality",
|
|
|
+ poKey: "anomalyPowerqualityPO",
|
|
|
+ module: "run",
|
|
|
+ moduleName: "控制参数",
|
|
|
+ title: "电能质量分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "核查功率因数、三相不平衡度与频率等电能质量指标。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "ctrl_op_state",
|
|
|
+ poKey: "anomalyOperationPO",
|
|
|
+ module: "run",
|
|
|
+ moduleName: "控制参数",
|
|
|
+ title: "运行状态分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "结合投影簇群偏离情况复核机组运行工况。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "ctrl_deload",
|
|
|
+ poKey: "anomalyDeloadPO",
|
|
|
+ module: "run",
|
|
|
+ moduleName: "控制参数",
|
|
|
+ title: "降载判定分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "核对限功率/降载策略是否符合调度与保护要求。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "aero_cp",
|
|
|
+ poKey: "anomalyCpPO",
|
|
|
+ module: "aero",
|
|
|
+ moduleName: "气动性能",
|
|
|
+ title: "Cp功率系数分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "核查叶片气动状态与控制参数对风能利用系数的影响。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "aero_tsr",
|
|
|
+ poKey: "anomalyTsrPO",
|
|
|
+ module: "aero",
|
|
|
+ moduleName: "气动性能",
|
|
|
+ title: "TSR叶尖速比分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "核对叶尖速比偏离最优区间的控制与测点原因。",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ templateKey: "aero_tsr_wind",
|
|
|
+ poKey: "anomalyCpTsrPO",
|
|
|
+ module: "aero",
|
|
|
+ moduleName: "气动性能",
|
|
|
+ title: "TSR-Cp联合分析",
|
|
|
+ granularity: "分钟级(近90天)",
|
|
|
+ suggestion: "结合 Cp 与 TSR 联合分布偏离情况开展气动性能复核。",
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+export const DETECTOR_CONFIG = DETECTOR_TEMPLATE_CONFIG.filter(
|
|
|
+ (item) => item.poKey,
|
|
|
+).map((item) => ({
|
|
|
+ key: item.poKey,
|
|
|
+ title: item.title,
|
|
|
+ templateKey: item.templateKey,
|
|
|
+}));
|
|
|
+
|
|
|
+const SCATTER_ONLY_PO_KEYS = new Set(["anomalyPowerqualityPO"]);
|
|
|
+const FARM_MAX_POINTS_PER_SERIES = 800;
|
|
|
+const FARM_PALETTE = [
|
|
|
+ "#1E7CF8", "#E11D48", "#16A34A", "#D97706", "#7C3AED", "#0D9488",
|
|
|
+ "#DB2777", "#2563EB", "#65A30D", "#EA580C", "#4F46E5", "#0891B2",
|
|
|
+ "#BE123C", "#15803D", "#C2410C", "#6D28D9", "#0F766E", "#9D174D",
|
|
|
+ "#1D4ED8", "#4D7C0F", "#B45309", "#5B21B6", "#155E75", "#A21CAF",
|
|
|
+];
|
|
|
+
|
|
|
+function farmTurbineColor(index) {
|
|
|
+ if (index >= 0 && index < FARM_PALETTE.length) return FARM_PALETTE[index];
|
|
|
+ const hue = Math.round(((Number(index) || 0) * 137.508) % 360);
|
|
|
+ return `hsl(${hue}, 68%, 44%)`;
|
|
|
+}
|
|
|
+
|
|
|
+function panelText(panel) {
|
|
|
+ return `${panel?.title || ""} ${panel?.xaxis || ""} ${panel?.yaxis || ""}`;
|
|
|
+}
|
|
|
+
|
|
|
+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;
|
|
|
+}
|
|
|
+
|
|
|
+export function isSharedFarmSeries(label, templateKey) {
|
|
|
+ const text = String(label || "");
|
|
|
+ if (templateKey === "wind_power_scatter") {
|
|
|
+ return /参考功率|理论功率|合同功率|参考曲线/.test(text);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+export function selectFarmPanels(panels, templateKey) {
|
|
|
+ const list = Array.isArray(panels) ? panels : [];
|
|
|
+ if (templateKey === "ctrl_deload") {
|
|
|
+ const power = list.filter((panel) => /有功功率/.test(panelText(panel)));
|
|
|
+ const timed = power.filter(
|
|
|
+ (panel) =>
|
|
|
+ /时间|时序/.test(panelText(panel)) ||
|
|
|
+ isTimeAxis(panel.xaxis, getRowXData(panel.data?.[0])[0]),
|
|
|
+ );
|
|
|
+ return (timed.length ? timed : power.length ? power : list).slice(0, 1);
|
|
|
+ }
|
|
|
+ if (templateKey === "ctrl_op_state") {
|
|
|
+ const matched = list.filter(
|
|
|
+ (panel) => /转速/.test(panelText(panel)) && /功率/.test(panelText(panel)),
|
|
|
+ );
|
|
|
+ return (matched.length ? matched : list).slice(0, 1);
|
|
|
+ }
|
|
|
+ if (templateKey === "pitch_regulation") return list.slice(0, 2);
|
|
|
+ if (templateKey === "ctrl_power_quality") return list.slice(0, 3);
|
|
|
+ return list.slice(0, 1);
|
|
|
+}
|
|
|
+
|
|
|
+function farmBladeKind(originalLabel) {
|
|
|
+ const text = String(originalLabel || "");
|
|
|
+ if (/桨叶\s*1/.test(text)) return "markers";
|
|
|
+ if (/桨叶\s*2/.test(text)) return "dashed";
|
|
|
+ if (/桨叶\s*3/.test(text)) return "solid";
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+function farmDeloadKind(originalLabel, label) {
|
|
|
+ const text = `${originalLabel || ""} ${label || ""}`;
|
|
|
+ if (/异常|降载/.test(text) && !/正常/.test(text)) return "anomaly";
|
|
|
+ if (/正常/.test(text)) return "normal";
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+function pickColor(label, index) {
|
|
|
+ const text = String(label || "");
|
|
|
+ if (/桨叶\s*1/.test(text)) return "#1E7CF8";
|
|
|
+ if (/桨叶\s*2/.test(text)) return "#7C5CFF";
|
|
|
+ if (/桨叶\s*3/.test(text)) return "#16A34A";
|
|
|
+ if (/异常/.test(text)) return "#EF4444";
|
|
|
+ if (/12h均值/.test(text)) return "#F59E0B";
|
|
|
+ if (/2h均值/.test(text)) return "#7C5CFF";
|
|
|
+ if (/上限|下限|阈值|参考|均值|平均|σ/.test(text)) return "#F59E0B";
|
|
|
+ if (/正常/.test(text)) return "#1E7CF8";
|
|
|
+ return SERIES_PALETTE[index % SERIES_PALETTE.length];
|
|
|
+}
|
|
|
+
|
|
|
+function isDashedLine(label) {
|
|
|
+ return /上限|下限|阈值|参考|均值|平均|σ/.test(String(label || ""));
|
|
|
+}
|
|
|
+
|
|
|
+export function isRemoteImageUrl(url) {
|
|
|
+ return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(String(url || ""));
|
|
|
+}
|
|
|
+
|
|
|
+export function extractPanels(plotJson) {
|
|
|
+ if (!plotJson) return [];
|
|
|
+ if (Array.isArray(plotJson.panels) && plotJson.panels.length) {
|
|
|
+ return plotJson.panels.map((panel) => ({
|
|
|
+ title: panel.panelTitle || plotJson.title || "",
|
|
|
+ xaxis: panel.xaxis || "",
|
|
|
+ yaxis: panel.yaxis || "",
|
|
|
+ data: panel.data || [],
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ title: plotJson.title || "",
|
|
|
+ xaxis: plotJson.xaxis || "",
|
|
|
+ yaxis: plotJson.yaxis || "",
|
|
|
+ data: plotJson.data || [],
|
|
|
+ },
|
|
|
+ ];
|
|
|
+}
|
|
|
+
|
|
|
+function getRowXData(row) {
|
|
|
+ if (Array.isArray(row?.timeData) && row.timeData.length) {
|
|
|
+ return row.timeData;
|
|
|
+ }
|
|
|
+ if (Array.isArray(row?.xData) && row.xData.length) return row.xData;
|
|
|
+ if (Array.isArray(row?.x) && row.x.length) return row.x;
|
|
|
+ return [];
|
|
|
+}
|
|
|
+
|
|
|
+function getRowYData(row) {
|
|
|
+ if (Array.isArray(row?.yData) && row.yData.length) return row.yData;
|
|
|
+ if (Array.isArray(row?.y) && row.y.length) return row.y;
|
|
|
+ return [];
|
|
|
+}
|
|
|
+
|
|
|
+function toYNumber(v) {
|
|
|
+ if (v == null || v === "") return null;
|
|
|
+ const n = Number(v);
|
|
|
+ return Number.isFinite(n) ? n : null;
|
|
|
+}
|
|
|
+
|
|
|
+function scatterSymbolSize(pointCount, farmMode, deloadKind) {
|
|
|
+ if (deloadKind === "anomaly") return farmMode ? 14 : 12;
|
|
|
+ if (farmMode) return pointCount > 500 ? 10 : 12;
|
|
|
+ if (pointCount > 4000) return 8;
|
|
|
+ if (pointCount > 1500) return 9;
|
|
|
+ return 10;
|
|
|
+}
|
|
|
+
|
|
|
+function isTimeAxis(xaxis, sampleX) {
|
|
|
+ if (xaxis && String(xaxis).includes("时间")) return true;
|
|
|
+ if (typeof sampleX === "string" && /\d{4}-\d{2}-\d{2}/.test(sampleX)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ const n = Number(sampleX);
|
|
|
+ return Number.isFinite(n) && n > 1e12;
|
|
|
+}
|
|
|
+
|
|
|
+function toMs(v) {
|
|
|
+ const n = Number(v);
|
|
|
+ if (!Number.isFinite(n)) return v;
|
|
|
+ return n > 1e15 ? Math.floor(n / 1e6) : n;
|
|
|
+}
|
|
|
+
|
|
|
+function toAxisValue(v, useTime) {
|
|
|
+ if (!useTime) return v;
|
|
|
+ const n = Number(v);
|
|
|
+ if (Number.isFinite(n)) return toMs(n);
|
|
|
+ if (typeof v === "string" && v) {
|
|
|
+ const parsed = Date.parse(v.replace(" ", "T"));
|
|
|
+ if (Number.isFinite(parsed)) return parsed;
|
|
|
+ }
|
|
|
+ return v;
|
|
|
+}
|
|
|
+
|
|
|
+function formatTimeAxisLabel(ms) {
|
|
|
+ const d = new Date(ms);
|
|
|
+ if (Number.isNaN(d.getTime())) return String(ms ?? "");
|
|
|
+ const pad = (n) => String(n).padStart(2, "0");
|
|
|
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}\n${pad(d.getHours())}:${pad(
|
|
|
+ d.getMinutes(),
|
|
|
+ )}`;
|
|
|
+}
|
|
|
+
|
|
|
+function downsamplePairs(pairs, maxPoints = MAX_STITCH_POINTS) {
|
|
|
+ if (!Array.isArray(pairs) || pairs.length <= maxPoints) return pairs;
|
|
|
+ const step = Math.ceil(pairs.length / maxPoints);
|
|
|
+ const next = [];
|
|
|
+ for (let i = 0; i < pairs.length; i += step) {
|
|
|
+ next.push(pairs[i]);
|
|
|
+ }
|
|
|
+ if (next[next.length - 1] !== pairs[pairs.length - 1]) {
|
|
|
+ next.push(pairs[pairs.length - 1]);
|
|
|
+ }
|
|
|
+ return next;
|
|
|
+}
|
|
|
+
|
|
|
+function isCompositionPanel(panel) {
|
|
|
+ if (panel.xaxis || panel.yaxis) return false;
|
|
|
+ const rows = panel.data || [];
|
|
|
+ if (!rows.length) return false;
|
|
|
+ return rows.every((row) => {
|
|
|
+ const xArr = getRowXData(row);
|
|
|
+ const yArr = getRowYData(row);
|
|
|
+ return (
|
|
|
+ xArr.length === 1 &&
|
|
|
+ yArr.length === 1 &&
|
|
|
+ Number(xArr[0]) === Number(yArr[0])
|
|
|
+ );
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function buildCompositionOption(panel, title) {
|
|
|
+ return {
|
|
|
+ backgroundColor: "#ffffff",
|
|
|
+ animation: false,
|
|
|
+ animationDuration: 0,
|
|
|
+ title: {
|
|
|
+ text: title || panel.title || "",
|
|
|
+ left: "center",
|
|
|
+ top: 8,
|
|
|
+ textStyle: { fontSize: 13, color: "#0f172a" },
|
|
|
+ },
|
|
|
+ tooltip: { trigger: "item" },
|
|
|
+ legend: {
|
|
|
+ bottom: 8,
|
|
|
+ left: "center",
|
|
|
+ textStyle: { fontSize: 10, color: "#334155" },
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: "pie",
|
|
|
+ radius: ["38%", "62%"],
|
|
|
+ center: ["50%", "52%"],
|
|
|
+ data: (panel.data || []).map((row, idx) => ({
|
|
|
+ name: row.label,
|
|
|
+ value: Number(getRowXData(row)[0]) || Number(getRowYData(row)[0]) || 0,
|
|
|
+ itemStyle: { color: pickColor(row.label, idx) },
|
|
|
+ })),
|
|
|
+ label: { fontSize: 11, formatter: "{b}\n{d}%" },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function buildSeriesFromPanel(panel, poKey, options = {}) {
|
|
|
+ const templateKey = options.templateKey || "";
|
|
|
+ const farmMode = Boolean(options.farmMode);
|
|
|
+ const forceScatter =
|
|
|
+ SCATTER_ONLY_PO_KEYS.has(poKey) ||
|
|
|
+ (farmMode &&
|
|
|
+ ["wind_power_scatter", "ctrl_op_state", "aero_tsr_wind"].includes(
|
|
|
+ templateKey,
|
|
|
+ ));
|
|
|
+ const firstRow = panel.data?.[0];
|
|
|
+ const sampleX = getRowXData(firstRow)[0];
|
|
|
+ const hasTimeData = (panel.data || []).some(
|
|
|
+ (row) => Array.isArray(row.timeData) && row.timeData.length,
|
|
|
+ );
|
|
|
+ const useTime = hasTimeData || isTimeAxis(panel.xaxis, sampleX);
|
|
|
+ const series = [];
|
|
|
+
|
|
|
+ (panel.data || []).forEach((row, index) => {
|
|
|
+ const xArr = getRowXData(row);
|
|
|
+ const yArr = getRowYData(row);
|
|
|
+ const len = Math.min(xArr.length, yArr.length);
|
|
|
+ const rawPairs = [];
|
|
|
+ for (let i = 0; i < len; i += 1) {
|
|
|
+ const y = toYNumber(yArr[i]);
|
|
|
+ if (y == null) continue;
|
|
|
+ rawPairs.push([toAxisValue(xArr[i], useTime), y]);
|
|
|
+ }
|
|
|
+ const pairs = downsamplePairs(
|
|
|
+ rawPairs,
|
|
|
+ farmMode ? FARM_MAX_POINTS_PER_SERIES : MAX_STITCH_POINTS,
|
|
|
+ );
|
|
|
+ if (!pairs.length) return;
|
|
|
+ const originalLabel = row.originalLabel || row.label;
|
|
|
+ const bladeKind = farmMode ? farmBladeKind(originalLabel) : null;
|
|
|
+ const deloadKind = farmMode
|
|
|
+ ? farmDeloadKind(originalLabel, row.label)
|
|
|
+ : null;
|
|
|
+ let mode = forceScatter && !row.__shared ? "markers" : String(row.mode || "lines");
|
|
|
+ if (farmMode && templateKey === "pitch_regulation") {
|
|
|
+ if (bladeKind === "markers") mode = "markers";
|
|
|
+ if (bladeKind === "dashed" || bladeKind === "solid") mode = "lines";
|
|
|
+ }
|
|
|
+ if (row.__shared) mode = "lines";
|
|
|
+ const color = farmMode
|
|
|
+ ? row.__shared
|
|
|
+ ? "#64748B"
|
|
|
+ : farmTurbineColor(row.turbineIndex ?? index)
|
|
|
+ : pickColor(row.label, index);
|
|
|
+ const isMarker = mode.includes("markers");
|
|
|
+ const isLine = mode.includes("lines");
|
|
|
+
|
|
|
+ if (isMarker && !isLine) {
|
|
|
+ series.push({
|
|
|
+ name: row.label,
|
|
|
+ type: "scatter",
|
|
|
+ data: pairs,
|
|
|
+ symbol: deloadKind === "anomaly" ? "diamond" : "circle",
|
|
|
+ symbolSize: scatterSymbolSize(pairs.length, farmMode, deloadKind),
|
|
|
+ large: false,
|
|
|
+ progressive: 0,
|
|
|
+ animation: false,
|
|
|
+ itemStyle: {
|
|
|
+ color,
|
|
|
+ opacity: deloadKind === "anomaly" ? 0.95 : farmMode ? 0.88 : 0.85,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const dashed =
|
|
|
+ bladeKind === "dashed" ||
|
|
|
+ Boolean(row.__shared) ||
|
|
|
+ isDashedLine(originalLabel) ||
|
|
|
+ isDashedLine(row.label);
|
|
|
+ series.push({
|
|
|
+ name: row.label,
|
|
|
+ type: "line",
|
|
|
+ data: pairs,
|
|
|
+ showSymbol: isMarker,
|
|
|
+ symbolSize: isMarker ? scatterSymbolSize(pairs.length, farmMode, deloadKind) : 4,
|
|
|
+ smooth: false,
|
|
|
+ animation: false,
|
|
|
+ progressive: 0,
|
|
|
+ lineStyle: {
|
|
|
+ width: row.__shared ? 2.5 : farmMode ? 1.8 : 2,
|
|
|
+ color,
|
|
|
+ type: dashed ? "dashed" : "solid",
|
|
|
+ },
|
|
|
+ itemStyle: { color },
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ return { series, useTime };
|
|
|
+}
|
|
|
+
|
|
|
+function buildOptionFromPanel(panel, title, poKey, extra = {}) {
|
|
|
+ if (isCompositionPanel(panel) && !extra.farmMode) {
|
|
|
+ return buildCompositionOption(panel, title);
|
|
|
+ }
|
|
|
+ const { series, useTime } = buildSeriesFromPanel(panel, poKey, extra);
|
|
|
+ const hasLegend = series.length > 1;
|
|
|
+ return {
|
|
|
+ backgroundColor: "#ffffff",
|
|
|
+ animation: false,
|
|
|
+ animationDuration: 0,
|
|
|
+ progressive: 0,
|
|
|
+ title: {
|
|
|
+ text: title || panel.title || "检测器图表",
|
|
|
+ left: "center",
|
|
|
+ top: 8,
|
|
|
+ textStyle: { fontSize: 13, color: "#0f172a" },
|
|
|
+ },
|
|
|
+ tooltip: { trigger: "axis" },
|
|
|
+ legend: hasLegend
|
|
|
+ ? {
|
|
|
+ type: "scroll",
|
|
|
+ bottom: 8,
|
|
|
+ left: "center",
|
|
|
+ itemWidth: 12,
|
|
|
+ itemHeight: 8,
|
|
|
+ textStyle: {
|
|
|
+ fontSize: series.length > 20 ? 9 : 10,
|
|
|
+ color: "#334155",
|
|
|
+ },
|
|
|
+ }
|
|
|
+ : undefined,
|
|
|
+ grid: {
|
|
|
+ top: 48,
|
|
|
+ left: 52,
|
|
|
+ right: 24,
|
|
|
+ bottom: hasLegend ? (useTime ? 72 : 56) : useTime ? 56 : 40,
|
|
|
+ containLabel: true,
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: useTime ? "time" : "value",
|
|
|
+ name: panel.xaxis,
|
|
|
+ nameLocation: "middle",
|
|
|
+ nameGap: useTime ? 36 : 28,
|
|
|
+ nameTextStyle: { fontSize: 10, color: "#64748b" },
|
|
|
+ axisLabel: {
|
|
|
+ fontSize: 10,
|
|
|
+ color: "#64748b",
|
|
|
+ hideOverlap: true,
|
|
|
+ formatter: useTime ? (v) => formatTimeAxisLabel(v) : undefined,
|
|
|
+ },
|
|
|
+ splitLine: { lineStyle: { color: "#e2e8f0" } },
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: "value",
|
|
|
+ name: panel.yaxis,
|
|
|
+ nameTextStyle: { fontSize: 10, color: "#64748b" },
|
|
|
+ axisLabel: { fontSize: 10, color: "#64748b" },
|
|
|
+ splitLine: { lineStyle: { color: "#e2e8f0" } },
|
|
|
+ },
|
|
|
+ series,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export function buildDetectorPlotOption(plotJson, title = "", poKey = "") {
|
|
|
+ const panels = extractPanels(plotJson);
|
|
|
+ const panel = panels[0] || { data: [], xaxis: "", yaxis: "", title: "" };
|
|
|
+ return buildOptionFromPanel(panel, title || panel.title, poKey, {
|
|
|
+ farmMode: Boolean(plotJson?.__farm),
|
|
|
+ templateKey: plotJson?.__templateKey || "",
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/** 单机分图:每个 panel 单独出一张,避免只画第一面导致空白/错轴。 */
|
|
|
+export function listDetectorPlotOptions(plotJson, title = "", poKey = "") {
|
|
|
+ if (plotJson?.__farm) {
|
|
|
+ return [buildDetectorPlotOption(plotJson, title, poKey)];
|
|
|
+ }
|
|
|
+ const panels = extractPanels(plotJson);
|
|
|
+ if (!panels.length) return [];
|
|
|
+ return panels.map((panel) =>
|
|
|
+ buildOptionFromPanel(
|
|
|
+ panel,
|
|
|
+ panels.length > 1 && panel.title
|
|
|
+ ? `${title}(${panel.title})`
|
|
|
+ : title || panel.title,
|
|
|
+ poKey,
|
|
|
+ { farmMode: false, templateKey: "" },
|
|
|
+ ),
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export function buildAnomalyOverviewBarOption(overview = {}, modelList = []) {
|
|
|
+ const categories = ["风功率", "偏航", "变桨", "运行状态", "气动性能"];
|
|
|
+ const values = [
|
|
|
+ Number(overview.model1Count) || 0,
|
|
|
+ Number(overview.model2Count) || 0,
|
|
|
+ Number(overview.model3Count) || 0,
|
|
|
+ Number(overview.model4Count) || 0,
|
|
|
+ Number(overview.model5Count) || 0,
|
|
|
+ ];
|
|
|
+ return {
|
|
|
+ backgroundColor: "#ffffff",
|
|
|
+ title: {
|
|
|
+ text: `异常风机 ${modelList.length} 台`,
|
|
|
+ left: "center",
|
|
|
+ top: 8,
|
|
|
+ textStyle: { fontSize: 13, color: "#0f172a" },
|
|
|
+ },
|
|
|
+ tooltip: { trigger: "axis" },
|
|
|
+ grid: { top: 48, left: 48, right: 24, bottom: 40, containLabel: true },
|
|
|
+ xAxis: { type: "category", data: categories },
|
|
|
+ yAxis: { type: "value", minInterval: 1 },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: "bar",
|
|
|
+ data: values,
|
|
|
+ barWidth: 36,
|
|
|
+ itemStyle: { color: "#1E7CF8", borderRadius: [4, 4, 0, 0] },
|
|
|
+ label: { show: true, position: "top" },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export function buildSensorAnomalyBarOption(rows = []) {
|
|
|
+ const list = rows.slice(0, 40);
|
|
|
+ return {
|
|
|
+ backgroundColor: "#ffffff",
|
|
|
+ title: {
|
|
|
+ text: "各机组数据感知异常分布",
|
|
|
+ left: "center",
|
|
|
+ top: 8,
|
|
|
+ textStyle: { fontSize: 13, color: "#0f172a" },
|
|
|
+ },
|
|
|
+ tooltip: { trigger: "axis" },
|
|
|
+ grid: { top: 48, left: 48, right: 24, bottom: 72, containLabel: true },
|
|
|
+ xAxis: {
|
|
|
+ type: "category",
|
|
|
+ data: list.map((row) => row.turbine_name),
|
|
|
+ axisLabel: { rotate: list.length > 12 ? 40 : 0, fontSize: 10 },
|
|
|
+ },
|
|
|
+ yAxis: { type: "value", minInterval: 1, name: "异常点数" },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: "bar",
|
|
|
+ data: list.map((row) => Number(row.anomaly_points) || 0),
|
|
|
+ barMaxWidth: 28,
|
|
|
+ itemStyle: { color: "#1E7CF8", borderRadius: [4, 4, 0, 0] },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function downsampleRow(row, maxPoints = MAX_STITCH_POINTS) {
|
|
|
+ const xArr = getRowXData(row);
|
|
|
+ const yArr = getRowYData(row);
|
|
|
+ const len = Math.min(xArr.length, yArr.length);
|
|
|
+ if (len <= maxPoints) {
|
|
|
+ return {
|
|
|
+ ...row,
|
|
|
+ xData: xArr.slice(0, len),
|
|
|
+ yData: yArr.slice(0, len),
|
|
|
+ timeData: Array.isArray(row.timeData)
|
|
|
+ ? row.timeData.slice(0, len)
|
|
|
+ : row.timeData,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const step = Math.ceil(len / maxPoints);
|
|
|
+ const xData = [];
|
|
|
+ const yData = [];
|
|
|
+ const timeData = Array.isArray(row.timeData) ? [] : undefined;
|
|
|
+ for (let i = 0; i < len; i += step) {
|
|
|
+ xData.push(xArr[i]);
|
|
|
+ yData.push(yArr[i]);
|
|
|
+ if (timeData) timeData.push(row.timeData[i]);
|
|
|
+ }
|
|
|
+ return { ...row, xData, yData, timeData };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 全场总图:按风机分色,并按检测器规则过滤系列/分面。
|
|
|
+ * items: [{ engineName, plotJson }]
|
|
|
+ */
|
|
|
+export function buildFarmPlotJsons(items = [], templateKey = "", title = "") {
|
|
|
+ const normalized = items
|
|
|
+ .map((item, index) => {
|
|
|
+ if (item && item.plotJson) {
|
|
|
+ return {
|
|
|
+ engineName: item.engineName || `风机${index + 1}`,
|
|
|
+ plotJson: item.plotJson,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ engineName: `风机${index + 1}`,
|
|
|
+ plotJson: item,
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .filter((item) => item.plotJson);
|
|
|
+ if (!normalized.length) return [];
|
|
|
+
|
|
|
+ const panelSets = normalized.map((item) =>
|
|
|
+ selectFarmPanels(extractPanels(item.plotJson), templateKey),
|
|
|
+ );
|
|
|
+ const panelCount = Math.max(0, ...panelSets.map((list) => list.length));
|
|
|
+ const plots = [];
|
|
|
+
|
|
|
+ for (let panelIndex = 0; panelIndex < panelCount; panelIndex += 1) {
|
|
|
+ const data = [];
|
|
|
+ let xaxis = "";
|
|
|
+ let yaxis = "";
|
|
|
+ let panelTitle = "";
|
|
|
+ const sharedSeen = new Set();
|
|
|
+
|
|
|
+ normalized.forEach((item, turbineIndex) => {
|
|
|
+ const panel = panelSets[turbineIndex][panelIndex];
|
|
|
+ if (!panel) return;
|
|
|
+ xaxis = panel.xaxis || xaxis;
|
|
|
+ yaxis = panel.yaxis || yaxis;
|
|
|
+ panelTitle = panel.title || panelTitle;
|
|
|
+ const rows = (panel.data || []).filter(
|
|
|
+ (row) => !shouldExcludeFarmLabel(row.label, templateKey),
|
|
|
+ );
|
|
|
+ const turbineRows = rows.filter(
|
|
|
+ (row) => !isSharedFarmSeries(row.label, templateKey),
|
|
|
+ );
|
|
|
+ rows.forEach((row) => {
|
|
|
+ if (isSharedFarmSeries(row.label, templateKey)) {
|
|
|
+ const key = String(row.label || "shared");
|
|
|
+ if (sharedSeen.has(key)) return;
|
|
|
+ sharedSeen.add(key);
|
|
|
+ data.push({
|
|
|
+ ...downsampleRow(row, FARM_MAX_POINTS_PER_SERIES),
|
|
|
+ label: row.label,
|
|
|
+ originalLabel: row.label,
|
|
|
+ __shared: true,
|
|
|
+ __farm: true,
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const seriesLabel =
|
|
|
+ turbineRows.length <= 1
|
|
|
+ ? item.engineName
|
|
|
+ : `${item.engineName} ${row.label || ""}`.trim();
|
|
|
+ data.push({
|
|
|
+ ...downsampleRow(row, FARM_MAX_POINTS_PER_SERIES),
|
|
|
+ label: seriesLabel,
|
|
|
+ originalLabel: row.label,
|
|
|
+ engineName: item.engineName,
|
|
|
+ turbineIndex,
|
|
|
+ __farm: true,
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!data.length) continue;
|
|
|
+ const chartTitle =
|
|
|
+ panelTitle && !String(title || "").includes(panelTitle)
|
|
|
+ ? `${title}(${panelTitle})`
|
|
|
+ : title || panelTitle;
|
|
|
+ plots.push({
|
|
|
+ title: chartTitle,
|
|
|
+ xaxis,
|
|
|
+ yaxis,
|
|
|
+ data,
|
|
|
+ panels: [{ panelTitle: chartTitle, xaxis, yaxis, data }],
|
|
|
+ __farm: true,
|
|
|
+ __templateKey: templateKey,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return plots;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 将同一检测器下多台风机的 Plot JSON 拼成全场图(默认第一张)。
|
|
|
+ */
|
|
|
+export function stitchFarmPlotJson(
|
|
|
+ plotJsonList = [],
|
|
|
+ title = "",
|
|
|
+ templateKey = "",
|
|
|
+) {
|
|
|
+ const items = plotJsonList.map((item, index) =>
|
|
|
+ item?.plotJson
|
|
|
+ ? item
|
|
|
+ : { engineName: `风机${index + 1}`, plotJson: item },
|
|
|
+ );
|
|
|
+ const plots = buildFarmPlotJsons(items, templateKey, title);
|
|
|
+ return plots[0] || null;
|
|
|
+}
|
|
|
+
|
|
|
+export function mergeModuleBundle(moduleBundle = {}) {
|
|
|
+ return {
|
|
|
+ ...(moduleBundle.wind || {}),
|
|
|
+ ...(moduleBundle.yaw || {}),
|
|
|
+ ...(moduleBundle.pitch || {}),
|
|
|
+ ...(moduleBundle.run || {}),
|
|
|
+ ...(moduleBundle.aero || {}),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export function collectDetectorCharts(moduleBundle = {}, engineId = "") {
|
|
|
+ const charts = [];
|
|
|
+ const merged = mergeModuleBundle(moduleBundle);
|
|
|
+ DETECTOR_TEMPLATE_CONFIG.forEach((cfg, index) => {
|
|
|
+ if (!cfg.poKey) return;
|
|
|
+ const po = merged[cfg.poKey];
|
|
|
+ const graphPath = po?.graphPath;
|
|
|
+ if (!graphPath) return;
|
|
|
+ charts.push({
|
|
|
+ detectorKey: cfg.poKey,
|
|
|
+ templateKey: cfg.templateKey,
|
|
|
+ detector_title: `${engineId} - ${cfg.title}`,
|
|
|
+ graphPath,
|
|
|
+ po,
|
|
|
+ imageKey: `anomaly_${engineId}_${cfg.poKey || index}`.replace(
|
|
|
+ /[^\w-]/g,
|
|
|
+ "_",
|
|
|
+ ),
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return charts;
|
|
|
+}
|