data_service.py 77 KB

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