Parcourir la source

Show first-cycle continuous curves with angle, volume and PV previews

18922397810 il y a 1 semaine
Parent
commit
9b6566103c

+ 2 - 0
backend/app/main.py

@@ -23,6 +23,7 @@ class WaveWindowInput(BaseModel):
     points: list[TimePointInput]
     maxPoints: int = Field(default=200000, ge=256, le=200000)
     noSampling: bool = False
+    firstCycleOnly: bool = False
 
 
 class AnnotationInput(BaseModel):
@@ -155,6 +156,7 @@ def wave_window(payload: WaveWindowInput, _auth: str = Depends(require_auth)) ->
             [point.model_dump() if hasattr(point, "model_dump") else point.dict() for point in payload.points],
             payload.maxPoints,
             payload.noSampling,
+            payload.firstCycleOnly,
         )
     except ValueError as error:
         raise HTTPException(status_code=400, detail=str(error)) from error

+ 369 - 0
backend/app/services/data_service.py

@@ -33,6 +33,10 @@ PHASES = (
 )
 ANNOTATION_LABELS = ("正常", "异常")
 
+# Extra samples fetched past cycle_end so detect_cycles can see the zero marker
+# that closes the first cycle (cycle_end is exclusive, the marker sits at it).
+FIRST_CYCLE_PAD = 64
+
 CYLINDER_BORE_MM = {
     "一缸": 360.0,
     "二缸": 490.0,
@@ -368,6 +372,7 @@ class DataService:
         points: list[dict[str, Any]],
         max_points: int,
         no_sampling: bool = False,
+        first_cycle_only: bool = False,
     ) -> dict[str, Any]:
         types = _validate_measurement_types(measurement_types)
         if not points:
@@ -377,6 +382,13 @@ class DataService:
         max_points = min(max(int(max_points), 256), 200000)
 
         def database_query():
+            if first_cycle_only:
+                return self._build_first_cycle_window(
+                    point_name,
+                    types,
+                    points,
+                    self._load_db_wave,
+                )
             return self._build_wave_window(
                 point_name,
                 types,
@@ -387,6 +399,13 @@ class DataService:
             )
 
         def demo_query():
+            if first_cycle_only:
+                return self._build_first_cycle_window(
+                    point_name,
+                    types,
+                    points,
+                    self._load_demo_wave,
+                )
             return self._build_wave_window(
                 point_name,
                 types,
@@ -879,6 +898,356 @@ class DataService:
             "diagnostics": diagnostics,
         }
 
