data_service.py 71 KB

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