data_service.py 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159
  1. from __future__ import annotations
  2. from collections import OrderedDict
  3. from datetime import datetime, timedelta
  4. from functools import lru_cache
  5. from time import monotonic
  6. from typing import Any, Callable
  7. import numpy as np
  8. from ..algorithms.cycles import (
  9. DetectedCycle,
  10. PULSES_PER_REVOLUTION,
  11. build_angle_vector,
  12. detect_cycles,
  13. downsample_indices,
  14. )
  15. from ..config import settings
  16. from ..db import get_connection
  17. MEASUREMENT_TYPES = ("位移", "加速度", "压力")
  18. MEASUREMENT_COLORS = {
  19. "压力": "#e4572e",
  20. "位移": "#1f7a8c",
  21. "加速度": "#7b61a8",
  22. }
  23. PHASES = (
  24. ("排气", "#7b1fa2", 0.0, 120.0),
  25. ("压缩", "#c62828", 120.0, 195.0),
  26. ("膨胀", "#1565c0", 195.0, 270.0),
  27. ("进气", "#2e7d32", 270.0, 330.0),
  28. )
  29. ANNOTATION_LABELS = ("正常", "异常")
  30. CYLINDER_BORE_MM = {
  31. "一缸": 360.0,
  32. "二缸": 490.0,
  33. "三缸": 390.0,
  34. "四缸": 490.0,
  35. "五缸": 390.0,
  36. "六缸": 490.0,
  37. }
  38. PISTON_STROKE_MM = 148.0
  39. CONNECTING_ROD_LENGTH_MM = 460.0
  40. CLEARANCE_VOLUME_L_BY_BORE = {
  41. 490.0: 1.51,
  42. 390.0: 0.74,
  43. 360.0: 0.62,
  44. }
  45. DEMO_POINT_NAME = "7号机组一缸压力盖侧"
  46. DEMO_START = datetime(2026, 4, 12, 8, 0, 0)
  47. DEMO_POINT_COUNT = 72
  48. DEMO_SAMPLE_COUNT = 32768
  49. DEMO_REVOLUTION_SAMPLES = 800
  50. DEMO_ID_BY_TYPE = {name: 100000 + index * 1000 for index, name in enumerate(MEASUREMENT_TYPES)}
  51. def _time_string(value: Any) -> str:
  52. if isinstance(value, datetime):
  53. return value.strftime("%Y-%m-%d %H:%M:%S")
  54. return str(value)
  55. def _parse_time(value: str | None) -> datetime | None:
  56. if not value:
  57. return None
  58. return datetime.fromisoformat(value.replace("Z", "+00:00").replace("T", " "))
  59. def _safe_float(value: Any) -> float | None:
  60. if value is None:
  61. return None
  62. number = float(value)
  63. return number if np.isfinite(number) else None
  64. def _annotation_dict(row: dict[str, Any]) -> dict[str, Any]:
  65. return {
  66. "id": int(row["id"]),
  67. "waveFileId": int(row["wave_file_id"]),
  68. "label": row["label"],
  69. "periodStart": int(row["period_start"]),
  70. "periodEnd": int(row["period_end"]),
  71. "sampleIndexStart": int(row["sample_index_start"]),
  72. "sampleIndexEnd": int(row["sample_index_end"]),
  73. }
  74. def _validate_measurement_types(values: list[str] | tuple[str, ...] | None) -> list[str]:
  75. selected = list(values or MEASUREMENT_TYPES)
  76. invalid = [value for value in selected if value not in MEASUREMENT_TYPES]
  77. if invalid:
  78. raise ValueError(f"不支持的数据名称:{'、'.join(invalid)}")
  79. return [value for value in MEASUREMENT_TYPES if value in selected]
  80. class DataService:
  81. # 数据库失败后的重试冷却时间(秒)。超过该时间后自动重连数据库,
  82. # 避免一次网络抖动就把服务永久锁死在演示数据模式。
  83. DB_RETRY_COOLDOWN = 30.0
  84. def __init__(self) -> None:
  85. self._db_failed = settings.demo_mode == "always"
  86. self._db_failed_at = monotonic() if self._db_failed else 0.0
  87. self._last_db_error = ""
  88. self._demo_annotations: dict[int, dict[str, Any]] = {}
  89. self._demo_annotation_seq = 1
  90. @property
  91. def source(self) -> str:
  92. return "demo" if self._db_failed else "database"
  93. @property
  94. def last_db_error(self) -> str:
  95. return self._last_db_error
  96. def _run_with_fallback(
  97. self,
  98. database_function: Callable[[], Any],
  99. demo_function: Callable[[], Any],
  100. ) -> tuple[Any, str]:
  101. if self._db_failed:
  102. if settings.demo_mode == "always":
  103. return demo_function(), "demo"
  104. if monotonic() - self._db_failed_at < self.DB_RETRY_COOLDOWN:
  105. return demo_function(), "demo"
  106. # 冷却结束,重新尝试数据库,数据库恢复后可自动切回真实数据。
  107. try:
  108. result = database_function()
  109. self._db_failed = False
  110. self._last_db_error = ""
  111. return result, "database"
  112. except Exception as error:
  113. if settings.demo_mode == "never":
  114. raise
  115. self._db_failed = True
  116. self._db_failed_at = monotonic()
  117. self._last_db_error = str(error)
  118. return demo_function(), "demo"
  119. def query_options(self) -> dict[str, Any]:
  120. def database_query():
  121. placeholders = ", ".join(["%s"] * len(MEASUREMENT_TYPES))
  122. with get_connection() as connection:
  123. with connection.cursor() as cursor:
  124. cursor.execute(
  125. f"""
  126. SELECT point_name, measurement_type,
  127. MAX(sample_time) AS max_time,
  128. MIN(sample_time) AS min_time,
  129. COUNT(*) AS file_count
  130. FROM wave_file
  131. WHERE measurement_type IN ({placeholders})
  132. GROUP BY point_name, measurement_type
  133. ORDER BY point_name, measurement_type
  134. """,
  135. MEASUREMENT_TYPES,
  136. )
  137. rows = cursor.fetchall()
  138. return [
  139. {
  140. "pointName": row["point_name"],
  141. "measurementType": row["measurement_type"],
  142. "minTime": _time_string(row["min_time"]),
  143. "maxTime": _time_string(row["max_time"]),
  144. "fileCount": int(row["file_count"]),
  145. }
  146. for row in rows
  147. ]
  148. rows, source = self._run_with_fallback(database_query, self._demo_options)
  149. point_names = list(dict.fromkeys(row["pointName"] for row in rows))
  150. return {
  151. "source": source,
  152. "measurementTypes": list(MEASUREMENT_TYPES),
  153. "pointNames": point_names,
  154. "options": rows,
  155. "notice": self._source_notice(source),
  156. }
  157. def abnormal_counts(self) -> dict[str, int]:
  158. """每个 point_name 的异常文件数量(tspluse_status > 0)。"""
  159. def database_query():
  160. with get_connection() as connection:
  161. with connection.cursor() as cursor:
  162. cursor.execute(
  163. """
  164. SELECT point_name, COUNT(*) AS cnt
  165. FROM wave_file
  166. WHERE tspluse_status > 0
  167. GROUP BY point_name
  168. """,
  169. )
  170. rows = cursor.fetchall()
  171. return {row["point_name"]: int(row["cnt"]) for row in rows}
  172. def demo_query():
  173. return {}
  174. result, _source = self._run_with_fallback(database_query, demo_query)
  175. return result
  176. def tspluse_ruler(self) -> dict[str, Any]:
  177. """压力部位 tspluse_status 的全局标尺,进入页面时只查询一次。"""
  178. def database_query():
  179. with get_connection() as connection:
  180. with connection.cursor() as cursor:
  181. cursor.execute(
  182. """
  183. SELECT MIN(tspluse_status) AS min_status,
  184. MAX(tspluse_status) AS max_status
  185. FROM wave_file
  186. WHERE rpm > 0 AND measurement_type = '压力'
  187. """,
  188. )
  189. row = cursor.fetchone()
  190. return {
  191. "min": int(row["min_status"]) if row and row["min_status"] is not None else 0,
  192. "max": int(row["max_status"]) if row and row["max_status"] is not None else 0,
  193. }
  194. def demo_query():
  195. return {"min": 0, "max": 0}
  196. result, source = self._run_with_fallback(database_query, demo_query)
  197. result["source"] = source
  198. return result
  199. def time_points(
  200. self,
  201. point_name: str,
  202. measurement_types: list[str] | None,
  203. min_time: str | None,
  204. max_time: str | None,
  205. include_stopped: bool = False,
  206. min_status: int | None = None,
  207. status_filter: list[str] | None = None,
  208. ) -> dict[str, Any]:
  209. if not point_name.strip():
  210. raise ValueError("机组与部位不能为空")
  211. types = _validate_measurement_types(measurement_types)
  212. status_filters = {value for value in (status_filter or [])}
  213. unknown = status_filters - {"abnormal", "no_cycle"}
  214. if unknown:
  215. raise ValueError(f"不支持的状态筛选:{'、'.join(sorted(unknown))}")
  216. start = _parse_time(min_time)
  217. end = _parse_time(max_time)
  218. if start and end and start > end:
  219. raise ValueError("开始时间不能晚于结束时间")
  220. def database_query():
  221. type_placeholders = ", ".join(["%s"] * len(types))
  222. clauses = [
  223. "point_name = %s",
  224. f"measurement_type IN ({type_placeholders})",
  225. ]
  226. params: list[Any] = [point_name, *types]
  227. if not include_stopped:
  228. clauses.append("rpm > 0")
  229. if min_status is not None and min_status > 0 and "no_cycle" not in status_filters:
  230. clauses.append("tspluse_status >= %s")
  231. params.append(min_status)
  232. if "abnormal" in status_filters and "no_cycle" in status_filters:
  233. clauses.append("(tspluse_status > 0 OR tspluse_status = -1)")
  234. elif "abnormal" in status_filters:
  235. clauses.append("tspluse_status > 0")
  236. elif "no_cycle" in status_filters:
  237. clauses.append("tspluse_status = -1")
  238. if start:
  239. clauses.append("sample_time >= %s")
  240. params.append(start)
  241. if end:
  242. clauses.append("sample_time <= %s")
  243. params.append(end)
  244. with get_connection() as connection:
  245. with connection.cursor() as cursor:
  246. cursor.execute(
  247. f"""
  248. SELECT id, point_name, measurement_type, sample_time,
  249. sample_count, sample_frequency_hz, rpm, tspluse_status
  250. FROM wave_file
  251. WHERE {' AND '.join(clauses)}
  252. ORDER BY sample_time ASC, id ASC
  253. """,
  254. params,
  255. )
  256. rows = cursor.fetchall()
  257. reference_rows: list[dict[str, Any]] = []
  258. if status_filters and rows:
  259. reference_where = [
  260. "point_name = %s",
  261. "measurement_type = '压力'",
  262. "tspluse_status = 0",
  263. ]
  264. reference_params: list[Any] = [point_name]
  265. if not include_stopped:
  266. reference_where.append("rpm > 0")
  267. if start:
  268. reference_where.append("sample_time >= %s")
  269. reference_params.append(start)
  270. if end:
  271. reference_where.append("sample_time <= %s")
  272. reference_params.append(end)
  273. reference_params.append(rows[0]["sample_time"])
  274. with get_connection() as connection:
  275. with connection.cursor() as cursor:
  276. cursor.execute(
  277. f"""
  278. SELECT id, point_name, measurement_type, sample_time,
  279. sample_count, sample_frequency_hz, rpm, tspluse_status
  280. FROM wave_file
  281. WHERE {' AND '.join(reference_where)}
  282. ORDER BY ABS(TIMESTAMPDIFF(SECOND, sample_time, %s)) ASC
  283. LIMIT 1
  284. """,
  285. reference_params,
  286. )
  287. reference_rows = cursor.fetchall()
  288. return self._group_time_points(rows), self._group_time_points(reference_rows)
  289. def demo_query():
  290. return self._demo_time_points(point_name, types, start, end), []
  291. result, source = self._run_with_fallback(database_query, demo_query)
  292. points, reference = result
  293. return {
  294. "source": source,
  295. "pointName": point_name,
  296. "measurementTypes": types,
  297. "total": len(points),
  298. "points": points,
  299. "referencePoints": reference,
  300. "notice": self._source_notice(source),
  301. }
  302. @staticmethod
  303. def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
  304. grouped: OrderedDict[Any, dict[str, Any]] = OrderedDict()
  305. for row in rows:
  306. key = row["sample_time"]
  307. point = grouped.setdefault(
  308. key,
  309. {
  310. "sampleTime": _time_string(key),
  311. "files": {},
  312. },
  313. )
  314. point["files"][row["measurement_type"]] = {
  315. "id": int(row["id"]),
  316. "sampleCount": int(row["sample_count"] or 0),
  317. "sampleFrequencyHz": int(row["sample_frequency_hz"] or 0),
  318. "rpm": float(row["rpm"] or 0),
  319. "status": int(row.get("tspluse_status") or 0),
  320. }
  321. return [
  322. {"index": index, **point}
  323. for index, point in enumerate(grouped.values())
  324. ]
  325. def wave_window(
  326. self,
  327. point_name: str,
  328. measurement_types: list[str],
  329. points: list[dict[str, Any]],
  330. max_points: int,
  331. no_sampling: bool = False,
  332. ) -> dict[str, Any]:
  333. types = _validate_measurement_types(measurement_types)
  334. if not points:
  335. raise ValueError("至少选择一个时间点")
  336. if len(points) > 200:
  337. raise ValueError("单次最多预览 200 个时间点,请缩小时间窗口")
  338. max_points = min(max(int(max_points), 256), 200000)
  339. def database_query():
  340. return self._build_wave_window(
  341. point_name,
  342. types,
  343. points,
  344. max_points,
  345. self._load_db_wave,
  346. no_sampling,
  347. )
  348. def demo_query():
  349. return self._build_wave_window(
  350. point_name,
  351. types,
  352. points,
  353. max_points,
  354. self._load_demo_wave,
  355. no_sampling,
  356. )
  357. result, source = self._run_with_fallback(database_query, demo_query)
  358. result["source"] = source
  359. result["notice"] = self._source_notice(source)
  360. return result
  361. def period_detail(self, wave_file_id: int, period_number: int) -> dict[str, Any]:
  362. if wave_file_id <= 0 or period_number <= 0:
  363. raise ValueError("wave_file_id 和周期编号必须为正整数")
  364. def database_query():
  365. metadata, samples = self._load_db_wave(wave_file_id)
  366. return self._build_period_detail(metadata, samples, period_number)
  367. def demo_query():
  368. metadata, samples = self._load_demo_wave(
  369. wave_file_id,
  370. "压力",
  371. DEMO_POINT_NAME,
  372. DEMO_START,
  373. )
  374. return self._build_period_detail(metadata, samples, period_number)
  375. result, source = self._run_with_fallback(database_query, demo_query)
  376. result["source"] = source
  377. result["notice"] = self._source_notice(source)
  378. return result
  379. def annotation_config(self) -> dict[str, Any]:
  380. return {
  381. "source": self.source,
  382. "annotationWidth": settings.annotation_width,
  383. "notice": self._source_notice(self.source),
  384. }
  385. def list_annotations(self, wave_file_ids: list[int]) -> dict[str, Any]:
  386. ids = sorted({int(value) for value in wave_file_ids if value})
  387. if not ids:
  388. return {
  389. "source": self.source,
  390. "annotations": [],
  391. "notice": self._source_notice(self.source),
  392. }
  393. def database_query():
  394. placeholders = ", ".join(["%s"] * len(ids))
  395. with get_connection() as connection:
  396. with connection.cursor() as cursor:
  397. cursor.execute(
  398. f"""
  399. SELECT id, wave_file_id, label, period_start, period_end,
  400. sample_index_start, sample_index_end
  401. FROM wave_annotation
  402. WHERE wave_file_id IN ({placeholders})
  403. ORDER BY id ASC
  404. """,
  405. ids,
  406. )
  407. rows = cursor.fetchall()
  408. return [_annotation_dict(row) for row in rows]
  409. def demo_query():
  410. return [
  411. _annotation_dict(annotation)
  412. for annotation in self._demo_annotations.values()
  413. if annotation["wave_file_id"] in ids
  414. ]
  415. annotations, source = self._run_with_fallback(database_query, demo_query)
  416. return {
  417. "source": source,
  418. "annotations": annotations,
  419. "notice": self._source_notice(source),
  420. }
  421. def create_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
  422. self._validate_annotation(payload)
  423. wave_file_id = int(payload["wave_file_id"])
  424. label = payload["label"]
  425. period_start = int(payload["period_start"])
  426. period_end = int(payload["period_end"])
  427. sample_index_start = int(payload["sample_index_start"])
  428. sample_index_end = int(payload["sample_index_end"])
  429. def database_query():
  430. with get_connection() as connection:
  431. with connection.cursor() as cursor:
  432. cursor.execute(
  433. """
  434. INSERT INTO wave_annotation
  435. (wave_file_id, label, period_start, period_end,
  436. sample_index_start, sample_index_end)
  437. VALUES (%s, %s, %s, %s, %s, %s)
  438. """,
  439. (
  440. wave_file_id,
  441. label,
  442. period_start,
  443. period_end,
  444. sample_index_start,
  445. sample_index_end,
  446. ),
  447. )
  448. annotation_id = cursor.lastrowid
  449. cursor.execute(
  450. """
  451. SELECT id, wave_file_id, label, period_start, period_end,
  452. sample_index_start, sample_index_end
  453. FROM wave_annotation
  454. WHERE id = %s
  455. """,
  456. (annotation_id,),
  457. )
  458. return _annotation_dict(cursor.fetchone())
  459. def demo_query():
  460. annotation_id = self._demo_annotation_seq
  461. self._demo_annotation_seq += 1
  462. annotation = {
  463. "id": annotation_id,
  464. "wave_file_id": wave_file_id,
  465. "label": label,
  466. "period_start": period_start,
  467. "period_end": period_end,
  468. "sample_index_start": sample_index_start,
  469. "sample_index_end": sample_index_end,
  470. }
  471. self._demo_annotations[annotation_id] = annotation
  472. return _annotation_dict(annotation)
  473. result, source = self._run_with_fallback(database_query, demo_query)
  474. result["source"] = source
  475. result["notice"] = self._source_notice(source)
  476. return result
  477. def delete_annotation(self, annotation_id: int) -> dict[str, Any]:
  478. if annotation_id <= 0:
  479. raise ValueError("标注 id 必须为正整数")
  480. def database_query():
  481. with get_connection() as connection:
  482. with connection.cursor() as cursor:
  483. cursor.execute(
  484. "DELETE FROM wave_annotation WHERE id = %s",
  485. (annotation_id,),
  486. )
  487. return int(cursor.rowcount)
  488. def demo_query():
  489. if annotation_id not in self._demo_annotations:
  490. return 0
  491. del self._demo_annotations[annotation_id]
  492. return 1
  493. deleted, source = self._run_with_fallback(database_query, demo_query)
  494. if not deleted:
  495. raise ValueError(f"标注 id={annotation_id} 不存在")
  496. return {
  497. "deleted": annotation_id,
  498. "source": source,
  499. "notice": self._source_notice(source),
  500. }
  501. @staticmethod
  502. def _validate_annotation(payload: dict[str, Any]) -> None:
  503. label = payload.get("label")
  504. if label not in ANNOTATION_LABELS:
  505. raise ValueError("样本类型只能是 正常 或 异常")
  506. for field in ("wave_file_id", "period_start", "period_end", "sample_index_start", "sample_index_end"):
  507. if payload.get(field) is None:
  508. raise ValueError(f"{field} 不能为空")
  509. if int(payload["wave_file_id"]) <= 0:
  510. raise ValueError("wave_file_id 必须为正整数")
  511. if int(payload["period_start"]) <= 0 or int(payload["period_end"]) <= 0:
  512. raise ValueError("周期编号必须为正整数")
  513. if int(payload["period_start"]) > int(payload["period_end"]):
  514. raise ValueError("起始周期不能大于结束周期")
  515. if int(payload["sample_index_start"]) < 0 or int(payload["sample_index_end"]) < 0:
  516. raise ValueError("采样点索引不能为负")
  517. if int(payload["sample_index_start"]) > int(payload["sample_index_end"]):
  518. raise ValueError("起始采样点不能大于结束采样点")
  519. def health(self) -> dict[str, Any]:
  520. return {
  521. "status": "ok",
  522. "source": self.source,
  523. "databaseError": self._last_db_error or None,
  524. }
  525. def _source_notice(self, source: str) -> str | None:
  526. if source == "demo":
  527. if self._last_db_error:
  528. return f"当前为演示数据:数据库暂不可用({self._last_db_error})"
  529. return "当前为演示数据:可设置 DEMO_MODE=never 强制使用数据库"
  530. return "已连接 MySQL 数据库"
  531. def _build_wave_window(
  532. self,
  533. point_name: str,
  534. types: list[str],
  535. points: list[dict[str, Any]],
  536. max_points: int,
  537. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  538. no_sampling: bool = False,
  539. ) -> dict[str, Any]:
  540. series_data: dict[str, list[dict[str, Any]]] = {measurement_type: [] for measurement_type in types}
  541. angle_data: list[dict[str, Any]] = []
  542. volume_data: list[dict[str, Any]] = []
  543. volume_info: dict[str, Any] | None = None
  544. cycles: list[dict[str, Any]] = []
  545. triggers: list[float] = []
  546. files: list[dict[str, Any]] = []
  547. diagnostics: list[dict[str, Any]] = []
  548. second_series_data: list[dict[str, Any]] = []
  549. second_finite_count = 0
  550. second_non_zero_count = 0
  551. second_min: float | None = None
  552. second_max: float | None = None
  553. source_type = "压力" if "压力" in types else types[0]
  554. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]] = {}
  555. for slot, point in enumerate(points):
  556. source_measurement_type = source_type
  557. source_file = point.get("files", {}).get(source_measurement_type)
  558. if not source_file:
  559. available = [
  560. (measurement_type, file_info)
  561. for measurement_type, file_info in point.get("files", {}).items()
  562. if measurement_type in types and file_info
  563. ]
  564. if available:
  565. source_measurement_type, source_file = available[0]
  566. else:
  567. continue
  568. source_id = int(source_file["id"] if isinstance(source_file, dict) else source_file)
  569. try:
  570. source_metadata, source_samples = self._load_for_window(
  571. loader,
  572. source_id,
  573. source_measurement_type,
  574. point_name,
  575. point["sampleTime"],
  576. load_cache,
  577. )
  578. except ValueError:
  579. # A selected file may legitimately have no samples. A database
  580. # connection error must escape and activate the demo fallback.
  581. continue
  582. detected, diagnostic = detect_cycles(source_samples)
  583. angle_vector = build_angle_vector(len(source_samples), detected)
  584. full_angle_vector = np.full(len(source_samples), np.nan, dtype=float)
  585. for detected_cycle in detected:
  586. full_angle_vector[detected_cycle.start_offset:detected_cycle.end_offset] = detected_cycle.angle
  587. current_volume, current_volume_info = self._build_volume_vector(
  588. len(source_samples),
  589. detected,
  590. point_name,
  591. )
  592. if current_volume_info is not None:
  593. volume_info = current_volume_info
  594. sample_indices = source_samples[:, 0].astype(np.int64)
  595. sample_span = max(len(source_samples), 1)
  596. finite_second = source_samples[:, 2][np.isfinite(source_samples[:, 2])]
  597. if len(finite_second):
  598. second_finite_count += int(len(finite_second))
  599. second_non_zero_count += int(np.count_nonzero(finite_second != 0))
  600. current_min = float(np.min(finite_second))
  601. current_max = float(np.max(finite_second))
  602. second_min = current_min if second_min is None else min(second_min, current_min)
  603. second_max = current_max if second_max is None else max(second_max, current_max)
  604. required = {0, len(source_samples) - 1}
  605. for cycle in detected:
  606. required.update(
  607. {
  608. cycle.start_offset,
  609. max(cycle.end_offset - 1, cycle.start_offset),
  610. *cycle.trigger_offsets,
  611. },
  612. )
  613. start_x = slot + cycle.start_offset / sample_span
  614. end_x = slot + cycle.end_offset / sample_span
  615. start_sample_index = int(sample_indices[cycle.start_offset])
  616. end_sample_index = int(sample_indices[max(cycle.end_offset - 1, cycle.start_offset)])
  617. cycles.append(
  618. {
  619. "id": f"{source_id}:{cycle.number}",
  620. "waveFileId": source_id,
  621. "periodNo": cycle.number,
  622. "pointIndex": slot,
  623. "sampleTime": point["sampleTime"],
  624. "startX": start_x,
  625. "endX": end_x,
  626. "startSampleIndex": start_sample_index,
  627. "endSampleIndex": end_sample_index,
  628. "sourceType": source_measurement_type,
  629. },
  630. )
  631. for trigger_offset in sorted(required):
  632. if trigger_offset in required and any(
  633. trigger_offset == run_offset
  634. for cycle in detected
  635. for run_offset in cycle.trigger_offsets
  636. ):
  637. triggers.append(slot + trigger_offset / sample_span)
  638. diagnostics.append(
  639. {
  640. "waveFileId": source_id,
  641. "measurementType": source_measurement_type,
  642. "sampleTime": point["sampleTime"],
  643. **diagnostic,
  644. },
  645. )
  646. for measurement_type in types:
  647. file_info = point.get("files", {}).get(measurement_type)
  648. if not file_info:
  649. continue
  650. file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
  651. if file_id == source_id:
  652. metadata, samples = source_metadata, source_samples
  653. else:
  654. try:
  655. metadata, samples = self._load_for_window(
  656. loader,
  657. file_id,
  658. measurement_type,
  659. point_name,
  660. point["sampleTime"],
  661. load_cache,
  662. )
  663. except ValueError:
  664. continue
  665. sample_count = len(samples)
  666. if not sample_count:
  667. continue
  668. sample_indices_for_file = samples[:, 0].astype(np.int64)
  669. file_base_index = int(sample_indices_for_file[0])
  670. file_span = max(sample_count, 1)
  671. file_required = {0, sample_count - 1}
  672. if file_id == source_id:
  673. file_required.update(required)
  674. if no_sampling:
  675. target_per_file = sample_count
  676. else:
  677. target_per_file = max(256, int(np.ceil(max_points / max(len(points), 1))))
  678. chosen = downsample_indices(samples[:, 1], target_per_file, file_required)
  679. for offset in chosen:
  680. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  681. second = _safe_float(samples[offset, 2])
  682. raw_value = float(samples[offset, 1])
  683. series_data[measurement_type].append(
  684. {
  685. "value": [x, raw_value],
  686. "x": x,
  687. "rawValue": raw_value,
  688. "sampleIndex": int(sample_indices_for_file[offset]),
  689. "waveFileId": file_id,
  690. "sampleTime": point["sampleTime"],
  691. "secondValue": second,
  692. "volume": (
  693. _safe_float(current_volume[offset])
  694. if file_id == source_id
  695. else None
  696. ),
  697. "angle360": (
  698. _safe_float(full_angle_vector[offset])
  699. if file_id == source_id
  700. else None
  701. ),
  702. },
  703. )
  704. if file_id == source_id:
  705. second_chosen = downsample_indices(samples[:, 2], target_per_file, file_required)
  706. for offset in second_chosen:
  707. second_value = _safe_float(samples[offset, 2])
  708. if second_value is None:
  709. continue
  710. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  711. second_series_data.append(
  712. {
  713. "value": [x, second_value],
  714. "x": x,
  715. "rawValue": second_value,
  716. "sampleIndex": int(sample_indices_for_file[offset]),
  717. "waveFileId": file_id,
  718. "sampleTime": point["sampleTime"],
  719. },
  720. )
  721. angle_chosen = downsample_indices(angle_vector, target_per_file, file_required)
  722. for offset in angle_chosen:
  723. value = _safe_float(angle_vector[offset])
  724. if value is None:
  725. continue
  726. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  727. angle_data.append(
  728. {
  729. "value": [x, value],
  730. "x": x,
  731. "angle": value,
  732. "sampleIndex": int(sample_indices_for_file[offset]),
  733. "waveFileId": file_id,
  734. "sampleTime": point["sampleTime"],
  735. },
  736. )
  737. volume_chosen = downsample_indices(current_volume, target_per_file, file_required)
  738. for offset in volume_chosen:
  739. value = _safe_float(current_volume[offset])
  740. if value is None:
  741. continue
  742. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  743. volume_data.append(
  744. {
  745. "value": [x, value],
  746. "x": x,
  747. "volume": value,
  748. "sampleIndex": int(sample_indices_for_file[offset]),
  749. "waveFileId": file_id,
  750. "sampleTime": point["sampleTime"],
  751. },
  752. )
  753. files.append(
  754. {
  755. "id": file_id,
  756. "pointIndex": slot,
  757. "sampleTime": point["sampleTime"],
  758. "measurementType": measurement_type,
  759. "sampleCount": int(metadata.get("sample_count") or sample_count),
  760. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  761. "pointName": str(metadata.get("point_name") or point_name),
  762. "rpm": float(metadata.get("rpm") or 0),
  763. "status": int(metadata.get("tspluse_status") or 0),
  764. "fileName": str(metadata.get("file_name") or ""),
  765. },
  766. )
  767. # Keep at most the current time point's raw arrays resident. The
  768. # response retains only downsampled values and period metadata.
  769. load_cache.clear()
  770. for measurement_type in series_data:
  771. series_data[measurement_type].sort(key=lambda item: item["x"])
  772. second_series_data.sort(key=lambda item: item["x"])
  773. angle_data.sort(key=lambda item: item["x"])
  774. volume_data.sort(key=lambda item: item["x"])
  775. return {
  776. "pointName": point_name,
  777. "measurementTypes": types,
  778. "points": points,
  779. "xMin": 0,
  780. "xMax": len(points),
  781. "series": [
  782. {
  783. "measurementType": measurement_type,
  784. "color": MEASUREMENT_COLORS[measurement_type],
  785. "data": series_data[measurement_type],
  786. }
  787. for measurement_type in types
  788. ],
  789. "secondSeries": {
  790. "name": "周期数据",
  791. "color": "#f56c6c",
  792. "sourceMeasurementType": source_type,
  793. "data": second_series_data,
  794. "finiteCount": second_finite_count,
  795. "nonZeroCount": second_non_zero_count,
  796. "min": second_min,
  797. "max": second_max,
  798. },
  799. "angleSeries": {
  800. "color": "#d59b2b",
  801. "data": angle_data,
  802. },
  803. "volumeSeries": {
  804. "color": "#4d9e6f",
  805. "data": volume_data,
  806. "info": volume_info,
  807. },
  808. "cycles": cycles,
  809. "triggerXs": sorted(set(triggers)),
  810. "files": files,
  811. "diagnostics": diagnostics,
  812. }
  813. @staticmethod
  814. def _build_volume_vector(
  815. sample_count: int,
  816. cycles: list[DetectedCycle],
  817. point_name: str,
  818. ) -> tuple[np.ndarray, dict[str, Any] | None]:
  819. volume = np.full(sample_count, np.nan, dtype=float)
  820. cylinder_name = next(
  821. (name for name in CYLINDER_BORE_MM if name in point_name),
  822. None,
  823. )
  824. if cylinder_name is None or not cycles:
  825. return volume, None
  826. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  827. clearance = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  828. crank_radius = PISTON_STROKE_MM / 2.0
  829. piston_area = np.pi * (bore_mm / 2.0) ** 2
  830. for cycle in cycles:
  831. angle_rad = np.deg2rad(cycle.angle)
  832. travel = (
  833. crank_radius * (1.0 - np.cos(angle_rad))
  834. + CONNECTING_ROD_LENGTH_MM
  835. - np.sqrt(
  836. CONNECTING_ROD_LENGTH_MM**2
  837. - (crank_radius * np.sin(angle_rad)) ** 2,
  838. )
  839. )
  840. volume[cycle.start_offset : cycle.end_offset] = (
  841. clearance + piston_area * travel / 1_000_000.0
  842. )
  843. finite = volume[np.isfinite(volume)]
  844. return volume, {
  845. "cylinder": cylinder_name,
  846. "boreMm": bore_mm,
  847. "clearanceVolumeL": clearance,
  848. "minVolumeL": float(np.min(finite)) if len(finite) else None,
  849. "maxVolumeL": float(np.max(finite)) if len(finite) else None,
  850. }
  851. @staticmethod
  852. def _build_period_detail(
  853. metadata: dict[str, Any],
  854. samples: np.ndarray,
  855. period_number: int,
  856. ) -> dict[str, Any]:
  857. detected, diagnostics = detect_cycles(samples)
  858. cycle = next((item for item in detected if item.number == period_number), None)
  859. if cycle is None:
  860. raise ValueError(f"没有找到周期 {period_number}")
  861. angles360 = np.linspace(0.0, 359.0, 360)
  862. pressure = np.interp(angles360, cycle.angle, cycle.signal)
  863. display_angles = np.where(angles360 <= 180.0, angles360, 360.0 - angles360)
  864. point_name = str(metadata.get("point_name") or "")
  865. cylinder_name = next(
  866. (name for name in CYLINDER_BORE_MM if name in point_name),
  867. None,
  868. )
  869. volume: np.ndarray | None = None
  870. volume_info: dict[str, Any] | None = None
  871. if cylinder_name:
  872. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  873. clearance_volume = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  874. angle_rad = np.deg2rad(angles360)
  875. crank_radius = PISTON_STROKE_MM / 2.0
  876. piston_travel = (
  877. crank_radius * (1.0 - np.cos(angle_rad))
  878. + CONNECTING_ROD_LENGTH_MM
  879. - np.sqrt(
  880. CONNECTING_ROD_LENGTH_MM**2
  881. - (crank_radius * np.sin(angle_rad)) ** 2,
  882. )
  883. )
  884. piston_area = np.pi * (bore_mm / 2.0) ** 2
  885. volume = clearance_volume + piston_area * piston_travel / 1_000_000.0
  886. volume_info = {
  887. "cylinder": cylinder_name,
  888. "boreMm": bore_mm,
  889. "clearanceVolumeL": clearance_volume,
  890. "minVolumeL": float(np.min(volume)),
  891. "maxVolumeL": float(np.max(volume)),
  892. }
  893. start_index = int(samples[cycle.start_offset, 0])
  894. end_offset = min(cycle.end_offset, len(samples) - 1)
  895. end_index = int(samples[max(cycle.end_offset - 1, cycle.start_offset), 0])
  896. return {
  897. "waveFile": {
  898. "id": int(metadata["id"]),
  899. "pointName": point_name,
  900. "measurementType": metadata.get("measurement_type"),
  901. "sampleTime": _time_string(metadata.get("sample_time")),
  902. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  903. "sampleCount": int(metadata.get("sample_count") or len(samples)),
  904. },
  905. "period": {
  906. "periodNo": cycle.number,
  907. "startSampleIndex": start_index,
  908. "endSampleIndex": end_index,
  909. "sampleCount": int(cycle.end_offset - cycle.start_offset),
  910. "triggerSampleIndices": [
  911. int(samples[offset, 0])
  912. for offset in cycle.trigger_offsets
  913. if 0 <= offset < len(samples)
  914. ],
  915. },
  916. "angles": display_angles.tolist(),
  917. "angles360": angles360.tolist(),
  918. "pressure": pressure.tolist(),
  919. "volume": volume.tolist() if volume is not None else None,
  920. "volumeInfo": volume_info,
  921. "phases": [
  922. {
  923. "name": name,
  924. "color": color,
  925. "start": start,
  926. "end": end,
  927. }
  928. for name, color, start, end in PHASES
  929. ],
  930. "diagnostics": diagnostics,
  931. }
  932. @staticmethod
  933. def _load_for_window(
  934. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  935. file_id: int,
  936. measurement_type: str,
  937. point_name: str,
  938. sample_time: str,
  939. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]],
  940. ) -> tuple[dict[str, Any], np.ndarray]:
  941. if file_id not in load_cache:
  942. if loader.__name__ == "_load_demo_wave":
  943. load_cache[file_id] = loader(file_id, measurement_type, point_name, sample_time)
  944. else:
  945. load_cache[file_id] = loader(file_id)
  946. return load_cache[file_id]
  947. def _load_db_wave(self, file_id: int) -> tuple[dict[str, Any], np.ndarray]:
  948. with get_connection() as connection:
  949. with connection.cursor() as cursor:
  950. cursor.execute(
  951. """
  952. SELECT id, point_name, measurement_type, sample_frequency_hz,
  953. sample_count, sample_time, rpm, file_name, tspluse_status
  954. FROM wave_file
  955. WHERE id = %s
  956. """,
  957. (file_id,),
  958. )
  959. metadata = cursor.fetchone()
  960. if metadata is None:
  961. raise ValueError(f"wave_file.id={file_id} 不存在")
  962. cursor.execute(
  963. """
  964. SELECT sample_index, signal_value, second_value
  965. FROM wave_sample
  966. WHERE wave_file_id = %s
  967. ORDER BY sample_index ASC
  968. """,
  969. (file_id,),
  970. )
  971. rows = cursor.fetchall()
  972. if not rows:
  973. raise ValueError(f"wave_file.id={file_id} 没有采样数据")
  974. samples = np.asarray(
  975. [
  976. (
  977. float(row["sample_index"]),
  978. float(row["signal_value"]),
  979. float(row["second_value"]) if row["second_value"] is not None else np.nan,
  980. )
  981. for row in rows
  982. ],
  983. dtype=float,
  984. )
  985. return metadata, samples
  986. @staticmethod
  987. @lru_cache(maxsize=24)
  988. def _demo_samples(file_id: int, measurement_type: str) -> np.ndarray:
  989. count = DEMO_SAMPLE_COUNT
  990. index = np.arange(count, dtype=float)
  991. revolution = DEMO_REVOLUTION_SAMPLES
  992. phase = (index % revolution) / revolution * 2 * np.pi
  993. second = np.zeros(count, dtype=float)
  994. for revolution_start in range(0, count, revolution):
  995. for pulse in range(PULSES_PER_REVOLUTION):
  996. pulse_start = revolution_start + int(round(pulse * revolution / PULSES_PER_REVOLUTION))
  997. width = 22 if pulse == 0 else 8
  998. pulse_end = min(count, pulse_start + width)
  999. second[pulse_start:pulse_end] = 40.0
  1000. variation = (file_id % 17) / 17.0
  1001. if measurement_type == "压力":
  1002. signal = (
  1003. 4.2
  1004. + 1.8 * np.sin(phase - 0.4)
  1005. + 0.55 * np.sin(2 * phase + variation)
  1006. + 0.22 * np.sin(7 * phase)
  1007. )
  1008. signal += 0.2 * np.maximum(np.sin(phase - 0.2), 0) ** 5
  1009. elif measurement_type == "位移":
  1010. signal = 0.5 + 0.18 * np.cos(phase) + 0.035 * np.sin(3 * phase + variation)
  1011. else:
  1012. signal = 0.15 * np.sin(phase * 2 + variation) + 0.04 * np.sin(11 * phase)
  1013. signal += 0.018 * np.cos(index / 37.0)
  1014. return np.column_stack((index, signal, second))
  1015. def _load_demo_wave(
  1016. self,
  1017. file_id: int,
  1018. measurement_type: str,
  1019. point_name: str,
  1020. sample_time: str,
  1021. ) -> tuple[dict[str, Any], np.ndarray]:
  1022. samples = self._demo_samples(file_id, measurement_type)
  1023. metadata = {
  1024. "id": file_id,
  1025. "point_name": point_name,
  1026. "measurement_type": measurement_type,
  1027. "sample_frequency_hz": 25600,
  1028. "sample_count": len(samples),
  1029. "sample_time": sample_time,
  1030. "rpm": 998.0,
  1031. "file_name": f"demo-{file_id}.dat",
  1032. }
  1033. return metadata, samples
  1034. @staticmethod
  1035. def _demo_options() -> list[dict[str, Any]]:
  1036. end = DEMO_START + timedelta(minutes=5 * (DEMO_POINT_COUNT - 1))
  1037. return [
  1038. {
  1039. "pointName": DEMO_POINT_NAME,
  1040. "measurementType": measurement_type,
  1041. "minTime": _time_string(DEMO_START),
  1042. "maxTime": _time_string(end),
  1043. "fileCount": DEMO_POINT_COUNT,
  1044. }
  1045. for measurement_type in MEASUREMENT_TYPES
  1046. ]
  1047. @staticmethod
  1048. def _demo_time_points(
  1049. point_name: str,
  1050. types: list[str],
  1051. start: datetime | None,
  1052. end: datetime | None,
  1053. ) -> list[dict[str, Any]]:
  1054. points = []
  1055. for index in range(DEMO_POINT_COUNT):
  1056. timestamp = DEMO_START + timedelta(minutes=5 * index)
  1057. if start and timestamp < start:
  1058. continue
  1059. if end and timestamp > end:
  1060. continue
  1061. files = {
  1062. measurement_type: {
  1063. "id": DEMO_ID_BY_TYPE[measurement_type] + index,
  1064. "sampleCount": DEMO_SAMPLE_COUNT,
  1065. "sampleFrequencyHz": 25600,
  1066. "rpm": 998.0,
  1067. }
  1068. for measurement_type in types
  1069. }
  1070. points.append(
  1071. {
  1072. "index": len(points),
  1073. "sampleTime": _time_string(timestamp),
  1074. "files": files,
  1075. },
  1076. )
  1077. return points
  1078. data_service = DataService()