+    def _build_first_cycle_window(
+        self,
+        point_name: str,
+        types: list[str],
+        points: list[dict[str, Any]],
+        loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
+    ) -> dict[str, Any]:
+        """Build a slot-layout window where each file shows only its first cycle.
+
+        Uses the ``wave_file.cycle_start / cycle_end`` bounds written by
+        detect_cycle_index.py and fetches every file's first-cycle slice in a
+        single JOIN query (range scan on the ``(wave_file_id, sample_index)``
+        primary key), so 50 files cost one query instead of 50 full-file loads.
+        Files without a recorded cycle (无周期 / 未回写) contribute no curve but
+        still appear in the file record list. The curve is continuous: all cycle
+        samples are kept and each file spans its own x slot.
+        """
+        series_data: dict[str, list[dict[str, Any]]] = {measurement_type: [] for measurement_type in types}
+        second_series_data: list[dict[str, Any]] = []
+        angle_data: list[dict[str, Any]] = []
+        volume_data: list[dict[str, Any]] = []
+        volume_info: dict[str, Any] | None = None
+        cycles: list[dict[str, Any]] = []
+        triggers: list[float] = []
+        files: list[dict[str, Any]] = []
+        diagnostics: list[dict[str, Any]] = []
+        second_finite_count = 0
+        second_non_zero_count = 0
+        second_min: float | None = None
+        second_max: float | None = None
+        source_type = "压力" if "压力" in types else types[0]
+
+        slot_sources: list[tuple[int, dict[str, Any], str, int]] = []
+        all_file_ids: set[int] = set()
+        for slot, point in enumerate(points):
+            source_measurement_type = source_type
+            source_file = point.get("files", {}).get(source_measurement_type)
+            if not source_file:
+                available = [
+                    (measurement_type, file_info)
+                    for measurement_type, file_info in point.get("files", {}).items()
+                    if measurement_type in types and file_info
+                ]
+                if available:
+                    source_measurement_type, source_file = available[0]
+                else:
+                    continue
+            source_id = int(source_file["id"] if isinstance(source_file, dict) else source_file)
+            slot_sources.append((slot, point, source_measurement_type, source_id))
+            for measurement_type in types:
+                file_info = point.get("files", {}).get(measurement_type)
+                if file_info:
+                    all_file_ids.add(int(file_info["id"] if isinstance(file_info, dict) else file_info))
+
+        if not slot_sources:
+            return self._assemble_first_cycle_window(
+                point_name, types, points, series_data, second_series_data,
+                angle_data, volume_data, volume_info,
+                second_finite_count, second_non_zero_count, second_min, second_max,
+                cycles, triggers, files, diagnostics, 0,
+            )
+
+        is_demo = loader.__name__ == "_load_demo_wave"
+        metas: dict[int, dict[str, Any]] = {}
+        # file_id -> (cycle_start, cycle_end, padded_slice[offset, signal, second])
+        slices: dict[int, tuple[int, int, np.ndarray]] = {}
+        if is_demo:
+            for _slot, point, source_measurement_type, source_id in slot_sources:
+                metadata, samples = self._load_for_window(
+                    loader, source_id, source_measurement_type, point_name,
+                    point["sampleTime"], {},
+                )
+                metas[source_id] = metadata
+                detected, _ = detect_cycles(samples)
+                if detected:
+                    cycle = detected[0]
+                    start_si = int(samples[cycle.start_offset, 0])
+                    end_si = int(samples[cycle.end_offset, 0])
+                    pad_end = min(cycle.end_offset + FIRST_CYCLE_PAD, len(samples))
+                    slices[source_id] = (start_si, end_si, samples[cycle.start_offset:pad_end])
+        else:
+            source_ids = [source_id for _, _, _, source_id in slot_sources]
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    placeholders = ", ".join(["%s"] * len(all_file_ids))
+                    cursor.execute(
+                        f"""
+                        SELECT id, point_name, measurement_type, sample_time,
+                               sample_count, sample_frequency_hz, rpm, tspluse_status,
+                               file_name, cycle_start, cycle_end
+                        FROM wave_file
+                        WHERE id IN ({placeholders})
+                        """,
+                        tuple(all_file_ids),
+                    )
+                    for row in cursor.fetchall():
+                        metas[int(row["id"])] = row
+                    source_placeholders = ", ".join(["%s"] * len(source_ids))
+                    cursor.execute(
+                        f"""
+                        SELECT ws.wave_file_id,
+                               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
+                        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),
+                    )
+                    raw: dict[int, list[tuple[float, float, float]]] = {}
+                    for row in cursor.fetchall():
+                        raw.setdefault(int(row["wave_file_id"]), []).append(
+                            (
+                                float(row["sample_index"]),
+                                float(row["sig"]),
+                                float(row["sec"]) if row["sec"] is not None else float("nan"),
+                            ),
+                        )
+            for file_id, rows in raw.items():
+                meta = metas.get(file_id)
+                if meta is None or meta.get("cycle_start") is None:
+                    continue
+                slices[file_id] = (
+                    int(meta["cycle_start"]),
+                    int(meta["cycle_end"]),
+                    np.asarray(rows, dtype=float),
+                )
+
+        for slot, point, source_measurement_type, source_id in slot_sources:
+            sample_time = point["sampleTime"]
+            cycle_bounds = slices.get(source_id)
+            cycle_start = 0
+            cycle_end = 0
+            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)
+            if has_cycle:
+                angle: np.ndarray | None = None
+                volume: np.ndarray | None = None
+                volume_info: dict[str, Any] | None = None
+                detected, _ = detect_cycles(slice_arr)
+                if detected:
+                    cycle = detected[0]
+                    angle = cycle.angle
+                    volume, volume_info = self._build_volume_vector(len(slice_arr), [cycle], point_name)
+                for offset in range(cycle_len):
+                    sample_index = int(slice_arr[offset, 0])
+                    raw_value = float(slice_arr[offset, 1])
+                    second = _safe_float(slice_arr[offset, 2])
+                    angle360 = _safe_float(angle[offset]) if angle is not None and offset < len(angle) else None
+                    volume_value = _safe_float(volume[offset]) if volume is not None and offset < len(volume) else None
+                    x = slot + (sample_index - cycle_start) / cycle_len
+                    series_data[source_measurement_type].append(
+                        {
+                            "value": [x, raw_value],
+                            "x": x,
+                            "rawValue": raw_value,
+                            "sampleIndex": sample_index,
+                            "waveFileId": source_id,
+                            "sampleTime": sample_time,
+                            "secondValue": second,
+                            "volume": volume_value,
+                            "angle360": angle360,
+                        },
+                    )
+                    if volume_value is not None:
+                        volume_data.append(
+                            {
+                                "value": [x, volume_value],
+                                "x": x,
+                                "volume": volume_value,
+                                "sampleIndex": sample_index,
+                                "waveFileId": source_id,
+                                "sampleTime": sample_time,
+                            },
+                        )
+                    if angle360 is not None:
+                        display_angle = angle360 if angle360 <= 180.0 else 360.0 - angle360
+                        angle_data.append(
+                            {
+                                "value": [x, display_angle],
+                                "x": x,
+                                "angle": display_angle,
+                                "sampleIndex": sample_index,
+                                "waveFileId": source_id,
+                                "sampleTime": sample_time,
+                            },
+                        )
+                    if second is not None:
+                        second_finite_count += 1
+                        if second != 0:
+                            second_non_zero_count += 1
+                        second_min = second if second_min is None else min(second_min, second)
+                        second_max = second if second_max is None else max(second_max, second)
+                        second_series_data.append(
+                            {
+                                "value": [x, second],
+                                "x": x,
+                                "rawValue": second,
+                                "sampleIndex": sample_index,
+                                "waveFileId": source_id,
+                                "sampleTime": sample_time,
+                            },
+                        )
+                    if offset > 0:
+                        prev = _safe_float(slice_arr[offset - 1, 2])
+                        if second is not None and (prev is None or prev < 30) and second >= 30:
+                            triggers.append(x)
+                cycles.append(
+                    {
+                        "id": f"{source_id}:1",
+                        "waveFileId": source_id,
+                        "periodNo": 1,
+                        "pointIndex": slot,
+                        "sampleTime": sample_time,
+                        "startX": float(slot),
+                        "endX": float(slot + 1),
+                        "startSampleIndex": cycle_start,
+                        "endSampleIndex": cycle_end - 1,
+                        "sourceType": source_measurement_type,
+                    },
+                )
+                diagnostics.append(
+                    {
+                        "waveFileId": source_id,
+                        "measurementType": source_measurement_type,
+                        "sampleTime": sample_time,
+                        "cycleStart": cycle_start,
+                        "cycleEnd": cycle_end,
+                        "cycleSampleCount": cycle_len,
+                    },
+                )
+
+            for measurement_type in types:
+                file_info = point.get("files", {}).get(measurement_type)
+                if not file_info:
+                    continue
+                file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
+                metadata = metas.get(file_id)
+                cycle_start = metadata.get("cycle_start") if metadata else None
+                cycle_end = metadata.get("cycle_end") if metadata else None
+                files.append(
+                    {
+                        "id": file_id,
+                        "pointIndex": slot,
+                        "sampleTime": sample_time,
+                        "measurementType": measurement_type,
+                        "sampleCount": int(
+                            (metadata.get("sample_count") if metadata else file_info.get("sampleCount") or 0) or 0,
+                        ),
+                        "sampleFrequencyHz": int(
+                            (metadata.get("sample_frequency_hz") if metadata else file_info.get("sampleFrequencyHz") or 0) or 0,
+                        ),
+                        "pointName": str((metadata.get("point_name") if metadata else point_name) or point_name),
+                        "rpm": float((metadata.get("rpm") if metadata else file_info.get("rpm") or 0) or 0),
+                        "status": int(
+                            (metadata.get("tspluse_status") if metadata else file_info.get("status") or 0) or 0,
+                        ),
+                        "fileName": str((metadata.get("file_name") if metadata else "") or ""),
+                        "cycleStart": int(cycle_start) if cycle_start is not None else None,
+                        "cycleEnd": int(cycle_end) if cycle_end is not None else None,
+                    },
+                )
+
+        return self._assemble_first_cycle_window(
+            point_name, types, points, series_data, second_series_data,
+            angle_data, volume_data, volume_info,
+            second_finite_count, second_non_zero_count, second_min, second_max,
+            cycles, triggers, files, diagnostics,
+            max(len(slot_sources) - len(cycles), 0),
+        )
+
+    @staticmethod
+    def _assemble_first_cycle_window(
+        point_name: str,
+        types: list[str],
+        points: list[dict[str, Any]],
+        series_data: dict[str, list[dict[str, Any]]],
+        second_series_data: list[dict[str, Any]],
+        angle_data: list[dict[str, Any]],
+        volume_data: list[dict[str, Any]],
+        volume_info: dict[str, Any] | None,
+        second_finite_count: int,
+        second_non_zero_count: int,
+        second_min: float | None,
+        second_max: float | None,
+        cycles: list[dict[str, Any]],
+        triggers: list[float],
+        files: list[dict[str, Any]],
+        diagnostics: list[dict[str, Any]],
+        missing_cycles: int = 0,
+    ) -> dict[str, Any]:
+        for measurement_type in series_data:
+            series_data[measurement_type].sort(key=lambda item: item["x"])
+        second_series_data.sort(key=lambda item: item["x"])
+        angle_data.sort(key=lambda item: item["x"])
+        volume_data.sort(key=lambda item: item["x"])
+        return {
+            "pointName": point_name,
+            "measurementTypes": types,
+            "points": points,
+            "xMin": 0,
+            "xMax": len(points),
+            "series": [
+                {
+                    "measurementType": measurement_type,
+                    "color": MEASUREMENT_COLORS[measurement_type],
+                    "data": series_data[measurement_type],
+                }
+                for measurement_type in types
+            ],
+            "secondSeries": {
+                "name": "周期数据",
+                "color": "#f56c6c",
+                "sourceMeasurementType": "压力" if "压力" in types else types[0],
+                "data": second_series_data,
+                "finiteCount": second_finite_count,
+                "nonZeroCount": second_non_zero_count,
+                "min": second_min,
+                "max": second_max,
+            },
+            "angleSeries": {
+                "color": "#d59b2b",
+                "data": angle_data,
+            },
+            "volumeSeries": {
+                "color": "#4d9e6f",
+                "data": volume_data,
+                "info": volume_info,
+            },
+            "cycles": cycles,
+            "triggerXs": sorted(set(triggers)),
+            "files": files,
+            "diagnostics": diagnostics,
+            "firstCycleMode": True,
+            "firstCycleNotice": (
+                f"窗口内 {missing_cycles} 个文件尚未回写首个周期索引(cycle_start),"
+                "请先运行 detect_cycle_index.py 回写后再查看。"
+                if missing_cycles > 0
+                else None
+            ),
+        }
+
     @staticmethod
     def _build_volume_vector(
         sample_count: int,

+ 15 - 1
frontend/src/App.vue

@@ -20,6 +20,7 @@ const includeStopped = ref(false)
 const abnormalOnly = ref(false)
 const noCycleOnly = ref(false)
 const minStatus = ref<number | null>(null)
+const firstCycleOnly = ref(false)
 const ruler = ref<{ min: number; max: number } | null>(null)
 const timePoints = ref<TimePoint[]>([])
 const referencePoints = ref<TimePoint[]>([])
@@ -214,6 +215,7 @@ async function loadWaveWindowNow() {
       points,
       maxPoints: maxPoints.value,
       noSampling: noSampling.value,
+      firstCycleOnly: firstCycleOnly.value,
     })
     if (requestId === waveRequestId) {
       waveData.value = result
@@ -485,6 +487,12 @@ watch(noCycleOnly, () => {
 watch(minStatus, () => {
   if (initialized.value) scheduleTimePoints()
 })
+watch(firstCycleOnly, () => {
+  if (!initialized.value) return
+  windowSize.value = firstCycleOnly.value ? 50 : 4
+  markChartDirty()
+  void loadWaveWindowNow()
+})
 watch(waveData, () => void refreshPretrainStatus(), { deep: false })
 
 async function refreshPretrainStatus() {
@@ -739,6 +747,10 @@ onBeforeUnmount(() => {
                 :title="noCycleOnly ? '勾选无周期时忽略质心距离' : undefined"
               />
             </label>
+            <label class="field field-first-cycle">
+              <span class="field-label">首个周期</span>
+              <el-checkbox v-model="firstCycleOnly" class="query-checkbox" size="large" :disabled="queryLoading">连续曲线</el-checkbox>
+            </label>
           </div>
         </div>
         <el-alert v-if="errorMessage" class="data-alert" type="error" :closable="false" show-icon :title="errorMessage" />
@@ -796,7 +808,8 @@ onBeforeUnmount(() => {
         </div>
         <div v-if="waveData?.files.length" class="window-files">
           <div class="window-files-title">当前窗口 wave_file 记录</div>
-          <table class="window-files-table">
+          <div class="window-files-scroll">
+            <table class="window-files-table">
             <thead>
               <tr>
                 <th>id</th>
@@ -842,6 +855,7 @@ onBeforeUnmount(() => {
               </tr>
             </tbody>
           </table>
+          </div>
         </div>
       </section>
 

+ 1 - 0
frontend/src/api.ts

@@ -95,6 +95,7 @@ export function fetchWaveWindow(params: {
   points: TimePoint[]
   maxPoints: number
   noSampling: boolean
+  firstCycleOnly?: boolean
 }) {
   return request<WaveWindowResponse>('/api/wave-window', {
     method: 'POST',

+ 6 - 1
frontend/src/components/WaveChart.vue

@@ -86,7 +86,11 @@ function minMaxOf(values: number[]): [number, number] {
 function pointAxisLabel(value: number) {
   const index = Math.round(value)
   if (Math.abs(value - index) > 0.04 || !props.points[index]) return ''
-  return `${index + 1}\n${formatTime(props.points[index].sampleTime)}`
+  const point = props.points[index]
+  const sourceType = props.data?.secondSeries.sourceMeasurementType
+  const fileId = sourceType ? point.files[sourceType]?.id : undefined
+  const time = point.sampleTime.replace('T', ' ').slice(0, 19)
+  return `${fileId ?? ''}\n${time}`
 }
 
 function periodAreas(cycles: Cycle[]): any[] {
@@ -716,5 +720,6 @@ onBeforeUnmount(() => {
     <div v-else-if="!data" class="chart-empty-hint">请点击「查询图表」加载波形数据。</div>
     <div v-else-if="data && !hasPlottableData" class="chart-empty-hint">当前窗口没有可绘制的波形数据,请检查时间点和数据名称选择。</div>
     <div v-if="data && !data.cycles.length" class="chart-empty-hint">当前窗口未检测到完整周期,仍可查看原始波形。</div>
+    <div v-if="data?.firstCycleNotice" class="chart-empty-hint">{{ data.firstCycleNotice }}</div>
   </div>
 </template>

+ 6 - 2
frontend/src/styles.css

@@ -121,9 +121,11 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .query-button.el-button { width: 100%; height: 40px; margin: 0; border-radius: 4px; font-size: 14px; }
 .query-row-2 { display: flex; align-items: flex-start; gap: 16px; grid-column: 1 / -1; }
 .query-row-2 .field-check,
-.query-row-2 .field-status { min-height: 68px; }
+.query-row-2 .field-status,
+.query-row-2 .field-first-cycle { min-height: 68px; }
 .query-row-2 .field-check .field-label,
-.query-row-2 .field-status .field-label { white-space: nowrap; }
+.query-row-2 .field-status .field-label,
+.query-row-2 .field-first-cycle .field-label { white-space: nowrap; }
 .query-checkbox.el-checkbox { height: 40px; margin-right: 20px; display: inline-flex; align-items: center; }
 .query-checkbox.el-checkbox:last-child { margin-right: 0; }
 .query-checkbox .el-checkbox__label { font-size: 14px; color: #303133; }
@@ -183,6 +185,8 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .window-files-table { width: 100%; border-collapse: collapse; font-size: 12px; }
 .window-files-table th, .window-files-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid #f0f2f5; white-space: nowrap; }
 .window-files-table th { color: #909399; font-weight: 500; background: #fafbfc; }
+.window-files-scroll { max-height: 180px; overflow-y: auto; }
+.window-files-scroll thead th { position: sticky; top: 0; z-index: 1; }
 .window-files-table td { color: #303133; }
 .window-files-table td.mono { color: #606266; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
 .window-files-table td.file-name { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }

+ 4 - 0
frontend/src/types.ts

@@ -91,6 +91,8 @@ export type WaveWindowFile = {
   rpm: number
   status?: number
   fileName: string
+  cycleStart?: number | null
+  cycleEnd?: number | null
 }
 
 export type WaveWindowResponse = {
@@ -152,6 +154,8 @@ export type WaveWindowResponse = {
   triggerXs: number[]
   files: WaveWindowFile[]
   diagnostics: Array<Record<string, string | number>>
+  firstCycleMode?: boolean
+  firstCycleNotice?: string | null
 }
 
 export type PeriodDetail = {