소스 검색

读取适配 wave_sample_one 单周期数据;时间点选择条叠加 compressor_fault 故障黄线

zhouhao 17 시간 전
부모
커밋
ab6cb529f7

+ 15 - 0
README.md

@@ -81,3 +81,18 @@ token 有效期默认 12 小时(`AUTH_TOKEN_TTL_SECONDS` 可配),服务重
 - 标注宽度由后端配置 `ANNOTATION_WIDTH`(默认 10,即 10 个周期为一段)。
 - 标注块不重叠:计算宽度块时会在点击周期所在的空闲区间内居中取宽度。
 - 标注数据只存索引,不存波形数据,表结构见 `wave_annotation`(`backend/app/db.py` 内建表语句,服务启动时自动建表)。
+
+
+
+打开 PowerShell:
+
+Set-Location "E:\Compressor"
+
+& ".\.venv\Scripts\python.exe" -m uvicorn app.main:app --app-dir backend --reload --port 8000
+
+
+启动前端
+打开另一个powershell
+
+Set-Location "E:\Compressor\frontend"
+npm run dev

+ 2 - 2
backend/LabelingPreTraining/detect_cycle_index.py

@@ -81,7 +81,7 @@ def _fetch_chunk(
                    CAST(signal_value AS FLOAT) AS sig,
                    CAST(second_value AS FLOAT) AS sec,
                    sample_index
-            FROM wave_sample
+            FROM wave_sample_one
             WHERE wave_file_id IN ({placeholders})
               AND sample_index >= %s AND sample_index < %s
             ORDER BY wave_file_id, sample_index
@@ -145,7 +145,7 @@ def _fetch_trigger_chunk(connection, file_ids: list[int], limit: int) -> set[int
         cursor.execute(
             f"""
             SELECT DISTINCT wave_file_id
-            FROM wave_sample
+            FROM wave_sample_one
             WHERE wave_file_id IN ({placeholders})
               AND sample_index < %s
               AND second_value >= %s

+ 1 - 1
backend/LabelingPreTraining/generate_samples.py

@@ -39,7 +39,7 @@ def read_wave_samples(connection, file_id: int) -> np.ndarray:
         cursor.execute(
             """
             SELECT sample_index, signal_value, second_value
-            FROM wave_sample
+            FROM wave_sample_one
             WHERE wave_file_id = %s
             ORDER BY wave_file_id, sample_index
             """,

+ 1 - 1
backend/LabelingPreTraining/predict.py

@@ -90,7 +90,7 @@ def _fetch_chunk(connection, file_ids: list[int], limit: int) -> dict[int, np.nd
             SELECT wave_file_id,
                    CAST(signal_value AS FLOAT) AS sig,
                    CAST(second_value AS FLOAT) AS sec
-            FROM wave_sample
+            FROM wave_sample_one
             WHERE wave_file_id IN ({placeholders}) AND sample_index < %s
             ORDER BY wave_file_id, sample_index
             """,

+ 1 - 1
backend/LabelingPreTraining/validate_samples.py

@@ -49,7 +49,7 @@ def read_wave_samples(connection, file_id: int) -> np.ndarray:
         cursor.execute(
             """
             SELECT sample_index, signal_value, second_value
-            FROM wave_sample
+            FROM wave_sample_one
             WHERE wave_file_id = %s
             ORDER BY wave_file_id, sample_index
             """,

+ 15 - 0
backend/app/main.py

@@ -156,6 +156,21 @@ def site_points(
         raise HTTPException(status_code=503, detail=str(error)) from error
 
 
+@app.get("/api/faults")
+def faults(
+    device_part: str = Query(min_length=1),
+    min_time: str | None = None,
+    max_time: str | None = None,
+    _auth: str = Depends(require_auth),
+) -> dict[str, Any]:
+    try:
+        return data_service.faults(device_part, min_time, max_time)
+    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.get("/api/tspluse-ruler")
 def tspluse_ruler(_auth: str = Depends(require_auth)) -> dict[str, Any]:
     try:

+ 120 - 17
backend/app/services/data_service.py

@@ -104,6 +104,43 @@ def _safe_float(value: Any) -> float | None:
     return number if np.isfinite(number) else None
 
 
+def _stored_first_cycle(samples: np.ndarray) -> DetectedCycle | None:
+    """Build a cycle from wave_sample_one, which contains one cycle only."""
+    if samples.ndim != 2 or samples.shape[1] < 3 or len(samples) < 2:
+        return None
+    start = 0
+    end = len(samples)
+    angle = np.linspace(0.0, 360.0, end - start, endpoint=False)
+    trigger_offsets = tuple(
+        int(offset)
+        for offset in np.flatnonzero(
+            np.nan_to_num(samples[:, 2], nan=-np.inf) >= 30.0,
+        )
+        if offset == 0 or samples[offset - 1, 2] < 30.0
+    )
+    return DetectedCycle(
+        number=1,
+        start_offset=start,
+        end_offset=end,
+        angle=angle,
+        signal=samples[start:end, 1].copy(),
+        trigger_offsets=trigger_offsets,
+    )
+
+
+def _detect_source_cycles(
+    samples: np.ndarray,
+    single_cycle: bool,
+) -> tuple[list[DetectedCycle], dict[str, Any]]:
+    """Detect cycles: wave_sample_one holds exactly one stored cycle already."""
+    if single_cycle:
+        stored = _stored_first_cycle(samples)
+        if stored is None:
+            return [], {"completeCycleCount": 0}
+        return [stored], {"completeCycleCount": 1, "storedFirstCycle": 1}
+    return detect_cycles(samples)
+
+
 def _series_extent(items: list[dict[str, Any]]) -> tuple[float, float]:
     """Whole-window min/max over a series' finite raw values."""
     if not items:
@@ -636,6 +673,63 @@ class DataService:
             "notice": self._source_notice(source),
         }
 
+    def faults(
+        self,
+        device_part: str,
+        min_time: str | None,
+        max_time: str | None,
+    ) -> dict[str, Any]:
+        """Return compressor_fault rows for the unit of ``device_part``.
+
+        ``compressor_fault.unit_name`` ("7号机组") is matched against the unit
+        prefix of ``wave_file.device_part`` ("7号机组一缸"). Only faults whose
+        ``fault_date`` falls inside the requested time range are returned.
+        """
+        unit = _unit_number(device_part)
+        start = _parse_time(min_time)
+        end = _parse_time(max_time)
+
+        def database_query():
+            if unit is None:
+                return []
+            clauses = ["(unit_name = %s OR unit_name LIKE %s)"]
+            params: list[Any] = [f"{unit}号机组", f"{unit}号机组%"]
+            if start:
+                clauses.append("fault_date >= DATE(%s)")
+                params.append(start)
+            if end:
+                clauses.append("fault_date <= DATE(%s)")
+                params.append(end)
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        f"""
+                        SELECT id, unit_name, fault_date, fault_category
+                        FROM compressor_fault
+                        WHERE {' AND '.join(clauses)}
+                        ORDER BY fault_date ASC, id ASC
+                        """,
+                        params,
+                    )
+                    rows = cursor.fetchall()
+            return [
+                {
+                    "id": int(row["id"]),
+                    "unitName": row["unit_name"] or "",
+                    "faultDate": _time_string(row["fault_date"])[:10],
+                    "faultCategory": row["fault_category"] or "",
+                }
+                for row in rows
+            ]
+
+        result, source = self._run_with_fallback(database_query, lambda: [])
+        return {
+            "source": source,
+            "unit": unit,
+            "faults": result,
+            "notice": self._source_notice(source),
+        }
+
     @staticmethod
     def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
         grouped: OrderedDict[Any, dict[str, Any]] = OrderedDict()
@@ -889,6 +983,8 @@ class DataService:
     def period_detail(self, wave_file_id: int, period_number: int) -> dict[str, Any]:
         if wave_file_id <= 0 or period_number <= 0:
             raise ValueError("wave_file_id 和周期编号必须为正整数")
+        if period_number != 1:
+            raise ValueError("当前数据仅保留第一个周期")
 
         def database_query():
             metadata, samples = self._load_db_wave(wave_file_id)
@@ -1102,6 +1198,7 @@ class DataService:
         primary_type = DEVICE_POINT_TO_TYPE[primary]
         pressure_points = [point for point in device_points if DEVICE_POINT_TO_TYPE[point] == "压力"]
         load_cache: dict[int, tuple[dict[str, Any], np.ndarray]] = {}
+        single_cycle = loader.__name__ != "_load_demo_wave"
 
         for slot, point in enumerate(points):
             target_per_file = max(256, int(np.ceil(max_points / max(len(points), 1))))
@@ -1142,7 +1239,7 @@ class DataService:
                 except ValueError:
                     source_samples = None
                 if source_samples is not None and len(source_samples):
-                    source_detected, source_diagnostic = detect_cycles(source_samples)
+                    source_detected, source_diagnostic = _detect_source_cycles(source_samples, single_cycle)
                     source_angle_vector = build_angle_vector(len(source_samples), source_detected)
                     source_angle_full = np.full(len(source_samples), np.nan, dtype=float)
                     for detected_cycle in source_detected:
@@ -1292,7 +1389,7 @@ class DataService:
                         if source_required is not None:
                             file_required.update(source_required)
                     else:
-                        detected_own, _ = detect_cycles(samples)
+                        detected_own, _ = _detect_source_cycles(samples, single_cycle)
                         for cycle in detected_own:
                             file_required.update(
                                 {
@@ -1513,15 +1610,11 @@ class DataService:
                                ws.sample_index,
                                CAST(ws.signal_value AS FLOAT) AS sig,
                                CAST(ws.second_value AS FLOAT) AS sec
-                        FROM wave_sample ws
-                        JOIN wave_file wf ON wf.id = ws.wave_file_id
+                        FROM wave_sample_one ws
                         WHERE ws.wave_file_id IN ({source_placeholders})
-                          AND wf.cycle_start >= 0
-                          AND ws.sample_index >= wf.cycle_start
-                          AND ws.sample_index < wf.cycle_end + %s
                         ORDER BY ws.wave_file_id, ws.sample_index
                         """,
-                        (*tuple(source_ids), FIRST_CYCLE_PAD),
+                        tuple(source_ids),
                     )
                     raw: dict[int, list[tuple[float, float, float]]] = {}
                     for row in cursor.fetchall():
@@ -1534,11 +1627,12 @@ class DataService:
                         )
             for file_id, rows in raw.items():
                 meta = metas.get(file_id)
-                if meta is None or meta.get("cycle_start") is None:
+                if meta is None or not rows:
                     continue
+                # wave_sample_one 只保留第一个周期,返回的数组本身就是该周期。
                 slices[file_id] = (
-                    int(meta["cycle_start"]),
-                    int(meta["cycle_end"]),
+                    int(rows[0][0]),
+                    int(rows[-1][0]) + 1,
                     np.asarray(rows, dtype=float),
                 )
 
@@ -1561,13 +1655,14 @@ class DataService:
             slice_arr: np.ndarray | None = None
             if cycle_bounds is not None:
                 cycle_start, cycle_end, slice_arr = cycle_bounds
-            cycle_len = max(cycle_end - cycle_start, 0)
-            has_cycle = slice_arr is not None and 1 < cycle_len <= len(slice_arr)
+            cycle_len = len(slice_arr) if slice_arr is not None else 0
+            has_cycle = slice_arr is not None and 1 < cycle_len
             if has_cycle:
                 angle: np.ndarray | None = None
                 volume: np.ndarray | None = None
                 point_volume_info: dict[str, Any] | None = None
-                detected, _ = detect_cycles(slice_arr)
+                stored_cycle = _stored_first_cycle(slice_arr)
+                detected = [stored_cycle] if stored_cycle is not None else []
                 if detected:
                     cycle = detected[0]
                     angle = cycle.angle
@@ -1837,7 +1932,12 @@ class DataService:
         samples: np.ndarray,
         period_number: int,
     ) -> dict[str, Any]:
-        detected, diagnostics = detect_cycles(samples)
+        stored_cycle = _stored_first_cycle(samples) if metadata.get("single_cycle") else None
+        if stored_cycle is not None:
+            detected = [stored_cycle]
+            diagnostics = {"completeCycleCount": 1, "storedFirstCycle": 1}
+        else:
+            detected, diagnostics = detect_cycles(samples)
         cycle = next((item for item in detected if item.number == period_number), None)
         if cycle is None:
             raise ValueError(f"没有找到周期 {period_number}")
@@ -1937,7 +2037,8 @@ class DataService:
                 cursor.execute(
                     """
                     SELECT id, point_name, measurement_type, sample_frequency_hz,
-                           sample_count, sample_time, rpm, file_name, tspluse_status
+                           sample_count, sample_time, rpm, file_name, tspluse_status,
+                           cycle_start, cycle_end
                     FROM wave_file
                     WHERE id = %s
                     """,
@@ -1946,10 +2047,11 @@ class DataService:
                 metadata = cursor.fetchone()
                 if metadata is None:
                     raise ValueError(f"wave_file.id={file_id} 不存在")
+                # wave_sample_one 只保留第一个周期,直接读取该文件全部样本。
                 cursor.execute(
                     """
                     SELECT sample_index, signal_value, second_value
-                    FROM wave_sample
+                    FROM wave_sample_one
                     WHERE wave_file_id = %s
                     ORDER BY sample_index ASC
                     """,
@@ -1958,6 +2060,7 @@ class DataService:
                 rows = cursor.fetchall()
         if not rows:
             raise ValueError(f"wave_file.id={file_id} 没有采样数据")
+        metadata["single_cycle"] = True
         samples = np.asarray(
             [
                 (

+ 22 - 2
frontend/src/App.vue

@@ -1,13 +1,13 @@
 <script setup lang="ts">
 import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessageBox } from 'element-plus'
-import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchSitePoints, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
+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 { fixedAxisRange } from './utils/axis'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
 import { STOPPED_COLOR, statusGradientColor } from './statusColor'
-import { DEVICE_POINT_TO_TYPE, DEVICE_POINTS, PRIMARY_DEVICE_POINT, type Annotation, type AnnotationLabel, type Cycle, type DevicePoint, type MeasurementType, type PeriodDetail, type QueryOption, type SitePoint, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
+import { DEVICE_POINT_TO_TYPE, DEVICE_POINTS, PRIMARY_DEVICE_POINT, type Annotation, type AnnotationLabel, type Cycle, type DevicePoint, type Fault, type MeasurementType, type PeriodDetail, type QueryOption, type SitePoint, type TimePoint, type QueryOptionsResponse, type WaveWindowFile, type WaveWindowResponse } from './types'
 
 const queryMeta = ref<QueryOptionsResponse | null>(null)
 const selectedDevicePart = ref('')
@@ -30,6 +30,7 @@ const pksTimeBounds = ref<{ min: string | null; max: string | null } | null>(nul
 const ruler = ref<{ min: number; max: number } | null>(null)
 const timePoints = ref<TimePoint[]>([])
 const referencePoints = ref<TimePoint[]>([])
+const faults = ref<Fault[]>([])
 const startIndex = ref(0)
 const waveData = ref<WaveWindowResponse | null>(null)
 const chartMode = ref<'split' | 'merge'>('split')
@@ -310,6 +311,23 @@ async function loadTspluseRuler() {
   }
 }
 
+async function loadFaults() {
+  if (!selectedDevicePart.value) {
+    faults.value = []
+    return
+  }
+  try {
+    const result = await fetchFaults({
+      devicePart: selectedDevicePart.value,
+      minTime: toApiTime(minTime.value),
+      maxTime: toApiTime(maxTime.value),
+    })
+    faults.value = result.faults
+  } catch {
+    faults.value = []
+  }
+}
+
 function firstRunningIndex(): number {
   const index = timePoints.value.findIndex((point) =>
     selectedDevicePoints.value.some((pointName) => (point.files[pointName]?.rpm ?? 0) > 0),
@@ -343,6 +361,7 @@ async function loadTimePoints() {
     )
     chartDirty.value = true
     void loadAnnotations()
+    void loadFaults()
   } catch (error) {
     timePoints.value = []
     referencePoints.value = []
@@ -1075,6 +1094,7 @@ onBeforeUnmount(() => {
           :min-time="minTime"
           :max-time="maxTime"
           :annotated-file-ids="annotatedFileIds"
+          :faults="faults"
           :ruler="ruler"
           :site-points="isPksMode ? selectedSitePoints : []"
           :site-descriptions="siteDescriptionMap"

+ 8 - 0
frontend/src/api.ts

@@ -4,6 +4,7 @@ import type {
   AnnotationLabel,
   AnnotationListResponse,
   DevicePoint,
+  FaultsResponse,
   PeriodDetail,
   QueryOptionsResponse,
   SitePoint,
@@ -97,6 +98,13 @@ export function fetchTspluseRuler() {
   return request<TspluseRulerResponse>('/api/tspluse-ruler')
 }
 
+export function fetchFaults(params: { devicePart: string; minTime?: string; maxTime?: string }) {
+  const search = new URLSearchParams({ device_part: params.devicePart })
+  if (params.minTime) search.set('min_time', params.minTime)
+  if (params.maxTime) search.set('max_time', params.maxTime)
+  return request<FaultsResponse>(`/api/faults?${search.toString()}`)
+}
+
 export function fetchWaveWindow(params: {
   devicePart: string
   devicePoints: DevicePoint[]

+ 51 - 2
frontend/src/components/TimePointStrip.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import type { DevicePoint, MeasurementType, TimePoint } from '../types'
+import type { DevicePoint, Fault, MeasurementType, TimePoint } from '../types'
 import { SITE_POINT_COLORS } from '../types'
 import { RUNNING_COLOR, STOPPED_COLOR, statusGradientColor } from '../statusColor'
 
@@ -14,6 +14,7 @@ const props = defineProps<{
   minTime?: string
   maxTime?: string
   annotatedFileIds?: number[]
+  faults?: Fault[]
   ruler?: { min: number; max: number } | null
   sitePoints?: string[]
   siteDescriptions?: Record<string, string>
@@ -167,6 +168,53 @@ function draw() {
     context.fill()
   })
 
+  // 故障竖线:按 fault_date 在时间轴上的位置显示,黄色,标签为 fault_category。
+  if (props.faults?.length && props.points.length) {
+    const faultColor = '#f5c400'
+    const pointTimes = props.points.map((item) => new Date(item.sampleTime.replace(' ', 'T')).getTime())
+    const firstTime = pointTimes[0]
+    const lastTime = pointTimes[pointTimes.length - 1]
+    const faultX = (iso: string): number | null => {
+      const target = new Date(`${iso}T00:00:00`).getTime()
+      if (!Number.isFinite(target)) return null
+      if (target <= firstTime) return xAt(0)
+      if (target >= lastTime) return xAt(props.points.length - 1)
+      let lo = 0
+      let hi = pointTimes.length - 1
+      while (lo < hi) {
+        const mid = (lo + hi) >> 1
+        if (pointTimes[mid] < target) lo = mid + 1
+        else hi = mid
+      }
+      const t0 = pointTimes[lo - 1]
+      const t1 = pointTimes[lo]
+      const ratio = t1 === t0 ? 0 : (target - t0) / (t1 - t0)
+      return xAt(lo - 1) + ratio * (xAt(lo) - xAt(lo - 1))
+    }
+    context.save()
+    context.strokeStyle = faultColor
+    context.fillStyle = faultColor
+    context.lineWidth = 1.5
+    context.setLineDash([])
+    for (const fault of props.faults) {
+      const x = faultX(fault.faultDate)
+      if (x == null) continue
+      context.beginPath()
+      context.moveTo(x, 14)
+      context.lineTo(x, tickY)
+      context.stroke()
+    }
+    context.textAlign = 'center'
+    context.textBaseline = 'top'
+    context.font = '600 11px -apple-system, BlinkMacSystemFont, sans-serif'
+    for (const fault of props.faults) {
+      const x = faultX(fault.faultDate)
+      if (x == null) continue
+      context.fillText(fault.faultCategory || '故障', x, 3)
+    }
+    context.restore()
+  }
+
   context.fillStyle = '#788892'
   context.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'
   const tickCount = Math.min(6, Math.max(2, Math.floor(width / 180)))
@@ -206,7 +254,7 @@ function updateSlider(value: number | number[]) {
 }
 
 watch(
-  () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.ruler, props.sitePoints],
+  () => [props.points, props.devicePoints, props.startIndex, props.windowSize, props.annotatedFileIds, props.faults, props.ruler, props.sitePoints],
   () => nextTick(draw),
   { deep: true },
 )
@@ -229,6 +277,7 @@ onBeforeUnmount(() => resizeObserver?.disconnect())
       <div class="time-summary">
         <span class="time-legend"><i class="legend-running"></i>运转</span>
         <span class="time-legend"><i class="legend-stopped"></i>停机</span>
+        <span class="time-legend"><i class="legend-fault"></i>故障</span>
         <span class="summary-divider">/</span>
         <span class="mono">{{ points.length.toLocaleString() }}</span> 个时间点
         <span class="summary-divider">/</span>

+ 1 - 0
frontend/src/styles.css

@@ -150,6 +150,7 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .time-legend i { width: 9px; height: 9px; display: inline-block; border-radius: 50%; }
 .legend-running { background: #2e7d32; }
 .legend-stopped { background: #c0c4cc; }
+.legend-fault { background: #f5c400; width: 3px; height: 12px; border-radius: 1px; }
 .time-summary .mono, .readout-number { color: #303133; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: 500; }
 .summary-divider { padding: 0 8px; color: #dcdfe6; }
 .timeline-canvas-host { position: relative; width: 100%; min-height: 210px; overflow: hidden; border: 1px solid #ebeef5; border-radius: 4px; background: #fff; }

+ 14 - 0
frontend/src/types.ts

@@ -86,6 +86,20 @@ export type TimePointsResponse = {
   notice: string | null
 }
 
+export type Fault = {
+  id: number
+  unitName: string
+  faultDate: string
+  faultCategory: string
+}
+
+export type FaultsResponse = {
+  source: 'database' | 'demo'
+  unit: string | null
+  faults: Fault[]
+  notice: string | null
+}
+
 export type WaveValue = {
   value: [number, number]
   x: number