data_service.py 79 KB

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