Преглед на файлове

新增模型2压力-角度分析页与接口;告警监控增强(压缩机下拉、状态/级别与扫描信息);油压告警表结构迁移;补充振动与油压异常段导出

zhouhao преди 2 дни
родител
ревизия
605b4d3030

+ 48 - 0
backend/app/main.py

@@ -48,6 +48,20 @@ class AlarmScanInput(BaseModel):
     hours: float = Field(gt=0)
 
 
+class PressureAngleSyncInput(BaseModel):
+    device_part: str = Field(min_length=1)
+    device_points: list[str] = Field(default=[])
+
+
+class PressureAngleQueryInput(BaseModel):
+    device_part: str = Field(min_length=1)
+    device_points: list[str] = Field(default=[])
+    min_time: str | None = None
+    max_time: str | None = None
+    angles: list[int] = Field(default=[])
+
+
+
 class PretrainRecordInput(BaseModel):
     id: int = Field(gt=0)
     point_name: str = Field(min_length=1)
@@ -199,6 +213,14 @@ def list_alarms(
         raise HTTPException(status_code=503, detail=str(error)) from error
 
 
+@app.get("/api/alarm-compressor-options")
+def alarm_compressor_options(_auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.alarm_compressor_options()
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
 @app.post("/api/alarm-scan")
 def alarm_scan(payload: AlarmScanInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:
@@ -209,6 +231,32 @@ def alarm_scan(payload: AlarmScanInput, _auth: str = Depends(require_auth)) -> d
         raise HTTPException(status_code=503, detail=str(error)) from error
 
 
+@app.post("/api/pressure-angle/sync")
+def pressure_angle_sync(payload: PressureAngleSyncInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.sync_pressure_angle(payload.device_part, payload.device_points)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
+@app.post("/api/pressure-angle/query")
+def pressure_angle_query(payload: PressureAngleQueryInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
+    try:
+        return data_service.query_pressure_angle(
+            payload.device_part,
+            payload.device_points,
+            payload.min_time,
+            payload.max_time,
+            payload.angles,
+        )
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+    except Exception as error:
+        raise HTTPException(status_code=503, detail=str(error)) from error
+
+
 @app.post("/api/wave-window")
 def wave_window(payload: WaveWindowInput, _auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:

+ 779 - 72
backend/app/services/data_service.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import re
+import json
 from bisect import bisect_left
 from collections import OrderedDict
 from datetime import datetime, timedelta
@@ -46,8 +47,12 @@ PHASES = (
 ANNOTATION_LABELS = ("正常", "异常")
 
 OIL_PRESSURE_ALARM_TYPE = "润滑油压力低"
+CRUCIFORM_FAULT_ALARM_TYPE = "十字头故障"
+VALVE_FAULT_ALARM_TYPE = "气阀故障"
 OIL_PRESSURE_DEVICE_PART = "润滑油"
 OIL_PRESSURE_DEVICE_POINT = "压力"
+VIBRATION_DEVICE_POINT = "振动"
+OIL_PRESSURE_SCAN_DAYS = 14
 
 # PKS 全场点位:机组号 -> pks_long_sample.import_batch_id
 # 7号机=30、8号机=31、9号机=32。
@@ -132,6 +137,24 @@ def _stored_first_cycle(samples: np.ndarray) -> DetectedCycle | None:
     )
 
 
+def _sample_first_cycle_360(samples: np.ndarray) -> list[tuple[int, float]]:
+    """Resample the stored first cycle into 360 integer-angle points.
+
+    Each integer angle 0..359 keeps the first raw sample whose crank angle is at
+    or after that integer value (the "取第一个值" rule from showPV).
+    """
+    if samples.ndim != 2 or samples.shape[1] < 2 or len(samples) < 2:
+        return []
+    count = len(samples)
+    angle = np.linspace(0.0, 360.0, count, endpoint=False)
+    indices = np.searchsorted(angle, np.arange(360, dtype=float), side="left")
+    indices = np.clip(indices, 0, count - 1)
+    return [
+        (int(deg), float(samples[int(idx), 1]))
+        for deg, idx in enumerate(indices)
+    ]
+
+
 def _detect_source_cycles(
     samples: np.ndarray,
     single_cycle: bool,
@@ -176,6 +199,11 @@ def _alarm_dict(row: dict[str, Any]) -> dict[str, Any]:
         "devicePoint": row["device_point"] or "",
         "alarmType": row["alarm_type"] or "",
         "alarmDes": row["alarm_des"] or "",
+        "status": int(row.get("status") or 0),
+        "alarmLevel": row.get("alarm_level"),
+        "scanStartTime": _time_string(row["scan_start_time"]) if row.get("scan_start_time") else "",
+        "scanEndTime": _time_string(row["scan_end_time"]) if row.get("scan_end_time") else "",
+        "alarmInfoJson": row.get("alarm_info_json") or "",
         "alarmTimeStart": _time_string(row["alarm_time_start"]),
         "alarmTimeEnd": _time_string(row["alarm_time_end"]),
     }
@@ -761,7 +789,8 @@ class DataService:
                     cursor.execute(
                         """
                         SELECT id, device_code, device_part, device_point, alarm_type,
-                               alarm_des, alarm_time_start, alarm_time_end
+                               alarm_des, alarm_time_start, alarm_time_end, status,
+                               alarm_level, scan_start_time, scan_end_time, alarm_info_json
                         FROM compressor_alarm
                         WHERE alarm_time_start <= %s AND alarm_time_end >= %s
                         ORDER BY alarm_time_start DESC, id DESC
@@ -796,9 +825,12 @@ class DataService:
             raise ValueError("预警时间不能为空")
         if hours <= 0:
             raise ValueError("告警时长必须大于 0")
+        if alarm_type == VALVE_FAULT_ALARM_TYPE:
+            return self._scan_valve_fault(device_code, start, hours)
 
         if alarm_type == OIL_PRESSURE_ALARM_TYPE:
-            hours = 24.0
+            # `hours` controls the alarm validity window, not the analysis window.
+            # Oil pressure analysis always uses the previous 14 days below.
             analysis = self._analyze_oil_pressure_alarm(device_code, start)
             if analysis is None:
                 return {
@@ -807,16 +839,41 @@ class DataService:
                     "action": "no_alarm",
                     "id": 0,
                 }
-            device_part = OIL_PRESSURE_DEVICE_PART
+            device_part = analysis["device_part"]
             device_point = OIL_PRESSURE_DEVICE_POINT
-            alarm_des = analysis
+            alarm_des = analysis["basis"]
+            status = 1
+            alarm_level = analysis["stage"]
+            scan_start = analysis["scan_start"]
+            scan_end = analysis["scan_end"]
+            alarm_info_json = json.dumps(analysis["info"], ensure_ascii=False, separators=(",", ":"))
+        elif alarm_type == CRUCIFORM_FAULT_ALARM_TYPE:
+            analysis = self._analyze_cruciform_vibration_alarm(device_code, start)
+            if analysis is None:
+                return {
+                    "source": "database",
+                    "notice": None,
+                    "action": "no_alarm",
+                    "id": 0,
+                }
+            device_part = analysis["device_part"]
+            device_point = VIBRATION_DEVICE_POINT
+            alarm_des = analysis["basis"]
             status = 1
+            alarm_level = analysis["stage"]
+            scan_start = analysis["scan_start"]
+            scan_end = analysis["scan_end"]
+            alarm_info_json = json.dumps(analysis["info"], ensure_ascii=False, separators=(",", ":"))
         else:
             # Keep the existing endpoint contract for algorithms not yet implemented.
             device_part = "测试组件"
             device_point = "测试组件"
             alarm_des = "测试数据"
             status = 0
+            alarm_level = None
+            scan_start = None
+            scan_end = None
+            alarm_info_json = None
         end = start + timedelta(hours=hours)
 
         def database_query():
@@ -838,20 +895,24 @@ class DataService:
                         cursor.execute(
                             """
                             UPDATE compressor_alarm
-                            SET alarm_time_end = %s, alarm_des = %s, status = %s
+                            SET alarm_time_end = %s, alarm_des = %s, status = %s,
+                                alarm_level = %s, scan_start_time = %s, scan_end_time = %s,
+                                alarm_info_json = %s
                             WHERE id = %s
                             """,
-                            (end, alarm_des, status, row["id"]),
+                            (end, alarm_des, status, alarm_level, scan_start, scan_end, alarm_info_json, row["id"]),
                         )
                         return {"action": "updated", "id": int(row["id"])}
                     cursor.execute(
                         """
                         INSERT INTO compressor_alarm
                             (device_code, device_part, device_point, alarm_type,
-                             alarm_des, alarm_time_start, alarm_time_end, status)
-                        VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+                             alarm_des, alarm_time_start, alarm_time_end, status,
+                             alarm_level, scan_start_time, scan_end_time, alarm_info_json)
+                        VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                         """,
-                            (device_code, device_part, device_point, alarm_type, alarm_des, start, end, status),
+                            (device_code, device_part, device_point, alarm_type, alarm_des, start, end, status,
+                             alarm_level, scan_start, scan_end, alarm_info_json),
                     )
                     return {"action": "inserted", "id": int(cursor.lastrowid)}
 
@@ -861,6 +922,226 @@ class DataService:
         )
         return {"source": source, "notice": self._source_notice(source), **result}
 
+    @staticmethod
+    def _valve_segment_values(values: list[float]) -> list[float]:
+        if not values:
+            return []
+        raw_average = sum(values) / len(values)
+        return [value for value in values if value >= raw_average * 0.5]
+
+    @classmethod
+    def _valve_segment_average(cls, values: list[float]) -> float | None:
+        filtered = cls._valve_segment_values(values)
+        return sum(filtered) / len(filtered) if filtered else None
+
+    @staticmethod
+    def _split_valve_segments(rows: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
+        segments: list[list[dict[str, Any]]] = []
+        current: list[dict[str, Any]] = []
+        for row in rows:
+            if current and row["sample_time"] - current[-1]["sample_time"] > timedelta(days=1):
+                segments.append(current)
+                current = []
+            current.append(row)
+        if current:
+            segments.append(current)
+        return segments
+
+    @staticmethod
+    def _previous_valve_segment(
+        cursor: Any, part: str, point: str, before: datetime, current_first: datetime
+    ) -> tuple[datetime, datetime] | None:
+        """Find the nearest preceding running segment lasting at least 24 hours."""
+        boundary = before
+        boundary_id = 1 << 63
+        newer = None
+        previous_end = None
+        previous_start = None
+        while True:
+            cursor.execute(
+                """
+                SELECT id, sample_time FROM wave_file
+                WHERE device_part = %s AND device_point = %s
+                  AND measurement_type = %s AND rpm > 0
+                  AND (sample_time < %s OR (sample_time = %s AND id < %s))
+                ORDER BY sample_time DESC, id DESC LIMIT 1000
+                """,
+                (part, point, "压力", boundary, boundary, boundary_id),
+            )
+            rows = cursor.fetchall()
+            if not rows:
+                break
+            for row in rows:
+                stamp = row["sample_time"]
+                if newer is None:
+                    previous_end = stamp
+                    previous_start = stamp
+                elif newer - stamp > timedelta(days=1):
+                    if previous_end - previous_start >= timedelta(hours=24):
+                        return previous_start, previous_end
+                    previous_end = stamp
+                    previous_start = stamp
+                else:
+                    previous_start = stamp
+                newer = stamp
+            boundary = rows[-1]["sample_time"]
+            boundary_id = int(rows[-1]["id"])
+            if len(rows) < 1000:
+                break
+        if (
+            previous_start is not None
+            and previous_end is not None
+            and previous_end - previous_start >= timedelta(hours=24)
+        ):
+            return previous_start, previous_end
+        return None
+
+    def _scan_valve_fault(self, device_code: str, forecast_time: datetime, hours: float) -> dict[str, Any]:
+        unit_name = device_code if device_code.endswith("号机组") else f"{device_code.rstrip('#')}号机组"
+        scan_start = forecast_time - timedelta(days=14)
+        scan_end = forecast_time + timedelta(days=1)
+
+        def database_query():
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        """
+                        SELECT f.device_part, f.device_point, f.sample_time, pa.pressure
+                        FROM wave_file f
+                        LEFT JOIN statistic_pressure_angle pa
+                          ON f.id = pa.wave_file_id AND pa.angle = 230
+                        WHERE SUBSTRING(f.device_part, 1, 4) = %s
+                          AND f.rpm > 0
+                          AND f.measurement_type = %s
+                          AND f.sample_time >= %s AND f.sample_time < %s
+                        ORDER BY f.device_part, f.device_point, f.sample_time
+                        """,
+                        (unit_name, "压力", scan_start, scan_end),
+                    )
+                    current_rows = cursor.fetchall()
+                    grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
+                    for row in current_rows:
+                        grouped.setdefault((row["device_part"], row["device_point"]), []).append(row)
+                    results = []
+                    for (part, point), running_rows in grouped.items():
+                        segments = self._split_valve_segments(running_rows)
+                        if not segments:
+                            continue
+                        current = segments[-1]
+                        current_scan_start = current[0]["sample_time"]
+                        current_values = self._valve_segment_values(
+                            [float(row["pressure"]) for row in current if row["pressure"] is not None]
+                        )
+                        current_avg = sum(current_values) / len(current_values) if current_values else None
+                        if current_avg is None or current_avg == 0:
+                            continue
+                        previous_range = None
+                        previous_values: list[float] = []
+                        eligible_previous = [
+                            segment for segment in segments[:-1]
+                            if segment[-1]["sample_time"] - segment[0]["sample_time"] >= timedelta(hours=24)
+                        ]
+                        if eligible_previous:
+                            previous = eligible_previous[-1]
+                            previous_start = previous[0]["sample_time"]
+                            previous_end = previous[-1]["sample_time"]
+                            previous_values = self._valve_segment_values(
+                                [float(row["pressure"]) for row in previous if row["pressure"] is not None]
+                            )
+                            previous_range = (previous_start, previous_end)
+                        else:
+                            previous_range = self._previous_valve_segment(
+                                cursor, part, point, current_scan_start, current_scan_start
+                            )
+                        previous_start = previous_end = None
+                        previous_avg = None
+                        if previous_range is not None:
+                            previous_start, previous_end = previous_range
+                            if not previous_values:
+                                cursor.execute(
+                                    """
+                                    SELECT pa.pressure FROM wave_file f
+                                    INNER JOIN statistic_pressure_angle pa
+                                      ON f.id = pa.wave_file_id AND pa.angle = 230
+                                    WHERE f.device_part = %s AND f.device_point = %s
+                                      AND f.measurement_type = %s AND f.rpm > 0
+                                      AND f.sample_time >= %s AND f.sample_time <= %s
+                                    """,
+                                    (part, point, "压力", previous_start, previous_end),
+                                )
+                                previous_values = self._valve_segment_values(
+                                    [float(row["pressure"]) for row in cursor.fetchall() if row["pressure"] is not None]
+                                )
+                            previous_avg = sum(previous_values) / len(previous_values) if previous_values else None
+                        else:
+                            previous_values = []
+                        is_fault = previous_avg is not None and previous_avg > current_avg * 1.05
+                        if not is_fault:
+                            continue
+                        info = json.dumps(
+                            {
+                                "当前段平均值": current_avg,
+                                "上一段平均值": previous_avg,
+                                "当前段最高值": max(current_values) if current_values else None,
+                                "当前段最低值": min(current_values) if current_values else None,
+                                "上一段最高值": max(previous_values) if previous_values else None,
+                                "上一段最低值": min(previous_values) if previous_values else None,
+                                "上一段开始时间": _time_string(previous_start) if previous_start else None,
+                                "上一段结束时间": _time_string(previous_end) if previous_end else None,
+                            },
+                            ensure_ascii=False,
+                        )
+                        description = (
+                            f"230°压力上一段均值{previous_avg:.4f}高于当前段均值"
+                            f"{current_avg:.4f}的105%"
+                        )
+                        status = 1
+                        alarm_level = "1"
+                        cursor.execute(
+                            """
+                            SELECT id FROM compressor_alarm
+                            WHERE device_code = %s AND device_part = %s AND device_point = %s
+                              AND alarm_type = %s AND alarm_time_start = %s
+                            LIMIT 1
+                            """,
+                            (device_code, part, point, VALVE_FAULT_ALARM_TYPE, forecast_time),
+                        )
+                        existing = cursor.fetchone()
+                        if existing:
+                            cursor.execute(
+                                """
+                                UPDATE compressor_alarm SET alarm_time_end = %s, alarm_des = %s,
+                                  alarm_level = %s, alarm_info_json = %s, status = %s,
+                                  scan_start_time = %s, scan_end_time = %s
+                                WHERE id = %s
+                                """,
+                                (forecast_time + timedelta(hours=hours), description, alarm_level, info,
+                                 status, current_scan_start, forecast_time, existing["id"]),
+                            )
+                            results.append(("updated", int(existing["id"])))
+                        else:
+                            cursor.execute(
+                                """
+                                INSERT INTO compressor_alarm
+                                  (device_code, device_part, device_point, alarm_type, alarm_des,
+                                   alarm_level, alarm_info_json, alarm_time_start, alarm_time_end,
+                                   scan_start_time, scan_end_time, status)
+                                  VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                                """,
+                                (device_code, part, point, VALVE_FAULT_ALARM_TYPE, description, alarm_level, info,
+                                 forecast_time, forecast_time + timedelta(hours=hours), current_scan_start, forecast_time, status),
+                            )
+                            results.append(("inserted", int(cursor.lastrowid)))
+                    return {
+                        "action": results[0][0] if results else "no_alarm",
+                        "id": results[0][1] if results else 0,
+                        "inserted": sum(action == "inserted" for action, _ in results),
+                        "updated": sum(action == "updated" for action, _ in results),
+                    }
+
+        result, source = self._run_with_fallback(database_query, lambda: {"action": "demo", "id": 0})
+        return {"source": source, "notice": self._source_notice(source), **result}
+
     @staticmethod
     def _alarm_limit(item: dict[str, Any], alarm_type: str) -> float | None:
         for index in range(1, 5):
@@ -868,25 +1149,18 @@ class DataService:
                 return _safe_float(item.get(f"AlarmLimit{index}"))
         return None
 
-    def _analyze_oil_pressure_alarm(self, device_code: str, end: datetime) -> str | None:
-        """Analyze YSJ_5 in the 24 hours before ``end``.
-
-        A low-pressure alarm requires either a configured low/low-low threshold
-        breach or a sustained decline of at least 10% from the 24-hour median.
-        The trend rule requires three consecutive non-rising valid hours or a
-        negative six-hour linear trend. Direct low-low threshold breaches remain
-        immediately reportable.
-        """
+    def _analyze_oil_pressure_alarm(self, device_code: str, end: datetime) -> dict[str, Any] | None:
+        """Analyze oil-pressure stages in the 14 days before ``end``."""
         unit = device_code.rstrip("#").strip()
-        if unit not in UNIT_BATCH:
-            raise ValueError("压缩机必须是 7#、8# 或 9#")
-        start = end - timedelta(hours=24)
+        if not device_code.endswith("#") or not device_code[:-1].isdigit():
+            raise ValueError("压缩机编号格式必须为数字+#")
+        start = end - timedelta(days=OIL_PRESSURE_SCAN_DAYS)
 
         with get_connection() as connection:
             with connection.cursor() as cursor:
                 cursor.execute(
                     """
-                    SELECT AlarmType1, AlarmType2, AlarmType3, AlarmType4,
+                    SELECT ItemName, ItemDescription, AlarmType1, AlarmType2, AlarmType3, AlarmType4,
                            AlarmLimit1, AlarmLimit2, AlarmLimit3, AlarmLimit4
                     FROM site_point
                     WHERE ItemName = %s
@@ -901,12 +1175,23 @@ class DataService:
                 if low is None or low_low is None:
                     raise ValueError(f"{device_code} 缺少润滑油压力低压报警阈值")
 
+                point_descriptions = {}
+                for point in (5, 41):
+                    cursor.execute(
+                        "SELECT ItemName, ItemDescription FROM site_point WHERE ItemName = %s",
+                        (f"YSJ{unit}_{point}",),
+                    )
+                    item = cursor.fetchone()
+                    if item:
+                        point_descriptions[f"YSJ_{point}"] = item.get("ItemDescription") or f"YSJ_{point}"
+
                 cursor.execute(
                     """
                     SELECT FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
                            COUNT(*) AS samples,
                            SUM(CASE WHEN YSJ_41 > 0 THEN 1 ELSE 0 END) AS running_samples,
-                           AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg
+                           AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
+                           GROUP_CONCAT(DISTINCT import_batch_id ORDER BY import_batch_id) AS batch_ids
                     FROM pks_long_sample
                     WHERE device_code = %s
                       AND sample_time >= %s AND sample_time < %s
@@ -920,61 +1205,455 @@ class DataService:
                     running = int(row["running_samples"] or 0)
                     value = _safe_float(row["oil_avg"])
                     if running / 720 >= 0.80 and value is not None:
-                        rows.append({"time": row["hour_start"], "value": value, "running": running})
+                        rows.append({
+                            "time": row["hour_start"], "value": value, "running": running,
+                            "batch_ids": [int(value) for value in str(row["batch_ids"] or "").split(",") if value],
+                        })
 
         if not rows:
             return None
-        values = [row["value"] for row in rows]
-        baseline = float(np.median(values))
-        current = rows[-1]["value"]
-        relative_drop = (baseline - current) / baseline if baseline else 0.0
-        low_low_rows = [row for row in rows if row["value"] <= low_low]
-        low_rows = [row for row in rows if row["value"] <= low]
-        consecutive_low = 0
-        for row in reversed(rows):
-            if row["value"] <= low:
-                consecutive_low += 1
-            else:
-                break
-        consecutive_low_low = 0
-        for row in reversed(rows):
-            if row["value"] <= low_low:
-                consecutive_low_low += 1
+        baseline = float(np.median([row["value"] for row in rows]))
+        candidates = []
+        history = []
+        for row in rows:
+            recent = history[-5:] + [row]
+            trend_values = [item["value"] for item in recent]
+            trend = float(np.polyfit(range(len(trend_values)), trend_values, 1)[0]) if len(trend_values) >= 3 else None
+            decline = 1
+            newer = row
+            for older in reversed(history):
+                if newer["time"] - older["time"] != timedelta(hours=1):
+                    break
+                if newer["value"] > older["value"]:
+                    break
+                decline += 1
+                newer = older
+            current = row["value"]
+            deviation = (baseline - current) / baseline if baseline else 0.0
+            downward = decline >= 3 or (trend is not None and trend < -0.0005)
+            if current <= low_low or (deviation >= 0.20 and downward):
+                phase = "严重异常"
+            elif current <= low or (deviation >= 0.10 and downward):
+                phase = "异常"
+            elif deviation >= 0.05 and downward:
+                phase = "轻微"
             else:
-                break
-        consecutive_decline = 1
-        for index in range(len(rows) - 1, 0, -1):
-            if rows[index]["time"] - rows[index - 1]["time"] != timedelta(hours=1):
-                break
-            if rows[index]["value"] > rows[index - 1]["value"]:
-                break
-            consecutive_decline += 1
-        trend_values = [row["value"] for row in rows[-6:]]
-        trend = float(np.polyfit(range(len(trend_values)), trend_values, 1)[0]) if len(trend_values) >= 3 else None
-        downward = consecutive_decline >= 3 or (trend is not None and trend < -0.0005)
-        evolution_alarm = relative_drop >= 0.10 and downward
+                phase = "正常"
+            candidates.append({"row": recent[-1], "phase": phase, "trend": trend, "decline": decline, "deviation": deviation})
+            history.append(recent[-1])
 
-        if not low_low_rows and not low_rows and not evolution_alarm:
+        abnormal = [item for item in candidates if item["phase"] != "正常"]
+        if not abnormal:
             return None
-        reasons = [
-            f"前24小时有效运行数据{len(rows)}小时",
-            f"当前小时油压{current:.3f},24小时基线{baseline:.3f},相对下降{relative_drop:.1%}",
-        ]
-        if consecutive_low_low:
-            reasons.append(f"连续{consecutive_low_low}小时低于低低报警阈值{low_low:.3f}")
-        elif low_low_rows:
-            reasons.append(f"低低报警区间出现{len(low_low_rows)}小时")
-        elif consecutive_low:
-            reasons.append(f"连续{consecutive_low}小时低于低报警阈值{low:.3f}")
-        else:
-            reasons.append(f"低报警区间出现{len(low_rows)}小时")
-        if evolution_alarm:
-            reasons.append(
-                f"相对24小时基线下降至少10%,连续下降{consecutive_decline}小时,"
-                f"近6小时斜率{trend:.6f}" if trend is not None
-                else f"相对24小时基线下降至少10%,连续下降{consecutive_decline}小时"
-            )
-        return ";".join(reasons)
+        stage_runs = []
+        current_run = []
+        for item in candidates:
+            if current_run and (
+                item["phase"] != current_run[-1]["phase"]
+                or item["row"]["time"] - current_run[-1]["row"]["time"] != timedelta(hours=1)
+            ):
+                if current_run[0]["phase"] != "正常":
+                    stage_runs.append(current_run)
+                current_run = []
+            current_run.append(item)
+        if current_run and current_run[0]["phase"] != "正常":
+            stage_runs.append(current_run)
+        stage_rows = max(
+            stage_runs,
+            key=lambda run: (
+                {"轻微": 1, "异常": 2, "严重异常": 3}[run[0]["phase"]],
+                len(run),
+            ),
+        )
+        stage = stage_rows[0]["phase"]
+        first = stage_rows[0]
+        last = stage_rows[-1]
+        stage_values = [item["row"]["value"] for item in stage_rows]
+        basis = (
+            f"油压{first['row']['value']:.3f}~{last['row']['value']:.3f}MPa,"
+            f"最低{min(stage_values):.3f}MPa,连续{len(stage_rows)}个有效运行小时;"
+            f"周期基线油压{baseline:.3f}MPa,相对基线变化{last['deviation']:+.1%}"
+        )
+        batch_ids = sorted({batch for item in stage_rows for batch in item["row"]["batch_ids"]})
+        info = {
+            "机组": f"{unit}号机",
+            "批次": batch_ids[0] if len(batch_ids) == 1 else batch_ids,
+            "周期编号": f"{device_code}-P14",
+            "开始小时": first["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
+            "结束小时": last["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
+            "持续自然小时数": int((last["row"]["time"] - first["row"]["time"]).total_seconds() / 3600) + 1,
+            "有效运行小时数": len(stage_rows),
+            "周期基线油压": baseline,
+            "正常波动带": "",
+            "阶段开始小时油压": first["row"]["value"],
+            "阶段结束小时油压": last["row"]["value"],
+            "阶段最低小时油压": min(stage_values),
+            "阶段开始相对周期基线变化": first["deviation"],
+            "阶段结束相对周期基线变化": last["deviation"],
+            "阶段最大连续下降有效小时数": max(item["decline"] for item in stage_rows),
+            "阶段低报警小时数": sum(item["row"]["value"] <= low for item in stage_rows),
+            "阶段低低报警小时数": sum(item["row"]["value"] <= low_low for item in stage_rows),
+            "是否形成确认等级": "是" if len(stage_rows) >= 3 else "否",
+        }
+        return {
+            "stage": stage,
+            "basis": basis,
+            "device_part": "-".join(dict.fromkeys(point_descriptions.values())),
+            "scan_start": start,
+            "scan_end": end,
+            "info": info,
+        }
+
+    def _analyze_cruciform_vibration_alarm(self, device_code: str, end: datetime) -> dict[str, Any] | None:
+        """Build the strongest 14-day vibration stage for the crosshead alarm."""
+        unit = device_code.rstrip("#").strip()
+        if not device_code.endswith("#") or not device_code[:-1].isdigit():
+            raise ValueError("压缩机编号格式必须为数字+#")
+        start = end - timedelta(days=OIL_PRESSURE_SCAN_DAYS)
+
+        with get_connection() as connection:
+            with connection.cursor() as cursor:
+                configs = {}
+                descriptions = []
+                for point, side in ((10, "联轴器端"), (11, "链轮端"), (41, "运行状态")):
+                    cursor.execute(
+                        """
+                        SELECT ItemDescription, AlarmType1, AlarmType2, AlarmType3, AlarmType4,
+                               AlarmLimit1, AlarmLimit2, AlarmLimit3, AlarmLimit4
+                        FROM site_point WHERE ItemName = %s
+                        """,
+                        (f"YSJ{unit}_{point}",),
+                    )
+                    item = cursor.fetchone()
+                    if item is None:
+                        raise ValueError(f"未找到 {device_code} 的 YSJ_{point} 点位配置")
+                    descriptions.append(item.get("ItemDescription") or f"YSJ_{point}")
+                    if point != 41:
+                        high = self._alarm_limit(item, "PVHigh")
+                        high_high = self._alarm_limit(item, "PVHighHigh")
+                        if high is None or high_high is None:
+                            raise ValueError(f"{device_code} YSJ_{point} 缺少振动报警阈值")
+                        configs[side] = {"high": high, "high_high": high_high}
+
+                cursor.execute(
+                    """
+                    SELECT FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
+                           SUM(CASE WHEN YSJ_41 > 0 THEN 1 ELSE 0 END) AS running_samples,
+                           AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
+                           MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
+                           AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
+                           MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
+                           GROUP_CONCAT(DISTINCT import_batch_id ORDER BY import_batch_id) AS batch_ids
+                    FROM pks_long_sample
+                    WHERE device_code = %s
+                      AND sample_time >= %s AND sample_time < %s
+                    GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
+                    ORDER BY hour_start
+                    """,
+                    (device_code, start, end),
+                )
+                rows = []
+                for row in cursor.fetchall():
+                    running = int(row["running_samples"] or 0)
+                    if running / 720 < 0.80:
+                        continue
+                    item = {
+                        "time": row["hour_start"], "running": running,
+                        "batch_ids": [int(value) for value in str(row["batch_ids"] or "").split(",") if value],
+                    }
+                    for key in ("coupling_avg", "coupling_max", "chain_avg", "chain_max"):
+                        item[key] = _safe_float(row[key])
+                    if item["coupling_max"] is not None or item["chain_max"] is not None:
+                        rows.append(item)
+
+        if len(rows) < 3:
+            return None
+        signals = []
+        for index, row in enumerate(rows):
+            prior = rows[max(0, index - 336):index]
+            if len(prior) < 24:
+                continue
+            side_signals = []
+            for side, prefix in (("联轴器端", "coupling"), ("链轮端", "chain")):
+                current = row[f"{prefix}_max"]
+                values = [item[f"{prefix}_max"] for item in prior if item[f"{prefix}_max"] is not None]
+                averages = [item[f"{prefix}_avg"] for item in prior if item[f"{prefix}_avg"] is not None]
+                if current is None or len(values) < 24:
+                    continue
+                baseline = float(np.median(values))
+                scale = max(float(np.median(np.abs(np.asarray(values) - baseline))) * 1.4826, abs(baseline) * 0.05, 1e-6)
+                peak_z = (current - baseline) / scale
+                average = row[f"{prefix}_avg"]
+                average_base = float(np.median(averages)) if averages else None
+                average_change = average / average_base - 1 if average is not None and average_base else 0.0
+                limit = configs[side]
+                severe = current >= limit["high_high"] or peak_z >= 8
+                abnormal = severe or current >= limit["high"] or peak_z >= 5 or average_change >= 0.10
+                mild = abnormal or peak_z >= 4 or average_change >= 0.05
+                if mild:
+                    side_signals.append({
+                        "side": side, "value": current, "baseline": baseline, "scale": scale,
+                        "peak_z": peak_z, "average_change": average_change,
+                        "level": "严重异常" if severe else "异常" if abnormal else "轻微",
+                    })
+            if side_signals:
+                signals.append({"row": row, "signals": side_signals})
+
+        if not signals:
+            return None
+        runs = []
+        current_run = []
+        for signal in signals:
+            if current_run and signal["row"]["time"] - current_run[-1]["row"]["time"] > timedelta(hours=6):
+                runs.append(current_run)
+                current_run = []
+            current_run.append(signal)
+        if current_run:
+            runs.append(current_run)
+        run = max(
+            runs,
+            key=lambda values: (
+                max({"轻微": 1, "异常": 2, "严重异常": 3}[item["level"]] for signal in values for item in signal["signals"]),
+                len(values),
+            ),
+        )
+        all_signals = [item for signal in run for item in signal["signals"]]
+        strongest = max(all_signals, key=lambda item: ({"轻微": 1, "异常": 2, "严重异常": 3}[item["level"]], item["peak_z"]))
+        stage = strongest["level"]
+        first, last = run[0], run[-1]
+        batch_ids = sorted({batch for signal in run for batch in signal["row"]["batch_ids"]})
+        max_value = max(item["value"] for item in all_signals)
+        basis = (
+            f"{strongest['side']}振动峰值或均值偏离近期基线,阶段内{len(run)}个有效运行小时触发;"
+            f"最大振动值{max_value:.3f}mm/s,峰值高于近期基线{strongest['peak_z']:.2f}个稳健尺度"
+            f"(峰值/基线{max_value / max(strongest['baseline'], 1e-6):.2f}倍)"
+        )
+        info = {
+            "机组": f"{unit}号机", "批次": batch_ids[0] if len(batch_ids) == 1 else batch_ids,
+            "周期编号": f"{device_code}-V14", "开始小时": first["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
+            "结束小时": last["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
+            "持续自然小时数": int((last["row"]["time"] - first["row"]["time"]).total_seconds() / 3600) + 1,
+            "有效运行小时数": len(run), "近期基线振动": strongest["baseline"],
+            "正常波动带": f"{strongest['baseline'] - 2 * strongest['scale']:.3f}~{strongest['baseline'] + 2 * strongest['scale']:.3f} mm/s",
+            "阶段开始小时振动": max(item["value"] for item in first["signals"]),
+            "阶段结束小时振动": max(item["value"] for item in last["signals"]),
+            "阶段最大振动值": max_value,
+            "阶段开始相对近期基线变化": max(item["value"] / item["baseline"] - 1 for item in first["signals"]),
+            "阶段结束相对近期基线变化": max(item["value"] / item["baseline"] - 1 for item in last["signals"]),
+            "阶段最大连续异常有效小时数": len(run),
+            "阶段高报警小时数": sum(any(item["value"] >= configs[item["side"]]["high"] for item in signal["signals"]) for signal in run),
+            "阶段高高报警小时数": sum(any(item["value"] >= configs[item["side"]]["high_high"] for item in signal["signals"]) for signal in run),
+            "是否形成确认等级": "是" if stage != "轻微" else "否",
+            "最强异常侧": strongest["side"], "最强侧近期峰值基线": strongest["baseline"],
+            "最强侧近期稳健尺度": strongest["scale"], "阶段最大峰值/基线比例": max_value / max(strongest["baseline"], 1e-6),
+        }
+        return {
+            "stage": stage, "basis": basis, "device_part": "-".join(dict.fromkeys(descriptions)),
+            "scan_start": start, "scan_end": end, "info": info,
+        }
+
+    def sync_pressure_angle(self, device_part: str, device_points: list[str] | None = None) -> dict[str, Any]:
+        """按机组与部位批量生成 statistic_pressure_angle 采样数据。
+
+        对每个压力 wave_file,若 statistic_pressure_angle 已存在至少一条记录则跳过,
+        否则读 wave_sample_one(第一个周期)按“每个整数角度取第一个值”生成 360 个角度点。
+        """
+        points = device_points or []
+
+        def database_query():
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    clauses = ["measurement_type = %s", "device_part = %s"]
+                    params: list[Any] = ["压力", device_part]
+                    if points:
+                        placeholders = ", ".join(["%s"] * len(points))
+                        clauses.append(f"device_point IN ({placeholders})")
+                        params.extend(points)
+                    cursor.execute(
+                        f"SELECT id FROM wave_file WHERE {' AND '.join(clauses)} ORDER BY id",
+                        params,
+                    )
+                    file_ids = [int(row["id"]) for row in cursor.fetchall()]
+                    if not file_ids:
+                        return {"total": 0, "created": 0, "skipped": 0}
+                    placeholders = ", ".join(["%s"] * len(file_ids))
+                    cursor.execute(
+                        f"SELECT DISTINCT wave_file_id FROM statistic_pressure_angle WHERE wave_file_id IN ({placeholders})",
+                        file_ids,
+                    )
+                    existing = {int(row["wave_file_id"]) for row in cursor.fetchall()}
+                    created = 0
+                    skipped = 0
+                    for file_id in file_ids:
+                        if file_id in existing:
+                            skipped += 1
+                            continue
+                        cursor.execute(
+                            "SELECT sample_index, signal_value, second_value FROM wave_sample_one "
+                            "WHERE wave_file_id = %s ORDER BY sample_index",
+                            (file_id,),
+                        )
+                        rows = cursor.fetchall()
+                        if not rows:
+                            skipped += 1
+                            continue
+                        samples = np.asarray(
+                            [
+                                (float(row["sample_index"]), float(row["signal_value"]),
+                                 float(row["second_value"]) if row["second_value"] is not None else 0.0)
+                                for row in rows
+                            ],
+                            dtype=float,
+                        )
+                        sampled = _sample_first_cycle_360(samples)
+                        if not sampled:
+                            skipped += 1
+                            continue
+                        cursor.executemany(
+                            "INSERT INTO statistic_pressure_angle (wave_file_id, period, angle, pressure) "
+                            "VALUES (%s, %s, %s, %s)",
+                            [(file_id, 1, angle, pressure) for angle, pressure in sampled],
+                        )
+                        created += 1
+                    return {"total": len(file_ids), "created": created, "skipped": skipped}
+
+        result, source = self._run_with_fallback(
+            database_query,
+            lambda: {"total": 0, "created": 0, "skipped": 0},
+        )
+        return {"source": source, "notice": self._source_notice(source), **result}
+
+    def query_pressure_angle(
+        self,
+        device_part: str,
+        device_points: list[str] | None,
+        min_time: str | None,
+        max_time: str | None,
+        angles: list[int],
+    ) -> dict[str, Any]:
+        """按时间范围和角度列表查询 statistic_pressure_angle 曲线数据。"""
+        if not angles:
+            raise ValueError("请至少输入一个角度")
+        angles = [int(angle) for angle in angles if 0 <= int(angle) <= 360]
+
+        def database_query():
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    clauses = ["wf.measurement_type = %s", "wf.device_part = %s"]
+                    params: list[Any] = ["压力", device_part]
+                    if device_points:
+                        placeholders = ", ".join(["%s"] * len(device_points))
+                        clauses.append(f"wf.device_point IN ({placeholders})")
+                        params.extend(device_points)
+                    start = _parse_time(min_time) if min_time else None
+                    end = _parse_time(max_time) if max_time else None
+                    if start:
+                        clauses.append("wf.sample_time >= %s")
+                        params.append(start)
+                    if end:
+                        clauses.append("wf.sample_time <= %s")
+                        params.append(end)
+                    angle_placeholders = ", ".join(["%s"] * len(angles))
+                    clauses.append(f"spa.angle IN ({angle_placeholders})")
+                    params.extend(angles)
+                    cursor.execute(
+                        f"""
+                        SELECT wf.sample_time, spa.angle, spa.pressure
+                        FROM statistic_pressure_angle spa
+                        INNER JOIN wave_file wf ON wf.id = spa.wave_file_id
+                        WHERE {' AND '.join(clauses)}
+                        ORDER BY wf.sample_time, spa.angle
+                        """,
+                        params,
+                    )
+                    rows = cursor.fetchall()
+
+                    stop_days = self._query_stop_days(
+                        cursor, device_part, device_points, start, end,
+                    )
+            series: dict[int, list[dict[str, Any]]] = {}
+            for row in rows:
+                angle = int(round(float(row["angle"])))
+                series.setdefault(angle, []).append({
+                    "time": _time_string(row["sample_time"]),
+                    "pressure": float(row["pressure"]),
+                })
+            for day in sorted(stop_days):
+                day_time = f"{day} 00:00:00"
+                for angle in angles:
+                    series.setdefault(angle, []).append({
+                        "time": day_time,
+                        "pressure": 0.0,
+                    })
+            for angle in angles:
+                series.setdefault(angle, []).sort(key=lambda point: point["time"])
+            return {
+                "angles": angles,
+                "series": [
+                    {"angle": angle, "points": series.get(angle, [])}
+                    for angle in angles
+                ],
+            }
+
+        result, source = self._run_with_fallback(
+            database_query,
+            lambda: {"angles": angles, "series": [{"angle": angle, "points": []} for angle in angles]},
+        )
+        return {"source": source, "notice": self._source_notice(source), **result}
+
+    @staticmethod
+    def _query_stop_days(
+        cursor: Any,
+        device_part: str,
+        device_points: list[str] | None,
+        start: datetime | None,
+        end: datetime | None,
+    ) -> set[str]:
+        """返回停机日集合:该机组在时间范围内 rpm=0 且当天无 rpm>0 记录的日期。"""
+        clauses = ["device_part = %s", "rpm = 0"]
+        params: list[Any] = [device_part]
+        if device_points:
+            placeholders = ", ".join(["%s"] * len(device_points))
+            clauses.append(f"device_point IN ({placeholders})")
+            params.extend(device_points)
+        if start:
+            clauses.append("sample_time >= %s")
+            params.append(start)
+        if end:
+            clauses.append("sample_time <= %s")
+            params.append(end)
+        cursor.execute(
+            f"""
+            SELECT DISTINCT DATE(sample_time) AS d
+            FROM wave_file
+            WHERE {' AND '.join(clauses)}
+            """,
+            params,
+        )
+        stop_days = {str(row["d"]) for row in cursor.fetchall()}
+
+        run_clauses = ["device_part = %s", "rpm > 0"]
+        run_params: list[Any] = [device_part]
+        if device_points:
+            placeholders = ", ".join(["%s"] * len(device_points))
+            run_clauses.append(f"device_point IN ({placeholders})")
+            run_params.extend(device_points)
+        if start:
+            run_clauses.append("sample_time >= %s")
+            run_params.append(start)
+        if end:
+            run_clauses.append("sample_time <= %s")
+            run_params.append(end)
+        cursor.execute(
+            f"""
+            SELECT DISTINCT DATE(sample_time) AS d
+            FROM wave_file
+            WHERE {' AND '.join(run_clauses)}
+            """,
+            run_params,
+        )
+        run_days = {str(row["d"]) for row in cursor.fetchall()}
+        return stop_days - run_days
 
     @staticmethod
     def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -1142,6 +1821,34 @@ class DataService:
             "notice": self._source_notice(source),
         }
 
+    def alarm_compressor_options(self) -> dict[str, Any]:
+        """Return running compressor groups using device_part's machine prefix."""
+        def database_query():
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        """
+                        SELECT SUBSTRING(device_part, 1, 4) AS device_name,
+                               COUNT(1) AS count_no
+                        FROM wave_file
+                        WHERE rpm > 0 AND device_part <> ''
+                        GROUP BY SUBSTRING(device_part, 1, 4)
+                        ORDER BY device_name
+                        """,
+                    )
+                    return [
+                        {
+                            "deviceName": row["device_name"],
+                            "deviceCode": f"{str(row['device_name'])[0]}#",
+                            "count": int(row["count_no"]),
+                        }
+                        for row in cursor.fetchall()
+                        if row["device_name"]
+                    ]
+
+        result, source = self._run_with_fallback(database_query, lambda: [])
+        return {"source": source, "notice": self._source_notice(source), "items": result}
+
     def wave_window(
         self,
         device_part: str,

+ 4 - 0
backend/migrate_compressor_alarm_oil_pressure.sql

@@ -0,0 +1,4 @@
+-- alarm_level stores the text value from the pressure stage:
+-- 轻微 / 异常 / 严重异常.
+ALTER TABLE compressor_alarm
+    MODIFY alarm_level VARCHAR(32) NULL;

+ 5 - 1
frontend/src/App.vue

@@ -4,6 +4,7 @@ import { ElMessageBox } from 'element-plus'
 import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchFaults, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
 import PeriodModal from './components/PeriodModal.vue'
 import AlarmMonitor from './components/AlarmMonitor.vue'
+import Model2 from './components/Model2.vue'
 import { fixedAxisRange } from './utils/axis'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
@@ -35,7 +36,7 @@ const faults = ref<Fault[]>([])
 const startIndex = ref(0)
 const waveData = ref<WaveWindowResponse | null>(null)
 const chartMode = ref<'split' | 'merge'>('split')
-const activeTab = ref<'model1' | 'alarm'>('model1')
+const activeTab = ref<'model1' | 'model2' | 'alarm'>('model1')
 const chartDirty = ref(false)
 const queryLoading = ref(false)
 const timePointsLoading = ref(false)
@@ -1282,6 +1283,9 @@ onBeforeUnmount(() => {
       </aside>
         </main>
       </el-tab-pane>
+      <el-tab-pane label="模型2" name="model2">
+        <Model2 />
+      </el-tab-pane>
       <el-tab-pane label="告警监控" name="alarm">
         <AlarmMonitor />
       </el-tab-pane>

+ 30 - 0
frontend/src/api.ts

@@ -1,5 +1,6 @@
 import type {
   AlarmListResponse,
+  AlarmCompressorOptionsResponse,
   AlarmScanResponse,
   Annotation,
   AnnotationConfigResponse,
@@ -8,6 +9,8 @@ import type {
   DevicePoint,
   FaultsResponse,
   PeriodDetail,
+  PressureAngleQueryResponse,
+  PressureAngleSyncResponse,
   QueryOptionsResponse,
   SitePoint,
   SitePointsResponse,
@@ -114,6 +117,10 @@ export function fetchAlarms(currentTime?: string) {
   return request<AlarmListResponse>(`/api/alarms${query ? `?${query}` : ''}`)
 }
 
+export function fetchAlarmCompressorOptions() {
+  return request<AlarmCompressorOptionsResponse>('/api/alarm-compressor-options')
+}
+
 export function runAlarmScan(payload: {
   forecast_time: string
   device_code: string
@@ -126,6 +133,29 @@ export function runAlarmScan(payload: {
   })
 }
 
+export function syncPressureAngle(payload: {
+  device_part: string
+  device_points: string[]
+}) {
+  return request<PressureAngleSyncResponse>('/api/pressure-angle/sync', {
+    method: 'POST',
+    body: JSON.stringify(payload),
+  })
+}
+
+export function queryPressureAngle(payload: {
+  device_part: string
+  device_points: string[]
+  min_time?: string
+  max_time?: string
+  angles: number[]
+}) {
+  return request<PressureAngleQueryResponse>('/api/pressure-angle/query', {
+    method: 'POST',
+    body: JSON.stringify(payload),
+  })
+}
+
 export function fetchWaveWindow(params: {
   devicePart: string
   devicePoints: DevicePoint[]

+ 30 - 16
frontend/src/components/AlarmMonitor.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
-import { fetchAlarms, runAlarmScan } from '../api'
+import { computed, onMounted, ref } from 'vue'
+import { fetchAlarmCompressorOptions, fetchAlarms, runAlarmScan } from '../api'
 import type { Alarm } from '../types'
 
 function pad(value: number) {
@@ -33,15 +33,11 @@ async function loadAlarms() {
   }
 }
 
-const compressorOptions = [
-  { label: '7号机组', value: '7#' },
-  { label: '8号机组', value: '8#' },
-  { label: '9号机组', value: '9#' },
-]
+const compressorOptions = ref<{ label: string; deviceName: string; deviceCode: string; count: number }[]>([])
 const algorithmOptions = ['润滑油压力低', '十字头故障', '气阀故障', '活塞故障']
 
 const forecastTime = ref(formatNow())
-const deviceCode = ref('7#')
+const selectedCompressor = ref<{ label: string; deviceName: string; deviceCode: string; count: number } | null>(null)
 const alarmType = ref('润滑油压力低')
 const hours = ref(24)
 const scanLoading = ref(false)
@@ -59,7 +55,9 @@ async function handleScan() {
   try {
     const result = await runAlarmScan({
       forecast_time: forecastTime.value,
-      device_code: deviceCode.value,
+      device_code: usesDeviceCode.value
+        ? selectedCompressor.value?.deviceCode ?? ''
+        : selectedCompressor.value?.deviceName ?? '',
       alarm_type: alarmType.value,
       hours: hours.value,
     })
@@ -68,7 +66,7 @@ async function handleScan() {
       : result.action === 'updated'
         ? '扫描完成:更新告警'
         : result.action === 'no_alarm'
-          ? '扫描完成:前24小时未发现润滑油压力异常'
+          ? `扫描完成:前14天未发现${alarmType.value}异常`
           : '扫描完成:演示模式未写库'
     currentTime.value = forecastTime.value
     await loadAlarms()
@@ -79,8 +77,24 @@ async function handleScan() {
   }
 }
 
+const usesDeviceCode = computed(() => alarmType.value === '润滑油压力低' || alarmType.value === '十字头故障')
+
+async function loadCompressorOptions() {
+  try {
+    const result = await fetchAlarmCompressorOptions()
+    compressorOptions.value = result.items.map((item) => ({
+      label: `${item.deviceName} (${item.count})`,
+      ...item,
+    }))
+    selectedCompressor.value = compressorOptions.value[0] ?? null
+  } catch (error) {
+    scanError.value = error instanceof Error ? error.message : '压缩机列表读取失败'
+  }
+}
+
 onMounted(() => {
   void loadAlarms()
+  void loadCompressorOptions()
 })
 </script>
 
@@ -143,8 +157,8 @@ onMounted(() => {
         </label>
         <label class="field">
           <span class="field-label">压缩机</span>
-          <el-select v-model="deviceCode" class="query-control" size="large">
-            <el-option v-for="item in compressorOptions" :key="item.value" :label="item.label" :value="item.value" />
+          <el-select v-model="selectedCompressor" class="query-control" size="large" value-key="deviceCode">
+            <el-option v-for="item in compressorOptions" :key="item.deviceCode" :label="item.label" :value="item" />
           </el-select>
         </label>
         <label class="field">
@@ -154,17 +168,17 @@ onMounted(() => {
           </el-select>
         </label>
         <label class="field field-hours">
-          <span class="field-label">分析窗口</span>
+          <span class="field-label">告警时长</span>
           <div class="hours-row">
             <el-input-number
               v-model="hours"
               class="query-control"
               size="large"
-              :min="24"
-              :max="24"
+              :min="1"
+              :max="8760"
               controls-position="right"
             />
-            <span class="unit-text">前24小时</span>
+            <span class="unit-text">小时</span>
           </div>
         </label>
         <el-button class="alarm-scan-button" type="primary" size="large" :loading="scanLoading" @click="handleScan">扫描</el-button>

+ 489 - 0
frontend/src/components/Model2.vue

@@ -0,0 +1,489 @@
+<script setup lang="ts">
+import * as echarts from 'echarts'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+import { fetchFaults, fetchQueryOptions, queryPressureAngle, syncPressureAngle } from '../api'
+import type { Fault, PressureAngleQueryResponse, QueryOption, QueryOptionsResponse } from '../types'
+
+type Phase = {
+  name: string
+  color: string
+  ranges: string
+  angles: string
+}
+
+const PHASES: Phase[] = [
+  { name: '进气', color: '#409eff', ranges: '330°-360° 和 0°-120°', angles: '340,0,110' },
+  { name: '压缩', color: '#e05252', ranges: '120°-195°', angles: '130,150,190' },
+  { name: '排气', color: '#67c23a', ranges: '195°-235°', angles: '200,215,230' },
+  { name: '膨胀', color: '#9b59b6', ranges: '235°-330°', angles: '240,280,320' },
+]
+
+const queryMeta = ref<QueryOptionsResponse | null>(null)
+const loading = ref(false)
+const errorMessage = ref('')
+
+const selectedDevicePart = ref('')
+const selectedDevicePoint = ref('')
+const minTime = ref('')
+const maxTime = ref('')
+const selectedPhase = ref<Phase>(PHASES[0])
+const angles = ref(PHASES[0].angles)
+const queryLoading = ref(false)
+const syncLoading = ref(false)
+const syncMessage = ref('')
+
+const queryResult = ref<PressureAngleQueryResponse | null>(null)
+const faults = ref<Fault[]>([])
+
+const pressureOptions = computed<QueryOption[]>(() => (
+  (queryMeta.value?.options ?? []).filter((row) => row.measurementType === '压力')
+))
+
+const deviceParts = computed<string[]>(() => (
+  [...new Set(pressureOptions.value.map((row) => row.devicePart))]
+))
+
+const devicePoints = computed<string[]>(() => (
+  [...new Set(pressureOptions.value.map((row) => row.devicePoint))]
+))
+
+const currentPhase = computed(() => (
+  PHASES.find((phase) => phase.name === selectedPhase.value.name) ?? PHASES[0]
+))
+
+const angleList = computed<number[]>(() => (
+  angles.value
+    .split(',')
+    .map((item) => Number(item.trim()))
+    .filter((value) => Number.isFinite(value) && value >= 0 && value <= 360)
+))
+
+const chartElement = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | undefined
+
+function hexToRgb(hex: string): [number, number, number] {
+  const clean = hex.replace('#', '')
+  const full = clean.length === 3 ? clean.split('').map((char) => char + char).join('') : clean
+  const value = parseInt(full, 16)
+  return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
+}
+
+function shadeColor(hex: string, pos: number): string {
+  const [r, g, b] = hexToRgb(hex)
+  const clamped = Math.max(0, Math.min(1, pos))
+  const factor = 0.45 + 0.55 * clamped
+  const mix = (channel: number) => Math.round(255 - (255 - channel) * factor)
+  return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`
+}
+
+function angleColor(angle: number): string {
+  const normalized = ((angle % 360) + 360) % 360
+  if (normalized >= 330 || normalized < 120) {
+    const seq = normalized >= 330 ? normalized - 330 : 30 + normalized
+    return shadeColor('#409eff', seq / 150)
+  }
+  if (normalized < 195) {
+    return shadeColor('#e05252', (normalized - 120) / 75)
+  }
+  if (normalized < 235) {
+    return shadeColor('#67c23a', (normalized - 195) / 40)
+  }
+  return shadeColor('#9b59b6', (normalized - 235) / 95)
+}
+
+function boundsForPart(part: string): { min: string; max: string } | null {
+  const rows = pressureOptions.value.filter((row) => row.devicePart === part)
+  if (!rows.length) return null
+  const min = rows.reduce((latest, row) => (row.minTime > latest ? row.minTime : latest), rows[0].minTime)
+  const max = rows.reduce((earliest, row) => (row.maxTime < earliest ? row.maxTime : earliest), rows[0].maxTime)
+  return { min, max }
+}
+
+function onDevicePartChange(part: string) {
+  if (!part) return
+  const bounds = boundsForPart(part)
+  if (bounds) {
+    minTime.value = bounds.min
+    maxTime.value = bounds.max
+  }
+}
+
+function onPhaseChange(phase: Phase) {
+  selectedPhase.value = phase
+  angles.value = phase.angles
+}
+
+async function loadOptions() {
+  loading.value = true
+  errorMessage.value = ''
+  try {
+    const result = await fetchQueryOptions()
+    queryMeta.value = result
+    selectedDevicePart.value = deviceParts.value[0] ?? ''
+    selectedDevicePoint.value = devicePoints.value[0] ?? ''
+    const bounds = boundsForPart(selectedDevicePart.value)
+    if (bounds) {
+      minTime.value = bounds.min
+      maxTime.value = bounds.max
+    }
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '查询条件读取失败'
+  } finally {
+    loading.value = false
+  }
+}
+
+async function sync() {
+  if (!selectedDevicePart.value) {
+    errorMessage.value = '请先选择机组与部位'
+    return
+  }
+  syncLoading.value = true
+  syncMessage.value = ''
+  errorMessage.value = ''
+  try {
+    const result = await syncPressureAngle({
+      device_part: selectedDevicePart.value,
+      device_points: [],
+    })
+    syncMessage.value = `同步完成:共 ${result.total} 个文件,新增 ${result.created} 个,跳过 ${result.skipped} 个`
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '同步失败'
+  } finally {
+    syncLoading.value = false
+  }
+}
+
+async function query() {
+  if (!selectedDevicePart.value || !angleList.value.length) {
+    errorMessage.value = '请选择机组与部位并输入角度'
+    return
+  }
+  queryLoading.value = true
+  errorMessage.value = ''
+  try {
+    const result = await queryPressureAngle({
+      device_part: selectedDevicePart.value,
+      device_points: selectedDevicePoint.value ? [selectedDevicePoint.value] : [],
+      min_time: minTime.value || undefined,
+      max_time: maxTime.value || undefined,
+      angles: angleList.value,
+    })
+    queryResult.value = result
+    faults.value = []
+    if (selectedDevicePart.value) {
+      const faultsResult = await fetchFaults({
+        devicePart: selectedDevicePart.value,
+        minTime: minTime.value || undefined,
+        maxTime: maxTime.value || undefined,
+      })
+      faults.value = faultsResult.faults.filter(
+        (fault) => fault.faultCategory === '活塞故障' || fault.faultCategory === '气阀故障',
+      )
+    }
+    await nextTick()
+    renderChart()
+  } catch (error) {
+    queryResult.value = null
+    errorMessage.value = error instanceof Error ? error.message : '查询失败'
+  } finally {
+    queryLoading.value = false
+  }
+}
+
+function toTimestamp(value: string): number {
+  const normalized = value.replace(' ', 'T')
+  const date = new Date(normalized)
+  return Number.isFinite(date.getTime()) ? date.getTime() : 0
+}
+
+function buildOption() {
+  const data = queryResult.value
+  if (!data) return {}
+  const series = data.series.map((item) => {
+    const color = angleColor(item.angle)
+    return {
+      name: `${item.angle}°`,
+      type: 'line' as const,
+      showSymbol: false,
+      connectNulls: false,
+      sampling: 'lttb' as const,
+      lineStyle: { width: 1.6, color },
+      itemStyle: { color },
+      data: item.points
+        .filter((point) => point.time && Number.isFinite(point.pressure))
+        .map((point) => [toTimestamp(point.time), point.pressure] as [number, number]),
+    }
+  })
+  const faultByDate = new Map<string, string[]>()
+  for (const fault of faults.value) {
+    if (fault.faultCategory !== '活塞故障' && fault.faultCategory !== '气阀故障') continue
+    const list = faultByDate.get(fault.faultDate) ?? []
+    if (!list.includes(fault.faultCategory)) list.push(fault.faultCategory)
+    faultByDate.set(fault.faultDate, list)
+  }
+  const faultMarkLine = {
+    silent: true,
+    symbol: 'none',
+    lineStyle: { color: '#f5c400', width: 2, type: 'dashed' as const },
+    label: {
+      show: true,
+      position: 'insideEndTop' as const,
+      formatter: (params: any) => String(params?.name ?? ''),
+      color: '#f5c400',
+      fontSize: 11,
+    },
+    data: [...faultByDate.entries()].map(([date, categories]) => ({
+      name: categories.join('/'),
+      xAxis: toTimestamp(`${date} 00:00:00`),
+    })),
+  }
+  if (series.length) {
+    series[0] = { ...series[0], markLine: faultMarkLine } as any
+  }
+  return {
+    animation: false,
+    color: data.series.map((item) => angleColor(item.angle)),
+    grid: { left: 70, right: 30, top: 50, bottom: 90, containLabel: false },
+    xAxis: {
+      type: 'time',
+      axisLine: { lineStyle: { color: '#b9c6cc' } },
+      axisLabel: {
+        color: '#71808a',
+        fontSize: 11,
+        formatter: (value: number) => {
+          const date = new Date(value)
+          const pad = (n: number) => String(n).padStart(2, '0')
+          return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
+        },
+      },
+    },
+    yAxis: {
+      type: 'value',
+      name: '压力',
+      nameTextStyle: { color: '#60717b', fontWeight: 600 },
+      axisLine: { lineStyle: { color: '#60717b' } },
+      axisLabel: { color: '#71808a', fontSize: 11 },
+      splitLine: { lineStyle: { color: '#e7edf0' } },
+      scale: true,
+    },
+    series,
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'line', snap: false },
+      backgroundColor: '#162b3c',
+      borderWidth: 0,
+      textStyle: { color: '#f7fafb', fontSize: 11 },
+      formatter: (params: any[]) => {
+        if (!params?.length) return ''
+        const axisTime = new Date(params[0].value?.[0])
+        const lines = [axisTime.toLocaleString()]
+        params.forEach((item) => {
+          if (item.seriesType === 'line' && item.value?.[1] != null) {
+            lines.push(`<span style="color:${item.color}">●</span> ${item.seriesName}: ${Number(item.value[1]).toPrecision(6)}`)
+          }
+        })
+        return lines.join('<br/>')
+      },
+    },
+    legend: {
+      data: data.series.map((item) => `${item.angle}°`),
+      top: 8,
+      left: 70,
+      itemWidth: 16,
+      itemHeight: 7,
+      textStyle: { color: '#60717b', fontSize: 11 },
+    },
+    dataZoom: [
+      { type: 'inside', zoomOnMouseWheel: true, moveOnMouseMove: true },
+      { type: 'slider', bottom: 20, height: 26, borderColor: '#d9e2e5' },
+    ],
+  }
+}
+
+function renderChart() {
+  if (!chartElement.value) return
+  if (!chart) {
+    chart = echarts.init(chartElement.value, undefined, { renderer: 'canvas' })
+  }
+  chart.setOption(buildOption(), true)
+  chart.resize()
+}
+
+function resize() {
+  chart?.resize()
+}
+
+onMounted(() => {
+  window.addEventListener('resize', resize)
+  void loadOptions()
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', resize)
+  chart?.dispose()
+})
+</script>
+
+<template>
+  <div class="model2-page">
+    <section class="panel model2-query">
+      <div class="panel-heading compact-heading">
+        <div>
+          <h2>查询条件</h2>
+        </div>
+      </div>
+      <div class="model2-grid">
+        <label class="field">
+          <span class="field-label">机组与部位</span>
+          <el-select
+            v-model="selectedDevicePart"
+            class="query-control"
+            size="large"
+            filterable
+            :disabled="loading"
+            placeholder="选择机组与部位"
+            @change="onDevicePartChange"
+          >
+            <el-option v-for="part in deviceParts" :key="part" :label="part" :value="part" />
+          </el-select>
+        </label>
+        <div class="field">
+          <span class="field-label">测试点位</span>
+          <el-select
+            v-model="selectedDevicePoint"
+            class="query-control"
+            size="large"
+            filterable
+            :disabled="loading"
+            placeholder="选择测试点位"
+          >
+            <el-option v-for="point in devicePoints" :key="point" :label="point" :value="point" />
+          </el-select>
+        </div>
+        <label class="field">
+          <span class="field-label">开始时间</span>
+          <el-date-picker
+            v-model="minTime"
+            class="query-control"
+            size="large"
+            type="datetime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            format="YYYY-MM-DD HH:mm:ss"
+            placeholder="选择开始时间"
+          />
+        </label>
+        <label class="field">
+          <span class="field-label">结束时间</span>
+          <el-date-picker
+            v-model="maxTime"
+            class="query-control"
+            size="large"
+            type="datetime"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            format="YYYY-MM-DD HH:mm:ss"
+            placeholder="选择结束时间"
+          />
+        </label>
+        <label class="field">
+          <span class="field-label">阶段</span>
+          <el-select
+            :model-value="currentPhase.name"
+            class="query-control"
+            size="large"
+            placeholder="选择阶段"
+            @update:model-value="(name: string) => onPhaseChange(PHASES.find((p) => p.name === name) ?? PHASES[0])"
+          >
+            <el-option v-for="phase in PHASES" :key="phase.name" :label="phase.name" :value="phase.name">
+              <span class="phase-option">
+                <i class="phase-swatch" :style="{ background: phase.color }"></i>
+                <span>{{ phase.name }}</span>
+                <span class="phase-ranges">{{ phase.ranges }}</span>
+              </span>
+            </el-option>
+          </el-select>
+        </label>
+        <label class="field">
+          <span class="field-label">角度</span>
+          <el-input
+            v-model="angles"
+            class="query-control"
+            size="large"
+            placeholder="输入角度,逗号分隔"
+          />
+        </label>
+        <el-button
+          class="model2-query-button"
+          type="primary"
+          size="large"
+          :loading="queryLoading"
+          :disabled="!selectedDevicePart || !selectedDevicePoint"
+          @click="query"
+        >
+          查询
+        </el-button>
+        <el-button
+          class="model2-sync-button"
+          size="large"
+          :loading="syncLoading"
+          :disabled="!selectedDevicePart"
+          @click="sync"
+        >
+          同步
+        </el-button>
+      </div>
+      <el-alert v-if="errorMessage" class="data-alert" type="error" :closable="false" show-icon :title="errorMessage" />
+      <el-alert v-else-if="syncMessage" class="data-alert" type="success" :closable="false" show-icon :title="syncMessage" />
+    </section>
+
+    <section class="panel model2-chart">
+      <div class="panel-heading compact-heading">
+        <div>
+          <h2>压力曲线</h2>
+        </div>
+        <div class="phase-legend">
+          <span v-for="phase in PHASES" :key="phase.name" class="phase-legend-item">
+            <i class="phase-swatch" :style="{ background: phase.color }"></i>
+            {{ phase.name }} {{ phase.ranges }}
+          </span>
+        </div>
+      </div>
+      <div v-if="queryResult" ref="chartElement" class="model2-chart-canvas"></div>
+      <div v-else class="model2-chart-placeholder">
+        二维曲线图(x 为时间,y 为压力),请选择条件后点击查询
+      </div>
+    </section>
+  </div>
+</template>
+
+<style>
+.model2-page { display: flex; flex-direction: column; gap: 16px; margin: 20px 0 0; }
+.model2-query { padding: 22px 24px 18px; }
+.model2-grid {
+  display: grid;
+  grid-template-columns: minmax(150px, 1.7fr) minmax(220px, 2fr) minmax(170px, 1.25fr) minmax(170px, 1.25fr) minmax(150px, 1.2fr) minmax(140px, 1fr) 110px 110px;
+  align-items: end;
+  gap: 16px 12px;
+}
+.model2-grid .query-control { width: 100%; }
+.model2-query-button { height: 40px; border-radius: 4px; }
+.model2-sync-button { height: 40px; border-radius: 4px; }
+.model2-chart { padding: 22px 24px 18px; }
+.phase-option { display: inline-flex; align-items: center; gap: 8px; }
+.phase-ranges { color: #909399; font-size: 12px; }
+.phase-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
+.phase-legend { display: flex; flex-wrap: wrap; gap: 14px; }
+.phase-legend-item { display: inline-flex; align-items: center; gap: 6px; color: #606266; font-size: 13px; }
+.model2-chart-canvas { width: 100%; height: 460px; }
+.model2-chart-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 420px;
+  color: #909399;
+  font-size: 14px;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  background: #fafbfc;
+}
+</style>

+ 43 - 0
frontend/src/types.ts

@@ -107,6 +107,11 @@ export type Alarm = {
   devicePoint: string
   alarmType: string
   alarmDes: string
+  status: number
+  alarmLevel?: string | null
+  scanStartTime?: string
+  scanEndTime?: string
+  alarmInfoJson?: string
   alarmTimeStart: string
   alarmTimeEnd: string
 }
@@ -125,6 +130,44 @@ export type AlarmScanResponse = {
   id: number
 }
 
+export type AlarmCompressorOption = {
+  deviceName: string
+  deviceCode: string
+  count: number
+}
+
+export type AlarmCompressorOptionsResponse = {
+  source: 'database' | 'demo'
+  notice: string | null
+  items: AlarmCompressorOption[]
+}
+
+export type PressureAngleSyncResponse = {
+  source: 'database' | 'demo'
+  notice: string | null
+  total: number
+  created: number
+  skipped: number
+}
+
+export type PressureAnglePoint = {
+  time: string
+  pressure: number
+}
+
+export type PressureAngleSeries = {
+  angle: number
+  points: PressureAnglePoint[]
+}
+
+export type PressureAngleQueryResponse = {
+  source: 'database' | 'demo'
+  notice: string | null
+  angles: number[]
+  series: PressureAngleSeries[]
+}
+
+
 export type WaveValue = {
   value: [number, number]
   x: number

Файловите разлики са ограничени, защото са твърде много
+ 26 - 0
振动异常段.csv


+ 23 - 0
润滑油压力小时阶段段.csv

@@ -0,0 +1,23 @@
+机组,批次,周期编号,压力阶段,开始小时,结束小时,持续自然小时数,有效运行小时数,周期基线油压,正常波动带,阶段开始小时油压,阶段结束小时油压,阶段最低小时油压,阶段开始相对周期基线变化,阶段结束相对周期基线变化,阶段最大连续下降有效小时数,阶段低报警小时数,阶段低低报警小时数,是否形成确认等级,阶段触发依据
+7号机,30,7号机-P02,轻微,2025-04-30 19:00:00,2025-04-30 19:00:00,1,1,0.499519,,0.416279,0.416279,0.416279,-0.166641,-0.166641,3,0,0,否,油压0.416~0.416MPa,最低0.416MPa,连续1个有效运行小时;油压0.416MPa,周期基线0.500MPa,相对周期基线变化-16.7%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+7号机,30,7号机-P08,轻微,2025-09-09 13:00:00,2025-09-09 16:00:00,4,4,0.492921,,0.446947,0.463393,0.445898,-0.093269,-0.059905,5,0,0,否,油压0.447~0.463MPa,最低0.446MPa,连续4个有效运行小时;油压0.447MPa,周期基线0.493MPa,相对周期基线变化-9.3%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+7号机,30,7号机-P10,轻微,2025-10-03 17:00:00,2025-10-03 19:00:00,3,3,0.490481,,0.443296,0.442371,0.442201,-0.096201,-0.098087,3,0,0,否,油压0.443~0.442MPa,最低0.442MPa,连续3个有效运行小时;油压0.443MPa,周期基线0.490MPa,相对周期基线变化-9.6%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+7号机,30,7号机-P10,轻微,2025-10-05 12:00:00,2025-10-05 12:00:00,1,1,0.490481,,0.434894,0.434894,0.434894,-0.113332,-0.113332,3,0,0,否,油压0.435~0.435MPa,最低0.435MPa,连续1个有效运行小时;油压0.435MPa,周期基线0.490MPa,相对周期基线变化-11.3%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+7号机,30,7号机-P11,轻微,2025-12-02 10:00:00,2025-12-02 10:00:00,1,1,0.480555,,0.430658,0.430658,0.430658,-0.103831,-0.103831,1,0,0,否,油压0.431~0.431MPa,最低0.431MPa,连续1个有效运行小时;油压0.431MPa,周期基线0.481MPa,相对周期基线变化-10.4%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+7号机,30,7号机-P18,轻微,2026-03-29 10:00:00,2026-03-29 10:00:00,1,1,0.483799,,0.428329,0.428329,0.428329,-0.114655,-0.114655,1,0,0,否,油压0.428~0.428MPa,最低0.428MPa,连续1个有效运行小时;油压0.428MPa,周期基线0.484MPa,相对周期基线变化-11.5%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P04,轻微,2025-05-27 17:00:00,2025-05-27 17:00:00,1,1,0.512445,,0.481116,0.481116,0.481116,-0.061136,-0.061136,1,0,0,否,油压0.481~0.481MPa,最低0.481MPa,连续1个有效运行小时;油压0.481MPa,周期基线0.512MPa,相对周期基线变化-6.1%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P07,轻微,2025-07-09 12:00:00,2025-07-09 12:00:00,1,1,0.459362,,0.402382,0.402382,0.402382,-0.12404,-0.12404,2,0,0,否,油压0.402~0.402MPa,最低0.402MPa,连续1个有效运行小时;油压0.402MPa,周期基线0.459MPa,相对周期基线变化-12.4%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P08,轻微,2025-09-27 17:00:00,2025-09-28 06:00:00,14,10,0.488687,,0.464073,0.460742,0.460742,-0.050368,-0.057185,10,0,0,否,油压0.464~0.461MPa,最低0.461MPa,连续10个有效运行小时;油压0.464MPa,周期基线0.489MPa,相对周期基线变化-5.0%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P08,轻微,2025-09-28 14:00:00,2025-09-29 06:00:00,17,11,0.488687,,0.460956,0.454298,0.454298,-0.056746,-0.070371,10,0,0,否,油压0.461~0.454MPa,最低0.454MPa,连续11个有效运行小时;油压0.461MPa,周期基线0.489MPa,相对周期基线变化-5.7%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P08,轻微,2025-09-29 14:00:00,2025-09-30 06:00:00,17,15,0.488687,,0.459052,0.452239,0.452239,-0.060641,-0.074583,15,0,0,否,油压0.459~0.452MPa,最低0.452MPa,连续15个有效运行小时;油压0.459MPa,周期基线0.489MPa,相对周期基线变化-6.1%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P08,轻微,2025-09-30 14:00:00,2025-10-02 07:00:00,42,25,0.488687,,0.454997,0.444466,0.444466,-0.068941,-0.090488,8,0,0,否,油压0.455~0.444MPa,最低0.444MPa,连续25个有效运行小时;油压0.455MPa,周期基线0.489MPa,相对周期基线变化-6.9%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P09,轻微,2025-10-26 05:00:00,2025-10-27 17:00:00,37,29,0.437814,,0.415149,0.394266,0.394266,-0.051769,-0.099467,16,0,0,否,油压0.415~0.394MPa,最低0.394MPa,连续29个有效运行小时;油压0.415MPa,周期基线0.438MPa,相对周期基线变化-5.2%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P09,异常,2025-10-27 18:00:00,2025-10-29 21:00:00,52,45,0.437814,,0.393562,0.350347,0.350347,-0.101076,-0.199783,24,0,0,是,油压0.394~0.350MPa,最低0.350MPa,连续45个有效运行小时;油压0.394MPa,周期基线0.438MPa,相对周期基线变化-10.1%,相对周期基线下降至少10%并持续下降,或低报警区间连续达到3小时
+8号机,31,8号机-P09,严重异常,2025-10-29 22:00:00,2025-10-30 08:00:00,11,11,0.437814,,0.349426,0.342649,0.342649,-0.201887,-0.217365,35,0,0,是,油压0.349~0.343MPa,最低0.343MPa,连续11个有效运行小时;油压0.349MPa,周期基线0.438MPa,相对周期基线变化-20.2%,相对周期基线下降至少20%并持续下降,或低低报警区间连续达到3小时
+8号机,31,8号机-P15,轻微,2026-03-17 08:00:00,2026-03-18 03:00:00,20,20,0.539746,,0.511957,0.486595,0.486595,-0.051485,-0.098474,55,0,0,否,油压0.512~0.487MPa,最低0.487MPa,连续20个有效运行小时;油压0.512MPa,周期基线0.540MPa,相对周期基线变化-5.1%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P16,轻微,2026-04-25 09:00:00,2026-04-25 10:00:00,2,2,0.535008,,0.507966,0.507897,0.507897,-0.050545,-0.050674,7,0,0,否,油压0.508~0.508MPa,最低0.508MPa,连续2个有效运行小时;油压0.508MPa,周期基线0.535MPa,相对周期基线变化-5.1%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P16,轻微,2026-04-26 05:00:00,2026-04-26 05:00:00,1,1,0.535008,,0.484926,0.484926,0.484926,-0.09361,-0.09361,8,0,0,否,油压0.485~0.485MPa,最低0.485MPa,连续1个有效运行小时;油压0.485MPa,周期基线0.535MPa,相对周期基线变化-9.4%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P16,轻微,2026-04-29 00:00:00,2026-04-30 02:00:00,27,27,0.535008,,0.507423,0.482506,0.482506,-0.051559,-0.098133,31,0,0,否,油压0.507~0.483MPa,最低0.483MPa,连续27个有效运行小时;油压0.507MPa,周期基线0.535MPa,相对周期基线变化-5.2%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+8号机,31,8号机-P16,异常,2026-04-30 03:00:00,2026-04-30 12:00:00,10,10,0.535008,,0.481381,0.474631,0.474451,-0.100236,-0.112852,22,0,0,是,油压0.481~0.475MPa,最低0.474MPa,连续10个有效运行小时;油压0.481MPa,周期基线0.535MPa,相对周期基线变化-10.0%,相对周期基线下降至少10%并持续下降,或低报警区间连续达到3小时
+9号机,32,9号机-P17,轻微,2026-01-30 05:00:00,2026-01-30 05:00:00,1,1,0.549734,,0.52106,0.52106,0.52106,-0.05216,-0.05216,5,0,0,否,油压0.521~0.521MPa,最低0.521MPa,连续1个有效运行小时;油压0.521MPa,周期基线0.550MPa,相对周期基线变化-5.2%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时
+9号机,32,9号机-P20,轻微,2026-04-17 03:00:00,2026-04-17 03:00:00,1,1,0.558379,,0.46019,0.46019,0.46019,-0.175847,-0.175847,1,0,0,否,油压0.460~0.460MPa,最低0.460MPa,连续1个有效运行小时;油压0.460MPa,周期基线0.558MPa,相对周期基线变化-17.6%,相对周期基线下降至少5%并持续下降,或普通异常持续不足3小时