|
|
@@ -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,
|