/**
* 算法检测器 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,
});
}