data_service.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092
  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 tspluse_ruler(self) -> dict[str, Any]:
  158. """压力部位 tspluse_status 的全局标尺,进入页面时只查询一次。"""
  159. def database_query():
  160. with get_connection() as connection:
  161. with connection.cursor() as cursor:
  162. cursor.execute(
  163. """
  164. SELECT MIN(tspluse_status) AS min_status,
  165. MAX(tspluse_status) AS max_status
  166. FROM wave_file
  167. WHERE rpm > 0 AND measurement_type = '压力'
  168. """,
  169. )
  170. row = cursor.fetchone()
  171. return {
  172. "min": int(row["min_status"]) if row and row["min_status"] is not None else 0,
  173. "max": int(row["max_status"]) if row and row["max_status"] is not None else 0,
  174. }
  175. def demo_query():
  176. return {"min": 0, "max": 0}
  177. result, source = self._run_with_fallback(database_query, demo_query)
  178. result["source"] = source
  179. return result
  180. def time_points(
  181. self,
  182. point_name: str,
  183. measurement_types: list[str] | None,
  184. min_time: str | None,
  185. max_time: str | None,
  186. include_stopped: bool = False,
  187. min_status: int | None = None,
  188. ) -> dict[str, Any]:
  189. if not point_name.strip():
  190. raise ValueError("机组与部位不能为空")
  191. types = _validate_measurement_types(measurement_types)
  192. start = _parse_time(min_time)
  193. end = _parse_time(max_time)
  194. if start and end and start > end:
  195. raise ValueError("开始时间不能晚于结束时间")
  196. def database_query():
  197. type_placeholders = ", ".join(["%s"] * len(types))
  198. clauses = [
  199. "point_name = %s",
  200. f"measurement_type IN ({type_placeholders})",
  201. ]
  202. params: list[Any] = [point_name, *types]
  203. if not include_stopped:
  204. clauses.append("rpm > 0")
  205. if min_status is not None and min_status > 0:
  206. clauses.append("tspluse_status >= %s")
  207. params.append(min_status)
  208. if start:
  209. clauses.append("sample_time >= %s")
  210. params.append(start)
  211. if end:
  212. clauses.append("sample_time <= %s")
  213. params.append(end)
  214. with get_connection() as connection:
  215. with connection.cursor() as cursor:
  216. cursor.execute(
  217. f"""
  218. SELECT id, point_name, measurement_type, sample_time,
  219. sample_count, sample_frequency_hz, rpm, tspluse_status
  220. FROM wave_file
  221. WHERE {' AND '.join(clauses)}
  222. ORDER BY sample_time ASC, id ASC
  223. """,
  224. params,
  225. )
  226. rows = cursor.fetchall()
  227. return self._group_time_points(rows)
  228. def demo_query():
  229. return self._demo_time_points(point_name, types, start, end)
  230. points, source = self._run_with_fallback(database_query, demo_query)
  231. return {
  232. "source": source,
  233. "pointName": point_name,
  234. "measurementTypes": types,
  235. "total": len(points),
  236. "points": points,
  237. "notice": self._source_notice(source),
  238. }
  239. @staticmethod
  240. def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
  241. grouped: OrderedDict[Any, dict[str, Any]] = OrderedDict()
  242. for row in rows:
  243. key = row["sample_time"]
  244. point = grouped.setdefault(
  245. key,
  246. {
  247. "sampleTime": _time_string(key),
  248. "files": {},
  249. },
  250. )
  251. point["files"][row["measurement_type"]] = {
  252. "id": int(row["id"]),
  253. "sampleCount": int(row["sample_count"] or 0),
  254. "sampleFrequencyHz": int(row["sample_frequency_hz"] or 0),
  255. "rpm": float(row["rpm"] or 0),
  256. "status": int(row.get("tspluse_status") or 0),
  257. }
  258. return [
  259. {"index": index, **point}
  260. for index, point in enumerate(grouped.values())
  261. ]
  262. def wave_window(
  263. self,
  264. point_name: str,
  265. measurement_types: list[str],
  266. points: list[dict[str, Any]],
  267. max_points: int,
  268. no_sampling: bool = False,
  269. ) -> dict[str, Any]:
  270. types = _validate_measurement_types(measurement_types)
  271. if not points:
  272. raise ValueError("至少选择一个时间点")
  273. if len(points) > 200:
  274. raise ValueError("单次最多预览 200 个时间点,请缩小时间窗口")
  275. max_points = min(max(int(max_points), 256), 200000)
  276. def database_query():
  277. return self._build_wave_window(
  278. point_name,
  279. types,
  280. points,
  281. max_points,
  282. self._load_db_wave,
  283. no_sampling,
  284. )
  285. def demo_query():
  286. return self._build_wave_window(
  287. point_name,
  288. types,
  289. points,
  290. max_points,
  291. self._load_demo_wave,
  292. no_sampling,
  293. )
  294. result, source = self._run_with_fallback(database_query, demo_query)
  295. result["source"] = source
  296. result["notice"] = self._source_notice(source)
  297. return result
  298. def period_detail(self, wave_file_id: int, period_number: int) -> dict[str, Any]:
  299. if wave_file_id <= 0 or period_number <= 0:
  300. raise ValueError("wave_file_id 和周期编号必须为正整数")
  301. def database_query():
  302. metadata, samples = self._load_db_wave(wave_file_id)
  303. return self._build_period_detail(metadata, samples, period_number)
  304. def demo_query():
  305. metadata, samples = self._load_demo_wave(
  306. wave_file_id,
  307. "压力",
  308. DEMO_POINT_NAME,
  309. DEMO_START,
  310. )
  311. return self._build_period_detail(metadata, samples, period_number)
  312. result, source = self._run_with_fallback(database_query, demo_query)
  313. result["source"] = source
  314. result["notice"] = self._source_notice(source)
  315. return result
  316. def annotation_config(self) -> dict[str, Any]:
  317. return {
  318. "source": self.source,
  319. "annotationWidth": settings.annotation_width,
  320. "notice": self._source_notice(self.source),
  321. }
  322. def list_annotations(self, wave_file_ids: list[int]) -> dict[str, Any]:
  323. ids = sorted({int(value) for value in wave_file_ids if value})
  324. if not ids:
  325. return {
  326. "source": self.source,
  327. "annotations": [],
  328. "notice": self._source_notice(self.source),
  329. }
  330. def database_query():
  331. placeholders = ", ".join(["%s"] * len(ids))
  332. with get_connection() as connection:
  333. with connection.cursor() as cursor:
  334. cursor.execute(
  335. f"""
  336. SELECT id, wave_file_id, label, period_start, period_end,
  337. sample_index_start, sample_index_end
  338. FROM wave_annotation
  339. WHERE wave_file_id IN ({placeholders})
  340. ORDER BY id ASC
  341. """,
  342. ids,
  343. )
  344. rows = cursor.fetchall()
  345. return [_annotation_dict(row) for row in rows]
  346. def demo_query():
  347. return [
  348. _annotation_dict(annotation)
  349. for annotation in self._demo_annotations.values()
  350. if annotation["wave_file_id"] in ids
  351. ]
  352. annotations, source = self._run_with_fallback(database_query, demo_query)
  353. return {
  354. "source": source,
  355. "annotations": annotations,
  356. "notice": self._source_notice(source),
  357. }
  358. def create_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
  359. self._validate_annotation(payload)
  360. wave_file_id = int(payload["wave_file_id"])
  361. label = payload["label"]
  362. period_start = int(payload["period_start"])
  363. period_end = int(payload["period_end"])
  364. sample_index_start = int(payload["sample_index_start"])
  365. sample_index_end = int(payload["sample_index_end"])
  366. def database_query():
  367. with get_connection() as connection:
  368. with connection.cursor() as cursor:
  369. cursor.execute(
  370. """
  371. INSERT INTO wave_annotation
  372. (wave_file_id, label, period_start, period_end,
  373. sample_index_start, sample_index_end)
  374. VALUES (%s, %s, %s, %s, %s, %s)
  375. """,
  376. (
  377. wave_file_id,
  378. label,
  379. period_start,
  380. period_end,
  381. sample_index_start,
  382. sample_index_end,
  383. ),
  384. )
  385. annotation_id = cursor.lastrowid
  386. cursor.execute(
  387. """
  388. SELECT id, wave_file_id, label, period_start, period_end,
  389. sample_index_start, sample_index_end
  390. FROM wave_annotation
  391. WHERE id = %s
  392. """,
  393. (annotation_id,),
  394. )
  395. return _annotation_dict(cursor.fetchone())
  396. def demo_query():
  397. annotation_id = self._demo_annotation_seq
  398. self._demo_annotation_seq += 1
  399. annotation = {
  400. "id": annotation_id,
  401. "wave_file_id": wave_file_id,
  402. "label": label,
  403. "period_start": period_start,
  404. "period_end": period_end,
  405. "sample_index_start": sample_index_start,
  406. "sample_index_end": sample_index_end,
  407. }
  408. self._demo_annotations[annotation_id] = annotation
  409. return _annotation_dict(annotation)
  410. result, source = self._run_with_fallback(database_query, demo_query)
  411. result["source"] = source
  412. result["notice"] = self._source_notice(source)
  413. return result
  414. def delete_annotation(self, annotation_id: int) -> dict[str, Any]:
  415. if annotation_id <= 0:
  416. raise ValueError("标注 id 必须为正整数")
  417. def database_query():
  418. with get_connection() as connection:
  419. with connection.cursor() as cursor:
  420. cursor.execute(
  421. "DELETE FROM wave_annotation WHERE id = %s",
  422. (annotation_id,),
  423. )
  424. return int(cursor.rowcount)
  425. def demo_query():
  426. if annotation_id not in self._demo_annotations:
  427. return 0
  428. del self._demo_annotations[annotation_id]
  429. return 1
  430. deleted, source = self._run_with_fallback(database_query, demo_query)
  431. if not deleted:
  432. raise ValueError(f"标注 id={annotation_id} 不存在")
  433. return {
  434. "deleted": annotation_id,
  435. "source": source,
  436. "notice": self._source_notice(source),
  437. }
  438. @staticmethod
  439. def _validate_annotation(payload: dict[str, Any]) -> None:
  440. label = payload.get("label")
  441. if label not in ANNOTATION_LABELS:
  442. raise ValueError("样本类型只能是 正常 或 异常")
  443. for field in ("wave_file_id", "period_start", "period_end", "sample_index_start", "sample_index_end"):
  444. if payload.get(field) is None:
  445. raise ValueError(f"{field} 不能为空")
  446. if int(payload["wave_file_id"]) <= 0:
  447. raise ValueError("wave_file_id 必须为正整数")
  448. if int(payload["period_start"]) <= 0 or int(payload["period_end"]) <= 0:
  449. raise ValueError("周期编号必须为正整数")
  450. if int(payload["period_start"]) > int(payload["period_end"]):
  451. raise ValueError("起始周期不能大于结束周期")
  452. if int(payload["sample_index_start"]) < 0 or int(payload["sample_index_end"]) < 0:
  453. raise ValueError("采样点索引不能为负")
  454. if int(payload["sample_index_start"]) > int(payload["sample_index_end"]):
  455. raise ValueError("起始采样点不能大于结束采样点")
  456. def health(self) -> dict[str, Any]:
  457. return {
  458. "status": "ok",
  459. "source": self.source,
  460. "databaseError": self._last_db_error or None,
  461. }
  462. def _source_notice(self, source: str) -> str | None:
  463. if source == "demo":
  464. if self._last_db_error:
  465. return f"当前为演示数据:数据库暂不可用({self._last_db_error})"
  466. return "当前为演示数据:可设置 DEMO_MODE=never 强制使用数据库"
  467. return "已连接 MySQL 数据库"
  468. def _build_wave_window(
  469. self,
  470. point_name: str,
  471. types: list[str],
  472. points: list[dict[str, Any]],
  473. max_points: int,
  474. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  475. no_sampling: bool = False,
  476. ) -> dict[str, Any]:
  477. series_data: dict[str, list[dict[str, Any]]] = {measurement_type: [] for measurement_type in types}
  478. angle_data: list[dict[str, Any]] = []
  479. volume_data: list[dict[str, Any]] = []
  480. volume_info: dict[str, Any] | None = None
  481. cycles: list[dict[str, Any]] = []
  482. triggers: list[float] = []
  483. files: list[dict[str, Any]] = []
  484. diagnostics: list[dict[str, Any]] = []
  485. second_series_data: list[dict[str, Any]] = []
  486. second_finite_count = 0
  487. second_non_zero_count = 0
  488. second_min: float | None = None
  489. second_max: float | None = None
  490. source_type = "压力" if "压力" in types else types[0]
  491. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]] = {}
  492. for slot, point in enumerate(points):
  493. source_measurement_type = source_type
  494. source_file = point.get("files", {}).get(source_measurement_type)
  495. if not source_file:
  496. available = [
  497. (measurement_type, file_info)
  498. for measurement_type, file_info in point.get("files", {}).items()
  499. if measurement_type in types and file_info
  500. ]
  501. if available:
  502. source_measurement_type, source_file = available[0]
  503. else:
  504. continue
  505. source_id = int(source_file["id"] if isinstance(source_file, dict) else source_file)
  506. try:
  507. source_metadata, source_samples = self._load_for_window(
  508. loader,
  509. source_id,
  510. source_measurement_type,
  511. point_name,
  512. point["sampleTime"],
  513. load_cache,
  514. )
  515. except ValueError:
  516. # A selected file may legitimately have no samples. A database
  517. # connection error must escape and activate the demo fallback.
  518. continue
  519. detected, diagnostic = detect_cycles(source_samples)
  520. angle_vector = build_angle_vector(len(source_samples), detected)
  521. full_angle_vector = np.full(len(source_samples), np.nan, dtype=float)
  522. for detected_cycle in detected:
  523. full_angle_vector[detected_cycle.start_offset:detected_cycle.end_offset] = detected_cycle.angle
  524. current_volume, current_volume_info = self._build_volume_vector(
  525. len(source_samples),
  526. detected,
  527. point_name,
  528. )
  529. if current_volume_info is not None:
  530. volume_info = current_volume_info
  531. sample_indices = source_samples[:, 0].astype(np.int64)
  532. sample_span = max(len(source_samples), 1)
  533. finite_second = source_samples[:, 2][np.isfinite(source_samples[:, 2])]
  534. if len(finite_second):
  535. second_finite_count += int(len(finite_second))
  536. second_non_zero_count += int(np.count_nonzero(finite_second != 0))
  537. current_min = float(np.min(finite_second))
  538. current_max = float(np.max(finite_second))
  539. second_min = current_min if second_min is None else min(second_min, current_min)
  540. second_max = current_max if second_max is None else max(second_max, current_max)
  541. required = {0, len(source_samples) - 1}
  542. for cycle in detected:
  543. required.update(
  544. {
  545. cycle.start_offset,
  546. max(cycle.end_offset - 1, cycle.start_offset),
  547. *cycle.trigger_offsets,
  548. },
  549. )
  550. start_x = slot + cycle.start_offset / sample_span
  551. end_x = slot + cycle.end_offset / sample_span
  552. start_sample_index = int(sample_indices[cycle.start_offset])
  553. end_sample_index = int(sample_indices[max(cycle.end_offset - 1, cycle.start_offset)])
  554. cycles.append(
  555. {
  556. "id": f"{source_id}:{cycle.number}",
  557. "waveFileId": source_id,
  558. "periodNo": cycle.number,
  559. "pointIndex": slot,
  560. "sampleTime": point["sampleTime"],
  561. "startX": start_x,
  562. "endX": end_x,
  563. "startSampleIndex": start_sample_index,
  564. "endSampleIndex": end_sample_index,
  565. "sourceType": source_measurement_type,
  566. },
  567. )
  568. for trigger_offset in sorted(required):
  569. if trigger_offset in required and any(
  570. trigger_offset == run_offset
  571. for cycle in detected
  572. for run_offset in cycle.trigger_offsets
  573. ):
  574. triggers.append(slot + trigger_offset / sample_span)
  575. diagnostics.append(
  576. {
  577. "waveFileId": source_id,
  578. "measurementType": source_measurement_type,
  579. "sampleTime": point["sampleTime"],
  580. **diagnostic,
  581. },
  582. )
  583. for measurement_type in types:
  584. file_info = point.get("files", {}).get(measurement_type)
  585. if not file_info:
  586. continue
  587. file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
  588. if file_id == source_id:
  589. metadata, samples = source_metadata, source_samples
  590. else:
  591. try:
  592. metadata, samples = self._load_for_window(
  593. loader,
  594. file_id,
  595. measurement_type,
  596. point_name,
  597. point["sampleTime"],
  598. load_cache,
  599. )
  600. except ValueError:
  601. continue
  602. sample_count = len(samples)
  603. if not sample_count:
  604. continue
  605. sample_indices_for_file = samples[:, 0].astype(np.int64)
  606. file_base_index = int(sample_indices_for_file[0])
  607. file_span = max(sample_count, 1)
  608. file_required = {0, sample_count - 1}
  609. if file_id == source_id:
  610. file_required.update(required)
  611. if no_sampling:
  612. target_per_file = sample_count
  613. else:
  614. target_per_file = max(256, int(np.ceil(max_points / max(len(points), 1))))
  615. chosen = downsample_indices(samples[:, 1], target_per_file, file_required)
  616. for offset in chosen:
  617. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  618. second = _safe_float(samples[offset, 2])
  619. raw_value = float(samples[offset, 1])
  620. series_data[measurement_type].append(
  621. {
  622. "value": [x, raw_value],
  623. "x": x,
  624. "rawValue": raw_value,
  625. "sampleIndex": int(sample_indices_for_file[offset]),
  626. "waveFileId": file_id,
  627. "sampleTime": point["sampleTime"],
  628. "secondValue": second,
  629. "volume": (
  630. _safe_float(current_volume[offset])
  631. if file_id == source_id
  632. else None
  633. ),
  634. "angle360": (
  635. _safe_float(full_angle_vector[offset])
  636. if file_id == source_id
  637. else None
  638. ),
  639. },
  640. )
  641. if file_id == source_id:
  642. second_chosen = downsample_indices(samples[:, 2], target_per_file, file_required)
  643. for offset in second_chosen:
  644. second_value = _safe_float(samples[offset, 2])
  645. if second_value is None:
  646. continue
  647. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  648. second_series_data.append(
  649. {
  650. "value": [x, second_value],
  651. "x": x,
  652. "rawValue": second_value,
  653. "sampleIndex": int(sample_indices_for_file[offset]),
  654. "waveFileId": file_id,
  655. "sampleTime": point["sampleTime"],
  656. },
  657. )
  658. angle_chosen = downsample_indices(angle_vector, target_per_file, file_required)
  659. for offset in angle_chosen:
  660. value = _safe_float(angle_vector[offset])
  661. if value is None:
  662. continue
  663. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  664. angle_data.append(
  665. {
  666. "value": [x, value],
  667. "x": x,
  668. "angle": value,
  669. "sampleIndex": int(sample_indices_for_file[offset]),
  670. "waveFileId": file_id,
  671. "sampleTime": point["sampleTime"],
  672. },
  673. )
  674. volume_chosen = downsample_indices(current_volume, target_per_file, file_required)
  675. for offset in volume_chosen:
  676. value = _safe_float(current_volume[offset])
  677. if value is None:
  678. continue
  679. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  680. volume_data.append(
  681. {
  682. "value": [x, value],
  683. "x": x,
  684. "volume": value,
  685. "sampleIndex": int(sample_indices_for_file[offset]),
  686. "waveFileId": file_id,
  687. "sampleTime": point["sampleTime"],
  688. },
  689. )
  690. files.append(
  691. {
  692. "id": file_id,
  693. "pointIndex": slot,
  694. "sampleTime": point["sampleTime"],
  695. "measurementType": measurement_type,
  696. "sampleCount": int(metadata.get("sample_count") or sample_count),
  697. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  698. "pointName": str(metadata.get("point_name") or point_name),
  699. "rpm": float(metadata.get("rpm") or 0),
  700. "status": int(metadata.get("tspluse_status") or 0),
  701. "fileName": str(metadata.get("file_name") or ""),
  702. },
  703. )
  704. # Keep at most the current time point's raw arrays resident. The
  705. # response retains only downsampled values and period metadata.
  706. load_cache.clear()
  707. for measurement_type in series_data:
  708. series_data[measurement_type].sort(key=lambda item: item["x"])
  709. second_series_data.sort(key=lambda item: item["x"])
  710. angle_data.sort(key=lambda item: item["x"])
  711. volume_data.sort(key=lambda item: item["x"])
  712. return {
  713. "pointName": point_name,
  714. "measurementTypes": types,
  715. "points": points,
  716. "xMin": 0,
  717. "xMax": len(points),
  718. "series": [
  719. {
  720. "measurementType": measurement_type,
  721. "color": MEASUREMENT_COLORS[measurement_type],
  722. "data": series_data[measurement_type],
  723. }
  724. for measurement_type in types
  725. ],
  726. "secondSeries": {
  727. "name": "周期数据",
  728. "color": "#f56c6c",
  729. "sourceMeasurementType": source_type,
  730. "data": second_series_data,
  731. "finiteCount": second_finite_count,
  732. "nonZeroCount": second_non_zero_count,
  733. "min": second_min,
  734. "max": second_max,
  735. },
  736. "angleSeries": {
  737. "color": "#d59b2b",
  738. "data": angle_data,
  739. },
  740. "volumeSeries": {
  741. "color": "#4d9e6f",
  742. "data": volume_data,
  743. "info": volume_info,
  744. },
  745. "cycles": cycles,
  746. "triggerXs": sorted(set(triggers)),
  747. "files": files,
  748. "diagnostics": diagnostics,
  749. }
  750. @staticmethod
  751. def _build_volume_vector(
  752. sample_count: int,
  753. cycles: list[DetectedCycle],
  754. point_name: str,
  755. ) -> tuple[np.ndarray, dict[str, Any] | None]:
  756. volume = np.full(sample_count, np.nan, dtype=float)
  757. cylinder_name = next(
  758. (name for name in CYLINDER_BORE_MM if name in point_name),
  759. None,
  760. )
  761. if cylinder_name is None or not cycles:
  762. return volume, None
  763. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  764. clearance = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  765. crank_radius = PISTON_STROKE_MM / 2.0
  766. piston_area = np.pi * (bore_mm / 2.0) ** 2
  767. for cycle in cycles:
  768. angle_rad = np.deg2rad(cycle.angle)
  769. travel = (
  770. crank_radius * (1.0 - np.cos(angle_rad))
  771. + CONNECTING_ROD_LENGTH_MM
  772. - np.sqrt(
  773. CONNECTING_ROD_LENGTH_MM**2
  774. - (crank_radius * np.sin(angle_rad)) ** 2,
  775. )
  776. )
  777. volume[cycle.start_offset : cycle.end_offset] = (
  778. clearance + piston_area * travel / 1_000_000.0
  779. )
  780. finite = volume[np.isfinite(volume)]
  781. return volume, {
  782. "cylinder": cylinder_name,
  783. "boreMm": bore_mm,
  784. "clearanceVolumeL": clearance,
  785. "minVolumeL": float(np.min(finite)) if len(finite) else None,
  786. "maxVolumeL": float(np.max(finite)) if len(finite) else None,
  787. }
  788. @staticmethod
  789. def _build_period_detail(
  790. metadata: dict[str, Any],
  791. samples: np.ndarray,
  792. period_number: int,
  793. ) -> dict[str, Any]:
  794. detected, diagnostics = detect_cycles(samples)
  795. cycle = next((item for item in detected if item.number == period_number), None)
  796. if cycle is None:
  797. raise ValueError(f"没有找到周期 {period_number}")
  798. angles360 = np.linspace(0.0, 359.0, 360)
  799. pressure = np.interp(angles360, cycle.angle, cycle.signal)
  800. display_angles = np.where(angles360 <= 180.0, angles360, 360.0 - angles360)
  801. point_name = str(metadata.get("point_name") or "")
  802. cylinder_name = next(
  803. (name for name in CYLINDER_BORE_MM if name in point_name),
  804. None,
  805. )
  806. volume: np.ndarray | None = None
  807. volume_info: dict[str, Any] | None = None
  808. if cylinder_name:
  809. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  810. clearance_volume = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  811. angle_rad = np.deg2rad(angles360)
  812. crank_radius = PISTON_STROKE_MM / 2.0
  813. piston_travel = (
  814. crank_radius * (1.0 - np.cos(angle_rad))
  815. + CONNECTING_ROD_LENGTH_MM
  816. - np.sqrt(
  817. CONNECTING_ROD_LENGTH_MM**2
  818. - (crank_radius * np.sin(angle_rad)) ** 2,
  819. )
  820. )
  821. piston_area = np.pi * (bore_mm / 2.0) ** 2
  822. volume = clearance_volume + piston_area * piston_travel / 1_000_000.0
  823. volume_info = {
  824. "cylinder": cylinder_name,
  825. "boreMm": bore_mm,
  826. "clearanceVolumeL": clearance_volume,
  827. "minVolumeL": float(np.min(volume)),
  828. "maxVolumeL": float(np.max(volume)),
  829. }
  830. start_index = int(samples[cycle.start_offset, 0])
  831. end_offset = min(cycle.end_offset, len(samples) - 1)
  832. end_index = int(samples[max(cycle.end_offset - 1, cycle.start_offset), 0])
  833. return {
  834. "waveFile": {
  835. "id": int(metadata["id"]),
  836. "pointName": point_name,
  837. "measurementType": metadata.get("measurement_type"),
  838. "sampleTime": _time_string(metadata.get("sample_time")),
  839. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  840. "sampleCount": int(metadata.get("sample_count") or len(samples)),
  841. },
  842. "period": {
  843. "periodNo": cycle.number,
  844. "startSampleIndex": start_index,
  845. "endSampleIndex": end_index,
  846. "sampleCount": int(cycle.end_offset - cycle.start_offset),
  847. "triggerSampleIndices": [
  848. int(samples[offset, 0])
  849. for offset in cycle.trigger_offsets
  850. if 0 <= offset < len(samples)
  851. ],
  852. },
  853. "angles": display_angles.tolist(),
  854. "angles360": angles360.tolist(),
  855. "pressure": pressure.tolist(),
  856. "volume": volume.tolist() if volume is not None else None,
  857. "volumeInfo": volume_info,
  858. "phases": [
  859. {
  860. "name": name,
  861. "color": color,
  862. "start": start,
  863. "end": end,
  864. }
  865. for name, color, start, end in PHASES
  866. ],
  867. "diagnostics": diagnostics,
  868. }
  869. @staticmethod
  870. def _load_for_window(
  871. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  872. file_id: int,
  873. measurement_type: str,
  874. point_name: str,
  875. sample_time: str,
  876. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]],
  877. ) -> tuple[dict[str, Any], np.ndarray]:
  878. if file_id not in load_cache:
  879. if loader.__name__ == "_load_demo_wave":
  880. load_cache[file_id] = loader(file_id, measurement_type, point_name, sample_time)
  881. else:
  882. load_cache[file_id] = loader(file_id)
  883. return load_cache[file_id]
  884. def _load_db_wave(self, file_id: int) -> tuple[dict[str, Any], np.ndarray]:
  885. with get_connection() as connection:
  886. with connection.cursor() as cursor:
  887. cursor.execute(
  888. """
  889. SELECT id, point_name, measurement_type, sample_frequency_hz,
  890. sample_count, sample_time, rpm, file_name, tspluse_status
  891. FROM wave_file
  892. WHERE id = %s
  893. """,
  894. (file_id,),
  895. )
  896. metadata = cursor.fetchone()
  897. if metadata is None:
  898. raise ValueError(f"wave_file.id={file_id} 不存在")
  899. cursor.execute(
  900. """
  901. SELECT sample_index, signal_value, second_value
  902. FROM wave_sample
  903. WHERE wave_file_id = %s
  904. ORDER BY sample_index ASC
  905. """,
  906. (file_id,),
  907. )
  908. rows = cursor.fetchall()
  909. if not rows:
  910. raise ValueError(f"wave_file.id={file_id} 没有采样数据")
  911. samples = np.asarray(
  912. [
  913. (
  914. float(row["sample_index"]),
  915. float(row["signal_value"]),
  916. float(row["second_value"]) if row["second_value"] is not None else np.nan,
  917. )
  918. for row in rows
  919. ],
  920. dtype=float,
  921. )
  922. return metadata, samples
  923. @staticmethod
  924. @lru_cache(maxsize=24)
  925. def _demo_samples(file_id: int, measurement_type: str) -> np.ndarray:
  926. count = DEMO_SAMPLE_COUNT
  927. index = np.arange(count, dtype=float)
  928. revolution = DEMO_REVOLUTION_SAMPLES
  929. phase = (index % revolution) / revolution * 2 * np.pi
  930. second = np.zeros(count, dtype=float)
  931. for revolution_start in range(0, count, revolution):
  932. for pulse in range(PULSES_PER_REVOLUTION):
  933. pulse_start = revolution_start + int(round(pulse * revolution / PULSES_PER_REVOLUTION))
  934. width = 22 if pulse == 0 else 8
  935. pulse_end = min(count, pulse_start + width)
  936. second[pulse_start:pulse_end] = 40.0
  937. variation = (file_id % 17) / 17.0
  938. if measurement_type == "压力":
  939. signal = (
  940. 4.2
  941. + 1.8 * np.sin(phase - 0.4)
  942. + 0.55 * np.sin(2 * phase + variation)
  943. + 0.22 * np.sin(7 * phase)
  944. )
  945. signal += 0.2 * np.maximum(np.sin(phase - 0.2), 0) ** 5
  946. elif measurement_type == "位移":
  947. signal = 0.5 + 0.18 * np.cos(phase) + 0.035 * np.sin(3 * phase + variation)
  948. else:
  949. signal = 0.15 * np.sin(phase * 2 + variation) + 0.04 * np.sin(11 * phase)
  950. signal += 0.018 * np.cos(index / 37.0)
  951. return np.column_stack((index, signal, second))
  952. def _load_demo_wave(
  953. self,
  954. file_id: int,
  955. measurement_type: str,
  956. point_name: str,
  957. sample_time: str,
  958. ) -> tuple[dict[str, Any], np.ndarray]:
  959. samples = self._demo_samples(file_id, measurement_type)
  960. metadata = {
  961. "id": file_id,
  962. "point_name": point_name,
  963. "measurement_type": measurement_type,
  964. "sample_frequency_hz": 25600,
  965. "sample_count": len(samples),
  966. "sample_time": sample_time,
  967. "rpm": 998.0,
  968. "file_name": f"demo-{file_id}.dat",
  969. }
  970. return metadata, samples
  971. @staticmethod
  972. def _demo_options() -> list[dict[str, Any]]:
  973. end = DEMO_START + timedelta(minutes=5 * (DEMO_POINT_COUNT - 1))
  974. return [
  975. {
  976. "pointName": DEMO_POINT_NAME,
  977. "measurementType": measurement_type,
  978. "minTime": _time_string(DEMO_START),
  979. "maxTime": _time_string(end),
  980. "fileCount": DEMO_POINT_COUNT,
  981. }
  982. for measurement_type in MEASUREMENT_TYPES
  983. ]
  984. @staticmethod
  985. def _demo_time_points(
  986. point_name: str,
  987. types: list[str],
  988. start: datetime | None,
  989. end: datetime | None,
  990. ) -> list[dict[str, Any]]:
  991. points = []
  992. for index in range(DEMO_POINT_COUNT):
  993. timestamp = DEMO_START + timedelta(minutes=5 * index)
  994. if start and timestamp < start:
  995. continue
  996. if end and timestamp > end:
  997. continue
  998. files = {
  999. measurement_type: {
  1000. "id": DEMO_ID_BY_TYPE[measurement_type] + index,
  1001. "sampleCount": DEMO_SAMPLE_COUNT,
  1002. "sampleFrequencyHz": 25600,
  1003. "rpm": 998.0,
  1004. }
  1005. for measurement_type in types
  1006. }
  1007. points.append(
  1008. {
  1009. "index": len(points),
  1010. "sampleTime": _time_string(timestamp),
  1011. "files": files,
  1012. },
  1013. )
  1014. return points
  1015. data_service = DataService()