data_service.py 93 KB

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