data_service.py 45 KB

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