liujiejie пре 9 часа
родитељ
комит
b2ae1fb709

+ 8 - 8
.env.dt

@@ -45,15 +45,15 @@ VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
 # # 乡镇/街道/村注记:nginx 需单独 alias 到 map/tiles/places/
 # VUE_APP_MAPVIEW_PLACES=/tiles_map/places/{z}/{x}/{y}.png
 # 公司配置——————————————————————————————————————————————————————————————
-VUE_APP_MAP="http://192.168.50.235:8000"
-VUE_APP_APIPROXY='http://192.168.50.235:16200'
-VUE_APP_HEALTH_APIPROXY='http://192.168.50.235:16880'
-VUE_APP_DatangEnosData_APIPROXY='http://192.168.50.235:8899'
+# VUE_APP_MAP="http://192.168.50.235:8000"
+# VUE_APP_APIPROXY='http://192.168.50.235:16200'
+# VUE_APP_HEALTH_APIPROXY='http://192.168.50.235:16880'
+# VUE_APP_DatangEnosData_APIPROXY='http://192.168.50.235:8899'
 # 大唐配置——————————————————————————————————————————————————————————————
-# VUE_APP_APIPROXY='http://10.172.12.211:16200'
-# VUE_APP_MAP="http://10.172.12.211:8899"
-# VUE_APP_HEALTH_APIPROXY='http://10.172.12.211:16880'
-# VUE_APP_DatangEnosData_APIPROXY='http://10.172.12.212:8101'
+VUE_APP_APIPROXY='http://10.172.12.211:16200'
+VUE_APP_MAP="http://10.172.12.211:8899"
+VUE_APP_HEALTH_APIPROXY='http://10.172.12.211:16880'
+VUE_APP_DatangEnosData_APIPROXY='http://10.172.12.212:8101'
 # —————————————————————————————————————————————————————————————————————
 VUE_APP_WZLAPIPROXY='http://192.168.50.241:9002'
 # VUE_APP_ETLAPIPROXY='http://192.168.50.241:9002'

BIN
downLoadServer/aa.zip


+ 167 - 41
downLoadServer/scripts/smoke-anomaly-report.mjs

