data_service.py 64 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545
  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. # Extra samples fetched past cycle_end so detect_cycles can see the zero marker
  31. # that closes the first cycle (cycle_end is exclusive, the marker sits at it).
  32. FIRST_CYCLE_PAD = 64
  33. CYLINDER_BORE_MM = {
  34. "一缸": 360.0,
  35. "二缸": 490.0,
  36. "三缸": 390.0,
  37. "四缸": 490.0,
  38. "五缸": 390.0,
  39. "六缸": 490.0,
  40. }
  41. PISTON_STROKE_MM = 148.0
  42. CONNECTING_ROD_LENGTH_MM = 460.0
  43. CLEARANCE_VOLUME_L_BY_BORE = {
  44. 490.0: 1.51,
  45. 390.0: 0.74,
  46. 360.0: 0.62,
  47. }
  48. DEMO_POINT_NAME = "7号机组一缸压力盖侧"
  49. DEMO_START = datetime(2026, 4, 12, 8, 0, 0)
  50. DEMO_POINT_COUNT = 72
  51. DEMO_SAMPLE_COUNT = 32768
  52. DEMO_REVOLUTION_SAMPLES = 800
  53. DEMO_ID_BY_TYPE = {name: 100000 + index * 1000 for index, name in enumerate(MEASUREMENT_TYPES)}
  54. def _time_string(value: Any) -> str:
  55. if isinstance(value, datetime):
  56. return value.strftime("%Y-%m-%d %H:%M:%S")
  57. return str(value)
  58. def _parse_time(value: str | None) -> datetime | None:
  59. if not value:
  60. return None
  61. return datetime.fromisoformat(value.replace("Z", "+00:00").replace("T", " "))
  62. def _safe_float(value: Any) -> float | None:
  63. if value is None:
  64. return None
  65. number = float(value)
  66. return number if np.isfinite(number) else None
  67. def _series_extent(items: list[dict[str, Any]]) -> tuple[float, float]:
  68. """Whole-window min/max over a series' finite raw values."""
  69. if not items:
  70. return (0.0, 1.0)
  71. values = np.fromiter((item["rawValue"] for item in items), dtype=float, count=len(items))
  72. values = values[np.isfinite(values)]
  73. if values.size == 0:
  74. return (0.0, 1.0)
  75. return (float(values.min()), float(values.max()))
  76. def _annotation_dict(row: dict[str, Any]) -> dict[str, Any]:
  77. return {
  78. "id": int(row["id"]),
  79. "waveFileId": int(row["wave_file_id"]),
  80. "label": row["label"],
  81. "periodStart": int(row["period_start"]),
  82. "periodEnd": int(row["period_end"]),
  83. "sampleIndexStart": int(row["sample_index_start"]),
  84. "sampleIndexEnd": int(row["sample_index_end"]),
  85. }
  86. def _validate_measurement_types(values: list[str] | tuple[str, ...] | None) -> list[str]:
  87. selected = list(values or MEASUREMENT_TYPES)
  88. invalid = [value for value in selected if value not in MEASUREMENT_TYPES]
  89. if invalid:
  90. raise ValueError(f"不支持的数据名称:{'、'.join(invalid)}")
  91. return [value for value in MEASUREMENT_TYPES if value in selected]
  92. class DataService:
  93. # 数据库失败后的重试冷却时间(秒)。超过该时间后自动重连数据库,
  94. # 避免一次网络抖动就把服务永久锁死在演示数据模式。
  95. DB_RETRY_COOLDOWN = 30.0
  96. def __init__(self) -> None:
  97. self._db_failed = settings.demo_mode == "always"
  98. self._db_failed_at = monotonic() if self._db_failed else 0.0
  99. self._last_db_error = ""
  100. self._demo_annotations: dict[int, dict[str, Any]] = {}
  101. self._demo_annotation_seq = 1
  102. @property
  103. def source(self) -> str:
  104. return "demo" if self._db_failed else "database"
  105. @property
  106. def last_db_error(self) -> str:
  107. return self._last_db_error
  108. def _run_with_fallback(
  109. self,
  110. database_function: Callable[[], Any],
  111. demo_function: Callable[[], Any],
  112. ) -> tuple[Any, str]:
  113. if self._db_failed:
  114. if settings.demo_mode == "always":
  115. return demo_function(), "demo"
  116. if monotonic() - self._db_failed_at < self.DB_RETRY_COOLDOWN:
  117. return demo_function(), "demo"
  118. # 冷却结束,重新尝试数据库,数据库恢复后可自动切回真实数据。
  119. try:
  120. result = database_function()
  121. self._db_failed = False
  122. self._last_db_error = ""
  123. return result, "database"
  124. except Exception as error:
  125. if settings.demo_mode == "never":
  126. raise
  127. self._db_failed = True
  128. self._db_failed_at = monotonic()
  129. self._last_db_error = str(error)
  130. return demo_function(), "demo"
  131. def query_options(self) -> dict[str, Any]:
  132. def database_query():
  133. placeholders = ", ".join(["%s"] * len(MEASUREMENT_TYPES))
  134. with get_connection() as connection:
  135. with connection.cursor() as cursor:
  136. cursor.execute(
  137. f"""
  138. SELECT point_name, measurement_type,
  139. MAX(sample_time) AS max_time,
  140. MIN(sample_time) AS min_time,
  141. COUNT(*) AS file_count
  142. FROM wave_file
  143. WHERE measurement_type IN ({placeholders})
  144. GROUP BY point_name, measurement_type
  145. ORDER BY point_name, measurement_type
  146. """,
  147. MEASUREMENT_TYPES,
  148. )
  149. rows = cursor.fetchall()
  150. return [
  151. {
  152. "pointName": row["point_name"],
  153. "measurementType": row["measurement_type"],
  154. "minTime": _time_string(row["min_time"]),
  155. "maxTime": _time_string(row["max_time"]),
  156. "fileCount": int(row["file_count"]),
  157. }
  158. for row in rows
  159. ]
  160. rows, source = self._run_with_fallback(database_query, self._demo_options)
  161. point_names = list(dict.fromkeys(row["pointName"] for row in rows))
  162. return {
  163. "source": source,
  164. "measurementTypes": list(MEASUREMENT_TYPES),
  165. "pointNames": point_names,
  166. "options": rows,
  167. "notice": self._source_notice(source),
  168. }
  169. def abnormal_counts(self) -> dict[str, int]:
  170. """每个 point_name 的异常文件数量(tspluse_status > 0)。"""
  171. def database_query():
  172. with get_connection() as connection:
  173. with connection.cursor() as cursor:
  174. cursor.execute(
  175. """
  176. SELECT point_name, COUNT(*) AS cnt
  177. FROM wave_file
  178. WHERE tspluse_status > 0
  179. GROUP BY point_name
  180. """,
  181. )
  182. rows = cursor.fetchall()
  183. return {row["point_name"]: int(row["cnt"]) for row in rows}
  184. def demo_query():
  185. return {}
  186. result, _source = self._run_with_fallback(database_query, demo_query)
  187. return result
  188. def tspluse_ruler(self) -> dict[str, Any]:
  189. """压力部位 tspluse_status 的全局标尺,进入页面时只查询一次。"""
  190. def database_query():
  191. with get_connection() as connection:
  192. with connection.cursor() as cursor:
  193. cursor.execute(
  194. """
  195. SELECT MIN(tspluse_status) AS min_status,
  196. MAX(tspluse_status) AS max_status
  197. FROM wave_file
  198. WHERE rpm > 0 AND measurement_type = '压力'
  199. """,
  200. )
  201. row = cursor.fetchone()
  202. return {
  203. "min": int(row["min_status"]) if row and row["min_status"] is not None else 0,
  204. "max": int(row["max_status"]) if row and row["max_status"] is not None else 0,
  205. }
  206. def demo_query():
  207. return {"min": 0, "max": 0}
  208. result, source = self._run_with_fallback(database_query, demo_query)
  209. result["source"] = source
  210. return result
  211. def time_points(
  212. self,
  213. point_name: str,
  214. measurement_types: list[str] | None,
  215. min_time: str | None,
  216. max_time: str | None,
  217. include_stopped: bool = False,
  218. min_status: int | None = None,
  219. status_filter: list[str] | None = None,
  220. ) -> dict[str, Any]:
  221. if not point_name.strip():
  222. raise ValueError("机组与部位不能为空")
  223. types = _validate_measurement_types(measurement_types)
  224. status_filters = {value for value in (status_filter or [])}
  225. unknown = status_filters - {"abnormal", "no_cycle"}
  226. if unknown:
  227. raise ValueError(f"不支持的状态筛选:{'、'.join(sorted(unknown))}")
  228. start = _parse_time(min_time)
  229. end = _parse_time(max_time)
  230. if start and end and start > end:
  231. raise ValueError("开始时间不能晚于结束时间")
  232. def database_query():
  233. type_placeholders = ", ".join(["%s"] * len(types))
  234. clauses = [
  235. "point_name = %s",
  236. f"measurement_type IN ({type_placeholders})",
  237. ]
  238. params: list[Any] = [point_name, *types]
  239. if not include_stopped:
  240. clauses.append("rpm > 0")
  241. if min_status is not None and min_status > 0 and "no_cycle" not in status_filters:
  242. clauses.append("tspluse_status >= %s")
  243. params.append(min_status)
  244. if "abnormal" in status_filters and "no_cycle" in status_filters:
  245. clauses.append("(tspluse_status > 0 OR tspluse_status = -1)")
  246. elif "abnormal" in status_filters:
  247. clauses.append("tspluse_status > 0")
  248. elif "no_cycle" in status_filters:
  249. clauses.append("tspluse_status = -1")
  250. if start:
  251. clauses.append("sample_time >= %s")
  252. params.append(start)
  253. if end:
  254. clauses.append("sample_time <= %s")
  255. params.append(end)
  256. with get_connection() as connection:
  257. with connection.cursor() as cursor:
  258. cursor.execute(
  259. f"""
  260. SELECT id, point_name, measurement_type, sample_time,
  261. sample_count, sample_frequency_hz, rpm, tspluse_status
  262. FROM wave_file
  263. WHERE {' AND '.join(clauses)}
  264. ORDER BY sample_time ASC, id ASC
  265. """,
  266. params,
  267. )
  268. rows = cursor.fetchall()
  269. reference_rows: list[dict[str, Any]] = []
  270. if status_filters and rows:
  271. reference_where = [
  272. "point_name = %s",
  273. "measurement_type = '压力'",
  274. "tspluse_status = 0",
  275. ]
  276. reference_params: list[Any] = [point_name]
  277. if not include_stopped:
  278. reference_where.append("rpm > 0")
  279. if start:
  280. reference_where.append("sample_time >= %s")
  281. reference_params.append(start)
  282. if end:
  283. reference_where.append("sample_time <= %s")
  284. reference_params.append(end)
  285. reference_params.append(rows[0]["sample_time"])
  286. with get_connection() as connection:
  287. with connection.cursor() as cursor:
  288. cursor.execute(
  289. f"""
  290. SELECT id, point_name, measurement_type, sample_time,
  291. sample_count, sample_frequency_hz, rpm, tspluse_status
  292. FROM wave_file
  293. WHERE {' AND '.join(reference_where)}
  294. ORDER BY ABS(TIMESTAMPDIFF(SECOND, sample_time, %s)) ASC
  295. LIMIT 1
  296. """,
  297. reference_params,
  298. )
  299. reference_rows = cursor.fetchall()
  300. return self._group_time_points(rows), self._group_time_points(reference_rows)
  301. def demo_query():
  302. return self._demo_time_points(point_name, types, start, end), []
  303. result, source = self._run_with_fallback(database_query, demo_query)
  304. points, reference = result
  305. return {
  306. "source": source,
  307. "pointName": point_name,
  308. "measurementTypes": types,
  309. "total": len(points),
  310. "points": points,
  311. "referencePoints": reference,
  312. "notice": self._source_notice(source),
  313. }
  314. @staticmethod
  315. def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
  316. grouped: OrderedDict[Any, dict[str, Any]] = OrderedDict()
  317. for row in rows:
  318. key = row["sample_time"]
  319. point = grouped.setdefault(
  320. key,
  321. {
  322. "sampleTime": _time_string(key),
  323. "files": {},
  324. },
  325. )
  326. point["files"][row["measurement_type"]] = {
  327. "id": int(row["id"]),
  328. "sampleCount": int(row["sample_count"] or 0),
  329. "sampleFrequencyHz": int(row["sample_frequency_hz"] or 0),
  330. "rpm": float(row["rpm"] or 0),
  331. "status": int(row.get("tspluse_status") or 0),
  332. }
  333. return [
  334. {"index": index, **point}
  335. for index, point in enumerate(grouped.values())
  336. ]
  337. def wave_window(
  338. self,
  339. point_name: str,
  340. measurement_types: list[str],
  341. points: list[dict[str, Any]],
  342. max_points: int,
  343. no_sampling: bool = False,
  344. first_cycle_only: bool = False,
  345. ) -> dict[str, Any]:
  346. types = _validate_measurement_types(measurement_types)
  347. if not points:
  348. raise ValueError("至少选择一个时间点")
  349. if len(points) > 200:
  350. raise ValueError("单次最多预览 200 个时间点,请缩小时间窗口")
  351. max_points = min(max(int(max_points), 256), 200000)
  352. def database_query():
  353. if first_cycle_only:
  354. return self._build_first_cycle_window(
  355. point_name,
  356. types,
  357. points,
  358. self._load_db_wave,
  359. )
  360. return self._build_wave_window(
  361. point_name,
  362. types,
  363. points,
  364. max_points,
  365. self._load_db_wave,
  366. no_sampling,
  367. )
  368. def demo_query():
  369. if first_cycle_only:
  370. return self._build_first_cycle_window(
  371. point_name,
  372. types,
  373. points,
  374. self._load_demo_wave,
  375. )
  376. return self._build_wave_window(
  377. point_name,
  378. types,
  379. points,
  380. max_points,
  381. self._load_demo_wave,
  382. no_sampling,
  383. )
  384. result, source = self._run_with_fallback(database_query, demo_query)
  385. result["source"] = source
  386. result["notice"] = self._source_notice(source)
  387. return result
  388. def period_detail(self, wave_file_id: int, period_number: int) -> dict[str, Any]:
  389. if wave_file_id <= 0 or period_number <= 0:
  390. raise ValueError("wave_file_id 和周期编号必须为正整数")
  391. def database_query():
  392. metadata, samples = self._load_db_wave(wave_file_id)
  393. return self._build_period_detail(metadata, samples, period_number)
  394. def demo_query():
  395. metadata, samples = self._load_demo_wave(
  396. wave_file_id,
  397. "压力",
  398. DEMO_POINT_NAME,
  399. DEMO_START,
  400. )
  401. return self._build_period_detail(metadata, samples, period_number)
  402. result, source = self._run_with_fallback(database_query, demo_query)
  403. result["source"] = source
  404. result["notice"] = self._source_notice(source)
  405. return result
  406. def annotation_config(self) -> dict[str, Any]:
  407. return {
  408. "source": self.source,
  409. "annotationWidth": settings.annotation_width,
  410. "notice": self._source_notice(self.source),
  411. }
  412. def list_annotations(self, wave_file_ids: list[int]) -> dict[str, Any]:
  413. ids = sorted({int(value) for value in wave_file_ids if value})
  414. if not ids:
  415. return {
  416. "source": self.source,
  417. "annotations": [],
  418. "notice": self._source_notice(self.source),
  419. }
  420. def database_query():
  421. placeholders = ", ".join(["%s"] * len(ids))
  422. with get_connection() as connection:
  423. with connection.cursor() as cursor:
  424. cursor.execute(
  425. f"""
  426. SELECT id, wave_file_id, label, period_start, period_end,
  427. sample_index_start, sample_index_end
  428. FROM wave_annotation
  429. WHERE wave_file_id IN ({placeholders})
  430. ORDER BY id ASC
  431. """,
  432. ids,
  433. )
  434. rows = cursor.fetchall()
  435. return [_annotation_dict(row) for row in rows]
  436. def demo_query():
  437. return [
  438. _annotation_dict(annotation)
  439. for annotation in self._demo_annotations.values()
  440. if annotation["wave_file_id"] in ids
  441. ]
  442. annotations, source = self._run_with_fallback(database_query, demo_query)
  443. return {
  444. "source": source,
  445. "annotations": annotations,
  446. "notice": self._source_notice(source),
  447. }
  448. def create_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
  449. self._validate_annotation(payload)
  450. wave_file_id = int(payload["wave_file_id"])
  451. label = payload["label"]
  452. period_start = int(payload["period_start"])
  453. period_end = int(payload["period_end"])
  454. sample_index_start = int(payload["sample_index_start"])
  455. sample_index_end = int(payload["sample_index_end"])
  456. def database_query():
  457. with get_connection() as connection:
  458. with connection.cursor() as cursor:
  459. cursor.execute(
  460. """
  461. INSERT INTO wave_annotation
  462. (wave_file_id, label, period_start, period_end,
  463. sample_index_start, sample_index_end)
  464. VALUES (%s, %s, %s, %s, %s, %s)
  465. """,
  466. (
  467. wave_file_id,
  468. label,
  469. period_start,
  470. period_end,
  471. sample_index_start,
  472. sample_index_end,
  473. ),
  474. )
  475. annotation_id = cursor.lastrowid
  476. cursor.execute(
  477. """
  478. SELECT id, wave_file_id, label, period_start, period_end,
  479. sample_index_start, sample_index_end
  480. FROM wave_annotation
  481. WHERE id = %s
  482. """,
  483. (annotation_id,),
  484. )
  485. return _annotation_dict(cursor.fetchone())
  486. def demo_query():
  487. annotation_id = self._demo_annotation_seq
  488. self._demo_annotation_seq += 1
  489. annotation = {
  490. "id": annotation_id,
  491. "wave_file_id": wave_file_id,
  492. "label": label,
  493. "period_start": period_start,
  494. "period_end": period_end,
  495. "sample_index_start": sample_index_start,
  496. "sample_index_end": sample_index_end,
  497. }
  498. self._demo_annotations[annotation_id] = annotation
  499. return _annotation_dict(annotation)
  500. result, source = self._run_with_fallback(database_query, demo_query)
  501. result["source"] = source
  502. result["notice"] = self._source_notice(source)
  503. return result
  504. def delete_annotation(self, annotation_id: int) -> dict[str, Any]:
  505. if annotation_id <= 0:
  506. raise ValueError("标注 id 必须为正整数")
  507. def database_query():
  508. with get_connection() as connection:
  509. with connection.cursor() as cursor:
  510. cursor.execute(
  511. "DELETE FROM wave_annotation WHERE id = %s",
  512. (annotation_id,),
  513. )
  514. return int(cursor.rowcount)
  515. def demo_query():
  516. if annotation_id not in self._demo_annotations:
  517. return 0
  518. del self._demo_annotations[annotation_id]
  519. return 1
  520. deleted, source = self._run_with_fallback(database_query, demo_query)
  521. if not deleted:
  522. raise ValueError(f"标注 id={annotation_id} 不存在")
  523. return {
  524. "deleted": annotation_id,
  525. "source": source,
  526. "notice": self._source_notice(source),
  527. }
  528. @staticmethod
  529. def _validate_annotation(payload: dict[str, Any]) -> None:
  530. label = payload.get("label")
  531. if label not in ANNOTATION_LABELS:
  532. raise ValueError("样本类型只能是 正常 或 异常")
  533. for field in ("wave_file_id", "period_start", "period_end", "sample_index_start", "sample_index_end"):
  534. if payload.get(field) is None:
  535. raise ValueError(f"{field} 不能为空")
  536. if int(payload["wave_file_id"]) <= 0:
  537. raise ValueError("wave_file_id 必须为正整数")
  538. if int(payload["period_start"]) <= 0 or int(payload["period_end"]) <= 0:
  539. raise ValueError("周期编号必须为正整数")
  540. if int(payload["period_start"]) > int(payload["period_end"]):
  541. raise ValueError("起始周期不能大于结束周期")
  542. if int(payload["sample_index_start"]) < 0 or int(payload["sample_index_end"]) < 0:
  543. raise ValueError("采样点索引不能为负")
  544. if int(payload["sample_index_start"]) > int(payload["sample_index_end"]):
  545. raise ValueError("起始采样点不能大于结束采样点")
  546. def health(self) -> dict[str, Any]:
  547. return {
  548. "status": "ok",
  549. "source": self.source,
  550. "databaseError": self._last_db_error or None,
  551. }
  552. def _source_notice(self, source: str) -> str | None:
  553. if source == "demo":
  554. if self._last_db_error:
  555. return f"当前为演示数据:数据库暂不可用({self._last_db_error})"
  556. return "当前为演示数据:可设置 DEMO_MODE=never 强制使用数据库"
  557. return "已连接 MySQL 数据库"
  558. def _build_wave_window(
  559. self,
  560. point_name: str,
  561. types: list[str],
  562. points: list[dict[str, Any]],
  563. max_points: int,
  564. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  565. no_sampling: bool = False,
  566. ) -> dict[str, Any]:
  567. series_data: dict[str, list[dict[str, Any]]] = {measurement_type: [] for measurement_type in types}
  568. angle_data: list[dict[str, Any]] = []
  569. volume_data: list[dict[str, Any]] = []
  570. volume_info: dict[str, Any] | None = None
  571. cycles: list[dict[str, Any]] = []
  572. triggers: list[float] = []
  573. files: list[dict[str, Any]] = []
  574. diagnostics: list[dict[str, Any]] = []
  575. second_series_data: list[dict[str, Any]] = []
  576. second_finite_count = 0
  577. second_non_zero_count = 0
  578. second_min: float | None = None
  579. second_max: float | None = None
  580. source_type = "压力" if "压力" in types else types[0]
  581. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]] = {}
  582. for slot, point in enumerate(points):
  583. source_measurement_type = source_type
  584. source_file = point.get("files", {}).get(source_measurement_type)
  585. if not source_file:
  586. available = [
  587. (measurement_type, file_info)
  588. for measurement_type, file_info in point.get("files", {}).items()
  589. if measurement_type in types and file_info
  590. ]
  591. if available:
  592. source_measurement_type, source_file = available[0]
  593. else:
  594. continue
  595. source_id = int(source_file["id"] if isinstance(source_file, dict) else source_file)
  596. try:
  597. source_metadata, source_samples = self._load_for_window(
  598. loader,
  599. source_id,
  600. source_measurement_type,
  601. point_name,
  602. point["sampleTime"],
  603. load_cache,
  604. )
  605. except ValueError:
  606. # A selected file may legitimately have no samples. A database
  607. # connection error must escape and activate the demo fallback.
  608. continue
  609. detected, diagnostic = detect_cycles(source_samples)
  610. angle_vector = build_angle_vector(len(source_samples), detected)
  611. full_angle_vector = np.full(len(source_samples), np.nan, dtype=float)
  612. for detected_cycle in detected:
  613. full_angle_vector[detected_cycle.start_offset:detected_cycle.end_offset] = detected_cycle.angle
  614. current_volume, current_volume_info = self._build_volume_vector(
  615. len(source_samples),
  616. detected,
  617. point_name,
  618. )
  619. if current_volume_info is not None:
  620. volume_info = current_volume_info
  621. sample_indices = source_samples[:, 0].astype(np.int64)
  622. sample_span = max(len(source_samples), 1)
  623. finite_second = source_samples[:, 2][np.isfinite(source_samples[:, 2])]
  624. if len(finite_second):
  625. second_finite_count += int(len(finite_second))
  626. second_non_zero_count += int(np.count_nonzero(finite_second != 0))
  627. current_min = float(np.min(finite_second))
  628. current_max = float(np.max(finite_second))
  629. second_min = current_min if second_min is None else min(second_min, current_min)
  630. second_max = current_max if second_max is None else max(second_max, current_max)
  631. required = {0, len(source_samples) - 1}
  632. for cycle in detected:
  633. required.update(
  634. {
  635. cycle.start_offset,
  636. max(cycle.end_offset - 1, cycle.start_offset),
  637. *cycle.trigger_offsets,
  638. },
  639. )
  640. start_x = slot + cycle.start_offset / sample_span
  641. end_x = slot + cycle.end_offset / sample_span
  642. start_sample_index = int(sample_indices[cycle.start_offset])
  643. end_sample_index = int(sample_indices[max(cycle.end_offset - 1, cycle.start_offset)])
  644. cycles.append(
  645. {
  646. "id": f"{source_id}:{cycle.number}",
  647. "waveFileId": source_id,
  648. "periodNo": cycle.number,
  649. "pointIndex": slot,
  650. "sampleTime": point["sampleTime"],
  651. "startX": start_x,
  652. "endX": end_x,
  653. "startSampleIndex": start_sample_index,
  654. "endSampleIndex": end_sample_index,
  655. "sourceType": source_measurement_type,
  656. },
  657. )
  658. for trigger_offset in sorted(required):
  659. if trigger_offset in required and any(
  660. trigger_offset == run_offset
  661. for cycle in detected
  662. for run_offset in cycle.trigger_offsets
  663. ):
  664. triggers.append(slot + trigger_offset / sample_span)
  665. diagnostics.append(
  666. {
  667. "waveFileId": source_id,
  668. "measurementType": source_measurement_type,
  669. "sampleTime": point["sampleTime"],
  670. **diagnostic,
  671. },
  672. )
  673. for measurement_type in types:
  674. file_info = point.get("files", {}).get(measurement_type)
  675. if not file_info:
  676. continue
  677. file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
  678. if file_id == source_id:
  679. metadata, samples = source_metadata, source_samples
  680. else:
  681. try:
  682. metadata, samples = self._load_for_window(
  683. loader,
  684. file_id,
  685. measurement_type,
  686. point_name,
  687. point["sampleTime"],
  688. load_cache,
  689. )
  690. except ValueError:
  691. continue
  692. sample_count = len(samples)
  693. if not sample_count:
  694. continue
  695. sample_indices_for_file = samples[:, 0].astype(np.int64)
  696. file_base_index = int(sample_indices_for_file[0])
  697. file_span = max(sample_count, 1)
  698. file_required = {0, sample_count - 1}
  699. if file_id == source_id:
  700. file_required.update(required)
  701. if no_sampling:
  702. target_per_file = sample_count
  703. else:
  704. target_per_file = max(256, int(np.ceil(max_points / max(len(points), 1))))
  705. chosen = downsample_indices(samples[:, 1], target_per_file, file_required)
  706. for offset in chosen:
  707. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  708. second = _safe_float(samples[offset, 2])
  709. raw_value = float(samples[offset, 1])
  710. series_data[measurement_type].append(
  711. {
  712. "value": [x, raw_value],
  713. "x": x,
  714. "rawValue": raw_value,
  715. "sampleIndex": int(sample_indices_for_file[offset]),
  716. "waveFileId": file_id,
  717. "sampleTime": point["sampleTime"],
  718. "secondValue": second,
  719. "volume": (
  720. _safe_float(current_volume[offset])
  721. if file_id == source_id
  722. else None
  723. ),
  724. "angle360": (
  725. _safe_float(full_angle_vector[offset])
  726. if file_id == source_id
  727. else None
  728. ),
  729. },
  730. )
  731. if file_id == source_id:
  732. second_chosen = downsample_indices(samples[:, 2], target_per_file, file_required)
  733. for offset in second_chosen:
  734. second_value = _safe_float(samples[offset, 2])
  735. if second_value is None:
  736. continue
  737. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  738. second_series_data.append(
  739. {
  740. "value": [x, second_value],
  741. "x": x,
  742. "rawValue": second_value,
  743. "sampleIndex": int(sample_indices_for_file[offset]),
  744. "waveFileId": file_id,
  745. "sampleTime": point["sampleTime"],
  746. },
  747. )
  748. angle_chosen = downsample_indices(angle_vector, target_per_file, file_required)
  749. for offset in angle_chosen:
  750. value = _safe_float(angle_vector[offset])
  751. if value is None:
  752. continue
  753. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  754. angle_data.append(
  755. {
  756. "value": [x, value],
  757. "x": x,
  758. "angle": value,
  759. "sampleIndex": int(sample_indices_for_file[offset]),
  760. "waveFileId": file_id,
  761. "sampleTime": point["sampleTime"],
  762. },
  763. )
  764. volume_chosen = downsample_indices(current_volume, target_per_file, file_required)
  765. for offset in volume_chosen:
  766. value = _safe_float(current_volume[offset])
  767. if value is None:
  768. continue
  769. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  770. volume_data.append(
  771. {
  772. "value": [x, value],
  773. "x": x,
  774. "volume": value,
  775. "sampleIndex": int(sample_indices_for_file[offset]),
  776. "waveFileId": file_id,
  777. "sampleTime": point["sampleTime"],
  778. },
  779. )
  780. files.append(
  781. {
  782. "id": file_id,
  783. "pointIndex": slot,
  784. "sampleTime": point["sampleTime"],
  785. "measurementType": measurement_type,
  786. "sampleCount": int(metadata.get("sample_count") or sample_count),
  787. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  788. "pointName": str(metadata.get("point_name") or point_name),
  789. "rpm": float(metadata.get("rpm") or 0),
  790. "status": int(metadata.get("tspluse_status") or 0),
  791. "fileName": str(metadata.get("file_name") or ""),
  792. },
  793. )
  794. # Keep at most the current time point's raw arrays resident. The
  795. # response retains only downsampled values and period metadata.
  796. load_cache.clear()
  797. for measurement_type in series_data:
  798. series_data[measurement_type].sort(key=lambda item: item["x"])
  799. second_series_data.sort(key=lambda item: item["x"])
  800. angle_data.sort(key=lambda item: item["x"])
  801. volume_data.sort(key=lambda item: item["x"])
  802. extents = {measurement_type: _series_extent(series_data[measurement_type]) for measurement_type in types}
  803. return {
  804. "pointName": point_name,
  805. "measurementTypes": types,
  806. "points": points,
  807. "xMin": 0,
  808. "xMax": len(points),
  809. "series": [
  810. {
  811. "measurementType": measurement_type,
  812. "color": MEASUREMENT_COLORS[measurement_type],
  813. "data": series_data[measurement_type],
  814. "min": extents[measurement_type][0],
  815. "max": extents[measurement_type][1],
  816. }
  817. for measurement_type in types
  818. ],
  819. "secondSeries": {
  820. "name": "周期数据",
  821. "color": "#f56c6c",
  822. "sourceMeasurementType": source_type,
  823. "data": second_series_data,
  824. "finiteCount": second_finite_count,
  825. "nonZeroCount": second_non_zero_count,
  826. "min": second_min,
  827. "max": second_max,
  828. },
  829. "angleSeries": {
  830. "color": "#d59b2b",
  831. "data": angle_data,
  832. },
  833. "volumeSeries": {
  834. "color": "#4d9e6f",
  835. "data": volume_data,
  836. "info": volume_info,
  837. },
  838. "cycles": cycles,
  839. "triggerXs": sorted(set(triggers)),
  840. "files": files,
  841. "diagnostics": diagnostics,
  842. }
  843. def _build_first_cycle_window(
  844. self,
  845. point_name: str,
  846. types: list[str],
  847. points: list[dict[str, Any]],
  848. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  849. ) -> dict[str, Any]:
  850. """Build a slot-layout window where each file shows only its first cycle.
  851. Uses the ``wave_file.cycle_start / cycle_end`` bounds written by
  852. detect_cycle_index.py and fetches every file's first-cycle slice in a
  853. single JOIN query (range scan on the ``(wave_file_id, sample_index)``
  854. primary key), so 50 files cost one query instead of 50 full-file loads.
  855. Files without a recorded cycle (无周期 / 未回写) contribute no curve but
  856. still appear in the file record list. The curve is continuous: all cycle
  857. samples are kept and each file spans its own x slot.
  858. """
  859. series_data: dict[str, list[dict[str, Any]]] = {measurement_type: [] for measurement_type in types}
  860. second_series_data: list[dict[str, Any]] = []
  861. angle_data: list[dict[str, Any]] = []
  862. volume_data: list[dict[str, Any]] = []
  863. volume_info: dict[str, Any] | None = None
  864. cycles: list[dict[str, Any]] = []
  865. triggers: list[float] = []
  866. files: list[dict[str, Any]] = []
  867. diagnostics: list[dict[str, Any]] = []
  868. second_finite_count = 0
  869. second_non_zero_count = 0
  870. second_min: float | None = None
  871. second_max: float | None = None
  872. source_type = "压力" if "压力" in types else types[0]
  873. slot_sources: list[tuple[int, dict[str, Any], str, int]] = []
  874. all_file_ids: set[int] = set()
  875. for slot, point in enumerate(points):
  876. source_measurement_type = source_type
  877. source_file = point.get("files", {}).get(source_measurement_type)
  878. if not source_file:
  879. available = [
  880. (measurement_type, file_info)
  881. for measurement_type, file_info in point.get("files", {}).items()
  882. if measurement_type in types and file_info
  883. ]
  884. if available:
  885. source_measurement_type, source_file = available[0]
  886. else:
  887. continue
  888. source_id = int(source_file["id"] if isinstance(source_file, dict) else source_file)
  889. slot_sources.append((slot, point, source_measurement_type, source_id))
  890. for measurement_type in types:
  891. file_info = point.get("files", {}).get(measurement_type)
  892. if file_info:
  893. all_file_ids.add(int(file_info["id"] if isinstance(file_info, dict) else file_info))
  894. if not slot_sources:
  895. return self._assemble_first_cycle_window(
  896. point_name, types, points, series_data, second_series_data,
  897. angle_data, volume_data, volume_info,
  898. second_finite_count, second_non_zero_count, second_min, second_max,
  899. cycles, triggers, files, diagnostics, 0,
  900. )
  901. is_demo = loader.__name__ == "_load_demo_wave"
  902. metas: dict[int, dict[str, Any]] = {}
  903. # file_id -> (cycle_start, cycle_end, padded_slice[offset, signal, second])
  904. slices: dict[int, tuple[int, int, np.ndarray]] = {}
  905. if is_demo:
  906. for _slot, point, source_measurement_type, source_id in slot_sources:
  907. metadata, samples = self._load_for_window(
  908. loader, source_id, source_measurement_type, point_name,
  909. point["sampleTime"], {},
  910. )
  911. metas[source_id] = metadata
  912. detected, _ = detect_cycles(samples)
  913. if detected:
  914. cycle = detected[0]
  915. start_si = int(samples[cycle.start_offset, 0])
  916. end_si = int(samples[cycle.end_offset, 0])
  917. pad_end = min(cycle.end_offset + FIRST_CYCLE_PAD, len(samples))
  918. slices[source_id] = (start_si, end_si, samples[cycle.start_offset:pad_end])
  919. else:
  920. source_ids = [source_id for _, _, _, source_id in slot_sources]
  921. with get_connection() as connection:
  922. with connection.cursor() as cursor:
  923. placeholders = ", ".join(["%s"] * len(all_file_ids))
  924. cursor.execute(
  925. f"""
  926. SELECT id, point_name, measurement_type, sample_time,
  927. sample_count, sample_frequency_hz, rpm, tspluse_status,
  928. file_name, cycle_start, cycle_end
  929. FROM wave_file
  930. WHERE id IN ({placeholders})
  931. """,
  932. tuple(all_file_ids),
  933. )
  934. for row in cursor.fetchall():
  935. metas[int(row["id"])] = row
  936. source_placeholders = ", ".join(["%s"] * len(source_ids))
  937. cursor.execute(
  938. f"""
  939. SELECT ws.wave_file_id,
  940. ws.sample_index,
  941. CAST(ws.signal_value AS FLOAT) AS sig,
  942. CAST(ws.second_value AS FLOAT) AS sec
  943. FROM wave_sample ws
  944. JOIN wave_file wf ON wf.id = ws.wave_file_id
  945. WHERE ws.wave_file_id IN ({source_placeholders})
  946. AND wf.cycle_start >= 0
  947. AND ws.sample_index >= wf.cycle_start
  948. AND ws.sample_index < wf.cycle_end + %s
  949. ORDER BY ws.wave_file_id, ws.sample_index
  950. """,
  951. (*tuple(source_ids), FIRST_CYCLE_PAD),
  952. )
  953. raw: dict[int, list[tuple[float, float, float]]] = {}
  954. for row in cursor.fetchall():
  955. raw.setdefault(int(row["wave_file_id"]), []).append(
  956. (
  957. float(row["sample_index"]),
  958. float(row["sig"]),
  959. float(row["sec"]) if row["sec"] is not None else float("nan"),
  960. ),
  961. )
  962. for file_id, rows in raw.items():
  963. meta = metas.get(file_id)
  964. if meta is None or meta.get("cycle_start") is None:
  965. continue
  966. slices[file_id] = (
  967. int(meta["cycle_start"]),
  968. int(meta["cycle_end"]),
  969. np.asarray(rows, dtype=float),
  970. )
  971. for slot, point, source_measurement_type, source_id in slot_sources:
  972. sample_time = point["sampleTime"]
  973. cycle_bounds = slices.get(source_id)
  974. cycle_start = 0
  975. cycle_end = 0
  976. slice_arr: np.ndarray | None = None
  977. if cycle_bounds is not None:
  978. cycle_start, cycle_end, slice_arr = cycle_bounds
  979. cycle_len = max(cycle_end - cycle_start, 0)
  980. has_cycle = slice_arr is not None and 1 < cycle_len <= len(slice_arr)
  981. if has_cycle:
  982. angle: np.ndarray | None = None
  983. volume: np.ndarray | None = None
  984. volume_info: dict[str, Any] | None = None
  985. detected, _ = detect_cycles(slice_arr)
  986. if detected:
  987. cycle = detected[0]
  988. angle = cycle.angle
  989. volume, volume_info = self._build_volume_vector(len(slice_arr), [cycle], point_name)
  990. for offset in range(cycle_len):
  991. sample_index = int(slice_arr[offset, 0])
  992. raw_value = float(slice_arr[offset, 1])
  993. second = _safe_float(slice_arr[offset, 2])
  994. angle360 = _safe_float(angle[offset]) if angle is not None and offset < len(angle) else None
  995. volume_value = _safe_float(volume[offset]) if volume is not None and offset < len(volume) else None
  996. x = slot + (sample_index - cycle_start) / cycle_len
  997. series_data[source_measurement_type].append(
  998. {
  999. "value": [x, raw_value],
  1000. "x": x,
  1001. "rawValue": raw_value,
  1002. "sampleIndex": sample_index,
  1003. "waveFileId": source_id,
  1004. "sampleTime": sample_time,
  1005. "secondValue": second,
  1006. "volume": volume_value,
  1007. "angle360": angle360,
  1008. },
  1009. )
  1010. if volume_value is not None:
  1011. volume_data.append(
  1012. {
  1013. "value": [x, volume_value],
  1014. "x": x,
  1015. "volume": volume_value,
  1016. "sampleIndex": sample_index,
  1017. "waveFileId": source_id,
  1018. "sampleTime": sample_time,
  1019. },
  1020. )
  1021. if angle360 is not None:
  1022. display_angle = angle360 if angle360 <= 180.0 else 360.0 - angle360
  1023. angle_data.append(
  1024. {
  1025. "value": [x, display_angle],
  1026. "x": x,
  1027. "angle": display_angle,
  1028. "sampleIndex": sample_index,
  1029. "waveFileId": source_id,
  1030. "sampleTime": sample_time,
  1031. },
  1032. )
  1033. if second is not None:
  1034. second_finite_count += 1
  1035. if second != 0:
  1036. second_non_zero_count += 1
  1037. second_min = second if second_min is None else min(second_min, second)
  1038. second_max = second if second_max is None else max(second_max, second)
  1039. second_series_data.append(
  1040. {
  1041. "value": [x, second],
  1042. "x": x,
  1043. "rawValue": second,
  1044. "sampleIndex": sample_index,
  1045. "waveFileId": source_id,
  1046. "sampleTime": sample_time,
  1047. },
  1048. )
  1049. if offset > 0:
  1050. prev = _safe_float(slice_arr[offset - 1, 2])
  1051. if second is not None and (prev is None or prev < 30) and second >= 30:
  1052. triggers.append(x)
  1053. cycles.append(
  1054. {
  1055. "id": f"{source_id}:1",
  1056. "waveFileId": source_id,
  1057. "periodNo": 1,
  1058. "pointIndex": slot,
  1059. "sampleTime": sample_time,
  1060. "startX": float(slot),
  1061. "endX": float(slot + 1),
  1062. "startSampleIndex": cycle_start,
  1063. "endSampleIndex": cycle_end - 1,
  1064. "sourceType": source_measurement_type,
  1065. },
  1066. )
  1067. diagnostics.append(
  1068. {
  1069. "waveFileId": source_id,
  1070. "measurementType": source_measurement_type,
  1071. "sampleTime": sample_time,
  1072. "cycleStart": cycle_start,
  1073. "cycleEnd": cycle_end,
  1074. "cycleSampleCount": cycle_len,
  1075. },
  1076. )
  1077. for measurement_type in types:
  1078. file_info = point.get("files", {}).get(measurement_type)
  1079. if not file_info:
  1080. continue
  1081. file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
  1082. metadata = metas.get(file_id)
  1083. cycle_start = metadata.get("cycle_start") if metadata else None
  1084. cycle_end = metadata.get("cycle_end") if metadata else None
  1085. files.append(
  1086. {
  1087. "id": file_id,
  1088. "pointIndex": slot,
  1089. "sampleTime": sample_time,
  1090. "measurementType": measurement_type,
  1091. "sampleCount": int(
  1092. (metadata.get("sample_count") if metadata else file_info.get("sampleCount") or 0) or 0,
  1093. ),
  1094. "sampleFrequencyHz": int(
  1095. (metadata.get("sample_frequency_hz") if metadata else file_info.get("sampleFrequencyHz") or 0) or 0,
  1096. ),
  1097. "pointName": str((metadata.get("point_name") if metadata else point_name) or point_name),
  1098. "rpm": float((metadata.get("rpm") if metadata else file_info.get("rpm") or 0) or 0),
  1099. "status": int(
  1100. (metadata.get("tspluse_status") if metadata else file_info.get("status") or 0) or 0,
  1101. ),
  1102. "fileName": str((metadata.get("file_name") if metadata else "") or ""),
  1103. "cycleStart": int(cycle_start) if cycle_start is not None else None,
  1104. "cycleEnd": int(cycle_end) if cycle_end is not None else None,
  1105. },
  1106. )
  1107. return self._assemble_first_cycle_window(
  1108. point_name, types, points, series_data, second_series_data,
  1109. angle_data, volume_data, volume_info,
  1110. second_finite_count, second_non_zero_count, second_min, second_max,
  1111. cycles, triggers, files, diagnostics,
  1112. max(len(slot_sources) - len(cycles), 0),
  1113. )
  1114. @staticmethod
  1115. def _assemble_first_cycle_window(
  1116. point_name: str,
  1117. types: list[str],
  1118. points: list[dict[str, Any]],
  1119. series_data: dict[str, list[dict[str, Any]]],
  1120. second_series_data: list[dict[str, Any]],
  1121. angle_data: list[dict[str, Any]],
  1122. volume_data: list[dict[str, Any]],
  1123. volume_info: dict[str, Any] | None,
  1124. second_finite_count: int,
  1125. second_non_zero_count: int,
  1126. second_min: float | None,
  1127. second_max: float | None,
  1128. cycles: list[dict[str, Any]],
  1129. triggers: list[float],
  1130. files: list[dict[str, Any]],
  1131. diagnostics: list[dict[str, Any]],
  1132. missing_cycles: int = 0,
  1133. ) -> dict[str, Any]:
  1134. for measurement_type in series_data:
  1135. series_data[measurement_type].sort(key=lambda item: item["x"])
  1136. second_series_data.sort(key=lambda item: item["x"])
  1137. angle_data.sort(key=lambda item: item["x"])
  1138. volume_data.sort(key=lambda item: item["x"])
  1139. extents = {measurement_type: _series_extent(series_data[measurement_type]) for measurement_type in types}
  1140. return {
  1141. "pointName": point_name,
  1142. "measurementTypes": types,
  1143. "points": points,
  1144. "xMin": 0,
  1145. "xMax": len(points),
  1146. "series": [
  1147. {
  1148. "measurementType": measurement_type,
  1149. "color": MEASUREMENT_COLORS[measurement_type],
  1150. "data": series_data[measurement_type],
  1151. "min": extents[measurement_type][0],
  1152. "max": extents[measurement_type][1],
  1153. }
  1154. for measurement_type in types
  1155. ],
  1156. "secondSeries": {
  1157. "name": "周期数据",
  1158. "color": "#f56c6c",
  1159. "sourceMeasurementType": "压力" if "压力" in types else types[0],
  1160. "data": second_series_data,
  1161. "finiteCount": second_finite_count,
  1162. "nonZeroCount": second_non_zero_count,
  1163. "min": second_min,
  1164. "max": second_max,
  1165. },
  1166. "angleSeries": {
  1167. "color": "#d59b2b",
  1168. "data": angle_data,
  1169. },
  1170. "volumeSeries": {
  1171. "color": "#4d9e6f",
  1172. "data": volume_data,
  1173. "info": volume_info,
  1174. },
  1175. "cycles": cycles,
  1176. "triggerXs": sorted(set(triggers)),
  1177. "files": files,
  1178. "diagnostics": diagnostics,
  1179. "firstCycleMode": True,
  1180. "firstCycleNotice": (
  1181. f"窗口内 {missing_cycles} 个文件尚未回写首个周期索引(cycle_start),"
  1182. "请先运行 detect_cycle_index.py 回写后再查看。"
  1183. if missing_cycles > 0
  1184. else None
  1185. ),
  1186. }
  1187. @staticmethod
  1188. def _build_volume_vector(
  1189. sample_count: int,
  1190. cycles: list[DetectedCycle],
  1191. point_name: str,
  1192. ) -> tuple[np.ndarray, dict[str, Any] | None]:
  1193. volume = np.full(sample_count, np.nan, dtype=float)
  1194. cylinder_name = next(
  1195. (name for name in CYLINDER_BORE_MM if name in point_name),
  1196. None,
  1197. )
  1198. if cylinder_name is None or not cycles:
  1199. return volume, None
  1200. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  1201. clearance = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  1202. crank_radius = PISTON_STROKE_MM / 2.0
  1203. piston_area = np.pi * (bore_mm / 2.0) ** 2
  1204. for cycle in cycles:
  1205. angle_rad = np.deg2rad(cycle.angle)
  1206. travel = (
  1207. crank_radius * (1.0 - np.cos(angle_rad))
  1208. + CONNECTING_ROD_LENGTH_MM
  1209. - np.sqrt(
  1210. CONNECTING_ROD_LENGTH_MM**2
  1211. - (crank_radius * np.sin(angle_rad)) ** 2,
  1212. )
  1213. )
  1214. volume[cycle.start_offset : cycle.end_offset] = (
  1215. clearance + piston_area * travel / 1_000_000.0
  1216. )
  1217. finite = volume[np.isfinite(volume)]
  1218. return volume, {
  1219. "cylinder": cylinder_name,
  1220. "boreMm": bore_mm,
  1221. "clearanceVolumeL": clearance,
  1222. "minVolumeL": float(np.min(finite)) if len(finite) else None,
  1223. "maxVolumeL": float(np.max(finite)) if len(finite) else None,
  1224. }
  1225. @staticmethod
  1226. def _build_period_detail(
  1227. metadata: dict[str, Any],
  1228. samples: np.ndarray,
  1229. period_number: int,
  1230. ) -> dict[str, Any]:
  1231. detected, diagnostics = detect_cycles(samples)
  1232. cycle = next((item for item in detected if item.number == period_number), None)
  1233. if cycle is None:
  1234. raise ValueError(f"没有找到周期 {period_number}")
  1235. angles360 = np.linspace(0.0, 359.0, 360)
  1236. pressure = np.interp(angles360, cycle.angle, cycle.signal)
  1237. display_angles = np.where(angles360 <= 180.0, angles360, 360.0 - angles360)
  1238. point_name = str(metadata.get("point_name") or "")
  1239. cylinder_name = next(
  1240. (name for name in CYLINDER_BORE_MM if name in point_name),
  1241. None,
  1242. )
  1243. volume: np.ndarray | None = None
  1244. volume_info: dict[str, Any] | None = None
  1245. if cylinder_name:
  1246. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  1247. clearance_volume = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  1248. angle_rad = np.deg2rad(angles360)
  1249. crank_radius = PISTON_STROKE_MM / 2.0
  1250. piston_travel = (
  1251. crank_radius * (1.0 - np.cos(angle_rad))
  1252. + CONNECTING_ROD_LENGTH_MM
  1253. - np.sqrt(
  1254. CONNECTING_ROD_LENGTH_MM**2
  1255. - (crank_radius * np.sin(angle_rad)) ** 2,
  1256. )
  1257. )
  1258. piston_area = np.pi * (bore_mm / 2.0) ** 2
  1259. volume = clearance_volume + piston_area * piston_travel / 1_000_000.0
  1260. volume_info = {
  1261. "cylinder": cylinder_name,
  1262. "boreMm": bore_mm,
  1263. "clearanceVolumeL": clearance_volume,
  1264. "minVolumeL": float(np.min(volume)),
  1265. "maxVolumeL": float(np.max(volume)),
  1266. }
  1267. start_index = int(samples[cycle.start_offset, 0])
  1268. end_offset = min(cycle.end_offset, len(samples) - 1)
  1269. end_index = int(samples[max(cycle.end_offset - 1, cycle.start_offset), 0])
  1270. return {
  1271. "waveFile": {
  1272. "id": int(metadata["id"]),
  1273. "pointName": point_name,
  1274. "measurementType": metadata.get("measurement_type"),
  1275. "sampleTime": _time_string(metadata.get("sample_time")),
  1276. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  1277. "sampleCount": int(metadata.get("sample_count") or len(samples)),
  1278. },
  1279. "period": {
  1280. "periodNo": cycle.number,
  1281. "startSampleIndex": start_index,
  1282. "endSampleIndex": end_index,
  1283. "sampleCount": int(cycle.end_offset - cycle.start_offset),
  1284. "triggerSampleIndices": [
  1285. int(samples[offset, 0])
  1286. for offset in cycle.trigger_offsets
  1287. if 0 <= offset < len(samples)
  1288. ],
  1289. },
  1290. "angles": display_angles.tolist(),
  1291. "angles360": angles360.tolist(),
  1292. "pressure": pressure.tolist(),
  1293. "volume": volume.tolist() if volume is not None else None,
  1294. "volumeInfo": volume_info,
  1295. "phases": [
  1296. {
  1297. "name": name,
  1298. "color": color,
  1299. "start": start,
  1300. "end": end,
  1301. }
  1302. for name, color, start, end in PHASES
  1303. ],
  1304. "diagnostics": diagnostics,
  1305. }
  1306. @staticmethod
  1307. def _load_for_window(
  1308. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  1309. file_id: int,
  1310. measurement_type: str,
  1311. point_name: str,
  1312. sample_time: str,
  1313. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]],
  1314. ) -> tuple[dict[str, Any], np.ndarray]:
  1315. if file_id not in load_cache:
  1316. if loader.__name__ == "_load_demo_wave":
  1317. load_cache[file_id] = loader(file_id, measurement_type, point_name, sample_time)
  1318. else:
  1319. load_cache[file_id] = loader(file_id)
  1320. return load_cache[file_id]
  1321. def _load_db_wave(self, file_id: int) -> tuple[dict[str, Any], np.ndarray]:
  1322. with get_connection() as connection:
  1323. with connection.cursor() as cursor:
  1324. cursor.execute(
  1325. """
  1326. SELECT id, point_name, measurement_type, sample_frequency_hz,
  1327. sample_count, sample_time, rpm, file_name, tspluse_status
  1328. FROM wave_file
  1329. WHERE id = %s
  1330. """,
  1331. (file_id,),
  1332. )
  1333. metadata = cursor.fetchone()
  1334. if metadata is None:
  1335. raise ValueError(f"wave_file.id={file_id} 不存在")
  1336. cursor.execute(
  1337. """
  1338. SELECT sample_index, signal_value, second_value
  1339. FROM wave_sample
  1340. WHERE wave_file_id = %s
  1341. ORDER BY sample_index ASC
  1342. """,
  1343. (file_id,),
  1344. )
  1345. rows = cursor.fetchall()
  1346. if not rows:
  1347. raise ValueError(f"wave_file.id={file_id} 没有采样数据")
  1348. samples = np.asarray(
  1349. [
  1350. (
  1351. float(row["sample_index"]),
  1352. float(row["signal_value"]),
  1353. float(row["second_value"]) if row["second_value"] is not None else np.nan,
  1354. )
  1355. for row in rows
  1356. ],
  1357. dtype=float,
  1358. )
  1359. return metadata, samples
  1360. @staticmethod
  1361. @lru_cache(maxsize=24)
  1362. def _demo_samples(file_id: int, measurement_type: str) -> np.ndarray:
  1363. count = DEMO_SAMPLE_COUNT
  1364. index = np.arange(count, dtype=float)
  1365. revolution = DEMO_REVOLUTION_SAMPLES
  1366. phase = (index % revolution) / revolution * 2 * np.pi
  1367. second = np.zeros(count, dtype=float)
  1368. for revolution_start in range(0, count, revolution):
  1369. for pulse in range(PULSES_PER_REVOLUTION):
  1370. pulse_start = revolution_start + int(round(pulse * revolution / PULSES_PER_REVOLUTION))
  1371. width = 22 if pulse == 0 else 8
  1372. pulse_end = min(count, pulse_start + width)
  1373. second[pulse_start:pulse_end] = 40.0
  1374. variation = (file_id % 17) / 17.0
  1375. if measurement_type == "压力":
  1376. signal = (
  1377. 4.2
  1378. + 1.8 * np.sin(phase - 0.4)
  1379. + 0.55 * np.sin(2 * phase + variation)
  1380. + 0.22 * np.sin(7 * phase)
  1381. )
  1382. signal += 0.2 * np.maximum(np.sin(phase - 0.2), 0) ** 5
  1383. elif measurement_type == "位移":
  1384. signal = 0.5 + 0.18 * np.cos(phase) + 0.035 * np.sin(3 * phase + variation)
  1385. else:
  1386. signal = 0.15 * np.sin(phase * 2 + variation) + 0.04 * np.sin(11 * phase)
  1387. signal += 0.018 * np.cos(index / 37.0)
  1388. return np.column_stack((index, signal, second))
  1389. def _load_demo_wave(
  1390. self,
  1391. file_id: int,
  1392. measurement_type: str,
  1393. point_name: str,
  1394. sample_time: str,
  1395. ) -> tuple[dict[str, Any], np.ndarray]:
  1396. samples = self._demo_samples(file_id, measurement_type)
  1397. metadata = {
  1398. "id": file_id,
  1399. "point_name": point_name,
  1400. "measurement_type": measurement_type,
  1401. "sample_frequency_hz": 25600,
  1402. "sample_count": len(samples),
  1403. "sample_time": sample_time,
  1404. "rpm": 998.0,
  1405. "file_name": f"demo-{file_id}.dat",
  1406. }
  1407. return metadata, samples
  1408. @staticmethod
  1409. def _demo_options() -> list[dict[str, Any]]:
  1410. end = DEMO_START + timedelta(minutes=5 * (DEMO_POINT_COUNT - 1))
  1411. return [
  1412. {
  1413. "pointName": DEMO_POINT_NAME,
  1414. "measurementType": measurement_type,
  1415. "minTime": _time_string(DEMO_START),
  1416. "maxTime": _time_string(end),
  1417. "fileCount": DEMO_POINT_COUNT,
  1418. }
  1419. for measurement_type in MEASUREMENT_TYPES
  1420. ]
  1421. @staticmethod
  1422. def _demo_time_points(
  1423. point_name: str,
  1424. types: list[str],
  1425. start: datetime | None,
  1426. end: datetime | None,
  1427. ) -> list[dict[str, Any]]:
  1428. points = []
  1429. for index in range(DEMO_POINT_COUNT):
  1430. timestamp = DEMO_START + timedelta(minutes=5 * index)
  1431. if start and timestamp < start:
  1432. continue
  1433. if end and timestamp > end:
  1434. continue
  1435. files = {
  1436. measurement_type: {
  1437. "id": DEMO_ID_BY_TYPE[measurement_type] + index,
  1438. "sampleCount": DEMO_SAMPLE_COUNT,
  1439. "sampleFrequencyHz": 25600,
  1440. "rpm": 998.0,
  1441. }
  1442. for measurement_type in types
  1443. }
  1444. points.append(
  1445. {
  1446. "index": len(points),
  1447. "sampleTime": _time_string(timestamp),
  1448. "files": files,
  1449. },
  1450. )
  1451. return points
  1452. data_service = DataService()