data_service.py 89 KB

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