@@ -135,7 +135,10 @@ const duplicateLocationCover = mapAnomalyCoverFields({
     provinceName: "大唐新疆公司",
     cityName: "大唐新疆公司",
   },
-  modelList: [{ machineTypeCode: "GW82-1500" }, { machineTypeCode: "GW82-1500" }],
+  modelList: [
+    { machineTypeCode: "GW82-1500" },
+    { machineTypeCode: "GW82-1500" },
+  ],
 });
 assert(
   duplicateLocationCover.Overview_of_the_Wind_Farm ===
@@ -247,10 +250,9 @@ if (fs.existsSync(templateSource)) {
   assert(tocHas42, "目录应保留 4.2 按功能诊断项统计 条目");
   assert(!tocHasFigure, "目录中不应出现图3/图4 图题");
   assert(!tocHasModuleTag, "目录中不应插入模块条件标签");
-  const detectorLoopOpen =
-    patchedXml.indexOf(
-      "{#zn-techcn-replace-tags-data_detector_anomaly-generalFiles}",
-    );
+  const detectorLoopOpen = patchedXml.indexOf(
+    "{#zn-techcn-replace-tags-data_detector_anomaly-generalFiles}",
+  );
   const detectorCaption = patchedXml.indexOf(
     "{figure_caption}",
     detectorLoopOpen,
@@ -480,10 +482,7 @@ assert(
   emphasisAnomaly.itemStyle?.color === "#FF1E1E",
   "报告全场散点异常点应为亮红",
 );
-assert(
-  !emphasisAnomaly.itemStyle?.borderWidth,
-  "报告全场散点异常点不描白边",
-);
+assert(!emphasisAnomaly.itemStyle?.borderWidth, "报告全场散点异常点不描白边");
 assert(farmScatterOption.grid3D, "风功率散点应输出 3D 图表");
 
 const singleScatterOption = buildDetectorPlotOption(
@@ -516,7 +515,8 @@ assert(curveSeries?.type === "line", "近90日功率曲线应渲染为折线而
 assert(curveSeries?.showSymbol === false, "近90日功率曲线折线不应显示散点符号");
 
 assert(
-  diagnosticSeriesColor({ label: "正常运行 (271)" }) === DIAGNOSTIC_COLORS.normal &&
+  diagnosticSeriesColor({ label: "正常运行 (271)" }) ===
+    DIAGNOSTIC_COLORS.normal &&
     diagnosticSeriesColor({ label: "异常点" }) === DIAGNOSTIC_COLORS.anomaly &&
     diagnosticSeriesColor({ label: "降载" }) === DIAGNOSTIC_COLORS.deload &&
     diagnosticSeriesColor({ label: "停机" }) === DIAGNOSTIC_COLORS.shutdown,
@@ -632,11 +632,13 @@ assert(
 );
 assert(
   Number(
-    (singleStatusScatter.series || []).find((item) => item.name === "合同功率曲线")
-      ?.z,
+    (singleStatusScatter.series || []).find(
+      (item) => item.name === "合同功率曲线",
+    )?.z,
   ) <
     Number(
-      (singleStatusScatter.series || []).find((item) => item.name === "正常")?.z,
+      (singleStatusScatter.series || []).find((item) => item.name === "正常")
+        ?.z,
     ),
   "单机散点图合同功率曲线应在散点下方",
 );
@@ -1106,6 +1108,44 @@ assert(
     .length >= 2,
   "阈值线应拉满时间轴",
 );
+const loneRateOption = listDetectorPlotOptions(
+  {
+    panelTitle: "累计降载率随时间变化",
+    xaxis: "时间",
+    yaxis: "累计降载率 (%)",
+    yrange: [0, 100],
+    data: [
+      {
+        label: "累计降载率",
+        xData: ["2026-10-13 07:05:00"],
+        yData: [0],
+        mode: "lines",
+        timeData: ["2026-10-13 07:05:00"],
+      },
+      {
+        label: "正常上限 5%",
+        xData: ["2026-10-13 07:05:00", "2026-10-13 07:05:00"],
+        yData: [5, 5],
+        mode: "lines",
+      },
+      {
+        label: "频繁阈值 20%",
+        xData: ["2026-10-13 07:05:00", "2026-10-13 07:05:00"],
+        yData: [20, 20],
+        mode: "lines",
+      },
+    ],
+  },
+  "002 降载判定分析",
+  "anomalyDeloadPO",
+  { templateKey: "ctrl_deload" },
+);
+assert(loneRateOption.length === 1, "单时刻累计降载率应出图");
+loneRateOption[0].series.forEach((item) => {
+  assert(item.showSymbol === true, `${item.name} 只有一个时刻时应画点`);
+  assert(item.symbol === "circle", `${item.name} 单点符号应为圆点`);
+  assert((item.data || []).length === 1, `${item.name} 重合时刻应收成一个点`);
+});
 
 const opFarm = buildFarmPlotJsons(
   [
@@ -1438,7 +1478,8 @@ assert(
 );
 assert(
   farmWithContract[0].data.some(
-    (row) => row.__shared && row.label === "合同功率曲线" && row.mode === "lines",
+    (row) =>
+      row.__shared && row.label === "合同功率曲线" && row.mode === "lines",
   ),
   "总图应绘制 referenceCurve 合同功率曲线",
 );
@@ -1473,6 +1514,80 @@ assert(
 );
 assert(contractSeries.lineStyle?.type === "solid", "合同功率曲线应为实线");
 
+const multiModelFarmJson = {
+  detector: "wind_power_scatter",
+  chartType: "series",
+  xaxis: "风速 (m/s)",
+  yaxis: "有功功率 (kW)",
+  turbines: [
+    {
+      turbine: "C07",
+      model: "S48-750",
+      anomaly: true,
+      xType: "number",
+      xData: [3.5, 7.5],
+      yData: [110, 710],
+    },
+  ],
+  referenceCurves: [
+    {
+      label: "合同功率曲线(EN156-3300)",
+      model: "EN156-3300",
+      xData: [3, 12],
+      yData: [51.6, 900],
+      mode: "lines",
+    },
+    {
+      label: "合同功率曲线(S48-750)",
+      model: "S48-750",
+      xData: [3, 12],
+      yData: [15.2, 750],
+      mode: "lines",
+    },
+  ],
+};
+const multiModelPanels = farmAggregateToPanels(multiModelFarmJson);
+assert(
+  multiModelPanels[0].data.filter((row) => row.label === "合同功率曲线")
+    .length === 2,
+  "不同机型的合同功率曲线都应保留",
+);
+const multiModelOption = buildDetectorPlotOption(
+  multiModelFarmJson,
+  "全场风功率散点分析",
+  "anomalyScatterPO",
+);
+const modelContracts = (multiModelOption.series || []).filter(
+  (item) => item.name === "合同功率曲线",
+);
+assert(modelContracts.length === 2, "总图应画出两条机型合同功率曲线");
+const s48Contract = modelContracts.find((item) => item.__model === "S48-750");
+assert(s48Contract, "合同功率曲线应带上机型");
+const contractHover = multiModelOption.tooltip.formatter({
+  seriesIndex: multiModelOption.series.indexOf(s48Contract),
+  seriesName: s48Contract.name,
+  value: s48Contract.data[0],
+  marker: "",
+});
+assert(
+  String(contractHover).includes("机型: S48-750"),
+  "合同功率曲线划过提示应显示机型",
+);
+const s48Turbine = (multiModelOption.series || []).find(
+  (item) => item.__model === "S48-750" && item.name !== "合同功率曲线",
+);
+assert(s48Turbine, "风机数据应带上机型");
+const turbineHover = multiModelOption.tooltip.formatter({
+  seriesIndex: multiModelOption.series.indexOf(s48Turbine),
+  seriesName: s48Turbine.name,
+  value: s48Turbine.data[0],
+  marker: "",
+});
+assert(
+  String(turbineHover).includes("机型: S48-750"),
+  "风机数据划过提示应显示机型",
+);
+
 const misplacedX = buildDetectorPlotOption(
   {
     title: "DT01 功率曲线分析",
@@ -1597,8 +1712,8 @@ const rangedPitch = listDetectorPlotOptions(
   { templateKey: "pitch_coord" },
 );
 assert(
-  rangedPitch[0].xAxis.min === 1000 && rangedPitch[0].xAxis.max == null,
-  "变桨协调分图横轴应从 1000 起,上限自适应",
+  rangedPitch[0].xAxis.min === 5 && rangedPitch[0].xAxis.max === 18,
+  "变桨协调分图横轴应按 xrange",
 );
 assert(
   rangedPitch[0].yAxis.min === 0 && rangedPitch[0].yAxis.max === 1500,
@@ -1939,7 +2054,8 @@ assert(
   "运行状态转速-功率分图纵轴应自适应",
 );
 assert(
-  opStateOptions[0].xAxis.scale === true && opStateOptions[0].yAxis.scale === true,
+  opStateOptions[0].xAxis.scale === true &&
+    opStateOptions[0].yAxis.scale === true,
   "运行状态分图应按数据自适应缩放",
 );
 assert(
@@ -1947,7 +2063,8 @@ assert(
   "运行状态没有 xrange/yrange 的分图应自适应",
 );
 assert(
-  opStateOptions[1].xAxis.scale === true && opStateOptions[1].yAxis.scale === true,
+  opStateOptions[1].xAxis.scale === true &&
+    opStateOptions[1].yAxis.scale === true,
   "运行状态缺省轴范围时应自适应缩放",
 );
 assert(
@@ -2003,9 +2120,10 @@ assert(
   "风场功率曲线总图应画出正常机组",
 );
 assert(
-  !(Array.isArray(farmCurveOptions[0].legend)
-    ? farmCurveOptions[0].legend
-    : [farmCurveOptions[0].legend]
+  !(
+    Array.isArray(farmCurveOptions[0].legend)
+      ? farmCurveOptions[0].legend
+      : [farmCurveOptions[0].legend]
   ).some((item) => (item?.data || []).some((name) => /01号/.test(name))),
   "风场功率曲线总图图例不展示正常机组",
 );
@@ -2158,8 +2276,8 @@ assert(
   "偏航次数总图图例应列出正常色和全部异常机组",
 );
 assert(
-  farmYawCountOptions[0].series.find((item) => item.name === "正常机组")?.data?.[1]
-    ?.itemStyle?.color === DIAGNOSTIC_COLORS.normal,
+  farmYawCountOptions[0].series.find((item) => item.name === "正常机组")
+    ?.data?.[1]?.itemStyle?.color === DIAGNOSTIC_COLORS.normal,
   "偏航次数正常机组应统一蓝色",
 );
 assert(
@@ -2627,10 +2745,7 @@ assert(
   formatSensorTypeText({ sensorAnomalyType: "1,5" }) === "功率异常、扭矩异常",
   "多个数据感知类型编码应按顿号拼接",
 );
-assert(
-  formatSensorTypeText({}) === "暂无异常",
-  "无数据感知异常时应写暂无异常",
-);
+assert(formatSensorTypeText({}) === "暂无异常", "无数据感知异常时应写暂无异常");
 
 const sensorHeat = buildSensorHeatmapData([
   {
@@ -2651,8 +2766,8 @@ assert(
 );
 assert(sensorHeat.yLabels[0] === "功率异常", "数据感知热力图首行应为功率异常");
 assert(
-  sensorHeat.yLabels.includes("偏航角、扭缆角、偏航误差等偏航"),
-  "数据感知热力图应含偏航角、扭缆角、偏航误差等偏航",
+  sensorHeat.yLabels.includes("偏航扭缆异常"),
+  "数据感知热力图应含偏航扭缆异常",
 );
 assert(
   sensorHeat.yLabels.includes("大部件温度异常"),
@@ -2705,7 +2820,9 @@ const heatRamp = (label) => {
     (item.data || []).some((cell) => cell.value[1] === y),
   );
   const maps = [].concat(detectorHeatOption.visualMap || []);
-  return (maps.find((item) => item.seriesIndex === seriesIndex)?.inRange?.color || []).join(",");
+  return (
+    maps.find((item) => item.seriesIndex === seriesIndex)?.inRange?.color || []
+  ).join(",");
 };
 assert(
   Array.isArray(detectorHeatOption.visualMap) &&
@@ -2758,10 +2875,25 @@ const summaryMetricRows = buildAnomalySummaryRows([
   {
     turbineName: "009",
     detectorHits: [
-      { title: "静态偏航误差分析", templateKey: "yaw_error", points: 4, rate: 8 },
+      {
+        title: "静态偏航误差分析",
+        templateKey: "yaw_error",
+        points: 4,
+        rate: 8,
+      },
       { title: "偏航次数分析", templateKey: "yaw_count", points: 2, rate: 110 },
-      { title: "最小桨距角分析", templateKey: "pitch_min", points: 1, rate: 1.5 },
-      { title: "变桨一致性分析", templateKey: "pitch_regulation", points: 3, rate: 0.021 },
+      {
+        title: "最小桨距角分析",
+        templateKey: "pitch_min",
+        points: 1,
+        rate: 1.5,
+      },
+      {
+        title: "变桨一致性分析",
+        templateKey: "pitch_regulation",
+        points: 3,
+        rate: 0.021,
+      },
     ],
   },
 ]);
@@ -3168,17 +3300,11 @@ assert(
   smokeDocText.includes("数据感知异常类型"),
   "报告中检测器明细表应包含数据感知异常类型列",
 );
-assert(
-  smokeDocText.includes("大部件温度异常"),
-  "报告应输出大部件温度异常",
-);
+assert(smokeDocText.includes("大部件温度异常"), "报告应输出大部件温度异常");
 assert(
   smokeDocText.includes("大部件温度(齿轮箱油温、发电机轴承"),
   "表3-1 应包含大部件温度关联测点说明",
 );
-assert(
-  !/\{[A-Za-z_]/.test(smokeDocText),
-  "报告中不应残留模板占位符",
-);
+assert(!/\{[A-Za-z_]/.test(smokeDocText), "报告中不应残留模板占位符");
 
 await shutdownChartService();

+ 32 - 3
downLoadServer/shared/anomalyDetectorPlotAdapter.mjs

@@ -200,7 +200,11 @@ function copyPanelMeta(panel, plotJson = {}) {
     yaxis: panel.yaxis || plotJson.yaxis || "",
     xrange: panel.xrange || plotJson.xrange,
     yrange: panel.yrange || plotJson.yrange,
-    data: panel.data || [],
+    data: appendReferenceCurveRows(panel.data || [], {
+      referenceCurve: panel.referenceCurve || plotJson.referenceCurve,
+      referenceCurves: panel.referenceCurves || plotJson.referenceCurves,
+      contractCurve: panel.contractCurve || plotJson.contractCurve,
+    }),
     rawMeans: panel.rawMeans,
     detectorName:
       plotJson.detector_name ||
@@ -310,9 +314,11 @@ function buildReferenceCurveRow(item) {
     : [];
   if (!xData.length) return null;
   const label = referenceCurveDisplayLabel(item);
+  const model = item.model == null ? "" : String(item.model).trim();
   return {
     label,
     originalLabel: item.label || label,
+    model,
     mode: "lines",
     xData,
     yData,
@@ -333,12 +339,34 @@ function collectFarmReferenceRows(farmJson = {}) {
   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);
+    // 同一条合同曲线仍只留一条;不同机型的 referenceCurves 都要保留
+    const key = `${row.label}\0${row.model || ""}`;
+    if (seen.has(key)) return false;
+    seen.add(key);
     return true;
   });
 }
 
+function referenceRowKey(row = {}) {
+  const label = referenceCurveDisplayLabel(row);
+  const model = String(row.model || row.extra?.model || "").trim();
+  return `${label}\0${model}`;
+}
+
+/** 单机 data 里没有的合同/参考曲线,从 referenceCurves 补上 */
+function appendReferenceCurveRows(data = [], source = {}) {
+  const rows = collectFarmReferenceRows(source);
+  if (!rows.length) return data;
+  const seen = new Set((data || []).map((row) => referenceRowKey(row)));
+  const extra = rows.filter((row) => {
+    const key = referenceRowKey(row);
+    if (seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+  return (data || []).concat(extra);
+}
+
 /** pointAnomaly[i]===true 才是异常点;字符串 "false" 不能当成 true */
 export function isPointAnomalyFlag(flag) {
   return flag === true || flag === 1 || flag === "1" || flag === "true";
@@ -661,6 +689,7 @@ export function formatFarmSeriesHover(extra = {}, detector = "") {
       : "";
     lines.push([turbineText, extra.bladeLabel].filter(Boolean).join(" "));
   }
+  push("机型", extra.model);
   push("状态", extra.status);
   push("发电量比值", extra.ratio);
   push("异常点数", extra.anomalyPoints);

+ 68 - 13
downLoadServer/shared/anomalyDiagnosticVisual.mjs

@@ -433,28 +433,27 @@ export function expandAxisToExtent(axis, dataMin, dataMax) {
   return axis;
 }
 
-/** 变桨-转速-功率协调:横轴固定从该值起展示 */
-export const PITCH_COORD_X_MIN = 1000;
-
-/** 变桨协调仍读取 yrange;横轴最小值固定,不跟 xrange 起点走 */
+/** 变桨协调的横轴、纵轴都读取数据里的 xrange / yrange */
 export function usesDataAxisRange(templateKey) {
   return templateKey === "pitch_coord";
 }
 
 /**
- * 变桨协调横轴:最小固定为 1000。xrange 上限大于 1000 时沿用,否则最大值自适应。
- * @returns {boolean} 横轴最小值已锁定
+ * 变桨协调横轴按 xrange 锁定。范围缺失或 min >= max 时不锁定,交给坐标轴自适应。
+ * @returns {boolean} 横轴已按 xrange 锁定
  */
 export function applyPitchCoordXAxis(axis, xrange) {
-  if (!axis) return false;
-  axis.min = PITCH_COORD_X_MIN;
-  const max = Array.isArray(xrange) ? Number(xrange[1]) : NaN;
-  if (Number.isFinite(max) && max > PITCH_COORD_X_MIN) {
-    axis.max = max;
-    axis.scale = false;
-  } else {
+  if (!lockAxisToRange(axis, xrange)) {
+    if (axis) axis.scale = true;
+    return false;
+  }
+  const min = Number(axis.min);
+  const max = Number(axis.max);
+  if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) {
+    axis.min = undefined;
     axis.max = undefined;
     axis.scale = true;
+    return false;
   }
   return true;
 }
@@ -484,6 +483,62 @@ export function lockAxisToRange(axis, range, convert) {
   return true;
 }
 
+export function isDeloadRateGuideLabel(rowOrLabel) {
+  return /累计降载|频繁阈值|正常上限/.test(seriesText(rowOrLabel));
+}
+
+export function isDeloadThresholdLabel(rowOrLabel) {
+  return /正常上限|频繁阈值|上限|下限|阈值/.test(seriesText(rowOrLabel));
+}
+
+function linePointX(point) {
+  const value = Array.isArray(point) ? point : point?.value;
+  if (!Array.isArray(value)) return undefined;
+  return value[0];
+}
+
+/** 折线横坐标去重后的个数。同一时刻重复两次仍算 1 个点 */
+export function distinctLineXCount(pairs) {
+  const seen = new Set();
+  (pairs || []).forEach((point) => {
+    const x = linePointX(point);
+    if (x == null || x === "") return;
+    seen.add(String(x));
+  });
+  return seen.size;
+}
+
+/**
+ * 不超过两个点的阈值线,在整图横轴有跨度时拉成水平线。
+ * 横轴只有一个时刻时返回 null,交给调用方画点。
+ * @returns {Array|null}
+ */
+export function spanThresholdLinePairs(pairs, allX) {
+  if (!pairs?.length || pairs.length > 2) return null;
+  const xs = [];
+  (allX || []).forEach((x) => {
+    const n = Number(x);
+    if (Number.isFinite(n)) xs.push(n);
+  });
+  if (!xs.length) return null;
+  const min = Math.min(...xs);
+  const max = Math.max(...xs);
+  if (!(max > min)) return null;
+  const first = pairs[0];
+  const y = Number(Array.isArray(first) ? first[1] : first?.value?.[1]);
+  if (!Number.isFinite(y)) return null;
+  if (Array.isArray(first)) {
+    return [
+      [min, y],
+      [max, y],
+    ];
+  }
+  return [
+    { ...first, value: [min, y] },
+    { ...first, value: [max, y] },
+  ];
+}
+
 export function classifyDiagnosticSeries(rowOrLabel) {
   const text = seriesText(rowOrLabel);
   if (/合同功率/.test(text)) return "contract";

+ 11 - 1
downLoadServer/shared/anomalyWindPower3D.mjs

@@ -328,6 +328,12 @@ function isReferenceLineRow(row) {
   return isSharedOverlayCurveSeries(row);
 }
 
+function readRowModel(row) {
+  const model = row?.model ?? row?.extra?.model;
+  if (model == null || String(model).trim() === "") return "";
+  return String(model).trim();
+}
+
 function isMarkerRow(row, templateKey) {
   if (isReferenceLineRow(row)) return false;
   if (templateKey === "wind_power_curve") return false;
@@ -573,6 +579,7 @@ export function buildWindPower3DChartOption({
       referenceSeries.push({
         name: row.label,
         type: "line3D",
+        __model: readRowModel(row),
         data,
         lineStyle: {
           width: visual.lineWidth,
@@ -600,6 +607,7 @@ export function buildWindPower3DChartOption({
     const item = {
       name: seriesName,
       type: useScatter ? "scatter3D" : "line3D",
+      __model: readRowModel(row),
       data: plotData,
       shading: "color",
       symbol: "circle",
@@ -738,7 +746,9 @@ export function buildWindPower3DChartOption({
         const turbineLine = turbineText
           ? `<br/>${WIND_POWER_3D_AXIS.x}: ${turbineText}`
           : "";
-        return `${params.marker || ""}${params.seriesName || ""}<br/>${WIND_POWER_3D_AXIS.y}: ${windSpeed}${turbineLine}<br/>${WIND_POWER_3D_AXIS.z}: ${power}`;
+        const model = series[params.seriesIndex]?.__model;
+        const modelLine = model ? `<br/>机型: ${model}` : "";
+        return `${params.marker || ""}${params.seriesName || ""}${modelLine}<br/>${WIND_POWER_3D_AXIS.y}: ${windSpeed}${turbineLine}<br/>${WIND_POWER_3D_AXIS.z}: ${power}`;
       },
     },
     legend: sideLegend

+ 29 - 17
downLoadServer/src/server/reportService/anomalyChartBuilder.js

@@ -22,6 +22,10 @@ import {
   diagnosticFarmStyle,
   farmSeriesPaintOrder,
   diagnosticLineType,
+  distinctLineXCount,
+  isDeloadRateGuideLabel,
+  isDeloadThresholdLabel,
+  spanThresholdLinePairs,
   diagnosticSeriesColor,
   diagnosticSeriesSymbol,
   diagnosticSingleSeriesZ,
@@ -700,23 +704,17 @@ function buildSeriesFromPanel(panel, poKey, options = {}) {
     const pairs = rawPairs;
     if (!pairs.length) return;
     const originalLabel = row.originalLabel || row.label;
-    const thresholdLine = /上限|下限|阈值/.test(String(originalLabel || ""));
+    const thresholdLine = isDeloadThresholdLabel(originalLabel);
     let linePairs = pairs;
-    if (thresholdLine && pairs.length <= 2) {
-      const y = pairs[0][1];
+    if (thresholdLine) {
       const xs = [];
       (panel.data || []).forEach((item) => {
         getRowXData(item, useTime).forEach((x) => {
           xs.push(toAxisValue(x, useTime));
         });
       });
-      const nums = xs.map(Number).filter((n) => Number.isFinite(n));
-      if (nums.length >= 2) {
-        linePairs = [
-          [Math.min(...nums), y],
-          [Math.max(...nums), y],
-        ];
-      }
+      const spanned = spanThresholdLinePairs(pairs, xs);
+      if (spanned) linePairs = spanned;
     }
     const bladeStyle = diagnosticBladeStyle(originalLabel);
     const deloadKind = farmDeloadKind(originalLabel, row.label);
@@ -892,23 +890,33 @@ function buildSeriesFromPanel(panel, poKey, options = {}) {
       templateKey === "pitch_regulation" &&
       /极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
       linePairs.length === 1;
+    const loneGuidePoint =
+      (deloadRatePanel ||
+        thresholdLine ||
+        isDeloadRateGuideLabel(originalLabel)) &&
+      distinctLineXCount(linePairs) <= 1;
+    const plottedPairs =
+      loneGuidePoint && linePairs.length > 1 ? [linePairs[0]] : linePairs;
     series.push({
       name: seriesName,
       type: "line",
-      data: linePairs,
+      data: plottedPairs,
       showSymbol: pitchRangeSinglePoint
         ? true
-        : deloadRatePanel || thresholdLine
-          ? false
-          : Boolean(bladeStyle?.showSymbol) || isMarker,
+        : loneGuidePoint
+          ? true
+          : deloadRatePanel || thresholdLine
+            ? false
+            : Boolean(bladeStyle?.showSymbol) || isMarker,
       symbol: bladeStyle ? bladeStyle.symbol : "circle",
-      symbolSize: pitchRangeSinglePoint
+      symbolSize: pitchRangeSinglePoint || loneGuidePoint
         ? 10
         : isMarker || bladeStyle
           ? bladeStyle
             ? 8
             : symbolSize
           : 4,
+      clip: loneGuidePoint ? false : true,
       smooth: false,
       animation: false,
       progressive: 0,
@@ -2487,12 +2495,16 @@ export function buildFarmPlotJsons(items = [], templateKey = "", title = "") {
       rows.forEach((row) => {
         if (isSharedFarmSeries(row.label, templateKey)) {
           const displayLabel = sharedFarmSeriesLabel(row.label);
-          if (sharedSeen.has(displayLabel)) return;
-          sharedSeen.add(displayLabel);
+          const model = String(row.model || row.extra?.model || "").trim();
+          const seenKey = `${displayLabel}\0${model}`;
+          if (sharedSeen.has(seenKey)) return;
+          sharedSeen.add(seenKey);
           sharedData.push({
             ...normalizeRowData(row, useTimeData),
             label: displayLabel,
             originalLabel: row.label,
+            model,
+            extra: row.extra || (model ? { model } : undefined),
             __shared: true,
             __farm: true,
           });

+ 32 - 26
downLoadServer/src/server/reportService/anomalyReportMapper.js

@@ -1,4 +1,7 @@
-import { DETECTOR_TEMPLATE_CONFIG, adaptiveRadarMax } from "./anomalyChartBuilder.js";
+import {
+  DETECTOR_TEMPLATE_CONFIG,
+  adaptiveRadarMax,
+} from "./anomalyChartBuilder.js";
 
 const SENSOR_TYPE_LABELS = {
   1: "功率异常",
@@ -8,7 +11,7 @@ const SENSOR_TYPE_LABELS = {
   5: "扭矩异常",
   6: "风速-功率逻辑异常",
   7: "转速-扭矩逻辑异常",
-  8: "偏航角、扭缆角、偏航误差等偏航",
+  8: "偏航扭缆异常",
   9: "大部件温度异常",
 };
 
@@ -22,7 +25,7 @@ const MODEL_SENSOR_FIELDS = [
   { field: "sensorAnomalySpdTrq", label: "转速-扭矩逻辑异常" },
   {
     field: "sensorAnomalyFreeze",
-    label: "偏航角、扭缆角、偏航误差等偏航",
+    label: "偏航扭缆异常",
   },
   { field: "sensorAnomalyMajorTemp", label: "大部件温度异常" },
 ];
@@ -37,7 +40,7 @@ const SENSOR_HEATMAP_FIELDS = [
   { ratioFields: ["sensorAnomalySpdTrqRatio"], label: "转速-扭矩逻辑异常" },
   {
     ratioFields: ["sensorAnomalyFreezeRatio"],
-    label: "偏航角、扭缆角、偏航误差等偏航",
+    label: "偏航扭缆异常",
   },
   { ratioFields: ["sensorAnomalyMajorTempRatio"], label: "大部件温度异常" },
 ];
@@ -107,7 +110,7 @@ const SENSOR_RADAR_FIELDS = [
   { key: "sensorAnomalySpdTrqCount", label: "转速-扭矩\n逻辑异常" },
   {
     key: "sensorAnomalyFreezeCount",
-    label: "偏航角、扭缆角、\n偏航误差等偏航",
+    label: "偏航扭缆异常",
   },
   { key: "sensorAnomalyMajorTempCount", label: "大部件温度异常" },
 ];
@@ -214,10 +217,7 @@ function parseCompletenessValue(raw) {
     if (typeof parsed === "number") return parsed;
     if (parsed && typeof parsed === "object") {
       const nested =
-        parsed.value ??
-        parsed.ratio ??
-        parsed.completeness ??
-        parsed.percent;
+        parsed.value ?? parsed.ratio ?? parsed.completeness ?? parsed.percent;
       const num = Number(nested);
       if (Number.isFinite(num)) return num;
     }
@@ -250,9 +250,7 @@ export function buildDataCompletenessHeatmapData(
 
   const engineNames = [
     ...new Set(
-      list.map((row) =>
-        formatHeatmapEngineLabel(displayTurbineName(row)),
-      ),
+      list.map((row) => formatHeatmapEngineLabel(displayTurbineName(row))),
     ),
   ].sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true }));
   const yLabels = engineNames;
@@ -324,7 +322,10 @@ export function chunkCompletenessHeatmapData(
   const yLabels = heatmap?.yLabels || [];
   const matrix = heatmap?.matrix || [];
   if (!xLabels.length || !yLabels.length) return [];
-  const turbineStep = Math.max(1, Number(turbineSize) || COMPLETENESS_TURBINE_CHUNK);
+  const turbineStep = Math.max(
+    1,
+    Number(turbineSize) || COMPLETENESS_TURBINE_CHUNK,
+  );
   const dateStep = Math.max(1, Number(dateSize) || HEATMAP_CHUNK_SIZE);
   const chunks = [];
   for (let y = 0; y < yLabels.length; y += turbineStep) {
@@ -687,7 +688,8 @@ export function buildModuleVisibilityPayload(detectorState = {}) {
   modules.forEach((moduleKey) => {
     const hasData = DETECTOR_TEMPLATE_CONFIG.some(
       (cfg) =>
-        cfg.module === moduleKey && detectorState[cfg.templateKey]?.hasChartData,
+        cfg.module === moduleKey &&
+        detectorState[cfg.templateKey]?.hasChartData,
     );
     payload[`show_module_${moduleKey}`] = hasData ? [{}] : [];
   });
@@ -824,16 +826,16 @@ export function mapAnomalyCoverFields({
     overview,
     fieldCode,
   });
-  const province = inferProvinceName(
-    fieldMeta.provinceName,
-    fieldMeta.province,
-    fieldName,
-    fieldMeta.fieldName,
-    fieldMeta.companyName,
-    rawFarm,
-  ) || normalizeHeaderProvince(
-    fieldMeta.provinceName || fieldMeta.province || "",
-  );
+  const province =
+    inferProvinceName(
+      fieldMeta.provinceName,
+      fieldMeta.province,
+      fieldName,
+      fieldMeta.fieldName,
+      fieldMeta.companyName,
+      rawFarm,
+    ) ||
+    normalizeHeaderProvince(fieldMeta.provinceName || fieldMeta.province || "");
   const farm = normalizeHeaderWindFarm(rawFarm, province);
   const turbineCount = turbines.length || Number(fieldMeta.engineCount) || 0;
   return {
@@ -845,7 +847,9 @@ export function mapAnomalyCoverFields({
     Year_now: parts.year,
     Month_now: parts.month,
     machineTypeCode: typeText,
-    Company: province ? `大唐${province}能源有限公司` : "大唐可再生能源试验研究院有限公司",
+    Company: province
+      ? `大唐${province}能源有限公司`
+      : "大唐可再生能源试验研究院有限公司",
     turbine_count: String(turbineCount),
     target_date:
       datatime ||
@@ -923,7 +927,9 @@ export function buildAnomalySummaryRows(turbineStats = []) {
       .filter((hit) => !isDeloadMainAnomalyType(hit?.title))
       .sort((a, b) => (Number(b.points) || 0) - (Number(a.points) || 0));
     if (hits.length) {
-      hits.forEach((hit) => rows.push(summaryRowFromHit(item.turbineName, hit)));
+      hits.forEach((hit) =>
+        rows.push(summaryRowFromHit(item.turbineName, hit)),
+      );
       return;
     }
     if (isDeloadMainAnomalyType(item.mainAnomalyType)) return;

BIN
downLoadServer/templates/_smoke_anomaly_report.docx


+ 32 - 3
shared/anomalyDetectorPlotAdapter.mjs

@@ -200,7 +200,11 @@ function copyPanelMeta(panel, plotJson = {}) {
     yaxis: panel.yaxis || plotJson.yaxis || "",
     xrange: panel.xrange || plotJson.xrange,
     yrange: panel.yrange || plotJson.yrange,
-    data: panel.data || [],
+    data: appendReferenceCurveRows(panel.data || [], {
+      referenceCurve: panel.referenceCurve || plotJson.referenceCurve,
+      referenceCurves: panel.referenceCurves || plotJson.referenceCurves,
+      contractCurve: panel.contractCurve || plotJson.contractCurve,
+    }),
     rawMeans: panel.rawMeans,
     detectorName:
       plotJson.detector_name ||
@@ -310,9 +314,11 @@ function buildReferenceCurveRow(item) {
     : [];
   if (!xData.length) return null;
   const label = referenceCurveDisplayLabel(item);
+  const model = item.model == null ? "" : String(item.model).trim();
   return {
     label,
     originalLabel: item.label || label,
+    model,
     mode: "lines",
     xData,
     yData,
@@ -333,12 +339,34 @@ function collectFarmReferenceRows(farmJson = {}) {
   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);
+    // 同一条合同曲线仍只留一条;不同机型的 referenceCurves 都要保留
+    const key = `${row.label}\0${row.model || ""}`;
+    if (seen.has(key)) return false;
+    seen.add(key);
     return true;
   });
 }
 
+function referenceRowKey(row = {}) {
+  const label = referenceCurveDisplayLabel(row);
+  const model = String(row.model || row.extra?.model || "").trim();
+  return `${label}\0${model}`;
+}
+
+/** 单机 data 里没有的合同/参考曲线,从 referenceCurves 补上 */
+function appendReferenceCurveRows(data = [], source = {}) {
+  const rows = collectFarmReferenceRows(source);
+  if (!rows.length) return data;
+  const seen = new Set((data || []).map((row) => referenceRowKey(row)));
+  const extra = rows.filter((row) => {
+    const key = referenceRowKey(row);
+    if (seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+  return (data || []).concat(extra);
+}
+
 /** pointAnomaly[i]===true 才是异常点;字符串 "false" 不能当成 true */
 export function isPointAnomalyFlag(flag) {
   return flag === true || flag === 1 || flag === "1" || flag === "true";
@@ -661,6 +689,7 @@ export function formatFarmSeriesHover(extra = {}, detector = "") {
       : "";
     lines.push([turbineText, extra.bladeLabel].filter(Boolean).join(" "));
   }
+  push("机型", extra.model);
   push("状态", extra.status);
   push("发电量比值", extra.ratio);
   push("异常点数", extra.anomalyPoints);

+ 68 - 13
shared/anomalyDiagnosticVisual.mjs

@@ -433,28 +433,27 @@ export function expandAxisToExtent(axis, dataMin, dataMax) {
   return axis;
 }
 
-/** 变桨-转速-功率协调:横轴固定从该值起展示 */
-export const PITCH_COORD_X_MIN = 1000;
-
-/** 变桨协调仍读取 yrange;横轴最小值固定,不跟 xrange 起点走 */
+/** 变桨协调的横轴、纵轴都读取数据里的 xrange / yrange */
 export function usesDataAxisRange(templateKey) {
   return templateKey === "pitch_coord";
 }
 
 /**
- * 变桨协调横轴:最小固定为 1000。xrange 上限大于 1000 时沿用,否则最大值自适应。
- * @returns {boolean} 横轴最小值已锁定
+ * 变桨协调横轴按 xrange 锁定。范围缺失或 min >= max 时不锁定,交给坐标轴自适应。
+ * @returns {boolean} 横轴已按 xrange 锁定
  */
 export function applyPitchCoordXAxis(axis, xrange) {
-  if (!axis) return false;
-  axis.min = PITCH_COORD_X_MIN;
-  const max = Array.isArray(xrange) ? Number(xrange[1]) : NaN;
-  if (Number.isFinite(max) && max > PITCH_COORD_X_MIN) {
-    axis.max = max;
-    axis.scale = false;
-  } else {
+  if (!lockAxisToRange(axis, xrange)) {
+    if (axis) axis.scale = true;
+    return false;
+  }
+  const min = Number(axis.min);
+  const max = Number(axis.max);
+  if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) {
+    axis.min = undefined;
     axis.max = undefined;
     axis.scale = true;
+    return false;
   }
   return true;
 }
@@ -484,6 +483,62 @@ export function lockAxisToRange(axis, range, convert) {
   return true;
 }
 
+export function isDeloadRateGuideLabel(rowOrLabel) {
+  return /累计降载|频繁阈值|正常上限/.test(seriesText(rowOrLabel));
+}
+
+export function isDeloadThresholdLabel(rowOrLabel) {
+  return /正常上限|频繁阈值|上限|下限|阈值/.test(seriesText(rowOrLabel));
+}
+
+function linePointX(point) {
+  const value = Array.isArray(point) ? point : point?.value;
+  if (!Array.isArray(value)) return undefined;
+  return value[0];
+}
+
+/** 折线横坐标去重后的个数。同一时刻重复两次仍算 1 个点 */
+export function distinctLineXCount(pairs) {
+  const seen = new Set();
+  (pairs || []).forEach((point) => {
+    const x = linePointX(point);
+    if (x == null || x === "") return;
+    seen.add(String(x));
+  });
+  return seen.size;
+}
+
+/**
+ * 不超过两个点的阈值线,在整图横轴有跨度时拉成水平线。
+ * 横轴只有一个时刻时返回 null,交给调用方画点。
+ * @returns {Array|null}
+ */
+export function spanThresholdLinePairs(pairs, allX) {
+  if (!pairs?.length || pairs.length > 2) return null;
+  const xs = [];
+  (allX || []).forEach((x) => {
+    const n = Number(x);
+    if (Number.isFinite(n)) xs.push(n);
+  });
+  if (!xs.length) return null;
+  const min = Math.min(...xs);
+  const max = Math.max(...xs);
+  if (!(max > min)) return null;
+  const first = pairs[0];
+  const y = Number(Array.isArray(first) ? first[1] : first?.value?.[1]);
+  if (!Number.isFinite(y)) return null;
+  if (Array.isArray(first)) {
+    return [
+      [min, y],
+      [max, y],
+    ];
+  }
+  return [
+    { ...first, value: [min, y] },
+    { ...first, value: [max, y] },
+  ];
+}
+
 export function classifyDiagnosticSeries(rowOrLabel) {
   const text = seriesText(rowOrLabel);
   if (/合同功率/.test(text)) return "contract";

+ 11 - 1
shared/anomalyWindPower3D.mjs

@@ -328,6 +328,12 @@ function isReferenceLineRow(row) {
   return isSharedOverlayCurveSeries(row);
 }
 
+function readRowModel(row) {
+  const model = row?.model ?? row?.extra?.model;
+  if (model == null || String(model).trim() === "") return "";
+  return String(model).trim();
+}
+
 function isMarkerRow(row, templateKey) {
   if (isReferenceLineRow(row)) return false;
   if (templateKey === "wind_power_curve") return false;
@@ -573,6 +579,7 @@ export function buildWindPower3DChartOption({
       referenceSeries.push({
         name: row.label,
         type: "line3D",
+        __model: readRowModel(row),
         data,
         lineStyle: {
           width: visual.lineWidth,
@@ -600,6 +607,7 @@ export function buildWindPower3DChartOption({
     const item = {
       name: seriesName,
       type: useScatter ? "scatter3D" : "line3D",
+      __model: readRowModel(row),
       data: plotData,
       shading: "color",
       symbol: "circle",
@@ -738,7 +746,9 @@ export function buildWindPower3DChartOption({
         const turbineLine = turbineText
           ? `<br/>${WIND_POWER_3D_AXIS.x}: ${turbineText}`
           : "";
-        return `${params.marker || ""}${params.seriesName || ""}<br/>${WIND_POWER_3D_AXIS.y}: ${windSpeed}${turbineLine}<br/>${WIND_POWER_3D_AXIS.z}: ${power}`;
+        const model = series[params.seriesIndex]?.__model;
+        const modelLine = model ? `<br/>机型: ${model}` : "";
+        return `${params.marker || ""}${params.seriesName || ""}${modelLine}<br/>${WIND_POWER_3D_AXIS.y}: ${windSpeed}${turbineLine}<br/>${WIND_POWER_3D_AXIS.z}: ${power}`;
       },
     },
     legend: sideLegend

+ 35 - 5
src/views/anomalyDetection/utils/anomalyChartOptions.js

@@ -449,6 +449,15 @@ export function buildHeatmapOption({
   };
 }
 
+function formatAtMost2Decimals(value) {
+  const num = Number(value);
+  if (!Number.isFinite(num)) return "";
+  return num
+    .toFixed(2)
+    .replace(/(\.\d*?)0+$/, "$1")
+    .replace(/\.$/, "");
+}
+
 function adjustColorForChart(color, amount) {
   const hex = color.replace("#", "");
   const r = Math.max(0, Math.min(255, parseInt(hex.substr(0, 2), 16) + amount));
@@ -510,7 +519,18 @@ export function buildCarouselOption(module) {
     return {
       name: detector.name,
       type: "bar",
-      data: module.turbines.map((turbine) => turbine.values[i] || 0),
+      data: module.turbines.map((turbine) => {
+        const value = Number(turbine.values[i]);
+        const safe = Number.isFinite(value) ? value : 0;
+        const negative = safe < 0;
+        return {
+          value: safe,
+          itemStyle: {
+            borderRadius: negative ? [0, 0, 3, 3] : [3, 3, 0, 0],
+          },
+          label: { position: negative ? "bottom" : "top" },
+        };
+      }),
       barGap: "10%",
       barMaxWidth: 28,
       itemStyle: {
@@ -518,7 +538,6 @@ export function buildCarouselOption(module) {
           { offset: 0, color: barColor },
           { offset: 1, color: adjustColorForChart(barColor, -45) },
         ]),
-        borderRadius: [3, 3, 0, 0],
       },
       label: {
         show: true,
@@ -600,7 +619,10 @@ export function buildCarouselOption(module) {
         interval: 0,
         hideOverlap: true,
       },
-      axisLine: { lineStyle: { color: theme.axisLine } },
+      axisLine: {
+        onZero: false,
+        lineStyle: { color: theme.axisLine },
+      },
     },
     dataZoom: showDataZoom
       ? [
@@ -630,12 +652,20 @@ export function buildCarouselOption(module) {
       type: "value",
       name: allPercent ? "占比(%)" : "数值",
       nameTextStyle: { color: theme.legendText, fontSize: 11 },
-      min: 0,
+      // 静态偏航误差等为负时,纵轴要露出负数;全是非负数时仍从 0 起
+      min(value) {
+        if (!Number.isFinite(value.min) || value.min >= 0) return 0;
+        const span = Math.max(value.max - value.min, Math.abs(value.min), 1);
+        return Number((value.min - span * 0.08).toFixed(2));
+      },
       max: allPercent ? 100 : undefined,
       axisLabel: {
         color: theme.legendText,
         fontSize: 11,
-        formatter: allPercent ? "{value}%" : "{value}",
+        formatter(value) {
+          const text = formatAtMost2Decimals(value);
+          return allPercent ? `${text}%` : text;
+        },
       },
       splitLine: { lineStyle: { color: theme.splitLine } },
     },

+ 8 - 11
src/views/anomalyDetection/utils/anomalyDashboardMapper.js

@@ -31,7 +31,7 @@ const SENSOR_RADAR_FIELDS = [
   { key: "sensorAnomalySpdTrqCount", label: "转速-扭矩\n逻辑异常" },
   {
     key: "sensorAnomalyFreezeCount",
-    label: "偏航角、扭缆角、\n偏航误差等偏航",
+    label: "偏航扭缆异常",
   },
   { key: "sensorAnomalyMajorTempCount", label: "大部件温度异常" },
 ];
@@ -275,7 +275,11 @@ function mapDetectorHeatmapFromModel(modelList, highColor) {
 }
 
 /** 数据感知热力图:优先用 getAnomalyModel 各传感器独立 ratio 字段 */
-function mapSensorHeatmapFromModel(modelList, highColor, anomalySensorMap = {}) {
+function mapSensorHeatmapFromModel(
+  modelList,
+  highColor,
+  anomalySensorMap = {},
+) {
   const list = modelList || [];
   if (!list.length) {
     return {
@@ -343,9 +347,7 @@ function buildCarouselModules(
   const list = modelList || [];
   const useModel = list.length > 0;
   const sortedModels = useModel
-    ? [...list].sort((a, b) =>
-        compareEngineNames(a?.engineName, b?.engineName),
-      )
+    ? [...list].sort((a, b) => compareEngineNames(a?.engineName, b?.engineName))
     : [];
   const engineNames = useModel
     ? sortedModels.map((item) => String(item.engineName ?? ""))
@@ -391,12 +393,7 @@ function buildEngineNameMap(modelList = []) {
 }
 
 function resolveFieldName(item) {
-  return (
-    item?.fieldName ||
-    item?.windFarmName ||
-    item?.farmName ||
-    ""
-  );
+  return item?.fieldName || item?.windFarmName || item?.farmName || "";
 }
 
 function resolveMachineType(item) {

+ 3 - 3
src/views/anomalyDetection/utils/anomalyMapEnums.js

@@ -7,8 +7,8 @@ export const ENUM_SENSOR = {
   sensor_anomaly_torque: "扭矩异常",
   sensor_anomaly_wind_pwr: "风速-功率逻辑异常",
   sensor_anomaly_spd_trq: "转速-扭矩逻辑异常",
-  sensor_anomaly_freeze: "偏航角、扭缆角、偏航误差等偏航",
-  sensor_anomaly_detector_point: "偏航角、扭缆角、偏航误差等偏航",
+  sensor_anomaly_freeze: "偏航扭缆异常",
+  sensor_anomaly_detector_point: "偏航扭缆异常",
   sensor_anomaly_major_temp: "大部件温度异常",
 };
 
@@ -34,7 +34,7 @@ export const ENUM_SENSOR_CODE = {
   5: "扭矩异常",
   6: "风速-功率逻辑异常",
   7: "转速-扭矩逻辑异常",
-  8: "偏航角、扭缆角、偏航误差等偏航",
+  8: "偏航扭缆异常",
   9: "大部件温度异常",
 };
 

+ 70 - 27
src/views/anomalyDetection/utils/anomalyPlotChartBuilder.js

@@ -17,6 +17,10 @@ import {
   diagnosticFarmSymbolSize,
   farmSeriesPaintOrder,
   diagnosticLineType,
+  distinctLineXCount,
+  isDeloadRateGuideLabel,
+  isDeloadThresholdLabel,
+  spanThresholdLinePairs,
   diagnosticSeriesColor,
   diagnosticSeriesSymbol,
   diagnosticSingleSeriesZ,
@@ -363,6 +367,19 @@ function toYNumber(v) {
   return Number.isFinite(n) ? n : null;
 }
 
+function hoverExtra(row) {
+  const extra = row?.extra;
+  const model = row?.model;
+  const extraHasModel =
+    extra &&
+    typeof extra === "object" &&
+    extra.model != null &&
+    extra.model !== "";
+  if (extraHasModel || model == null || model === "") return extra || null;
+  if (extra && typeof extra === "object") return { ...extra, model };
+  return { model };
+}
+
 function buildScatterPairs(row, useTime) {
   const xArr = getRowXData(row, useTime);
   const yArr = getRowYData(row);
@@ -372,6 +389,7 @@ function buildScatterPairs(row, useTime) {
   );
   const points = Array.isArray(row.pointData) ? row.pointData : [];
   const len = Math.min(xArr.length, yArr.length);
+  const extra = hoverExtra(row);
   const pairs = [];
   for (let i = 0; i < len; i += 1) {
     const y = toYNumber(yArr[i]);
@@ -381,7 +399,7 @@ function buildScatterPairs(row, useTime) {
     pairs.push({
       value,
       point: points[i] || null,
-      extra: row.extra || null,
+      extra,
     });
   }
   return pairs;
@@ -522,14 +540,20 @@ function buildSeries(panel, theme, options = {}) {
       getRowYData(row).length,
     );
     const colorMetric = resolveRowColorMetric(row, sourcePointCount);
+    const colorExtent = finiteExtent(colorMetric);
     const ignoreColorbar = Boolean(statusLegendPick);
+    // 正常系列 x/y 都为空时,色值个数和点数都是 0,不能当成色带。
+    // 否则 visualMap 的 min/max 会变成 Infinity / -Infinity,画布渐变报 addColorStop。
     const hasColorbar =
       !ignoreColorbar &&
+      sourcePointCount > 0 &&
       Array.isArray(colorMetric) &&
-      colorMetric.length === sourcePointCount;
+      colorMetric.length === sourcePointCount &&
+      Number.isFinite(colorExtent.min) &&
+      Number.isFinite(colorExtent.max) &&
+      colorExtent.max > colorExtent.min;
     const useBubble = hasColorbar && isBubbleColorbar(row, templateKey);
-    const useBubbleSize =
-      hasColorbar && usesBubbleSymbolSize(row, templateKey);
+    const useBubbleSize = hasColorbar && usesBubbleSymbolSize(row, templateKey);
     const scatterPairs = useBubble
       ? sortScatterPairsByColorMetricAsc(pairs)
       : pairs;
@@ -561,7 +585,7 @@ function buildSeries(panel, theme, options = {}) {
         : undefined;
 
     if (hasColorbar) {
-      const { min: cbMin, max: cbMax } = finiteExtent(colorMetric);
+      const { min: cbMin, max: cbMax } = colorExtent;
       const seriesIndex = series.length;
       const useColorbar = !farmAnomaly && diagnosticRole !== "anomaly";
       series.push({
@@ -588,15 +612,11 @@ function buildSeries(panel, theme, options = {}) {
         animation: false,
         hoverAnimation: false,
         emphasis: { scale: false },
-        clip: false,
+        clip: true,
         itemStyle: useColorbar
           ? {
               opacity:
-                templateKey === "pitch_min"
-                  ? 0.92
-                  : densePitchCoord
-                  ? 0.72
-                  : 1,
+                templateKey === "pitch_min" ? 0.92 : densePitchCoord ? 0.72 : 1,
             }
           : {
               color: DIAGNOSTIC_COLORS.anomaly,
@@ -661,7 +681,7 @@ function buildSeries(panel, theme, options = {}) {
         animation: false,
         hoverAnimation: false,
         emphasis: { scale: false },
-        clip: false,
+        clip: true,
         itemStyle: {
           color,
           opacity: farmMode
@@ -679,34 +699,51 @@ function buildSeries(panel, theme, options = {}) {
     }
 
     const farmPitchBlade = farmMode && templateKey === "pitch_regulation";
-    const lineType = bladeStyle
-      ? bladeStyle.lineType
-      : diagnosticLineType(row);
+    const lineType = bladeStyle ? bladeStyle.lineType : diagnosticLineType(row);
 
     if (isLine && !isMarker) {
       const pitchRangeSinglePoint =
         templateKey === "pitch_regulation" &&
         /极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
         pairs.length === 1;
+      const deloadGuide = isDeloadRateGuideLabel(row);
+      let lineData = pairs;
+      if (deloadGuide && isDeloadThresholdLabel(row)) {
+        const axisX = [];
+        (panel.data || []).forEach((item) => {
+          getRowXData(item, useTime).forEach((x) => {
+            axisX.push(toAxisValue(x, useTime));
+          });
+        });
+        const spanned = spanThresholdLinePairs(pairs, axisX);
+        if (spanned) lineData = spanned;
+      }
+      // 只有一个时刻时折线没有线段。累计降载率和阈值都改成画点。
+      const showLonePoint =
+        (pitchRangeSinglePoint || deloadGuide) &&
+        distinctLineXCount(lineData) <= 1;
+      if (showLonePoint && lineData.length > 1) {
+        lineData = [lineData[0]];
+      }
       series.push({
         name: seriesName,
         type: "line",
-        data: pairs,
-        showSymbol: pitchRangeSinglePoint,
-        symbol: pitchRangeSinglePoint ? "circle" : "none",
-        symbolSize: pitchRangeSinglePoint ? 10 : 0,
+        data: lineData,
+        showSymbol: showLonePoint,
+        symbol: showLonePoint ? "circle" : "none",
+        symbolSize: showLonePoint ? 10 : 0,
         smooth: false,
-        clip: false,
+        clip: showLonePoint ? false : true,
         lineStyle: {
           ...buildLineStyle(color, row.label),
           type: lineType,
           width: farmMode
             ? farmStyle.lineWidth
             : diagnosticRole === "contract"
-              ? 3.8
-              : row.__shared
-                ? 2.5
-                : 2,
+            ? 3.8
+            : row.__shared
+            ? 2.5
+            : 2,
           opacity: farmMode ? farmStyle.lineOpacity : 1,
         },
         itemStyle: { color },
@@ -748,7 +785,7 @@ function buildSeries(panel, theme, options = {}) {
             name: pitchSegmentSeriesName(row, segment.anomaly),
             type: "line",
             data,
-            clip: false,
+            clip: true,
             showSymbol: true,
             symbol,
             symbolSize: singlePoint ? 10 : 8,
@@ -775,7 +812,7 @@ function buildSeries(panel, theme, options = {}) {
       name: seriesName,
       type: "line",
       data: pairs,
-      clip: false,
+      clip: true,
       showSymbol:
         (templateKey === "pitch_regulation" &&
           /极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
@@ -1273,7 +1310,9 @@ export function buildPanelChartOption(panel, options = {}) {
   const xRangeLocked = pitchCoord
     ? applyPitchCoordXAxis(xAxis, panel.xrange)
     : false;
-  const yRangeLocked = pitchCoord ? lockAxisToRange(yAxis, panel.yrange) : false;
+  const yRangeLocked = pitchCoord
+    ? lockAxisToRange(yAxis, panel.yrange)
+    : false;
   if (farmMode && templateKey === "aero_cp" && !useTime) {
     const extent = seriesAxisDataMin(series);
     applyZeroStartAxis(xAxis, extent.xMin);
@@ -1391,6 +1430,10 @@ export function buildPanelChartOption(panel, options = {}) {
             : `${item.marker}${item.seriesName}: ${itemCoord[1]}${unit}<br/>`;
           const extra = formatPointHover(item.data?.point);
           if (extra) html += `${extra}<br/>`;
+          const model = item.data?.extra?.model;
+          if (!farmMode && model != null && model !== "") {
+            html += `机型: ${model}<br/>`;
+          }
           const extraHover = farmMode
             ? formatFarmSeriesHover(
                 item.data?.extra,

BIN
项目截图/异常检测.png


BIN
项目截图/异常检测驾驶舱.png


BIN
项目截图/项目截图.docx


BIN
项目截图/风机异常详情.png


BIN
项目截图/风机异常详情偏航模块.png


BIN
项目截图/风机异常详情变桨模块.png


BIN
项目截图/风机异常详情气动性能模块.png


BIN
项目截图/风机异常详情运行状态模块.png


BIN
项目截图/风机异常详情风功率模块.png