| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051 |
- from __future__ import annotations
- from collections import OrderedDict
- from datetime import datetime, timedelta
- from functools import lru_cache
- from time import monotonic
- from typing import Any, Callable
- import numpy as np
- from ..algorithms.cycles import (
- DetectedCycle,
- PULSES_PER_REVOLUTION,
- build_angle_vector,
- detect_cycles,
- downsample_indices,
- )
- from ..config import settings
- from ..db import get_connection
- MEASUREMENT_TYPES = ("位移", "加速度", "压力")
- MEASUREMENT_COLORS = {
- "压力": "#e4572e",
- "位移": "#1f7a8c",
- "加速度": "#7b61a8",
- }
- PHASES = (
- ("排气", "#7b1fa2", 0.0, 120.0),
- ("压缩", "#c62828", 120.0, 195.0),
- ("膨胀", "#1565c0", 195.0, 270.0),
- ("进气", "#2e7d32", 270.0, 330.0),
- )
- ANNOTATION_LABELS = ("正常", "异常")
- CYLINDER_BORE_MM = {
- "一缸": 360.0,
- "二缸": 490.0,
- "三缸": 390.0,
- "四缸": 490.0,
- "五缸": 390.0,
- "六缸": 490.0,
- }
- PISTON_STROKE_MM = 148.0
- CONNECTING_ROD_LENGTH_MM = 460.0
- CLEARANCE_VOLUME_L_BY_BORE = {
- 490.0: 1.51,
- 390.0: 0.74,
- 360.0: 0.62,
- }
- DEMO_POINT_NAME = "7号机组一缸压力盖侧"
- DEMO_START = datetime(2026, 4, 12, 8, 0, 0)
- DEMO_POINT_COUNT = 72
- DEMO_SAMPLE_COUNT = 32768
- DEMO_REVOLUTION_SAMPLES = 800
- DEMO_ID_BY_TYPE = {name: 100000 + index * 1000 for index, name in enumerate(MEASUREMENT_TYPES)}
- def _time_string(value: Any) -> str:
- if isinstance(value, datetime):
- return value.strftime("%Y-%m-%d %H:%M:%S")
- return str(value)
- def _parse_time(value: str | None) -> datetime | None:
- if not value:
- return None
- return datetime.fromisoformat(value.replace("Z", "+00:00").replace("T", " "))
- def _safe_float(value: Any) -> float | None:
- if value is None:
- return None
- number = float(value)
- return number if np.isfinite(number) else None
- def _annotation_dict(row: dict[str, Any]) -> dict[str, Any]:
- return {
- "id": int(row["id"]),
- "waveFileId": int(row["wave_file_id"]),
- "label": row["label"],
- "periodStart": int(row["period_start"]),
- "periodEnd": int(row["period_end"]),
- "sampleIndexStart": int(row["sample_index_start"]),
- "sampleIndexEnd": int(row["sample_index_end"]),
- }
- def _validate_measurement_types(values: list[str] | tuple[str, ...] | None) -> list[str]:
- selected = list(values or MEASUREMENT_TYPES)
- invalid = [value for value in selected if value not in MEASUREMENT_TYPES]
- if invalid:
- raise ValueError(f"不支持的数据名称:{'、'.join(invalid)}")
- return [value for value in MEASUREMENT_TYPES if value in selected]
- class DataService:
- # 数据库失败后的重试冷却时间(秒)。超过该时间后自动重连数据库,
- # 避免一次网络抖动就把服务永久锁死在演示数据模式。
- DB_RETRY_COOLDOWN = 30.0
- def __init__(self) -> None:
- self._db_failed = settings.demo_mode == "always"
- self._db_failed_at = monotonic() if self._db_failed else 0.0
- self._last_db_error = ""
- self._demo_annotations: dict[int, dict[str, Any]] = {}
- self._demo_annotation_seq = 1
- @property
- def source(self) -> str:
- return "demo" if self._db_failed else "database"
- @property
- def last_db_error(self) -> str:
- return self._last_db_error
- def _run_with_fallback(
- self,
- database_function: Callable[[], Any],
- demo_function: Callable[[], Any],
- ) -> tuple[Any, str]:
- if self._db_failed:
- if settings.demo_mode == "always":
- return demo_function(), "demo"
- if monotonic() - self._db_failed_at < self.DB_RETRY_COOLDOWN:
- return demo_function(), "demo"
- # 冷却结束,重新尝试数据库,数据库恢复后可自动切回真实数据。
- try:
- result = database_function()
- self._db_failed = False
- self._last_db_error = ""
- return result, "database"
- except Exception as error:
- if settings.demo_mode == "never":
- raise
- self._db_failed = True
- self._db_failed_at = monotonic()
- self._last_db_error = str(error)
- return demo_function(), "demo"
- def query_options(self) -> dict[str, Any]:
- def database_query():
- placeholders = ", ".join(["%s"] * len(MEASUREMENT_TYPES))
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- f"""
- SELECT point_name, measurement_type,
- MAX(sample_time) AS max_time,
- MIN(sample_time) AS min_time,
- COUNT(*) AS file_count
- FROM wave_file
- WHERE measurement_type IN ({placeholders})
- GROUP BY point_name, measurement_type
- ORDER BY point_name, measurement_type
- """,
- MEASUREMENT_TYPES,
- )
- rows = cursor.fetchall()
- return [
- {
- "pointName": row["point_name"],
- "measurementType": row["measurement_type"],
- "minTime": _time_string(row["min_time"]),
- "maxTime": _time_string(row["max_time"]),
- "fileCount": int(row["file_count"]),
- }
- for row in rows
- ]
- rows, source = self._run_with_fallback(database_query, self._demo_options)
- point_names = list(dict.fromkeys(row["pointName"] for row in rows))
- return {
- "source": source,
- "measurementTypes": list(MEASUREMENT_TYPES),
- "pointNames": point_names,
- "options": rows,
- "notice": self._source_notice(source),
- }
- def time_points(
- self,
- point_name: str,
- measurement_types: list[str] | None,
- min_time: str | None,
- max_time: str | None,
- ) -> dict[str, Any]:
- if not point_name.strip():
- raise ValueError("机组与部位不能为空")
- types = _validate_measurement_types(measurement_types)
- start = _parse_time(min_time)
- end = _parse_time(max_time)
- if start and end and start > end:
- raise ValueError("开始时间不能晚于结束时间")
- def database_query():
- type_placeholders = ", ".join(["%s"] * len(types))
- clauses = [
- "point_name = %s",
- f"measurement_type IN ({type_placeholders})",
- ]
- params: list[Any] = [point_name, *types]
- if start:
- clauses.append("sample_time >= %s")
- params.append(start)
- if end:
- clauses.append("sample_time <= %s")
- params.append(end)
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- f"""
- SELECT id, point_name, measurement_type, sample_time,
- sample_count, sample_frequency_hz, rpm
- FROM wave_file
- WHERE {' AND '.join(clauses)}
- ORDER BY sample_time ASC, id ASC
- """,
- params,
- )
- rows = cursor.fetchall()
- return self._group_time_points(rows)
- def demo_query():
- return self._demo_time_points(point_name, types, start, end)
- points, source = self._run_with_fallback(database_query, demo_query)
- return {
- "source": source,
- "pointName": point_name,
- "measurementTypes": types,
- "total": len(points),
- "points": points,
- "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()
- for row in rows:
- key = row["sample_time"]
- point = grouped.setdefault(
- key,
- {
- "sampleTime": _time_string(key),
- "files": {},
- },
- )
- point["files"][row["measurement_type"]] = {
- "id": int(row["id"]),
- "sampleCount": int(row["sample_count"] or 0),
- "sampleFrequencyHz": int(row["sample_frequency_hz"] or 0),
- "rpm": float(row["rpm"] or 0),
- }
- return [
- {"index": index, **point}
- for index, point in enumerate(grouped.values())
- ]
- def wave_window(
- self,
- point_name: str,
- measurement_types: list[str],
- points: list[dict[str, Any]],
- max_points: int,
- no_sampling: bool = False,
- ) -> dict[str, Any]:
- types = _validate_measurement_types(measurement_types)
- if not points:
- raise ValueError("至少选择一个时间点")
- if len(points) > 200:
- raise ValueError("单次最多预览 200 个时间点,请缩小时间窗口")
- max_points = min(max(int(max_points), 256), 200000)
- def database_query():
- return self._build_wave_window(
- point_name,
- types,
- points,
- max_points,
- self._load_db_wave,
- no_sampling,
- )
- def demo_query():
- return self._build_wave_window(
- point_name,
- types,
- points,
- max_points,
- self._load_demo_wave,
- no_sampling,
- )
- result, source = self._run_with_fallback(database_query, demo_query)
- result["source"] = source
- result["notice"] = self._source_notice(source)
- return result
- 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 和周期编号必须为正整数")
- def database_query():
- metadata, samples = self._load_db_wave(wave_file_id)
- return self._build_period_detail(metadata, samples, period_number)
- def demo_query():
- metadata, samples = self._load_demo_wave(
- wave_file_id,
- "压力",
- DEMO_POINT_NAME,
- DEMO_START,
- )
- return self._build_period_detail(metadata, samples, period_number)
- result, source = self._run_with_fallback(database_query, demo_query)
- result["source"] = source
- result["notice"] = self._source_notice(source)
- return result
- def annotation_config(self) -> dict[str, Any]:
- return {
- "source": self.source,
- "annotationWidth": settings.annotation_width,
- "notice": self._source_notice(self.source),
- }
- def list_annotations(self, wave_file_ids: list[int]) -> dict[str, Any]:
- ids = sorted({int(value) for value in wave_file_ids if value})
- if not ids:
- return {
- "source": self.source,
- "annotations": [],
- "notice": self._source_notice(self.source),
- }
- def database_query():
- placeholders = ", ".join(["%s"] * len(ids))
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- f"""
- SELECT id, wave_file_id, label, period_start, period_end,
- sample_index_start, sample_index_end
- FROM wave_annotation
- WHERE wave_file_id IN ({placeholders})
- ORDER BY id ASC
- """,
- ids,
- )
- rows = cursor.fetchall()
- return [_annotation_dict(row) for row in rows]
- def demo_query():
- return [
- _annotation_dict(annotation)
- for annotation in self._demo_annotations.values()
- if annotation["wave_file_id"] in ids
- ]
- annotations, source = self._run_with_fallback(database_query, demo_query)
- return {
- "source": source,
- "annotations": annotations,
- "notice": self._source_notice(source),
- }
- def create_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
- self._validate_annotation(payload)
- wave_file_id = int(payload["wave_file_id"])
- label = payload["label"]
- period_start = int(payload["period_start"])
- period_end = int(payload["period_end"])
- sample_index_start = int(payload["sample_index_start"])
- sample_index_end = int(payload["sample_index_end"])
- def database_query():
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- """
- INSERT INTO wave_annotation
- (wave_file_id, label, period_start, period_end,
- sample_index_start, sample_index_end)
- VALUES (%s, %s, %s, %s, %s, %s)
- """,
- (
- wave_file_id,
- label,
- period_start,
- period_end,
- sample_index_start,
- sample_index_end,
- ),
- )
- annotation_id = cursor.lastrowid
- cursor.execute(
- """
- SELECT id, wave_file_id, label, period_start, period_end,
- sample_index_start, sample_index_end
- FROM wave_annotation
- WHERE id = %s
- """,
- (annotation_id,),
- )
- return _annotation_dict(cursor.fetchone())
- def demo_query():
- annotation_id = self._demo_annotation_seq
- self._demo_annotation_seq += 1
- annotation = {
- "id": annotation_id,
- "wave_file_id": wave_file_id,
- "label": label,
- "period_start": period_start,
- "period_end": period_end,
- "sample_index_start": sample_index_start,
- "sample_index_end": sample_index_end,
- }
- self._demo_annotations[annotation_id] = annotation
- return _annotation_dict(annotation)
- result, source = self._run_with_fallback(database_query, demo_query)
- result["source"] = source
- result["notice"] = self._source_notice(source)
- return result
- def delete_annotation(self, annotation_id: int) -> dict[str, Any]:
- if annotation_id <= 0:
- raise ValueError("标注 id 必须为正整数")
- def database_query():
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- "DELETE FROM wave_annotation WHERE id = %s",
- (annotation_id,),
- )
- return int(cursor.rowcount)
- def demo_query():
- if annotation_id not in self._demo_annotations:
- return 0
- del self._demo_annotations[annotation_id]
- return 1
- deleted, source = self._run_with_fallback(database_query, demo_query)
- if not deleted:
- raise ValueError(f"标注 id={annotation_id} 不存在")
- return {
- "deleted": annotation_id,
- "source": source,
- "notice": self._source_notice(source),
- }
- @staticmethod
- def _validate_annotation(payload: dict[str, Any]) -> None:
- label = payload.get("label")
- if label not in ANNOTATION_LABELS:
- raise ValueError("样本类型只能是 正常 或 异常")
- for field in ("wave_file_id", "period_start", "period_end", "sample_index_start", "sample_index_end"):
- if payload.get(field) is None:
- raise ValueError(f"{field} 不能为空")
- if int(payload["wave_file_id"]) <= 0:
- raise ValueError("wave_file_id 必须为正整数")
- if int(payload["period_start"]) <= 0 or int(payload["period_end"]) <= 0:
- raise ValueError("周期编号必须为正整数")
- if int(payload["period_start"]) > int(payload["period_end"]):
- raise ValueError("起始周期不能大于结束周期")
- if int(payload["sample_index_start"]) < 0 or int(payload["sample_index_end"]) < 0:
- raise ValueError("采样点索引不能为负")
- if int(payload["sample_index_start"]) > int(payload["sample_index_end"]):
- raise ValueError("起始采样点不能大于结束采样点")
- def health(self) -> dict[str, Any]:
- return {
- "status": "ok",
- "source": self.source,
- "databaseError": self._last_db_error or None,
- }
- def _source_notice(self, source: str) -> str | None:
- if source == "demo":
- if self._last_db_error:
- return f"当前为演示数据:数据库暂不可用({self._last_db_error})"
- return "当前为演示数据:可设置 DEMO_MODE=never 强制使用数据库"
- return "已连接 MySQL 数据库"
- def _build_wave_window(
- self,
- point_name: str,
- types: list[str],
- points: list[dict[str, Any]],
- max_points: int,
- loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
- no_sampling: bool = False,
- ) -> dict[str, Any]:
- series_data: dict[str, list[dict[str, Any]]] = {measurement_type: [] for measurement_type in types}
- 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_series_data: 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]
- load_cache: dict[int, tuple[dict[str, Any], np.ndarray]] = {}
- 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)
- try:
- source_metadata, source_samples = self._load_for_window(
- loader,
- source_id,
- source_measurement_type,
- point_name,
- point["sampleTime"],
- load_cache,
- )
- except ValueError:
- # A selected file may legitimately have no samples. A database
- # connection error must escape and activate the demo fallback.
- continue
- detected, diagnostic = detect_cycles(source_samples)
- angle_vector = build_angle_vector(len(source_samples), detected)
- full_angle_vector = np.full(len(source_samples), np.nan, dtype=float)
- for detected_cycle in detected:
- full_angle_vector[detected_cycle.start_offset:detected_cycle.end_offset] = detected_cycle.angle
- current_volume, current_volume_info = self._build_volume_vector(
- len(source_samples),
- detected,
- point_name,
- )
- if current_volume_info is not None:
- volume_info = current_volume_info
- sample_indices = source_samples[:, 0].astype(np.int64)
- sample_span = max(len(source_samples), 1)
- finite_second = source_samples[:, 2][np.isfinite(source_samples[:, 2])]
- if len(finite_second):
- second_finite_count += int(len(finite_second))
- second_non_zero_count += int(np.count_nonzero(finite_second != 0))
- current_min = float(np.min(finite_second))
- current_max = float(np.max(finite_second))
- second_min = current_min if second_min is None else min(second_min, current_min)
- second_max = current_max if second_max is None else max(second_max, current_max)
- required = {0, len(source_samples) - 1}
- for cycle in detected:
- required.update(
- {
- cycle.start_offset,
- max(cycle.end_offset - 1, cycle.start_offset),
- *cycle.trigger_offsets,
- },
- )
- start_x = slot + cycle.start_offset / sample_span
- end_x = slot + cycle.end_offset / sample_span
- start_sample_index = int(sample_indices[cycle.start_offset])
- end_sample_index = int(sample_indices[max(cycle.end_offset - 1, cycle.start_offset)])
- cycles.append(
- {
- "id": f"{source_id}:{cycle.number}",
- "waveFileId": source_id,
- "periodNo": cycle.number,
- "pointIndex": slot,
- "sampleTime": point["sampleTime"],
- "startX": start_x,
- "endX": end_x,
- "startSampleIndex": start_sample_index,
- "endSampleIndex": end_sample_index,
- "sourceType": source_measurement_type,
- },
- )
- for trigger_offset in sorted(required):
- if trigger_offset in required and any(
- trigger_offset == run_offset
- for cycle in detected
- for run_offset in cycle.trigger_offsets
- ):
- triggers.append(slot + trigger_offset / sample_span)
- diagnostics.append(
- {
- "waveFileId": source_id,
- "measurementType": source_measurement_type,
- "sampleTime": point["sampleTime"],
- **diagnostic,
- },
- )
- 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)
- if file_id == source_id:
- metadata, samples = source_metadata, source_samples
- else:
- try:
- metadata, samples = self._load_for_window(
- loader,
- file_id,
- measurement_type,
- point_name,
- point["sampleTime"],
- load_cache,
- )
- except ValueError:
- continue
- sample_count = len(samples)
- if not sample_count:
- continue
- sample_indices_for_file = samples[:, 0].astype(np.int64)
- file_base_index = int(sample_indices_for_file[0])
- file_span = max(sample_count, 1)
- file_required = {0, sample_count - 1}
- if file_id == source_id:
- file_required.update(required)
- if no_sampling:
- target_per_file = sample_count
- else:
- target_per_file = max(256, int(np.ceil(max_points / max(len(points), 1))))
- chosen = downsample_indices(samples[:, 1], target_per_file, file_required)
- for offset in chosen:
- x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
- second = _safe_float(samples[offset, 2])
- raw_value = float(samples[offset, 1])
- series_data[measurement_type].append(
- {
- "value": [x, raw_value],
- "x": x,
- "rawValue": raw_value,
- "sampleIndex": int(sample_indices_for_file[offset]),
- "waveFileId": file_id,
- "sampleTime": point["sampleTime"],
- "secondValue": second,
- "volume": (
- _safe_float(current_volume[offset])
- if file_id == source_id
- else None
- ),
- "angle360": (
- _safe_float(full_angle_vector[offset])
- if file_id == source_id
- else None
- ),
- },
- )
- if file_id == source_id:
- second_chosen = downsample_indices(samples[:, 2], target_per_file, file_required)
- for offset in second_chosen:
- second_value = _safe_float(samples[offset, 2])
- if second_value is None:
- continue
- x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
- second_series_data.append(
- {
- "value": [x, second_value],
- "x": x,
- "rawValue": second_value,
- "sampleIndex": int(sample_indices_for_file[offset]),
- "waveFileId": file_id,
- "sampleTime": point["sampleTime"],
- },
- )
- angle_chosen = downsample_indices(angle_vector, target_per_file, file_required)
- for offset in angle_chosen:
- value = _safe_float(angle_vector[offset])
- if value is None:
- continue
- x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
- angle_data.append(
- {
- "value": [x, value],
- "x": x,
- "angle": value,
- "sampleIndex": int(sample_indices_for_file[offset]),
- "waveFileId": file_id,
- "sampleTime": point["sampleTime"],
- },
- )
- volume_chosen = downsample_indices(current_volume, target_per_file, file_required)
- for offset in volume_chosen:
- value = _safe_float(current_volume[offset])
- if value is None:
- continue
- x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
- volume_data.append(
- {
- "value": [x, value],
- "x": x,
- "volume": value,
- "sampleIndex": int(sample_indices_for_file[offset]),
- "waveFileId": file_id,
- "sampleTime": point["sampleTime"],
- },
- )
- files.append(
- {
- "id": file_id,
- "pointIndex": slot,
- "sampleTime": point["sampleTime"],
- "measurementType": measurement_type,
- "sampleCount": int(metadata.get("sample_count") or sample_count),
- "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
- },
- )
- # Keep at most the current time point's raw arrays resident. The
- # response retains only downsampled values and period metadata.
- load_cache.clear()
- 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": source_type,
- "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,
- }
- @staticmethod
- def _build_volume_vector(
- sample_count: int,
- cycles: list[DetectedCycle],
- point_name: str,
- ) -> tuple[np.ndarray, dict[str, Any] | None]:
- volume = np.full(sample_count, np.nan, dtype=float)
- cylinder_name = next(
- (name for name in CYLINDER_BORE_MM if name in point_name),
- None,
- )
- if cylinder_name is None or not cycles:
- return volume, None
- bore_mm = CYLINDER_BORE_MM[cylinder_name]
- clearance = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
- crank_radius = PISTON_STROKE_MM / 2.0
- piston_area = np.pi * (bore_mm / 2.0) ** 2
- for cycle in cycles:
- angle_rad = np.deg2rad(cycle.angle)
- travel = (
- crank_radius * (1.0 - np.cos(angle_rad))
- + CONNECTING_ROD_LENGTH_MM
- - np.sqrt(
- CONNECTING_ROD_LENGTH_MM**2
- - (crank_radius * np.sin(angle_rad)) ** 2,
- )
- )
- volume[cycle.start_offset : cycle.end_offset] = (
- clearance + piston_area * travel / 1_000_000.0
- )
- finite = volume[np.isfinite(volume)]
- return volume, {
- "cylinder": cylinder_name,
- "boreMm": bore_mm,
- "clearanceVolumeL": clearance,
- "minVolumeL": float(np.min(finite)) if len(finite) else None,
- "maxVolumeL": float(np.max(finite)) if len(finite) else None,
- }
- @staticmethod
- def _build_period_detail(
- metadata: dict[str, Any],
- samples: np.ndarray,
- period_number: int,
- ) -> dict[str, Any]:
- 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}")
- angles360 = np.linspace(0.0, 359.0, 360)
- pressure = np.interp(angles360, cycle.angle, cycle.signal)
- display_angles = np.where(angles360 <= 180.0, angles360, 360.0 - angles360)
- point_name = str(metadata.get("point_name") or "")
- cylinder_name = next(
- (name for name in CYLINDER_BORE_MM if name in point_name),
- None,
- )
- volume: np.ndarray | None = None
- volume_info: dict[str, Any] | None = None
- if cylinder_name:
- bore_mm = CYLINDER_BORE_MM[cylinder_name]
- clearance_volume = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
- angle_rad = np.deg2rad(angles360)
- crank_radius = PISTON_STROKE_MM / 2.0
- piston_travel = (
- crank_radius * (1.0 - np.cos(angle_rad))
- + CONNECTING_ROD_LENGTH_MM
- - np.sqrt(
- CONNECTING_ROD_LENGTH_MM**2
- - (crank_radius * np.sin(angle_rad)) ** 2,
- )
- )
- piston_area = np.pi * (bore_mm / 2.0) ** 2
- volume = clearance_volume + piston_area * piston_travel / 1_000_000.0
- volume_info = {
- "cylinder": cylinder_name,
- "boreMm": bore_mm,
- "clearanceVolumeL": clearance_volume,
- "minVolumeL": float(np.min(volume)),
- "maxVolumeL": float(np.max(volume)),
- }
- start_index = int(samples[cycle.start_offset, 0])
- end_offset = min(cycle.end_offset, len(samples) - 1)
- end_index = int(samples[max(cycle.end_offset - 1, cycle.start_offset), 0])
- return {
- "waveFile": {
- "id": int(metadata["id"]),
- "pointName": point_name,
- "measurementType": metadata.get("measurement_type"),
- "sampleTime": _time_string(metadata.get("sample_time")),
- "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
- "sampleCount": int(metadata.get("sample_count") or len(samples)),
- },
- "period": {
- "periodNo": cycle.number,
- "startSampleIndex": start_index,
- "endSampleIndex": end_index,
- "sampleCount": int(cycle.end_offset - cycle.start_offset),
- "triggerSampleIndices": [
- int(samples[offset, 0])
- for offset in cycle.trigger_offsets
- if 0 <= offset < len(samples)
- ],
- },
- "angles": display_angles.tolist(),
- "angles360": angles360.tolist(),
- "pressure": pressure.tolist(),
- "volume": volume.tolist() if volume is not None else None,
- "volumeInfo": volume_info,
- "phases": [
- {
- "name": name,
- "color": color,
- "start": start,
- "end": end,
- }
- for name, color, start, end in PHASES
- ],
- "diagnostics": diagnostics,
- }
- @staticmethod
- def _load_for_window(
- loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
- file_id: int,
- measurement_type: str,
- point_name: str,
- sample_time: str,
- load_cache: dict[int, tuple[dict[str, Any], np.ndarray]],
- ) -> tuple[dict[str, Any], np.ndarray]:
- if file_id not in load_cache:
- if loader.__name__ == "_load_demo_wave":
- load_cache[file_id] = loader(file_id, measurement_type, point_name, sample_time)
- else:
- load_cache[file_id] = loader(file_id)
- return load_cache[file_id]
- def _load_db_wave(self, file_id: int) -> tuple[dict[str, Any], np.ndarray]:
- with get_connection() as connection:
- with connection.cursor() as cursor:
- cursor.execute(
- """
- SELECT id, point_name, measurement_type, sample_frequency_hz,
- sample_count, sample_time
- FROM wave_file
- WHERE id = %s
- """,
- (file_id,),
- )
- metadata = cursor.fetchone()
- if metadata is None:
- raise ValueError(f"wave_file.id={file_id} 不存在")
- cursor.execute(
- """
- SELECT sample_index, signal_value, second_value
- FROM wave_sample
- WHERE wave_file_id = %s
- ORDER BY sample_index ASC
- """,
- (file_id,),
- )
- rows = cursor.fetchall()
- if not rows:
- raise ValueError(f"wave_file.id={file_id} 没有采样数据")
- samples = np.asarray(
- [
- (
- float(row["sample_index"]),
- float(row["signal_value"]),
- float(row["second_value"]) if row["second_value"] is not None else np.nan,
- )
- for row in rows
- ],
- dtype=float,
- )
- return metadata, samples
- @staticmethod
- @lru_cache(maxsize=24)
- def _demo_samples(file_id: int, measurement_type: str) -> np.ndarray:
- count = DEMO_SAMPLE_COUNT
- index = np.arange(count, dtype=float)
- revolution = DEMO_REVOLUTION_SAMPLES
- phase = (index % revolution) / revolution * 2 * np.pi
- second = np.zeros(count, dtype=float)
- for revolution_start in range(0, count, revolution):
- for pulse in range(PULSES_PER_REVOLUTION):
- pulse_start = revolution_start + int(round(pulse * revolution / PULSES_PER_REVOLUTION))
- width = 22 if pulse == 0 else 8
- pulse_end = min(count, pulse_start + width)
- second[pulse_start:pulse_end] = 40.0
- variation = (file_id % 17) / 17.0
- if measurement_type == "压力":
- signal = (
- 4.2
- + 1.8 * np.sin(phase - 0.4)
- + 0.55 * np.sin(2 * phase + variation)
- + 0.22 * np.sin(7 * phase)
- )
- signal += 0.2 * np.maximum(np.sin(phase - 0.2), 0) ** 5
- elif measurement_type == "位移":
- signal = 0.5 + 0.18 * np.cos(phase) + 0.035 * np.sin(3 * phase + variation)
- else:
- signal = 0.15 * np.sin(phase * 2 + variation) + 0.04 * np.sin(11 * phase)
- signal += 0.018 * np.cos(index / 37.0)
- return np.column_stack((index, signal, second))
- def _load_demo_wave(
- self,
- file_id: int,
- measurement_type: str,
- point_name: str,
- sample_time: str,
- ) -> tuple[dict[str, Any], np.ndarray]:
- samples = self._demo_samples(file_id, measurement_type)
- metadata = {
- "id": file_id,
- "point_name": point_name,
- "measurement_type": measurement_type,
- "sample_frequency_hz": 25600,
- "sample_count": len(samples),
- "sample_time": sample_time,
- }
- return metadata, samples
- @staticmethod
- def _demo_options() -> list[dict[str, Any]]:
- end = DEMO_START + timedelta(minutes=5 * (DEMO_POINT_COUNT - 1))
- return [
- {
- "pointName": DEMO_POINT_NAME,
- "measurementType": measurement_type,
- "minTime": _time_string(DEMO_START),
- "maxTime": _time_string(end),
- "fileCount": DEMO_POINT_COUNT,
- }
- for measurement_type in MEASUREMENT_TYPES
- ]
- @staticmethod
- def _demo_time_points(
- point_name: str,
- types: list[str],
- start: datetime | None,
- end: datetime | None,
- ) -> list[dict[str, Any]]:
- points = []
- for index in range(DEMO_POINT_COUNT):
- timestamp = DEMO_START + timedelta(minutes=5 * index)
- if start and timestamp < start:
- continue
- if end and timestamp > end:
- continue
- files = {
- measurement_type: {
- "id": DEMO_ID_BY_TYPE[measurement_type] + index,
- "sampleCount": DEMO_SAMPLE_COUNT,
- "sampleFrequencyHz": 25600,
- "rpm": 998.0,
- }
- for measurement_type in types
- }
- points.append(
- {
- "index": len(points),
- "sampleTime": _time_string(timestamp),
- "files": files,
- },
- )
- return points
- data_service = DataService()
|