data_service.py 148 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296
  1. from __future__ import annotations
  2. import re
  3. import json
  4. from bisect import bisect_left
  5. from collections import OrderedDict
  6. from datetime import datetime, timedelta
  7. from functools import lru_cache
  8. from time import monotonic
  9. from typing import Any, Callable
  10. import numpy as np
  11. from ..algorithms.cycles import (
  12. DetectedCycle,
  13. PULSES_PER_REVOLUTION,
  14. build_angle_vector,
  15. detect_cycles,
  16. downsample_indices,
  17. )
  18. from ..config import settings
  19. from ..db import get_connection
  20. MEASUREMENT_TYPES = ("位移", "加速度", "压力")
  21. MEASUREMENT_COLORS = {
  22. "压力": "#e4572e",
  23. "位移": "#1f7a8c",
  24. "加速度": "#7b61a8",
  25. }
  26. DEVICE_POINTS = ("压力盖侧", "压力轴侧", "活塞杆沉降", "十字头振动", "自由端振动", "驱动端振动")
  27. PRESSURE_SIDES = ("压力盖侧", "压力轴侧")
  28. DEVICE_POINT_TO_TYPE = {
  29. "压力盖侧": "压力",
  30. "压力轴侧": "压力",
  31. "活塞杆沉降": "位移",
  32. "十字头振动": "加速度",
  33. "自由端振动": "加速度",
  34. "驱动端振动": "加速度",
  35. }
  36. PRIMARY_DEVICE_POINT = "压力盖侧"
  37. PHASES = (
  38. ("排气", "#7b1fa2", 0.0, 120.0),
  39. ("压缩", "#c62828", 120.0, 195.0),
  40. ("膨胀", "#1565c0", 195.0, 270.0),
  41. ("进气", "#2e7d32", 270.0, 330.0),
  42. )
  43. ANNOTATION_LABELS = ("正常", "异常")
  44. OIL_PRESSURE_ALARM_TYPE = "润滑油压力低"
  45. CRUCIFORM_FAULT_ALARM_TYPE = "十字头故障"
  46. VALVE_FAULT_ALARM_TYPE = "气阀故障"
  47. OIL_PRESSURE_DEVICE_PART = "润滑油"
  48. OIL_PRESSURE_DEVICE_POINT = "压力"
  49. VIBRATION_DEVICE_POINT = "振动"
  50. OIL_PRESSURE_SCAN_DAYS = 14
  51. # PKS 全场点位:机组号 -> compressor_data_import 中 directory_path 为 "{号}#"
  52. # 且 data_type='PKS' 的所有导入批次(同一机组可能有多个时间段批次)。
  53. UNIT_PKS_DIRECTORY_SUFFIX = "#"
  54. # 全场点位(PKS)每个时间点的曲线窗口:以采样时刻为中心的前后各 7.5 分钟。
  55. PKS_WINDOW_SECONDS = 15 * 60
  56. # 仅 PKS 时间点返回给时间条的最大锚点数:超过后按等步长均匀采样(首尾必保)。
  57. PKS_STRIP_MAX_ANCHORS = 5000
  58. _SITE_POINT_PATTERN = re.compile(r"^YSJ([4-9])_([1-9]|[1-3][0-9]|4[0-4])$")
  59. _SITE_INDEX_MAX = 44
  60. # 不作为全场点位(PKS)下拉候选的序号(各机组不同:备用/累计运行等信号)。
  61. _EXCLUDED_SITE_INDEXES: dict[str, frozenset[int]] = {
  62. "4": frozenset({31, 32, 33, 34, 35, 36}),
  63. "5": frozenset(),
  64. "6": frozenset(),
  65. "7": frozenset({33, 34, 35, 41}),
  66. "8": frozenset({33, 34, 35, 41}),
  67. "9": frozenset({33, 34, 35, 41}),
  68. }
  69. # 仅这些机组的 YSJ_41 是转速/运行信号,可用于停机判断;4/5/6 的 YSJ_41 非转速,
  70. # 无法据此判断停机,一律按运转处理。
  71. _RUNNING_SIGNAL_UNITS = frozenset({"7", "8", "9"})
  72. # Extra samples fetched past cycle_end so detect_cycles can see the zero marker
  73. # that closes the first cycle (cycle_end is exclusive, the marker sits at it).
  74. FIRST_CYCLE_PAD = 64
  75. CYLINDER_BORE_MM = {
  76. "一缸": 360.0,
  77. "二缸": 490.0,
  78. "三缸": 390.0,
  79. "四缸": 490.0,
  80. "五缸": 390.0,
  81. "六缸": 490.0,
  82. }
  83. PISTON_STROKE_MM = 148.0
  84. CONNECTING_ROD_LENGTH_MM = 460.0
  85. CLEARANCE_VOLUME_L_BY_BORE = {
  86. 490.0: 1.51,
  87. 390.0: 0.74,
  88. 360.0: 0.62,
  89. }
  90. DEMO_POINT_NAME = "7号机组一缸压力盖侧"
  91. DEMO_DEVICE_PART = "7号机组一缸"
  92. DEMO_START = datetime(2026, 4, 12, 8, 0, 0)
  93. DEMO_POINT_COUNT = 72
  94. DEMO_SAMPLE_COUNT = 32768
  95. DEMO_REVOLUTION_SAMPLES = 800
  96. DEMO_ID_BY_TYPE = {name: 100000 + index * 1000 for index, name in enumerate(MEASUREMENT_TYPES)}
  97. def _time_string(value: Any) -> str:
  98. if isinstance(value, datetime):
  99. return value.strftime("%Y-%m-%d %H:%M:%S")
  100. return str(value)
  101. def _parse_time(value: str | None) -> datetime | None:
  102. if not value:
  103. return None
  104. return datetime.fromisoformat(value.replace("Z", "+00:00").replace("T", " "))
  105. def _safe_float(value: Any) -> float | None:
  106. if value is None:
  107. return None
  108. number = float(value)
  109. return number if np.isfinite(number) else None
  110. def _stored_first_cycle(samples: np.ndarray) -> DetectedCycle | None:
  111. """Build a cycle from wave_sample_one, which contains one cycle only."""
  112. if samples.ndim != 2 or samples.shape[1] < 3 or len(samples) < 2:
  113. return None
  114. start = 0
  115. end = len(samples)
  116. angle = np.linspace(0.0, 360.0, end - start, endpoint=False)
  117. trigger_offsets = tuple(
  118. int(offset)
  119. for offset in np.flatnonzero(
  120. np.nan_to_num(samples[:, 2], nan=-np.inf) >= 30.0,
  121. )
  122. if offset == 0 or samples[offset - 1, 2] < 30.0
  123. )
  124. return DetectedCycle(
  125. number=1,
  126. start_offset=start,
  127. end_offset=end,
  128. angle=angle,
  129. signal=samples[start:end, 1].copy(),
  130. trigger_offsets=trigger_offsets,
  131. )
  132. def _sample_first_cycle_360(samples: np.ndarray) -> list[tuple[int, float]]:
  133. """Resample the stored first cycle into 360 integer-angle points.
  134. Each integer angle 0..359 keeps the first raw sample whose crank angle is at
  135. or after that integer value (the "取第一个值" rule from showPV).
  136. """
  137. if samples.ndim != 2 or samples.shape[1] < 2 or len(samples) < 2:
  138. return []
  139. count = len(samples)
  140. angle = np.linspace(0.0, 360.0, count, endpoint=False)
  141. indices = np.searchsorted(angle, np.arange(360, dtype=float), side="left")
  142. indices = np.clip(indices, 0, count - 1)
  143. return [
  144. (int(deg), float(samples[int(idx), 1]))
  145. for deg, idx in enumerate(indices)
  146. ]
  147. def _detect_source_cycles(
  148. samples: np.ndarray,
  149. single_cycle: bool,
  150. ) -> tuple[list[DetectedCycle], dict[str, Any]]:
  151. """Detect cycles: wave_sample_one holds exactly one stored cycle already."""
  152. if single_cycle:
  153. stored = _stored_first_cycle(samples)
  154. if stored is None:
  155. return [], {"completeCycleCount": 0}
  156. return [stored], {"completeCycleCount": 1, "storedFirstCycle": 1}
  157. return detect_cycles(samples)
  158. def _series_extent(items: list[dict[str, Any]]) -> tuple[float, float]:
  159. """Whole-window min/max over a series' finite raw values."""
  160. if not items:
  161. return (0.0, 1.0)
  162. values = np.fromiter((item["rawValue"] for item in items), dtype=float, count=len(items))
  163. values = values[np.isfinite(values)]
  164. if values.size == 0:
  165. return (0.0, 1.0)
  166. return (float(values.min()), float(values.max()))
  167. def _annotation_dict(row: dict[str, Any]) -> dict[str, Any]:
  168. return {
  169. "id": int(row["id"]),
  170. "waveFileId": int(row["wave_file_id"]),
  171. "label": row["label"],
  172. "periodStart": int(row["period_start"]),
  173. "periodEnd": int(row["period_end"]),
  174. "sampleIndexStart": int(row["sample_index_start"]),
  175. "sampleIndexEnd": int(row["sample_index_end"]),
  176. }
  177. def _alarm_dict(row: dict[str, Any]) -> dict[str, Any]:
  178. return {
  179. "id": int(row["id"]),
  180. "deviceCode": row["device_code"] or "",
  181. "devicePart": row["device_part"] or "",
  182. "devicePoint": row["device_point"] or "",
  183. "alarmType": row["alarm_type"] or "",
  184. "alarmDes": row["alarm_des"] or "",
  185. "status": int(row.get("status") or 0),
  186. "alarmLevel": row.get("alarm_level"),
  187. "scanStartTime": _time_string(row["scan_start_time"]) if row.get("scan_start_time") else "",
  188. "scanEndTime": _time_string(row["scan_end_time"]) if row.get("scan_end_time") else "",
  189. "alarmInfoJson": row.get("alarm_info_json") or "",
  190. "alarmTimeStart": _time_string(row["alarm_time_start"]),
  191. "alarmTimeEnd": _time_string(row["alarm_time_end"]),
  192. }
  193. def _validate_device_points(
  194. values: list[str] | tuple[str, ...] | None,
  195. *,
  196. allow_empty: bool = False,
  197. ) -> list[str]:
  198. """校验波形点位。None 表示未传参 → 回退为全部点位;显式空列表仅在 allow_empty 时放行。"""
  199. selected = list(DEVICE_POINTS) if values is None else list(values)
  200. invalid = [value for value in selected if value not in DEVICE_POINTS]
  201. if invalid:
  202. raise ValueError(f"不支持的测试点位:{'、'.join(invalid)}")
  203. result = [value for value in DEVICE_POINTS if value in selected]
  204. if not result and not allow_empty:
  205. raise ValueError("请至少选择一个测试点位")
  206. return result
  207. def _primary_device_point(points: list[str]) -> str:
  208. """Prefer pressure cap, then pressure shaft, then any selected point."""
  209. for point in ("压力盖侧", "压力轴侧"):
  210. if point in points:
  211. return point
  212. return points[0]
  213. def _unit_number(device_part: str) -> str | None:
  214. """从 机组与部位 提取机组号(4~9),非 4~9 机组返回 None。"""
  215. match = re.match(r"^([4-9])号机组", device_part.strip())
  216. return match.group(1) if match else None
  217. def _site_point_column(item_name: str, unit: str | None) -> str | None:
  218. """校验全场点位名称并映射到 pks_long_sample 的列名(如 YSJ7_3 -> YSJ_3)。"""
  219. match = _SITE_POINT_PATTERN.match(item_name)
  220. if not match or match.group(1) != unit:
  221. return None
  222. return f"YSJ_{match.group(2)}"
  223. class DataService:
  224. # 数据库失败后的重试冷却时间(秒)。超过该时间后自动重连数据库,
  225. # 避免一次网络抖动就把服务永久锁死在演示数据模式。
  226. DB_RETRY_COOLDOWN = 30.0
  227. def __init__(self) -> None:
  228. self._db_failed = settings.demo_mode == "always"
  229. self._db_failed_at = monotonic() if self._db_failed else 0.0
  230. self._last_db_error = ""
  231. self._demo_annotations: dict[int, dict[str, Any]] = {}
  232. self._demo_annotation_seq = 1
  233. # 机组号 -> pks_long_sample 该机组所有批次 [min_time, max_time],进程内只查一次。
  234. self._pks_batch_bounds: dict[str, tuple[datetime, datetime] | None] = {}
  235. # 机组号 -> compressor_data_import 中该机组 PKS 批次 id 列表,进程内只查一次。
  236. self._pks_batch_ids_cache: dict[str, list[int]] = {}
  237. @property
  238. def source(self) -> str:
  239. return "demo" if self._db_failed else "database"
  240. @property
  241. def last_db_error(self) -> str:
  242. return self._last_db_error
  243. def _run_with_fallback(
  244. self,
  245. database_function: Callable[[], Any],
  246. demo_function: Callable[[], Any],
  247. ) -> tuple[Any, str]:
  248. if self._db_failed:
  249. if settings.demo_mode == "always":
  250. return demo_function(), "demo"
  251. if monotonic() - self._db_failed_at < self.DB_RETRY_COOLDOWN:
  252. return demo_function(), "demo"
  253. # 冷却结束,重新尝试数据库,数据库恢复后可自动切回真实数据。
  254. try:
  255. result = database_function()
  256. self._db_failed = False
  257. self._last_db_error = ""
  258. return result, "database"
  259. except Exception as error:
  260. if settings.demo_mode == "never":
  261. raise
  262. self._db_failed = True
  263. self._db_failed_at = monotonic()
  264. self._last_db_error = str(error)
  265. return demo_function(), "demo"
  266. def query_options(self) -> dict[str, Any]:
  267. def database_query():
  268. with get_connection() as connection:
  269. with connection.cursor() as cursor:
  270. cursor.execute(
  271. """
  272. SELECT device_part, device_point, measurement_type,
  273. MAX(sample_time) AS max_time,
  274. MIN(sample_time) AS min_time,
  275. COUNT(*) AS file_count
  276. FROM wave_file
  277. WHERE rpm > 0 AND device_part <> '' AND device_point <> ''
  278. GROUP BY device_part, device_point, measurement_type
  279. ORDER BY device_part, device_point
  280. """,
  281. )
  282. rows = cursor.fetchall()
  283. return [
  284. {
  285. "devicePart": row["device_part"],
  286. "devicePoint": row["device_point"],
  287. "measurementType": row["measurement_type"],
  288. "minTime": _time_string(row["min_time"]),
  289. "maxTime": _time_string(row["max_time"]),
  290. "fileCount": int(row["file_count"]),
  291. }
  292. for row in rows
  293. ]
  294. rows, source = self._run_with_fallback(database_query, self._demo_options)
  295. device_parts = list(dict.fromkeys(row["devicePart"] for row in rows))
  296. if source == "database":
  297. device_parts.extend(self._pks_only_device_parts(device_parts))
  298. return {
  299. "source": source,
  300. "measurementTypes": list(MEASUREMENT_TYPES),
  301. "deviceParts": device_parts,
  302. "devicePoints": list(DEVICE_POINTS),
  303. "devicePointToType": dict(DEVICE_POINT_TO_TYPE),
  304. "options": rows,
  305. "notice": self._source_notice(source),
  306. }
  307. def _pks_only_device_parts(self, existing: list[str]) -> list[str]:
  308. """有 PKS 数据但 wave_file 无部位的机组,补一个纯 PKS 的"{号}号机组"入口。"""
  309. existing_units = {unit for part in existing if (unit := _unit_number(part)) is not None}
  310. try:
  311. with get_connection() as connection:
  312. with connection.cursor() as cursor:
  313. cursor.execute(
  314. "SELECT DISTINCT directory_path FROM compressor_data_import "
  315. "WHERE data_type = 'PKS' AND directory_path REGEXP '^[4-9]#$'",
  316. )
  317. directories = [str(row["directory_path"]) for row in cursor.fetchall()]
  318. except Exception:
  319. return []
  320. parts: list[str] = []
  321. for directory in directories:
  322. unit = directory.rstrip(UNIT_PKS_DIRECTORY_SUFFIX).strip()
  323. if unit.isdigit() and unit not in existing_units:
  324. parts.append(f"{unit}号机组")
  325. return parts
  326. def abnormal_counts(self) -> dict[str, int]:
  327. """每个 point_name 的异常文件数量(tspluse_status > 0)。"""
  328. def database_query():
  329. with get_connection() as connection:
  330. with connection.cursor() as cursor:
  331. cursor.execute(
  332. """
  333. SELECT point_name, COUNT(*) AS cnt
  334. FROM wave_file
  335. WHERE tspluse_status > 0 AND point_name <> ''
  336. GROUP BY point_name
  337. """,
  338. )
  339. rows = cursor.fetchall()
  340. return {row["point_name"]: int(row["cnt"]) for row in rows}
  341. def demo_query():
  342. return {}
  343. result, _source = self._run_with_fallback(database_query, demo_query)
  344. return result
  345. def tspluse_ruler(self) -> dict[str, Any]:
  346. """压力部位 tspluse_status 的全局标尺,进入页面时只查询一次。"""
  347. def database_query():
  348. with get_connection() as connection:
  349. with connection.cursor() as cursor:
  350. cursor.execute(
  351. """
  352. SELECT MIN(tspluse_status) AS min_status,
  353. MAX(tspluse_status) AS max_status
  354. FROM wave_file
  355. WHERE rpm > 0 AND measurement_type = '压力'
  356. """,
  357. )
  358. row = cursor.fetchone()
  359. return {
  360. "min": int(row["min_status"]) if row and row["min_status"] is not None else 0,
  361. "max": int(row["max_status"]) if row and row["max_status"] is not None else 0,
  362. }
  363. def demo_query():
  364. return {"min": 0, "max": 0}
  365. result, source = self._run_with_fallback(database_query, demo_query)
  366. result["source"] = source
  367. return result
  368. def _pks_batch_ids(self, unit: str) -> list[int]:
  369. """机组 PKS 导入批次 id 列表(directory_path = "{号}#" 且 data_type='PKS')。"""
  370. cached = self._pks_batch_ids_cache.get(unit)
  371. if cached is not None:
  372. return cached
  373. with get_connection() as connection:
  374. with connection.cursor() as cursor:
  375. cursor.execute(
  376. "SELECT id FROM compressor_data_import "
  377. "WHERE data_type = 'PKS' AND directory_path = %s ORDER BY id",
  378. (f"{unit}{UNIT_PKS_DIRECTORY_SUFFIX}",),
  379. )
  380. batches = [int(row["id"]) for row in cursor.fetchall()]
  381. self._pks_batch_ids_cache[unit] = batches
  382. return batches
  383. @staticmethod
  384. def _in_clause(values: list[Any]) -> str:
  385. return ", ".join(["%s"] * len(values))
  386. def _pks_batch_range(self, unit: str) -> tuple[datetime, datetime] | None:
  387. """机组 pks 所有批次的时间覆盖范围(仅查一次并缓存)。"""
  388. cached = self._pks_batch_bounds.get(unit)
  389. if cached is not None:
  390. return cached
  391. batches = self._pks_batch_ids(unit)
  392. if not batches:
  393. self._pks_batch_bounds[unit] = None
  394. return None
  395. with get_connection() as connection:
  396. with connection.cursor() as cursor:
  397. cursor.execute(
  398. "SELECT MIN(sample_time) AS lo, MAX(sample_time) AS hi "
  399. f"FROM pks_long_sample WHERE import_batch_id IN ({self._in_clause(batches)})",
  400. tuple(batches),
  401. )
  402. row = cursor.fetchone()
  403. bounds = (row["lo"], row["hi"]) if row and row["lo"] is not None else None
  404. self._pks_batch_bounds[unit] = bounds
  405. return bounds
  406. def site_points(self, device_part: str) -> dict[str, Any]:
  407. """机组(4~9)的全场点位记录(site_point 表中 YSJ{机组号}_*)。
  408. 附带该机组 pks 批次的时间覆盖范围,供“仅 PKS 点位”组合自动赋值
  409. 开始/结束时间。
  410. """
  411. unit = _unit_number(device_part)
  412. def database_query():
  413. if unit is None:
  414. return {"items": [], "minTime": None, "maxTime": None}
  415. allowed = {
  416. f"YSJ{unit}_{n}"
  417. for n in range(1, _SITE_INDEX_MAX + 1)
  418. if n not in _EXCLUDED_SITE_INDEXES.get(unit, frozenset())
  419. }
  420. with get_connection() as connection:
  421. with connection.cursor() as cursor:
  422. cursor.execute(
  423. "SELECT ItemName, ItemDescription FROM site_point WHERE ItemName LIKE %s",
  424. (f"YSJ{unit}\\_%",),
  425. )
  426. rows = cursor.fetchall()
  427. items = [
  428. {"itemName": row["ItemName"], "itemDescription": row["ItemDescription"] or ""}
  429. for row in rows
  430. if row["ItemName"] in allowed
  431. ]
  432. items.sort(key=lambda item: int(item["itemName"].rsplit("_", 1)[1]))
  433. bounds = self._pks_batch_range(unit)
  434. return {
  435. "items": items,
  436. "minTime": _time_string(bounds[0]) if bounds else None,
  437. "maxTime": _time_string(bounds[1]) if bounds else None,
  438. }
  439. def demo_query():
  440. return {"items": [], "minTime": None, "maxTime": None}
  441. result, source = self._run_with_fallback(database_query, demo_query)
  442. return {
  443. "source": source,
  444. "unit": unit,
  445. "items": result["items"],
  446. "minTime": result["minTime"],
  447. "maxTime": result["maxTime"],
  448. "notice": self._source_notice(source),
  449. }
  450. def _attach_site_values(self, points: list[dict[str, Any]], unit: str, site_points: list[str]) -> None:
  451. """把 pks_long_sample 的最近邻值挂到每个时间点的 siteValues 上。
  452. pks 数据 5 秒一条、wave_file 15 分钟一条。按用户口径做分钟/5秒级对齐:
  453. 把 wave 采样时刻四舍五入到最近的 5 秒格点,再用一次 ``sample_time IN (...)``
  454. 精确取数(结果行数 = 时间点数),避免把整段 pks 拉出来。
  455. """
  456. columns = [
  457. (item_name, column)
  458. for item_name in site_points
  459. if (column := _site_point_column(item_name, unit)) is not None
  460. ]
  461. batches = self._pks_batch_ids(unit)
  462. if not columns or not points or not batches:
  463. for point in points:
  464. point.setdefault("siteValues", {})
  465. return
  466. rounded: list[datetime] = []
  467. for point in points:
  468. timestamp = datetime.strptime(point["sampleTime"], "%Y-%m-%d %H:%M:%S")
  469. rounded.append(datetime.fromtimestamp(round(timestamp.timestamp() / 5.0) * 5))
  470. chunk_size = 1000
  471. select_expr = ", ".join(f"`{column}`" for _item, column in columns)
  472. for index in range(0, len(rounded), chunk_size):
  473. chunk = rounded[index:index + chunk_size]
  474. placeholders = ", ".join(["%s"] * len(chunk))
  475. with get_connection() as connection:
  476. with connection.cursor() as cursor:
  477. cursor.execute(
  478. f"SELECT sample_time, {select_expr} FROM pks_long_sample "
  479. f"WHERE import_batch_id IN ({self._in_clause(batches)}) "
  480. f"AND sample_time IN ({placeholders})",
  481. (*batches, *chunk),
  482. )
  483. rows = cursor.fetchall()
  484. by_time: dict[datetime, dict[str, Any]] = {
  485. row["sample_time"]: row for row in rows
  486. }
  487. for point_index in range(index, min(index + chunk_size, len(rounded))):
  488. target_time = rounded[point_index]
  489. row = by_time.get(target_time)
  490. if row is None:
  491. continue
  492. site_values = points[point_index].setdefault("siteValues", {})
  493. for item_name, column in columns:
  494. value = row[column]
  495. site_values[item_name] = float(value) if value is not None else None
  496. def _build_site_series(
  497. self,
  498. site_points: list[str],
  499. unit: str | None,
  500. points: list[dict[str, Any]],
  501. ) -> dict[str, Any]:
  502. """把每个时间点扩展为前后各 7.5 分钟的 pks 5s 曲线段。
  503. 每个选中点位(PKS)在该窗口内的每个时间点不再只画单值,而是以该
  504. 时间点采样时刻为中心,取 [t-7.5min, t+7.5min) 的原始 5s 数据映射
  505. 到该时间点在 x 轴占据的格子(索引 slot ~ slot+1)内连成一小段曲线。
  506. 相邻时间点若恰好间隔 15 分钟,则相邻窗口首尾衔接、无重叠。
  507. 数据按列做一次整段范围查询,再按时间点二分切段。
  508. """
  509. columns = [
  510. (item_name, column)
  511. for item_name in site_points
  512. if (column := _site_point_column(item_name, unit)) is not None
  513. ]
  514. centers: list[tuple[int, datetime]] = []
  515. for index, point in enumerate(points):
  516. try:
  517. timestamp = datetime.strptime(point["sampleTime"], "%Y-%m-%d %H:%M:%S")
  518. except (TypeError, ValueError):
  519. continue
  520. centers.append((index, timestamp))
  521. batches = self._pks_batch_ids(unit) if unit else []
  522. if not columns or not centers or not batches:
  523. return {"points": site_points, "series": []}
  524. half = timedelta(seconds=PKS_WINDOW_SECONDS // 2)
  525. query_start = min(timestamp for _index, timestamp in centers) - half
  526. query_end = max(timestamp for _index, timestamp in centers) + half
  527. select_expr = ", ".join(f"`{column}`" for _item, column in columns)
  528. with get_connection() as connection:
  529. with connection.cursor() as cursor:
  530. cursor.execute(
  531. f"SELECT sample_time, {select_expr} FROM pks_long_sample "
  532. f"WHERE import_batch_id IN ({self._in_clause(batches)}) "
  533. "AND sample_time >= %s AND sample_time < %s "
  534. "ORDER BY sample_time",
  535. (*batches, query_start, query_end),
  536. )
  537. rows = cursor.fetchall()
  538. # 同一机组可能有多个时间段批次,按 sample_time 去重,避免边界重叠重复绘制。
  539. dedup: dict[datetime, dict[str, Any]] = {}
  540. for row in rows:
  541. dedup.setdefault(row["sample_time"], row)
  542. ordered = [dedup[timestamp] for timestamp in sorted(dedup)]
  543. sample_times = [row["sample_time"] for row in ordered]
  544. series = []
  545. for item_name, column in columns:
  546. values = [row[column] for row in ordered]
  547. data: list[dict[str, Any]] = []
  548. for index, center in centers:
  549. low = bisect_left(sample_times, center - half)
  550. high = bisect_left(sample_times, center + half)
  551. for pos in range(low, high):
  552. raw_value = values[pos]
  553. if raw_value is None:
  554. continue
  555. value = float(raw_value)
  556. if not np.isfinite(value):
  557. continue
  558. x = index + 0.5 + (sample_times[pos] - center).total_seconds() / PKS_WINDOW_SECONDS
  559. data.append(
  560. {
  561. "value": [x, value],
  562. "x": x,
  563. "rawValue": value,
  564. "sampleTime": _time_string(sample_times[pos]),
  565. },
  566. )
  567. series.append({"itemName": item_name, "data": data})
  568. return {"points": site_points, "series": series}
  569. def time_points(
  570. self,
  571. device_part: str,
  572. device_points: list[str] | None,
  573. min_time: str | None,
  574. max_time: str | None,
  575. include_stopped: bool = False,
  576. min_status: int | None = None,
  577. status_filter: list[str] | None = None,
  578. site_points: list[str] | None = None,
  579. ) -> dict[str, Any]:
  580. if not device_part.strip():
  581. raise ValueError("机组与部位不能为空")
  582. status_filters = {value for value in (status_filter or [])}
  583. unknown = status_filters - {"abnormal", "no_cycle"}
  584. if unknown:
  585. raise ValueError(f"不支持的状态筛选:{'、'.join(sorted(unknown))}")
  586. start = _parse_time(min_time)
  587. end = _parse_time(max_time)
  588. if start and end and start > end:
  589. raise ValueError("开始时间不能晚于结束时间")
  590. unit = _unit_number(device_part)
  591. site_list = list(site_points or [])
  592. site_columns: list[tuple[str, str]] = []
  593. if unit:
  594. site_columns = [
  595. (item, column)
  596. for item in site_list
  597. if (column := _site_point_column(item, unit)) is not None
  598. ]
  599. if device_points is None or device_points == []:
  600. selected_points = (
  601. [] if (unit and site_columns) else _validate_device_points(None)
  602. )
  603. else:
  604. selected_points = _validate_device_points(device_points, allow_empty=True)
  605. pks_only = bool(unit and site_columns and not selected_points)
  606. if not selected_points and not pks_only:
  607. raise ValueError("请至少选择一个测试点位")
  608. point_names = [f"{device_part}{point}" for point in selected_points]
  609. if pks_only:
  610. points, source = self._run_with_fallback(
  611. lambda: self._pks_time_points(unit, site_columns, start, end, include_stopped),
  612. lambda: [],
  613. )
  614. return {
  615. "source": source,
  616. "devicePart": device_part,
  617. "devicePoints": [],
  618. "total": len(points),
  619. "points": points,
  620. "referencePoints": [],
  621. "notice": self._source_notice(source),
  622. }
  623. def database_query():
  624. point_placeholders = ", ".join(["%s"] * len(point_names))
  625. clauses = [
  626. f"point_name IN ({point_placeholders})",
  627. ]
  628. params: list[Any] = list(point_names)
  629. if not include_stopped:
  630. clauses.append("rpm > 0")
  631. if min_status is not None and min_status > 0 and "no_cycle" not in status_filters:
  632. clauses.append("tspluse_status >= %s")
  633. params.append(min_status)
  634. if "abnormal" in status_filters and "no_cycle" in status_filters:
  635. clauses.append("(tspluse_status > 0 OR tspluse_status = -1)")
  636. elif "abnormal" in status_filters:
  637. clauses.append("tspluse_status > 0")
  638. elif "no_cycle" in status_filters:
  639. clauses.append("tspluse_status = -1")
  640. if start:
  641. clauses.append("sample_time >= %s")
  642. params.append(start)
  643. if end:
  644. clauses.append("sample_time <= %s")
  645. params.append(end)
  646. with get_connection() as connection:
  647. with connection.cursor() as cursor:
  648. cursor.execute(
  649. f"""
  650. SELECT id, point_name, device_point, measurement_type,
  651. sample_time, sample_count, sample_frequency_hz,
  652. rpm, tspluse_status
  653. FROM wave_file
  654. WHERE {' AND '.join(clauses)}
  655. ORDER BY sample_time ASC, id ASC
  656. """,
  657. params,
  658. )
  659. rows = cursor.fetchall()
  660. reference_rows: list[dict[str, Any]] = []
  661. if status_filters and rows:
  662. primary = _primary_device_point(selected_points)
  663. reference_where = [
  664. "point_name = %s",
  665. "measurement_type = '压力'",
  666. "tspluse_status = 0",
  667. ]
  668. reference_params: list[Any] = [f"{device_part}{primary}"]
  669. if not include_stopped:
  670. reference_where.append("rpm > 0")
  671. if start:
  672. reference_where.append("sample_time >= %s")
  673. reference_params.append(start)
  674. if end:
  675. reference_where.append("sample_time <= %s")
  676. reference_params.append(end)
  677. reference_params.append(rows[0]["sample_time"])
  678. with get_connection() as connection:
  679. with connection.cursor() as cursor:
  680. cursor.execute(
  681. f"""
  682. SELECT id, point_name, device_point, measurement_type,
  683. sample_time, sample_count, sample_frequency_hz,
  684. rpm, tspluse_status
  685. FROM wave_file
  686. WHERE {' AND '.join(reference_where)}
  687. ORDER BY ABS(TIMESTAMPDIFF(SECOND, sample_time, %s)) ASC
  688. LIMIT 1
  689. """,
  690. reference_params,
  691. )
  692. reference_rows = cursor.fetchall()
  693. return self._group_time_points(rows), self._group_time_points(reference_rows)
  694. def demo_query():
  695. return self._demo_time_points(device_part, selected_points, start, end), []
  696. result, source = self._run_with_fallback(database_query, demo_query)
  697. points, reference = result
  698. # 全场点位(PKS)数据是辅助层:任意失败都静默跳过,不影响主查询。
  699. try:
  700. unit = _unit_number(device_part)
  701. if unit and site_points:
  702. self._attach_site_values(points, unit, site_points)
  703. except Exception:
  704. pass
  705. for point in points:
  706. point.setdefault("siteValues", {})
  707. return {
  708. "source": source,
  709. "devicePart": device_part,
  710. "devicePoints": selected_points,
  711. "total": len(points),
  712. "points": points,
  713. "referencePoints": reference,
  714. "notice": self._source_notice(source),
  715. }
  716. def faults(
  717. self,
  718. device_part: str,
  719. min_time: str | None,
  720. max_time: str | None,
  721. ) -> dict[str, Any]:
  722. """Return compressor_fault rows for the unit of ``device_part``.
  723. ``compressor_fault.unit_name`` ("7号机组") is matched against the unit
  724. prefix of ``wave_file.device_part`` ("7号机组一缸"). Only faults whose
  725. ``fault_date`` falls inside the requested time range are returned.
  726. """
  727. unit = _unit_number(device_part)
  728. start = _parse_time(min_time)
  729. end = _parse_time(max_time)
  730. def database_query():
  731. if unit is None:
  732. return []
  733. clauses = ["(unit_name = %s OR unit_name LIKE %s)"]
  734. params: list[Any] = [f"{unit}号机组", f"{unit}号机组%"]
  735. if start:
  736. clauses.append("fault_date >= DATE(%s)")
  737. params.append(start)
  738. if end:
  739. clauses.append("fault_date <= DATE(%s)")
  740. params.append(end)
  741. with get_connection() as connection:
  742. with connection.cursor() as cursor:
  743. cursor.execute(
  744. f"""
  745. SELECT id, unit_name, fault_date, fault_category
  746. FROM compressor_fault
  747. WHERE {' AND '.join(clauses)}
  748. ORDER BY fault_date ASC, id ASC
  749. """,
  750. params,
  751. )
  752. rows = cursor.fetchall()
  753. return [
  754. {
  755. "id": int(row["id"]),
  756. "unitName": row["unit_name"] or "",
  757. "faultDate": _time_string(row["fault_date"])[:10],
  758. "faultCategory": row["fault_category"] or "",
  759. }
  760. for row in rows
  761. ]
  762. result, source = self._run_with_fallback(database_query, lambda: [])
  763. return {
  764. "source": source,
  765. "unit": unit,
  766. "faults": result,
  767. "notice": self._source_notice(source),
  768. }
  769. def list_alarms(self, current_time: str | None = None) -> dict[str, Any]:
  770. """列出在给定时刻命中的告警。
  771. 命中条件为 ``alarm_time_start <= 当前时间 <= alarm_time_end``,
  772. 即告警时间段覆盖“告警当前时间”。
  773. """
  774. moment = _parse_time(current_time) or datetime.now()
  775. def database_query():
  776. with get_connection() as connection:
  777. with connection.cursor() as cursor:
  778. cursor.execute(
  779. """
  780. SELECT id, device_code, device_part, device_point, alarm_type,
  781. alarm_des, alarm_time_start, alarm_time_end, status,
  782. alarm_level, scan_start_time, scan_end_time, alarm_info_json
  783. FROM compressor_alarm
  784. WHERE alarm_time_start <= %s AND alarm_time_end >= %s
  785. ORDER BY alarm_time_start DESC, id DESC
  786. """,
  787. (moment, moment),
  788. )
  789. rows = cursor.fetchall()
  790. return [_alarm_dict(row) for row in rows]
  791. result, source = self._run_with_fallback(database_query, lambda: [])
  792. return {
  793. "source": source,
  794. "currentTime": _time_string(moment),
  795. "alarms": result,
  796. "notice": self._source_notice(source),
  797. }
  798. def scan_alarm(self, payload: dict[str, Any]) -> dict[str, Any]:
  799. """执行告警算法并按原有协议写入 compressor_alarm。"""
  800. device_code = str(payload.get("device_code") or "").strip()
  801. alarm_type = str(payload.get("alarm_type") or "").strip()
  802. start = _parse_time(payload.get("forecast_time"))
  803. try:
  804. hours = float(payload.get("hours") or 0)
  805. except (TypeError, ValueError):
  806. raise ValueError("告警时长必须是数字") from None
  807. if not device_code:
  808. raise ValueError("压缩机不能为空")
  809. if not alarm_type:
  810. raise ValueError("算法不能为空")
  811. if start is None:
  812. raise ValueError("预警时间不能为空")
  813. if hours <= 0:
  814. raise ValueError("告警时长必须大于 0")
  815. if alarm_type == VALVE_FAULT_ALARM_TYPE:
  816. return self._scan_valve_fault(device_code, start, hours)
  817. if alarm_type == OIL_PRESSURE_ALARM_TYPE:
  818. # `hours` controls the alarm validity window, not the analysis window.
  819. # Oil pressure analysis always uses the previous 14 days below.
  820. analysis = self._analyze_oil_pressure_alarm(device_code, start)
  821. if analysis is None:
  822. return {
  823. "source": "database",
  824. "notice": None,
  825. "action": "no_alarm",
  826. "id": 0,
  827. }
  828. device_part = analysis["device_part"]
  829. device_point = OIL_PRESSURE_DEVICE_POINT
  830. alarm_des = analysis["basis"]
  831. status = 1
  832. alarm_level = analysis["stage"]
  833. scan_start = analysis["scan_start"]
  834. scan_end = analysis["scan_end"]
  835. alarm_info_json = json.dumps(analysis["info"], ensure_ascii=False, separators=(",", ":"))
  836. elif alarm_type == CRUCIFORM_FAULT_ALARM_TYPE:
  837. analysis = self._analyze_cruciform_vibration_alarm(device_code, start)
  838. if analysis is None:
  839. return {
  840. "source": "database",
  841. "notice": None,
  842. "action": "no_alarm",
  843. "id": 0,
  844. }
  845. device_part = analysis["device_part"]
  846. device_point = VIBRATION_DEVICE_POINT
  847. alarm_des = analysis["basis"]
  848. status = 1
  849. alarm_level = analysis["stage"]
  850. scan_start = analysis["scan_start"]
  851. scan_end = analysis["scan_end"]
  852. alarm_info_json = json.dumps(analysis["info"], ensure_ascii=False, separators=(",", ":"))
  853. else:
  854. # Keep the existing endpoint contract for algorithms not yet implemented.
  855. device_part = "测试组件"
  856. device_point = "测试组件"
  857. alarm_des = "测试数据"
  858. status = 0
  859. alarm_level = None
  860. scan_start = None
  861. scan_end = None
  862. alarm_info_json = None
  863. end = start + timedelta(hours=hours)
  864. def database_query():
  865. with get_connection() as connection:
  866. with connection.cursor() as cursor:
  867. cursor.execute(
  868. """
  869. SELECT id FROM compressor_alarm
  870. WHERE device_code = %s AND device_part = %s
  871. AND device_point = %s AND alarm_type = %s
  872. AND alarm_time_start <= %s AND alarm_time_end >= %s
  873. ORDER BY id ASC
  874. LIMIT 1
  875. """,
  876. (device_code, device_part, device_point, alarm_type, start, start),
  877. )
  878. row = cursor.fetchone()
  879. if row is not None:
  880. cursor.execute(
  881. """
  882. UPDATE compressor_alarm
  883. SET alarm_time_end = %s, alarm_des = %s, status = %s,
  884. alarm_level = %s, scan_start_time = %s, scan_end_time = %s,
  885. alarm_info_json = %s
  886. WHERE id = %s
  887. """,
  888. (end, alarm_des, status, alarm_level, scan_start, scan_end, alarm_info_json, row["id"]),
  889. )
  890. return {"action": "updated", "id": int(row["id"])}
  891. cursor.execute(
  892. """
  893. INSERT INTO compressor_alarm
  894. (device_code, device_part, device_point, alarm_type,
  895. alarm_des, alarm_time_start, alarm_time_end, status,
  896. alarm_level, scan_start_time, scan_end_time, alarm_info_json)
  897. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  898. """,
  899. (device_code, device_part, device_point, alarm_type, alarm_des, start, end, status,
  900. alarm_level, scan_start, scan_end, alarm_info_json),
  901. )
  902. return {"action": "inserted", "id": int(cursor.lastrowid)}
  903. result, source = self._run_with_fallback(
  904. database_query,
  905. lambda: {"action": "demo", "id": 0},
  906. )
  907. return {"source": source, "notice": self._source_notice(source), **result}
  908. @staticmethod
  909. def _valve_segment_values(values: list[float]) -> list[float]:
  910. if not values:
  911. return []
  912. raw_average = sum(values) / len(values)
  913. return [value for value in values if value >= raw_average * 0.5]
  914. @classmethod
  915. def _valve_segment_average(cls, values: list[float]) -> float | None:
  916. filtered = cls._valve_segment_values(values)
  917. return sum(filtered) / len(filtered) if filtered else None
  918. @staticmethod
  919. def _split_valve_segments(rows: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
  920. segments: list[list[dict[str, Any]]] = []
  921. current: list[dict[str, Any]] = []
  922. for row in rows:
  923. if current and row["sample_time"] - current[-1]["sample_time"] > timedelta(days=1):
  924. segments.append(current)
  925. current = []
  926. current.append(row)
  927. if current:
  928. segments.append(current)
  929. return segments
  930. @staticmethod
  931. def _previous_valve_segment(
  932. cursor: Any, part: str, point: str, before: datetime, current_first: datetime
  933. ) -> tuple[datetime, datetime] | None:
  934. """Find the nearest preceding running segment lasting at least 24 hours."""
  935. boundary = before
  936. boundary_id = 1 << 63
  937. newer = None
  938. previous_end = None
  939. previous_start = None
  940. while True:
  941. cursor.execute(
  942. """
  943. SELECT id, sample_time FROM wave_file
  944. WHERE device_part = %s AND device_point = %s
  945. AND measurement_type = %s AND rpm > 0
  946. AND (sample_time < %s OR (sample_time = %s AND id < %s))
  947. ORDER BY sample_time DESC, id DESC LIMIT 1000
  948. """,
  949. (part, point, "压力", boundary, boundary, boundary_id),
  950. )
  951. rows = cursor.fetchall()
  952. if not rows:
  953. break
  954. for row in rows:
  955. stamp = row["sample_time"]
  956. if newer is None:
  957. previous_end = stamp
  958. previous_start = stamp
  959. elif newer - stamp > timedelta(days=1):
  960. if previous_end - previous_start >= timedelta(hours=24):
  961. return previous_start, previous_end
  962. previous_end = stamp
  963. previous_start = stamp
  964. else:
  965. previous_start = stamp
  966. newer = stamp
  967. boundary = rows[-1]["sample_time"]
  968. boundary_id = int(rows[-1]["id"])
  969. if len(rows) < 1000:
  970. break
  971. if (
  972. previous_start is not None
  973. and previous_end is not None
  974. and previous_end - previous_start >= timedelta(hours=24)
  975. ):
  976. return previous_start, previous_end
  977. return None
  978. def _scan_valve_fault(self, device_code: str, forecast_time: datetime, hours: float) -> dict[str, Any]:
  979. unit_name = device_code if device_code.endswith("号机组") else f"{device_code.rstrip('#')}号机组"
  980. scan_start = forecast_time - timedelta(days=14)
  981. scan_end = forecast_time + timedelta(days=1)
  982. def database_query():
  983. with get_connection() as connection:
  984. with connection.cursor() as cursor:
  985. cursor.execute(
  986. """
  987. SELECT f.device_part, f.device_point, f.sample_time, pa.pressure
  988. FROM wave_file f
  989. LEFT JOIN statistic_pressure_angle pa
  990. ON f.id = pa.wave_file_id AND pa.angle = 230
  991. WHERE SUBSTRING(f.device_part, 1, 4) = %s
  992. AND f.rpm > 0
  993. AND f.measurement_type = %s
  994. AND f.sample_time >= %s AND f.sample_time < %s
  995. ORDER BY f.device_part, f.device_point, f.sample_time
  996. """,
  997. (unit_name, "压力", scan_start, scan_end),
  998. )
  999. current_rows = cursor.fetchall()
  1000. grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
  1001. for row in current_rows:
  1002. grouped.setdefault((row["device_part"], row["device_point"]), []).append(row)
  1003. results = []
  1004. for (part, point), running_rows in grouped.items():
  1005. segments = self._split_valve_segments(running_rows)
  1006. if not segments:
  1007. continue
  1008. current = segments[-1]
  1009. current_scan_start = current[0]["sample_time"]
  1010. current_values = self._valve_segment_values(
  1011. [float(row["pressure"]) for row in current if row["pressure"] is not None]
  1012. )
  1013. current_avg = sum(current_values) / len(current_values) if current_values else None
  1014. if current_avg is None or current_avg == 0:
  1015. continue
  1016. previous_range = None
  1017. previous_values: list[float] = []
  1018. eligible_previous = [
  1019. segment for segment in segments[:-1]
  1020. if segment[-1]["sample_time"] - segment[0]["sample_time"] >= timedelta(hours=24)
  1021. ]
  1022. if eligible_previous:
  1023. previous = eligible_previous[-1]
  1024. previous_start = previous[0]["sample_time"]
  1025. previous_end = previous[-1]["sample_time"]
  1026. previous_values = self._valve_segment_values(
  1027. [float(row["pressure"]) for row in previous if row["pressure"] is not None]
  1028. )
  1029. previous_range = (previous_start, previous_end)
  1030. else:
  1031. previous_range = self._previous_valve_segment(
  1032. cursor, part, point, current_scan_start, current_scan_start
  1033. )
  1034. previous_start = previous_end = None
  1035. previous_avg = None
  1036. if previous_range is not None:
  1037. previous_start, previous_end = previous_range
  1038. if not previous_values:
  1039. cursor.execute(
  1040. """
  1041. SELECT pa.pressure FROM wave_file f
  1042. INNER JOIN statistic_pressure_angle pa
  1043. ON f.id = pa.wave_file_id AND pa.angle = 230
  1044. WHERE f.device_part = %s AND f.device_point = %s
  1045. AND f.measurement_type = %s AND f.rpm > 0
  1046. AND f.sample_time >= %s AND f.sample_time <= %s
  1047. """,
  1048. (part, point, "压力", previous_start, previous_end),
  1049. )
  1050. previous_values = self._valve_segment_values(
  1051. [float(row["pressure"]) for row in cursor.fetchall() if row["pressure"] is not None]
  1052. )
  1053. previous_avg = sum(previous_values) / len(previous_values) if previous_values else None
  1054. else:
  1055. previous_values = []
  1056. is_fault = previous_avg is not None and previous_avg > current_avg * 1.05
  1057. if not is_fault:
  1058. continue
  1059. info = json.dumps(
  1060. {
  1061. "当前段平均值": current_avg,
  1062. "上一段平均值": previous_avg,
  1063. "当前段最高值": max(current_values) if current_values else None,
  1064. "当前段最低值": min(current_values) if current_values else None,
  1065. "上一段最高值": max(previous_values) if previous_values else None,
  1066. "上一段最低值": min(previous_values) if previous_values else None,
  1067. "上一段开始时间": _time_string(previous_start) if previous_start else None,
  1068. "上一段结束时间": _time_string(previous_end) if previous_end else None,
  1069. },
  1070. ensure_ascii=False,
  1071. )
  1072. description = (
  1073. f"230°压力上一段均值{previous_avg:.4f}高于当前段均值"
  1074. f"{current_avg:.4f}的105%"
  1075. )
  1076. status = 1
  1077. alarm_level = "1"
  1078. cursor.execute(
  1079. """
  1080. SELECT id FROM compressor_alarm
  1081. WHERE device_code = %s AND device_part = %s AND device_point = %s
  1082. AND alarm_type = %s AND alarm_time_start = %s
  1083. LIMIT 1
  1084. """,
  1085. (device_code, part, point, VALVE_FAULT_ALARM_TYPE, forecast_time),
  1086. )
  1087. existing = cursor.fetchone()
  1088. if existing:
  1089. cursor.execute(
  1090. """
  1091. UPDATE compressor_alarm SET alarm_time_end = %s, alarm_des = %s,
  1092. alarm_level = %s, alarm_info_json = %s, status = %s,
  1093. scan_start_time = %s, scan_end_time = %s
  1094. WHERE id = %s
  1095. """,
  1096. (forecast_time + timedelta(hours=hours), description, alarm_level, info,
  1097. status, current_scan_start, forecast_time, existing["id"]),
  1098. )
  1099. results.append(("updated", int(existing["id"])))
  1100. else:
  1101. cursor.execute(
  1102. """
  1103. INSERT INTO compressor_alarm
  1104. (device_code, device_part, device_point, alarm_type, alarm_des,
  1105. alarm_level, alarm_info_json, alarm_time_start, alarm_time_end,
  1106. scan_start_time, scan_end_time, status)
  1107. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  1108. """,
  1109. (device_code, part, point, VALVE_FAULT_ALARM_TYPE, description, alarm_level, info,
  1110. forecast_time, forecast_time + timedelta(hours=hours), current_scan_start, forecast_time, status),
  1111. )
  1112. results.append(("inserted", int(cursor.lastrowid)))
  1113. return {
  1114. "action": results[0][0] if results else "no_alarm",
  1115. "id": results[0][1] if results else 0,
  1116. "inserted": sum(action == "inserted" for action, _ in results),
  1117. "updated": sum(action == "updated" for action, _ in results),
  1118. }
  1119. result, source = self._run_with_fallback(database_query, lambda: {"action": "demo", "id": 0})
  1120. return {"source": source, "notice": self._source_notice(source), **result}
  1121. @staticmethod
  1122. def _alarm_limit(item: dict[str, Any], alarm_type: str) -> float | None:
  1123. for index in range(1, 5):
  1124. if str(item.get(f"AlarmType{index}") or "").strip() == alarm_type:
  1125. return _safe_float(item.get(f"AlarmLimit{index}"))
  1126. return None
  1127. def _analyze_oil_pressure_alarm(self, device_code: str, end: datetime) -> dict[str, Any] | None:
  1128. """Analyze oil-pressure stages in the 14 days before ``end``."""
  1129. unit = device_code.rstrip("#").strip()
  1130. if not device_code.endswith("#") or not device_code[:-1].isdigit():
  1131. raise ValueError("压缩机编号格式必须为数字+#")
  1132. start = end - timedelta(days=OIL_PRESSURE_SCAN_DAYS)
  1133. with get_connection() as connection:
  1134. with connection.cursor() as cursor:
  1135. cursor.execute(
  1136. """
  1137. SELECT ItemName, ItemDescription, AlarmType1, AlarmType2, AlarmType3, AlarmType4,
  1138. AlarmLimit1, AlarmLimit2, AlarmLimit3, AlarmLimit4
  1139. FROM site_point
  1140. WHERE ItemName = %s
  1141. """,
  1142. (f"YSJ{unit}_5",),
  1143. )
  1144. config = cursor.fetchone()
  1145. if config is None:
  1146. raise ValueError(f"未找到 {device_code} 的润滑油压力报警配置")
  1147. low = self._alarm_limit(config, "PVLow")
  1148. low_low = self._alarm_limit(config, "PVLowLow")
  1149. if low is None or low_low is None:
  1150. raise ValueError(f"{device_code} 缺少润滑油压力低压报警阈值")
  1151. point_descriptions = {}
  1152. for point in (5, 41):
  1153. cursor.execute(
  1154. "SELECT ItemName, ItemDescription FROM site_point WHERE ItemName = %s",
  1155. (f"YSJ{unit}_{point}",),
  1156. )
  1157. item = cursor.fetchone()
  1158. if item:
  1159. point_descriptions[f"YSJ_{point}"] = item.get("ItemDescription") or f"YSJ_{point}"
  1160. cursor.execute(
  1161. """
  1162. SELECT FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
  1163. COUNT(*) AS samples,
  1164. SUM(CASE WHEN YSJ_41 > 0 THEN 1 ELSE 0 END) AS running_samples,
  1165. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
  1166. GROUP_CONCAT(DISTINCT import_batch_id ORDER BY import_batch_id) AS batch_ids
  1167. FROM pks_long_sample
  1168. WHERE device_code = %s
  1169. AND sample_time >= %s AND sample_time < %s
  1170. GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
  1171. ORDER BY hour_start
  1172. """,
  1173. (device_code, start, end),
  1174. )
  1175. rows = []
  1176. for row in cursor.fetchall():
  1177. running = int(row["running_samples"] or 0)
  1178. value = _safe_float(row["oil_avg"])
  1179. if running / 720 >= 0.80 and value is not None:
  1180. rows.append({
  1181. "time": row["hour_start"], "value": value, "running": running,
  1182. "batch_ids": [int(value) for value in str(row["batch_ids"] or "").split(",") if value],
  1183. })
  1184. if not rows:
  1185. return None
  1186. baseline = float(np.median([row["value"] for row in rows]))
  1187. candidates = []
  1188. history = []
  1189. for row in rows:
  1190. recent = history[-5:] + [row]
  1191. trend_values = [item["value"] for item in recent]
  1192. trend = float(np.polyfit(range(len(trend_values)), trend_values, 1)[0]) if len(trend_values) >= 3 else None
  1193. decline = 1
  1194. newer = row
  1195. for older in reversed(history):
  1196. if newer["time"] - older["time"] != timedelta(hours=1):
  1197. break
  1198. if newer["value"] > older["value"]:
  1199. break
  1200. decline += 1
  1201. newer = older
  1202. current = row["value"]
  1203. deviation = (baseline - current) / baseline if baseline else 0.0
  1204. downward = decline >= 3 or (trend is not None and trend < -0.0005)
  1205. if current <= low_low or (deviation >= 0.20 and downward):
  1206. phase = "严重异常"
  1207. elif current <= low or (deviation >= 0.10 and downward):
  1208. phase = "异常"
  1209. elif deviation >= 0.05 and downward:
  1210. phase = "轻微"
  1211. else:
  1212. phase = "正常"
  1213. candidates.append({"row": recent[-1], "phase": phase, "trend": trend, "decline": decline, "deviation": deviation})
  1214. history.append(recent[-1])
  1215. abnormal = [item for item in candidates if item["phase"] != "正常"]
  1216. if not abnormal:
  1217. return None
  1218. stage_runs = []
  1219. current_run = []
  1220. for item in candidates:
  1221. if current_run and (
  1222. item["phase"] != current_run[-1]["phase"]
  1223. or item["row"]["time"] - current_run[-1]["row"]["time"] != timedelta(hours=1)
  1224. ):
  1225. if current_run[0]["phase"] != "正常":
  1226. stage_runs.append(current_run)
  1227. current_run = []
  1228. current_run.append(item)
  1229. if current_run and current_run[0]["phase"] != "正常":
  1230. stage_runs.append(current_run)
  1231. stage_rows = max(
  1232. stage_runs,
  1233. key=lambda run: (
  1234. {"轻微": 1, "异常": 2, "严重异常": 3}[run[0]["phase"]],
  1235. len(run),
  1236. ),
  1237. )
  1238. stage = stage_rows[0]["phase"]
  1239. first = stage_rows[0]
  1240. last = stage_rows[-1]
  1241. stage_values = [item["row"]["value"] for item in stage_rows]
  1242. basis = (
  1243. f"油压{first['row']['value']:.3f}~{last['row']['value']:.3f}MPa,"
  1244. f"最低{min(stage_values):.3f}MPa,连续{len(stage_rows)}个有效运行小时;"
  1245. f"周期基线油压{baseline:.3f}MPa,相对基线变化{last['deviation']:+.1%}"
  1246. )
  1247. batch_ids = sorted({batch for item in stage_rows for batch in item["row"]["batch_ids"]})
  1248. info = {
  1249. "机组": f"{unit}号机",
  1250. "批次": batch_ids[0] if len(batch_ids) == 1 else batch_ids,
  1251. "周期编号": f"{device_code}-P14",
  1252. "开始小时": first["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
  1253. "结束小时": last["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
  1254. "持续自然小时数": int((last["row"]["time"] - first["row"]["time"]).total_seconds() / 3600) + 1,
  1255. "有效运行小时数": len(stage_rows),
  1256. "周期基线油压": baseline,
  1257. "正常波动带": "",
  1258. "阶段开始小时油压": first["row"]["value"],
  1259. "阶段结束小时油压": last["row"]["value"],
  1260. "阶段最低小时油压": min(stage_values),
  1261. "阶段开始相对周期基线变化": first["deviation"],
  1262. "阶段结束相对周期基线变化": last["deviation"],
  1263. "阶段最大连续下降有效小时数": max(item["decline"] for item in stage_rows),
  1264. "阶段低报警小时数": sum(item["row"]["value"] <= low for item in stage_rows),
  1265. "阶段低低报警小时数": sum(item["row"]["value"] <= low_low for item in stage_rows),
  1266. "是否形成确认等级": "是" if len(stage_rows) >= 3 else "否",
  1267. }
  1268. return {
  1269. "stage": stage,
  1270. "basis": basis,
  1271. "device_part": "-".join(dict.fromkeys(point_descriptions.values())),
  1272. "scan_start": start,
  1273. "scan_end": end,
  1274. "info": info,
  1275. }
  1276. def _analyze_cruciform_vibration_alarm(self, device_code: str, end: datetime) -> dict[str, Any] | None:
  1277. """Build the strongest 14-day vibration stage for the crosshead alarm."""
  1278. unit = device_code.rstrip("#").strip()
  1279. if not device_code.endswith("#") or not device_code[:-1].isdigit():
  1280. raise ValueError("压缩机编号格式必须为数字+#")
  1281. start = end - timedelta(days=OIL_PRESSURE_SCAN_DAYS)
  1282. with get_connection() as connection:
  1283. with connection.cursor() as cursor:
  1284. configs = {}
  1285. descriptions = []
  1286. for point, side in ((10, "联轴器端"), (11, "链轮端"), (41, "运行状态")):
  1287. cursor.execute(
  1288. """
  1289. SELECT ItemDescription, AlarmType1, AlarmType2, AlarmType3, AlarmType4,
  1290. AlarmLimit1, AlarmLimit2, AlarmLimit3, AlarmLimit4
  1291. FROM site_point WHERE ItemName = %s
  1292. """,
  1293. (f"YSJ{unit}_{point}",),
  1294. )
  1295. item = cursor.fetchone()
  1296. if item is None:
  1297. raise ValueError(f"未找到 {device_code} 的 YSJ_{point} 点位配置")
  1298. descriptions.append(item.get("ItemDescription") or f"YSJ_{point}")
  1299. if point != 41:
  1300. high = self._alarm_limit(item, "PVHigh")
  1301. high_high = self._alarm_limit(item, "PVHighHigh")
  1302. if high is None or high_high is None:
  1303. raise ValueError(f"{device_code} YSJ_{point} 缺少振动报警阈值")
  1304. configs[side] = {"high": high, "high_high": high_high}
  1305. cursor.execute(
  1306. """
  1307. SELECT FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
  1308. SUM(CASE WHEN YSJ_41 > 0 THEN 1 ELSE 0 END) AS running_samples,
  1309. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
  1310. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
  1311. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
  1312. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
  1313. GROUP_CONCAT(DISTINCT import_batch_id ORDER BY import_batch_id) AS batch_ids
  1314. FROM pks_long_sample
  1315. WHERE device_code = %s
  1316. AND sample_time >= %s AND sample_time < %s
  1317. GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
  1318. ORDER BY hour_start
  1319. """,
  1320. (device_code, start, end),
  1321. )
  1322. rows = []
  1323. for row in cursor.fetchall():
  1324. running = int(row["running_samples"] or 0)
  1325. if running / 720 < 0.80:
  1326. continue
  1327. item = {
  1328. "time": row["hour_start"], "running": running,
  1329. "batch_ids": [int(value) for value in str(row["batch_ids"] or "").split(",") if value],
  1330. }
  1331. for key in ("coupling_avg", "coupling_max", "chain_avg", "chain_max"):
  1332. item[key] = _safe_float(row[key])
  1333. if item["coupling_max"] is not None or item["chain_max"] is not None:
  1334. rows.append(item)
  1335. if len(rows) < 3:
  1336. return None
  1337. signals = []
  1338. for index, row in enumerate(rows):
  1339. prior = rows[max(0, index - 336):index]
  1340. if len(prior) < 24:
  1341. continue
  1342. side_signals = []
  1343. for side, prefix in (("联轴器端", "coupling"), ("链轮端", "chain")):
  1344. current = row[f"{prefix}_max"]
  1345. values = [item[f"{prefix}_max"] for item in prior if item[f"{prefix}_max"] is not None]
  1346. averages = [item[f"{prefix}_avg"] for item in prior if item[f"{prefix}_avg"] is not None]
  1347. if current is None or len(values) < 24:
  1348. continue
  1349. baseline = float(np.median(values))
  1350. scale = max(float(np.median(np.abs(np.asarray(values) - baseline))) * 1.4826, abs(baseline) * 0.05, 1e-6)
  1351. peak_z = (current - baseline) / scale
  1352. average = row[f"{prefix}_avg"]
  1353. average_base = float(np.median(averages)) if averages else None
  1354. average_change = average / average_base - 1 if average is not None and average_base else 0.0
  1355. limit = configs[side]
  1356. severe = current >= limit["high_high"] or peak_z >= 8
  1357. abnormal = severe or current >= limit["high"] or peak_z >= 5 or average_change >= 0.10
  1358. mild = abnormal or peak_z >= 4 or average_change >= 0.05
  1359. if mild:
  1360. side_signals.append({
  1361. "side": side, "value": current, "baseline": baseline, "scale": scale,
  1362. "peak_z": peak_z, "average_change": average_change,
  1363. "level": "严重异常" if severe else "异常" if abnormal else "轻微",
  1364. })
  1365. if side_signals:
  1366. signals.append({"row": row, "signals": side_signals})
  1367. if not signals:
  1368. return None
  1369. runs = []
  1370. current_run = []
  1371. for signal in signals:
  1372. if current_run and signal["row"]["time"] - current_run[-1]["row"]["time"] > timedelta(hours=6):
  1373. runs.append(current_run)
  1374. current_run = []
  1375. current_run.append(signal)
  1376. if current_run:
  1377. runs.append(current_run)
  1378. run = max(
  1379. runs,
  1380. key=lambda values: (
  1381. max({"轻微": 1, "异常": 2, "严重异常": 3}[item["level"]] for signal in values for item in signal["signals"]),
  1382. len(values),
  1383. ),
  1384. )
  1385. all_signals = [item for signal in run for item in signal["signals"]]
  1386. strongest = max(all_signals, key=lambda item: ({"轻微": 1, "异常": 2, "严重异常": 3}[item["level"]], item["peak_z"]))
  1387. stage = strongest["level"]
  1388. first, last = run[0], run[-1]
  1389. batch_ids = sorted({batch for signal in run for batch in signal["row"]["batch_ids"]})
  1390. max_value = max(item["value"] for item in all_signals)
  1391. basis = (
  1392. f"{strongest['side']}振动峰值或均值偏离近期基线,阶段内{len(run)}个有效运行小时触发;"
  1393. f"最大振动值{max_value:.3f}mm/s,峰值高于近期基线{strongest['peak_z']:.2f}个稳健尺度"
  1394. f"(峰值/基线{max_value / max(strongest['baseline'], 1e-6):.2f}倍)"
  1395. )
  1396. info = {
  1397. "机组": f"{unit}号机", "批次": batch_ids[0] if len(batch_ids) == 1 else batch_ids,
  1398. "周期编号": f"{device_code}-V14", "开始小时": first["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
  1399. "结束小时": last["row"]["time"].strftime("%Y-%m-%d %H:%M:%S"),
  1400. "持续自然小时数": int((last["row"]["time"] - first["row"]["time"]).total_seconds() / 3600) + 1,
  1401. "有效运行小时数": len(run), "近期基线振动": strongest["baseline"],
  1402. "正常波动带": f"{strongest['baseline'] - 2 * strongest['scale']:.3f}~{strongest['baseline'] + 2 * strongest['scale']:.3f} mm/s",
  1403. "阶段开始小时振动": max(item["value"] for item in first["signals"]),
  1404. "阶段结束小时振动": max(item["value"] for item in last["signals"]),
  1405. "阶段最大振动值": max_value,
  1406. "阶段开始相对近期基线变化": max(item["value"] / item["baseline"] - 1 for item in first["signals"]),
  1407. "阶段结束相对近期基线变化": max(item["value"] / item["baseline"] - 1 for item in last["signals"]),
  1408. "阶段最大连续异常有效小时数": len(run),
  1409. "阶段高报警小时数": sum(any(item["value"] >= configs[item["side"]]["high"] for item in signal["signals"]) for signal in run),
  1410. "阶段高高报警小时数": sum(any(item["value"] >= configs[item["side"]]["high_high"] for item in signal["signals"]) for signal in run),
  1411. "是否形成确认等级": "是" if stage != "轻微" else "否",
  1412. "最强异常侧": strongest["side"], "最强侧近期峰值基线": strongest["baseline"],
  1413. "最强侧近期稳健尺度": strongest["scale"], "阶段最大峰值/基线比例": max_value / max(strongest["baseline"], 1e-6),
  1414. }
  1415. return {
  1416. "stage": stage, "basis": basis, "device_part": "-".join(dict.fromkeys(descriptions)),
  1417. "scan_start": start, "scan_end": end, "info": info,
  1418. }
  1419. def sync_pressure_angle(self, device_part: str, device_points: list[str] | None = None) -> dict[str, Any]:
  1420. """按机组与部位批量生成 statistic_pressure_angle 采样数据。
  1421. 对每个压力 wave_file,若 statistic_pressure_angle 已存在至少一条记录则跳过,
  1422. 否则读 wave_sample_one(第一个周期)按“每个整数角度取第一个值”生成 360 个角度点。
  1423. """
  1424. points = device_points or []
  1425. def database_query():
  1426. with get_connection() as connection:
  1427. with connection.cursor() as cursor:
  1428. clauses = ["measurement_type = %s", "device_part = %s"]
  1429. params: list[Any] = ["压力", device_part]
  1430. if points:
  1431. placeholders = ", ".join(["%s"] * len(points))
  1432. clauses.append(f"device_point IN ({placeholders})")
  1433. params.extend(points)
  1434. cursor.execute(
  1435. f"SELECT id FROM wave_file WHERE {' AND '.join(clauses)} ORDER BY id",
  1436. params,
  1437. )
  1438. file_ids = [int(row["id"]) for row in cursor.fetchall()]
  1439. if not file_ids:
  1440. return {"total": 0, "created": 0, "skipped": 0}
  1441. placeholders = ", ".join(["%s"] * len(file_ids))
  1442. cursor.execute(
  1443. f"SELECT DISTINCT wave_file_id FROM statistic_pressure_angle WHERE wave_file_id IN ({placeholders})",
  1444. file_ids,
  1445. )
  1446. existing = {int(row["wave_file_id"]) for row in cursor.fetchall()}
  1447. created = 0
  1448. skipped = 0
  1449. for file_id in file_ids:
  1450. if file_id in existing:
  1451. skipped += 1
  1452. continue
  1453. cursor.execute(
  1454. "SELECT sample_index, signal_value, second_value FROM wave_sample_one "
  1455. "WHERE wave_file_id = %s ORDER BY sample_index",
  1456. (file_id,),
  1457. )
  1458. rows = cursor.fetchall()
  1459. if not rows:
  1460. skipped += 1
  1461. continue
  1462. samples = np.asarray(
  1463. [
  1464. (float(row["sample_index"]), float(row["signal_value"]),
  1465. float(row["second_value"]) if row["second_value"] is not None else 0.0)
  1466. for row in rows
  1467. ],
  1468. dtype=float,
  1469. )
  1470. sampled = _sample_first_cycle_360(samples)
  1471. if not sampled:
  1472. skipped += 1
  1473. continue
  1474. cursor.executemany(
  1475. "INSERT INTO statistic_pressure_angle (wave_file_id, period, angle, pressure) "
  1476. "VALUES (%s, %s, %s, %s)",
  1477. [(file_id, 1, angle, pressure) for angle, pressure in sampled],
  1478. )
  1479. created += 1
  1480. return {"total": len(file_ids), "created": created, "skipped": skipped}
  1481. result, source = self._run_with_fallback(
  1482. database_query,
  1483. lambda: {"total": 0, "created": 0, "skipped": 0},
  1484. )
  1485. return {"source": source, "notice": self._source_notice(source), **result}
  1486. def query_pressure_angle(
  1487. self,
  1488. device_part: str,
  1489. device_points: list[str] | None,
  1490. min_time: str | None,
  1491. max_time: str | None,
  1492. angles: list[int],
  1493. ) -> dict[str, Any]:
  1494. """按时间范围和角度列表查询 statistic_pressure_angle 曲线数据。"""
  1495. if not angles:
  1496. raise ValueError("请至少输入一个角度")
  1497. angles = [int(angle) for angle in angles if 0 <= int(angle) <= 360]
  1498. def database_query():
  1499. with get_connection() as connection:
  1500. with connection.cursor() as cursor:
  1501. clauses = ["wf.measurement_type = %s", "wf.device_part = %s"]
  1502. params: list[Any] = ["压力", device_part]
  1503. if device_points:
  1504. placeholders = ", ".join(["%s"] * len(device_points))
  1505. clauses.append(f"wf.device_point IN ({placeholders})")
  1506. params.extend(device_points)
  1507. start = _parse_time(min_time) if min_time else None
  1508. end = _parse_time(max_time) if max_time else None
  1509. if start:
  1510. clauses.append("wf.sample_time >= %s")
  1511. params.append(start)
  1512. if end:
  1513. clauses.append("wf.sample_time <= %s")
  1514. params.append(end)
  1515. angle_placeholders = ", ".join(["%s"] * len(angles))
  1516. clauses.append(f"spa.angle IN ({angle_placeholders})")
  1517. params.extend(angles)
  1518. cursor.execute(
  1519. f"""
  1520. SELECT wf.sample_time, spa.angle, spa.pressure
  1521. FROM statistic_pressure_angle spa
  1522. INNER JOIN wave_file wf ON wf.id = spa.wave_file_id
  1523. WHERE {' AND '.join(clauses)}
  1524. ORDER BY wf.sample_time, spa.angle
  1525. """,
  1526. params,
  1527. )
  1528. rows = cursor.fetchall()
  1529. stop_days = self._query_stop_days(
  1530. cursor, device_part, device_points, start, end,
  1531. )
  1532. series: dict[int, list[dict[str, Any]]] = {}
  1533. for row in rows:
  1534. angle = int(round(float(row["angle"])))
  1535. series.setdefault(angle, []).append({
  1536. "time": _time_string(row["sample_time"]),
  1537. "pressure": float(row["pressure"]),
  1538. })
  1539. for day in sorted(stop_days):
  1540. day_time = f"{day} 00:00:00"
  1541. for angle in angles:
  1542. series.setdefault(angle, []).append({
  1543. "time": day_time,
  1544. "pressure": 0.0,
  1545. })
  1546. for angle in angles:
  1547. series.setdefault(angle, []).sort(key=lambda point: point["time"])
  1548. return {
  1549. "angles": angles,
  1550. "series": [
  1551. {"angle": angle, "points": series.get(angle, [])}
  1552. for angle in angles
  1553. ],
  1554. }
  1555. result, source = self._run_with_fallback(
  1556. database_query,
  1557. lambda: {"angles": angles, "series": [{"angle": angle, "points": []} for angle in angles]},
  1558. )
  1559. return {"source": source, "notice": self._source_notice(source), **result}
  1560. @staticmethod
  1561. def _query_stop_days(
  1562. cursor: Any,
  1563. device_part: str,
  1564. device_points: list[str] | None,
  1565. start: datetime | None,
  1566. end: datetime | None,
  1567. ) -> set[str]:
  1568. """返回停机日集合:该机组在时间范围内 rpm=0 且当天无 rpm>0 记录的日期。"""
  1569. clauses = ["device_part = %s", "rpm = 0"]
  1570. params: list[Any] = [device_part]
  1571. if device_points:
  1572. placeholders = ", ".join(["%s"] * len(device_points))
  1573. clauses.append(f"device_point IN ({placeholders})")
  1574. params.extend(device_points)
  1575. if start:
  1576. clauses.append("sample_time >= %s")
  1577. params.append(start)
  1578. if end:
  1579. clauses.append("sample_time <= %s")
  1580. params.append(end)
  1581. cursor.execute(
  1582. f"""
  1583. SELECT DISTINCT DATE(sample_time) AS d
  1584. FROM wave_file
  1585. WHERE {' AND '.join(clauses)}
  1586. """,
  1587. params,
  1588. )
  1589. stop_days = {str(row["d"]) for row in cursor.fetchall()}
  1590. run_clauses = ["device_part = %s", "rpm > 0"]
  1591. run_params: list[Any] = [device_part]
  1592. if device_points:
  1593. placeholders = ", ".join(["%s"] * len(device_points))
  1594. run_clauses.append(f"device_point IN ({placeholders})")
  1595. run_params.extend(device_points)
  1596. if start:
  1597. run_clauses.append("sample_time >= %s")
  1598. run_params.append(start)
  1599. if end:
  1600. run_clauses.append("sample_time <= %s")
  1601. run_params.append(end)
  1602. cursor.execute(
  1603. f"""
  1604. SELECT DISTINCT DATE(sample_time) AS d
  1605. FROM wave_file
  1606. WHERE {' AND '.join(run_clauses)}
  1607. """,
  1608. run_params,
  1609. )
  1610. run_days = {str(row["d"]) for row in cursor.fetchall()}
  1611. return stop_days - run_days
  1612. @staticmethod
  1613. def _group_time_points(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
  1614. grouped: OrderedDict[Any, dict[str, Any]] = OrderedDict()
  1615. for row in rows:
  1616. # Channels from one acquisition batch can be stamped a few seconds
  1617. # apart (e.g. 10:30:02 vs 10:30:05), yet the acquisition cadence is
  1618. # 15 minutes. Align on the minute so one batch is one time point.
  1619. sample_time = row["sample_time"]
  1620. key = str(sample_time)[:16]
  1621. point = grouped.setdefault(
  1622. key,
  1623. {
  1624. "sampleTime": _time_string(sample_time),
  1625. "files": {},
  1626. },
  1627. )
  1628. device_point = row["device_point"]
  1629. point["files"].setdefault(device_point, {
  1630. "id": int(row["id"]),
  1631. "devicePoint": device_point,
  1632. "measurementType": row["measurement_type"],
  1633. "sampleCount": int(row["sample_count"] or 0),
  1634. "sampleFrequencyHz": int(row["sample_frequency_hz"] or 0),
  1635. "rpm": float(row["rpm"] or 0),
  1636. "status": int(row.get("tspluse_status") or 0),
  1637. })
  1638. return [
  1639. {"index": index, **point}
  1640. for index, point in enumerate(grouped.values())
  1641. ]
  1642. def _pks_time_points(
  1643. self,
  1644. unit: str,
  1645. columns: list[tuple[str, str]],
  1646. start: datetime | None,
  1647. end: datetime | None,
  1648. include_stopped: bool,
  1649. ) -> list[dict[str, Any]]:
  1650. """PKS-only 时间点:按 15 分钟槽从 pks_long_sample 生成骨架。
  1651. columns 为 [(item_name, pks 列名), ...],siteValues 直挂所选各列
  1652. 原值。7/8/9 机组的停机/运转由该机组批次内的 YSJ_41(转速)决定
  1653. (>0=运转、=0=停机);4/5/6 无转速信号,按运转处理。未勾选停机时
  1654. 仅保留运转锚点;勾选停机则停机锚点一并显示。
  1655. """
  1656. batches = self._pks_batch_ids(unit)
  1657. if not batches:
  1658. return []
  1659. batch_clause = f"import_batch_id IN ({self._in_clause(batches)})"
  1660. clauses = [batch_clause]
  1661. params: list[Any] = list(batches)
  1662. if start:
  1663. clauses.append("sample_time >= %s")
  1664. params.append(start)
  1665. if end:
  1666. clauses.append("sample_time <= %s")
  1667. params.append(end)
  1668. with get_connection() as connection:
  1669. with connection.cursor() as cursor:
  1670. cursor.execute(
  1671. f"""
  1672. SELECT MIN(sample_time) AS anchor
  1673. FROM pks_long_sample
  1674. WHERE {' AND '.join(clauses)}
  1675. GROUP BY FLOOR(UNIX_TIMESTAMP(sample_time) / 900)
  1676. ORDER BY anchor
  1677. """,
  1678. params,
  1679. )
  1680. anchors = [row["anchor"] for row in cursor.fetchall()]
  1681. if not anchors:
  1682. return []
  1683. if len(anchors) > PKS_STRIP_MAX_ANCHORS:
  1684. step = (len(anchors) + PKS_STRIP_MAX_ANCHORS - 1) // PKS_STRIP_MAX_ANCHORS
  1685. sampled = anchors[::step]
  1686. if sampled[-1] != anchors[-1]:
  1687. sampled.append(anchors[-1])
  1688. anchors = sampled
  1689. chunk_size = 1000
  1690. select_expr = ", ".join(f"`{column}`" for _item, column in columns)
  1691. use_running_signal = unit in _RUNNING_SIGNAL_UNITS
  1692. values_by_time: dict[datetime, dict[str, float | None]] = {anchor: {} for anchor in anchors}
  1693. running_by_time: dict[datetime, bool] = {}
  1694. for index in range(0, len(anchors), chunk_size):
  1695. chunk = anchors[index:index + chunk_size]
  1696. placeholders = ", ".join(["%s"] * len(chunk))
  1697. with get_connection() as connection:
  1698. with connection.cursor() as cursor:
  1699. cursor.execute(
  1700. f"SELECT sample_time, {select_expr}, `YSJ_41` FROM pks_long_sample "
  1701. f"WHERE {batch_clause} AND sample_time IN ({placeholders})",
  1702. (*batches, *chunk),
  1703. )
  1704. rows = cursor.fetchall()
  1705. for row in rows:
  1706. anchor_time = row["sample_time"]
  1707. values = values_by_time[anchor_time]
  1708. for item_name, column in columns:
  1709. value = row[column]
  1710. values[item_name] = float(value) if value is not None else None
  1711. if use_running_signal:
  1712. running_value = row["YSJ_41"]
  1713. running_by_time[anchor_time] = bool(
  1714. running_value is not None and running_value > 0
  1715. )
  1716. else:
  1717. running_by_time.setdefault(anchor_time, True)
  1718. points: list[dict[str, Any]] = []
  1719. for anchor in anchors:
  1720. running = running_by_time.get(anchor, False)
  1721. if not include_stopped and not running:
  1722. continue
  1723. points.append(
  1724. {
  1725. "sampleTime": _time_string(anchor),
  1726. "files": {},
  1727. "siteValues": values_by_time[anchor],
  1728. "machineRunning": running,
  1729. },
  1730. )
  1731. for index, point in enumerate(points):
  1732. point["index"] = index
  1733. return points
  1734. def _pks_only_wave_window(
  1735. self,
  1736. device_part: str,
  1737. points: list[dict[str, Any]],
  1738. site_points: list[str],
  1739. unit: str,
  1740. ) -> dict[str, Any]:
  1741. """PKS-only 波形窗口:不取 wave_file,仅构建所选 PKS 点位的微曲线。"""
  1742. def database_query():
  1743. return {
  1744. "points": points,
  1745. "siteSeries": self._build_site_series(site_points, unit, points),
  1746. }
  1747. def demo_query():
  1748. return {"points": points, "siteSeries": {"points": [], "series": []}}
  1749. result, source = self._run_with_fallback(database_query, demo_query)
  1750. return {
  1751. "devicePart": device_part,
  1752. "devicePoints": [],
  1753. "primaryPoint": None,
  1754. "points": points,
  1755. "xMin": 0,
  1756. "xMax": len(points),
  1757. "series": [],
  1758. "secondSeries": {
  1759. "name": "周期数据",
  1760. "color": "#f56c6c",
  1761. "sourceMeasurementType": None,
  1762. "sourceDevicePoint": None,
  1763. "data": [],
  1764. "finiteCount": 0,
  1765. "nonZeroCount": 0,
  1766. "min": None,
  1767. "max": None,
  1768. },
  1769. "angleSeries": {"color": "#d59b2b", "data": []},
  1770. "volumeSeries": {"color": "#4d9e6f", "data": [], "info": None},
  1771. "cycles": [],
  1772. "triggerXs": [],
  1773. "files": [],
  1774. "diagnostics": [],
  1775. "siteSeries": result["siteSeries"],
  1776. "source": source,
  1777. "notice": self._source_notice(source),
  1778. }
  1779. def alarm_compressor_options(self) -> dict[str, Any]:
  1780. """Return running compressor groups using device_part's machine prefix."""
  1781. def database_query():
  1782. with get_connection() as connection:
  1783. with connection.cursor() as cursor:
  1784. cursor.execute(
  1785. """
  1786. SELECT SUBSTRING(device_part, 1, 4) AS device_name,
  1787. COUNT(1) AS count_no
  1788. FROM wave_file
  1789. WHERE rpm > 0 AND device_part <> ''
  1790. GROUP BY SUBSTRING(device_part, 1, 4)
  1791. ORDER BY device_name
  1792. """,
  1793. )
  1794. return [
  1795. {
  1796. "deviceName": row["device_name"],
  1797. "deviceCode": f"{str(row['device_name'])[0]}#",
  1798. "count": int(row["count_no"]),
  1799. }
  1800. for row in cursor.fetchall()
  1801. if row["device_name"]
  1802. ]
  1803. result, source = self._run_with_fallback(database_query, lambda: [])
  1804. return {"source": source, "notice": self._source_notice(source), "items": result}
  1805. def wave_window(
  1806. self,
  1807. device_part: str,
  1808. device_points: list[str],
  1809. points: list[dict[str, Any]],
  1810. max_points: int,
  1811. no_sampling: bool = False,
  1812. first_cycle_only: bool = False,
  1813. site_points: list[str] | None = None,
  1814. ) -> dict[str, Any]:
  1815. selected_points = _validate_device_points(device_points, allow_empty=True)
  1816. if not points:
  1817. raise ValueError("至少选择一个时间点")
  1818. if len(points) > 200:
  1819. raise ValueError("单次最多预览 200 个时间点,请缩小时间窗口")
  1820. max_points = min(max(int(max_points), 256), 200000)
  1821. unit = _unit_number(device_part)
  1822. site_list = list(site_points or [])
  1823. site_columns: list[tuple[str, str]] = []
  1824. if unit:
  1825. site_columns = [
  1826. (item, column)
  1827. for item in site_list
  1828. if (column := _site_point_column(item, unit)) is not None
  1829. ]
  1830. pks_only = bool(unit and site_columns and not selected_points)
  1831. if not selected_points and not pks_only:
  1832. raise ValueError("请至少选择一个测试点位")
  1833. if pks_only:
  1834. return self._pks_only_wave_window(device_part, points, site_list, unit)
  1835. def database_query():
  1836. if first_cycle_only:
  1837. return self._build_first_cycle_window(
  1838. device_part,
  1839. selected_points,
  1840. points,
  1841. self._load_db_wave,
  1842. )
  1843. return self._build_wave_window(
  1844. device_part,
  1845. selected_points,
  1846. points,
  1847. max_points,
  1848. self._load_db_wave,
  1849. no_sampling,
  1850. )
  1851. def demo_query():
  1852. if first_cycle_only:
  1853. return self._build_first_cycle_window(
  1854. device_part,
  1855. selected_points,
  1856. points,
  1857. self._load_demo_wave,
  1858. )
  1859. return self._build_wave_window(
  1860. device_part,
  1861. selected_points,
  1862. points,
  1863. max_points,
  1864. self._load_demo_wave,
  1865. no_sampling,
  1866. )
  1867. result, source = self._run_with_fallback(database_query, demo_query)
  1868. # 全场点位(PKS)系列:每周期(PKS 5s)一小段、跨时间点连续的 15 分钟窗口曲线。
  1869. if first_cycle_only and site_points:
  1870. try:
  1871. unit = _unit_number(device_part)
  1872. result["siteSeries"] = (
  1873. self._build_site_series(site_points, unit, result["points"])
  1874. if unit is not None
  1875. else {"points": [], "series": []}
  1876. )
  1877. except Exception:
  1878. result["siteSeries"] = {"points": [], "series": []}
  1879. else:
  1880. result["siteSeries"] = {"points": [], "series": []}
  1881. result["source"] = source
  1882. result["notice"] = self._source_notice(source)
  1883. return result
  1884. def period_detail(self, wave_file_id: int, period_number: int) -> dict[str, Any]:
  1885. if wave_file_id <= 0 or period_number <= 0:
  1886. raise ValueError("wave_file_id 和周期编号必须为正整数")
  1887. if period_number != 1:
  1888. raise ValueError("当前数据仅保留第一个周期")
  1889. def database_query():
  1890. metadata, samples = self._load_db_wave(wave_file_id)
  1891. details = [self._build_period_detail(metadata, samples, period_number)]
  1892. notice: str | None = None
  1893. companion = self._find_opposite_pressure_metadata(metadata)
  1894. if companion is None:
  1895. notice = "未找到同一时刻的对侧压力数据,仅显示当前侧。"
  1896. else:
  1897. try:
  1898. companion_meta, companion_samples = self._load_db_wave(int(companion["id"]))
  1899. details.append(
  1900. self._build_period_detail(companion_meta, companion_samples, period_number),
  1901. )
  1902. except Exception as exc:
  1903. notice = f"对侧压力数据读取失败,仅显示当前侧:{exc}"
  1904. return self._assemble_period_detail(metadata, details, notice)
  1905. def demo_query():
  1906. metadata, samples = self._load_demo_wave(
  1907. wave_file_id,
  1908. "压力",
  1909. DEMO_POINT_NAME,
  1910. DEMO_START,
  1911. )
  1912. details = [self._build_period_detail(metadata, samples, period_number)]
  1913. return self._assemble_period_detail(
  1914. metadata,
  1915. details,
  1916. "演示模式仅提供单侧压力数据。",
  1917. )
  1918. result, source = self._run_with_fallback(database_query, demo_query)
  1919. result["source"] = source
  1920. result["notice"] = self._source_notice(source)
  1921. return result
  1922. def _find_opposite_pressure_metadata(self, metadata: dict[str, Any]) -> dict[str, Any] | None:
  1923. point_name = str(metadata.get("point_name") or "")
  1924. side = metadata.get("device_point")
  1925. if side not in PRESSURE_SIDES:
  1926. side = next((item for item in PRESSURE_SIDES if point_name.endswith(item)), None)
  1927. if side not in PRESSURE_SIDES:
  1928. return None
  1929. other_side = PRESSURE_SIDES[1] if side == PRESSURE_SIDES[0] else PRESSURE_SIDES[0]
  1930. device_part = metadata.get("device_part")
  1931. sample_time = metadata.get("sample_time")
  1932. if not device_part or sample_time is None:
  1933. return None
  1934. try:
  1935. with get_connection() as connection:
  1936. with connection.cursor() as cursor:
  1937. cursor.execute(
  1938. """
  1939. SELECT id
  1940. FROM wave_file
  1941. WHERE measurement_type = %s
  1942. AND device_part = %s
  1943. AND sample_time = %s
  1944. AND device_point = %s
  1945. ORDER BY id ASC
  1946. LIMIT 1
  1947. """,
  1948. ("压力", device_part, sample_time, other_side),
  1949. )
  1950. row = cursor.fetchone()
  1951. return row
  1952. except Exception:
  1953. return None
  1954. @staticmethod
  1955. def _assemble_period_detail(
  1956. metadata: dict[str, Any],
  1957. details: list[dict[str, Any]],
  1958. side_notice: str | None,
  1959. ) -> dict[str, Any]:
  1960. order = {side: index for index, side in enumerate(PRESSURE_SIDES)}
  1961. details.sort(key=lambda item: order.get(str(item.get("side")), len(order)))
  1962. point_name = str(metadata.get("point_name") or "")
  1963. device_part = metadata.get("device_part")
  1964. sample_time = metadata.get("sample_time")
  1965. primary_side = metadata.get("device_point")
  1966. if primary_side not in PRESSURE_SIDES:
  1967. primary_side = next(
  1968. (item for item in PRESSURE_SIDES if point_name.endswith(item)),
  1969. details[0].get("side") if details else None,
  1970. )
  1971. if not device_part and primary_side and point_name.endswith(str(primary_side)):
  1972. device_part = point_name[: -len(str(primary_side))]
  1973. return {
  1974. "devicePart": device_part or "",
  1975. "sampleTime": _time_string(sample_time) if sample_time is not None else "",
  1976. "primarySide": primary_side,
  1977. "sideNotice": side_notice,
  1978. "sides": details,
  1979. }
  1980. def annotation_config(self) -> dict[str, Any]:
  1981. return {
  1982. "source": self.source,
  1983. "annotationWidth": settings.annotation_width,
  1984. "notice": self._source_notice(self.source),
  1985. }
  1986. def list_annotations(self, wave_file_ids: list[int]) -> dict[str, Any]:
  1987. ids = sorted({int(value) for value in wave_file_ids if value})
  1988. if not ids:
  1989. return {
  1990. "source": self.source,
  1991. "annotations": [],
  1992. "notice": self._source_notice(self.source),
  1993. }
  1994. def database_query():
  1995. placeholders = ", ".join(["%s"] * len(ids))
  1996. with get_connection() as connection:
  1997. with connection.cursor() as cursor:
  1998. cursor.execute(
  1999. f"""
  2000. SELECT id, wave_file_id, label, period_start, period_end,
  2001. sample_index_start, sample_index_end
  2002. FROM wave_annotation
  2003. WHERE wave_file_id IN ({placeholders})
  2004. ORDER BY id ASC
  2005. """,
  2006. ids,
  2007. )
  2008. rows = cursor.fetchall()
  2009. return [_annotation_dict(row) for row in rows]
  2010. def demo_query():
  2011. return [
  2012. _annotation_dict(annotation)
  2013. for annotation in self._demo_annotations.values()
  2014. if annotation["wave_file_id"] in ids
  2015. ]
  2016. annotations, source = self._run_with_fallback(database_query, demo_query)
  2017. return {
  2018. "source": source,
  2019. "annotations": annotations,
  2020. "notice": self._source_notice(source),
  2021. }
  2022. def create_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
  2023. self._validate_annotation(payload)
  2024. wave_file_id = int(payload["wave_file_id"])
  2025. label = payload["label"]
  2026. period_start = int(payload["period_start"])
  2027. period_end = int(payload["period_end"])
  2028. sample_index_start = int(payload["sample_index_start"])
  2029. sample_index_end = int(payload["sample_index_end"])
  2030. def database_query():
  2031. with get_connection() as connection:
  2032. with connection.cursor() as cursor:
  2033. cursor.execute(
  2034. """
  2035. INSERT INTO wave_annotation
  2036. (wave_file_id, label, period_start, period_end,
  2037. sample_index_start, sample_index_end)
  2038. VALUES (%s, %s, %s, %s, %s, %s)
  2039. """,
  2040. (
  2041. wave_file_id,
  2042. label,
  2043. period_start,
  2044. period_end,
  2045. sample_index_start,
  2046. sample_index_end,
  2047. ),
  2048. )
  2049. annotation_id = cursor.lastrowid
  2050. cursor.execute(
  2051. """
  2052. SELECT id, wave_file_id, label, period_start, period_end,
  2053. sample_index_start, sample_index_end
  2054. FROM wave_annotation
  2055. WHERE id = %s
  2056. """,
  2057. (annotation_id,),
  2058. )
  2059. return _annotation_dict(cursor.fetchone())
  2060. def demo_query():
  2061. annotation_id = self._demo_annotation_seq
  2062. self._demo_annotation_seq += 1
  2063. annotation = {
  2064. "id": annotation_id,
  2065. "wave_file_id": wave_file_id,
  2066. "label": label,
  2067. "period_start": period_start,
  2068. "period_end": period_end,
  2069. "sample_index_start": sample_index_start,
  2070. "sample_index_end": sample_index_end,
  2071. }
  2072. self._demo_annotations[annotation_id] = annotation
  2073. return _annotation_dict(annotation)
  2074. result, source = self._run_with_fallback(database_query, demo_query)
  2075. result["source"] = source
  2076. result["notice"] = self._source_notice(source)
  2077. return result
  2078. def delete_annotation(self, annotation_id: int) -> dict[str, Any]:
  2079. if annotation_id <= 0:
  2080. raise ValueError("标注 id 必须为正整数")
  2081. def database_query():
  2082. with get_connection() as connection:
  2083. with connection.cursor() as cursor:
  2084. cursor.execute(
  2085. "DELETE FROM wave_annotation WHERE id = %s",
  2086. (annotation_id,),
  2087. )
  2088. return int(cursor.rowcount)
  2089. def demo_query():
  2090. if annotation_id not in self._demo_annotations:
  2091. return 0
  2092. del self._demo_annotations[annotation_id]
  2093. return 1
  2094. deleted, source = self._run_with_fallback(database_query, demo_query)
  2095. if not deleted:
  2096. raise ValueError(f"标注 id={annotation_id} 不存在")
  2097. return {
  2098. "deleted": annotation_id,
  2099. "source": source,
  2100. "notice": self._source_notice(source),
  2101. }
  2102. @staticmethod
  2103. def _validate_annotation(payload: dict[str, Any]) -> None:
  2104. label = payload.get("label")
  2105. if label not in ANNOTATION_LABELS:
  2106. raise ValueError("样本类型只能是 正常 或 异常")
  2107. for field in ("wave_file_id", "period_start", "period_end", "sample_index_start", "sample_index_end"):
  2108. if payload.get(field) is None:
  2109. raise ValueError(f"{field} 不能为空")
  2110. if int(payload["wave_file_id"]) <= 0:
  2111. raise ValueError("wave_file_id 必须为正整数")
  2112. if int(payload["period_start"]) <= 0 or int(payload["period_end"]) <= 0:
  2113. raise ValueError("周期编号必须为正整数")
  2114. if int(payload["period_start"]) > int(payload["period_end"]):
  2115. raise ValueError("起始周期不能大于结束周期")
  2116. if int(payload["sample_index_start"]) < 0 or int(payload["sample_index_end"]) < 0:
  2117. raise ValueError("采样点索引不能为负")
  2118. if int(payload["sample_index_start"]) > int(payload["sample_index_end"]):
  2119. raise ValueError("起始采样点不能大于结束采样点")
  2120. def health(self) -> dict[str, Any]:
  2121. return {
  2122. "status": "ok",
  2123. "source": self.source,
  2124. "databaseError": self._last_db_error or None,
  2125. }
  2126. def _source_notice(self, source: str) -> str | None:
  2127. if source == "demo":
  2128. if self._last_db_error:
  2129. return f"当前为演示数据:数据库暂不可用({self._last_db_error})"
  2130. return "当前为演示数据:可设置 DEMO_MODE=never 强制使用数据库"
  2131. return "已连接 MySQL 数据库"
  2132. def _build_wave_window(
  2133. self,
  2134. device_part: str,
  2135. device_points: list[str],
  2136. points: list[dict[str, Any]],
  2137. max_points: int,
  2138. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  2139. no_sampling: bool = False,
  2140. ) -> dict[str, Any]:
  2141. series_data: dict[str, list[dict[str, Any]]] = {point: [] for point in device_points}
  2142. angle_data: list[dict[str, Any]] = []
  2143. volume_data: list[dict[str, Any]] = []
  2144. volume_info: dict[str, Any] | None = None
  2145. cycles: list[dict[str, Any]] = []
  2146. triggers: list[float] = []
  2147. files: list[dict[str, Any]] = []
  2148. diagnostics: list[dict[str, Any]] = []
  2149. second_series_data: list[dict[str, Any]] = []
  2150. second_finite_count = 0
  2151. second_non_zero_count = 0
  2152. second_min: float | None = None
  2153. second_max: float | None = None
  2154. primary = _primary_device_point(device_points)
  2155. primary_type = DEVICE_POINT_TO_TYPE[primary]
  2156. pressure_points = [point for point in device_points if DEVICE_POINT_TO_TYPE[point] == "压力"]
  2157. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]] = {}
  2158. single_cycle = loader.__name__ != "_load_demo_wave"
  2159. for slot, point in enumerate(points):
  2160. target_per_file = max(256, int(np.ceil(max_points / max(len(points), 1))))
  2161. slot_files: dict[str, dict[str, Any]] = {}
  2162. for device_point in device_points:
  2163. file_info = point.get("files", {}).get(device_point)
  2164. if file_info:
  2165. slot_files[device_point] = file_info
  2166. # The per-slot "period source" supplies the 周期数据/体积/角度 series
  2167. # and the background cycle bands. Primary point is preferred; when a
  2168. # selected point's timestamp differs (e.g. 10:30:02 vs 10:30:05), a
  2169. # slot may only contain another device point, which is used instead
  2170. # so the period data and cycle bands do not disappear there.
  2171. source_point = primary if primary in slot_files else (next(iter(slot_files)) if slot_files else None)
  2172. source_samples: np.ndarray | None = None
  2173. source_detected: list[Any] = []
  2174. source_angle_full: np.ndarray | None = None
  2175. source_volume: np.ndarray | None = None
  2176. source_required: set[int] | None = None
  2177. source_id: int | None = None
  2178. source_type = ""
  2179. if source_point is not None:
  2180. source_info = slot_files[source_point]
  2181. source_id = int(source_info["id"] if isinstance(source_info, dict) else source_info)
  2182. source_type = DEVICE_POINT_TO_TYPE[source_point]
  2183. try:
  2184. source_meta, source_samples = self._load_for_window(
  2185. loader,
  2186. source_id,
  2187. source_type,
  2188. device_part + source_point,
  2189. point["sampleTime"],
  2190. load_cache,
  2191. )
  2192. except ValueError:
  2193. source_samples = None
  2194. if source_samples is not None and len(source_samples):
  2195. source_detected, source_diagnostic = _detect_source_cycles(source_samples, single_cycle)
  2196. source_angle_vector = build_angle_vector(len(source_samples), source_detected)
  2197. source_angle_full = np.full(len(source_samples), np.nan, dtype=float)
  2198. for detected_cycle in source_detected:
  2199. source_angle_full[detected_cycle.start_offset:detected_cycle.end_offset] = detected_cycle.angle
  2200. source_volume, current_volume_info = self._build_volume_vector(
  2201. len(source_samples),
  2202. source_detected,
  2203. device_part + primary,
  2204. )
  2205. if current_volume_info is not None:
  2206. volume_info = current_volume_info
  2207. source_indices = source_samples[:, 0].astype(np.int64)
  2208. source_span = max(len(source_samples), 1)
  2209. finite_second = source_samples[:, 2][np.isfinite(source_samples[:, 2])]
  2210. if len(finite_second):
  2211. second_finite_count += int(len(finite_second))
  2212. second_non_zero_count += int(np.count_nonzero(finite_second != 0))
  2213. current_min = float(np.min(finite_second))
  2214. current_max = float(np.max(finite_second))
  2215. second_min = current_min if second_min is None else min(second_min, current_min)
  2216. second_max = current_max if second_max is None else max(second_max, current_max)
  2217. source_required = {0, len(source_samples) - 1}
  2218. for cycle in source_detected:
  2219. source_required.update(
  2220. {
  2221. cycle.start_offset,
  2222. max(cycle.end_offset - 1, cycle.start_offset),
  2223. *cycle.trigger_offsets,
  2224. },
  2225. )
  2226. start_x = slot + cycle.start_offset / source_span
  2227. end_x = slot + cycle.end_offset / source_span
  2228. cycles.append(
  2229. {
  2230. "id": f"{source_id}:{cycle.number}",
  2231. "waveFileId": source_id,
  2232. "periodNo": cycle.number,
  2233. "pointIndex": slot,
  2234. "sampleTime": point["sampleTime"],
  2235. "startX": start_x,
  2236. "endX": end_x,
  2237. "startSampleIndex": int(source_indices[cycle.start_offset]),
  2238. "endSampleIndex": int(
  2239. source_indices[max(cycle.end_offset - 1, cycle.start_offset)],
  2240. ),
  2241. "sourceType": source_type,
  2242. "devicePoint": source_point,
  2243. "background": True,
  2244. },
  2245. )
  2246. for trigger_offset in sorted(source_required):
  2247. if any(
  2248. trigger_offset == run_offset
  2249. for cycle in source_detected
  2250. for run_offset in cycle.trigger_offsets
  2251. ):
  2252. triggers.append(slot + trigger_offset / source_span)
  2253. diagnostics.append(
  2254. {
  2255. "waveFileId": source_id,
  2256. "measurementType": source_type,
  2257. "devicePoint": source_point,
  2258. "sampleTime": point["sampleTime"],
  2259. **source_diagnostic,
  2260. },
  2261. )
  2262. source_base_index = int(source_indices[0])
  2263. second_chosen = downsample_indices(source_samples[:, 2], target_per_file, source_required)
  2264. for offset in second_chosen:
  2265. second_value = _safe_float(source_samples[offset, 2])
  2266. if second_value is None:
  2267. continue
  2268. x = slot + (int(source_indices[offset]) - source_base_index) / source_span
  2269. second_series_data.append(
  2270. {
  2271. "value": [x, second_value],
  2272. "x": x,
  2273. "rawValue": second_value,
  2274. "sampleIndex": int(source_indices[offset]),
  2275. "waveFileId": source_id,
  2276. "sampleTime": point["sampleTime"],
  2277. },
  2278. )
  2279. angle_chosen = downsample_indices(source_angle_vector, target_per_file, source_required)
  2280. for offset in angle_chosen:
  2281. value = _safe_float(source_angle_vector[offset])
  2282. if value is None:
  2283. continue
  2284. x = slot + (int(source_indices[offset]) - source_base_index) / source_span
  2285. angle_data.append(
  2286. {
  2287. "value": [x, value],
  2288. "x": x,
  2289. "angle": value,
  2290. "sampleIndex": int(source_indices[offset]),
  2291. "waveFileId": source_id,
  2292. "sampleTime": point["sampleTime"],
  2293. },
  2294. )
  2295. volume_chosen = downsample_indices(source_volume, target_per_file, source_required)
  2296. for offset in volume_chosen:
  2297. value = _safe_float(source_volume[offset])
  2298. if value is None:
  2299. continue
  2300. x = slot + (int(source_indices[offset]) - source_base_index) / source_span
  2301. volume_data.append(
  2302. {
  2303. "value": [x, value],
  2304. "x": x,
  2305. "volume": value,
  2306. "sampleIndex": int(source_indices[offset]),
  2307. "waveFileId": source_id,
  2308. "sampleTime": point["sampleTime"],
  2309. },
  2310. )
  2311. for device_point in device_points:
  2312. file_info = slot_files.get(device_point)
  2313. if not file_info:
  2314. continue
  2315. file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
  2316. measurement_type = DEVICE_POINT_TO_TYPE[device_point]
  2317. try:
  2318. metadata, samples = self._load_for_window(
  2319. loader,
  2320. file_id,
  2321. measurement_type,
  2322. device_part + device_point,
  2323. point["sampleTime"],
  2324. load_cache,
  2325. )
  2326. except ValueError:
  2327. continue
  2328. sample_count = len(samples)
  2329. if not sample_count:
  2330. continue
  2331. sample_indices_for_file = samples[:, 0].astype(np.int64)
  2332. file_base_index = int(sample_indices_for_file[0])
  2333. file_span = max(sample_count, 1)
  2334. file_required = {0, sample_count - 1}
  2335. own_volume: np.ndarray | None = None
  2336. own_angle: np.ndarray | None = None
  2337. if device_point in pressure_points:
  2338. if source_point == device_point and source_samples is not None:
  2339. own_volume = source_volume
  2340. own_angle = source_angle_full
  2341. if source_required is not None:
  2342. file_required.update(source_required)
  2343. else:
  2344. detected_own, _ = _detect_source_cycles(samples, single_cycle)
  2345. for cycle in detected_own:
  2346. file_required.update(
  2347. {
  2348. cycle.start_offset,
  2349. max(cycle.end_offset - 1, cycle.start_offset),
  2350. *cycle.trigger_offsets,
  2351. },
  2352. )
  2353. cycles.append(
  2354. {
  2355. "id": f"{file_id}:{cycle.number}",
  2356. "waveFileId": file_id,
  2357. "periodNo": cycle.number,
  2358. "pointIndex": slot,
  2359. "sampleTime": point["sampleTime"],
  2360. "startX": slot + cycle.start_offset / file_span,
  2361. "endX": slot + cycle.end_offset / file_span,
  2362. "startSampleIndex": int(sample_indices_for_file[cycle.start_offset]),
  2363. "endSampleIndex": int(
  2364. sample_indices_for_file[max(cycle.end_offset - 1, cycle.start_offset)],
  2365. ),
  2366. "sourceType": measurement_type,
  2367. "devicePoint": device_point,
  2368. "background": False,
  2369. },
  2370. )
  2371. if detected_own:
  2372. angle_own = build_angle_vector(len(samples), detected_own)
  2373. full_own = np.full(len(samples), np.nan, dtype=float)
  2374. for detected_cycle in detected_own:
  2375. full_own[detected_cycle.start_offset:detected_cycle.end_offset] = detected_cycle.angle
  2376. own_volume, _ = self._build_volume_vector(
  2377. len(samples),
  2378. detected_own,
  2379. device_part + device_point,
  2380. )
  2381. own_angle = full_own
  2382. if no_sampling:
  2383. target_per_file_own = sample_count
  2384. else:
  2385. target_per_file_own = target_per_file
  2386. chosen = downsample_indices(samples[:, 1], target_per_file_own, file_required)
  2387. for offset in chosen:
  2388. x = slot + (int(sample_indices_for_file[offset]) - file_base_index) / file_span
  2389. raw_value = float(samples[offset, 1])
  2390. series_data[device_point].append(
  2391. {
  2392. "value": [x, raw_value],
  2393. "x": x,
  2394. "rawValue": raw_value,
  2395. "sampleIndex": int(sample_indices_for_file[offset]),
  2396. "waveFileId": file_id,
  2397. "sampleTime": point["sampleTime"],
  2398. "secondValue": _safe_float(samples[offset, 2]),
  2399. "volume": _safe_float(own_volume[offset]) if own_volume is not None else None,
  2400. "angle360": _safe_float(own_angle[offset]) if own_angle is not None else None,
  2401. },
  2402. )
  2403. files.append(
  2404. {
  2405. "id": file_id,
  2406. "pointIndex": slot,
  2407. "sampleTime": point["sampleTime"],
  2408. "devicePoint": device_point,
  2409. "measurementType": measurement_type,
  2410. "sampleCount": int(metadata.get("sample_count") or sample_count),
  2411. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  2412. "pointName": str(metadata.get("point_name") or device_part + device_point),
  2413. "rpm": float(metadata.get("rpm") or 0),
  2414. "status": int(metadata.get("tspluse_status") or 0),
  2415. "fileName": str(metadata.get("file_name") or ""),
  2416. },
  2417. )
  2418. load_cache.clear()
  2419. for device_point in series_data:
  2420. series_data[device_point].sort(key=lambda item: item["x"])
  2421. second_series_data.sort(key=lambda item: item["x"])
  2422. angle_data.sort(key=lambda item: item["x"])
  2423. volume_data.sort(key=lambda item: item["x"])
  2424. extents = {device_point: _series_extent(series_data[device_point]) for device_point in device_points}
  2425. return {
  2426. "devicePart": device_part,
  2427. "devicePoints": device_points,
  2428. "primaryPoint": primary,
  2429. "points": points,
  2430. "xMin": 0,
  2431. "xMax": len(points),
  2432. "series": [
  2433. {
  2434. "devicePoint": device_point,
  2435. "measurementType": DEVICE_POINT_TO_TYPE[device_point],
  2436. "color": MEASUREMENT_COLORS[DEVICE_POINT_TO_TYPE[device_point]],
  2437. "data": series_data[device_point],
  2438. "min": extents[device_point][0],
  2439. "max": extents[device_point][1],
  2440. }
  2441. for device_point in device_points
  2442. ],
  2443. "secondSeries": {
  2444. "name": "周期数据",
  2445. "color": "#f56c6c",
  2446. "sourceMeasurementType": primary_type,
  2447. "sourceDevicePoint": primary,
  2448. "data": second_series_data,
  2449. "finiteCount": second_finite_count,
  2450. "nonZeroCount": second_non_zero_count,
  2451. "min": second_min,
  2452. "max": second_max,
  2453. },
  2454. "angleSeries": {
  2455. "color": "#d59b2b",
  2456. "data": angle_data,
  2457. },
  2458. "volumeSeries": {
  2459. "color": "#4d9e6f",
  2460. "data": volume_data,
  2461. "info": volume_info,
  2462. },
  2463. "cycles": cycles,
  2464. "triggerXs": sorted(set(triggers)),
  2465. "files": files,
  2466. "diagnostics": diagnostics,
  2467. }
  2468. def _build_first_cycle_window(
  2469. self,
  2470. device_part: str,
  2471. device_points: list[str],
  2472. points: list[dict[str, Any]],
  2473. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  2474. ) -> dict[str, Any]:
  2475. """Build a slot-layout window where each file shows only its first cycle.
  2476. The primary point is selected in pressure-cap, pressure-shaft, then
  2477. selected-point order. It is the point used for the first-cycle slice;
  2478. files without recorded bounds still appear in the file list.
  2479. The slice for every primary file is fetched in a single JOIN query (range
  2480. scan on the ``(wave_file_id, sample_index)`` primary key). Other selected
  2481. device points contribute no curve but still appear in the file list. The
  2482. curve is continuous: all cycle samples are kept and each file spans its
  2483. own x slot.
  2484. """
  2485. primary = _primary_device_point(device_points)
  2486. primary_type = DEVICE_POINT_TO_TYPE[primary]
  2487. series_data: dict[str, list[dict[str, Any]]] = {point: [] for point in device_points}
  2488. second_series_data: list[dict[str, Any]] = []
  2489. angle_data: list[dict[str, Any]] = []
  2490. volume_data: list[dict[str, Any]] = []
  2491. volume_info: dict[str, Any] | None = None
  2492. cycles: list[dict[str, Any]] = []
  2493. triggers: list[float] = []
  2494. files: list[dict[str, Any]] = []
  2495. diagnostics: list[dict[str, Any]] = []
  2496. second_finite_count = 0
  2497. second_non_zero_count = 0
  2498. second_min: float | None = None
  2499. second_max: float | None = None
  2500. slot_sources: list[tuple[int, dict[str, Any], str, int]] = []
  2501. all_file_ids: set[int] = set()
  2502. for slot, point in enumerate(points):
  2503. for device_point in device_points:
  2504. file_info = point.get("files", {}).get(device_point)
  2505. if not file_info:
  2506. continue
  2507. file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
  2508. slot_sources.append((slot, point, device_point, file_id))
  2509. all_file_ids.add(file_id)
  2510. if not slot_sources:
  2511. return self._assemble_first_cycle_window(
  2512. device_part, device_points, primary, points, series_data, second_series_data,
  2513. angle_data, volume_data, volume_info,
  2514. second_finite_count, second_non_zero_count, second_min, second_max,
  2515. cycles, triggers, files, diagnostics, 0,
  2516. )
  2517. is_demo = loader.__name__ == "_load_demo_wave"
  2518. metas: dict[int, dict[str, Any]] = {}
  2519. # file_id -> (cycle_start, cycle_end, padded_slice[offset, signal, second])
  2520. slices: dict[int, tuple[int, int, np.ndarray]] = {}
  2521. if is_demo:
  2522. for _slot, point, device_point, source_id in slot_sources:
  2523. metadata, samples = self._load_for_window(
  2524. loader, source_id, DEVICE_POINT_TO_TYPE[device_point],
  2525. device_part + device_point, point["sampleTime"], {},
  2526. )
  2527. metas[source_id] = metadata
  2528. detected, _ = detect_cycles(samples)
  2529. if detected:
  2530. cycle = detected[0]
  2531. start_si = int(samples[cycle.start_offset, 0])
  2532. end_si = int(samples[cycle.end_offset, 0])
  2533. pad_end = min(cycle.end_offset + FIRST_CYCLE_PAD, len(samples))
  2534. slices[source_id] = (start_si, end_si, samples[cycle.start_offset:pad_end])
  2535. else:
  2536. source_ids = [source_id for _, _, _, source_id in slot_sources]
  2537. with get_connection() as connection:
  2538. with connection.cursor() as cursor:
  2539. placeholders = ", ".join(["%s"] * len(all_file_ids))
  2540. cursor.execute(
  2541. f"""
  2542. SELECT id, point_name, measurement_type, sample_time,
  2543. sample_count, sample_frequency_hz, rpm, tspluse_status,
  2544. file_name, cycle_start, cycle_end
  2545. FROM wave_file
  2546. WHERE id IN ({placeholders})
  2547. """,
  2548. tuple(all_file_ids),
  2549. )
  2550. for row in cursor.fetchall():
  2551. metas[int(row["id"])] = row
  2552. source_placeholders = ", ".join(["%s"] * len(source_ids))
  2553. cursor.execute(
  2554. f"""
  2555. SELECT ws.wave_file_id,
  2556. ws.sample_index,
  2557. CAST(ws.signal_value AS FLOAT) AS sig,
  2558. CAST(ws.second_value AS FLOAT) AS sec
  2559. FROM wave_sample_one ws
  2560. WHERE ws.wave_file_id IN ({source_placeholders})
  2561. ORDER BY ws.wave_file_id, ws.sample_index
  2562. """,
  2563. tuple(source_ids),
  2564. )
  2565. raw: dict[int, list[tuple[float, float, float]]] = {}
  2566. for row in cursor.fetchall():
  2567. raw.setdefault(int(row["wave_file_id"]), []).append(
  2568. (
  2569. float(row["sample_index"]),
  2570. float(row["sig"]),
  2571. float(row["sec"]) if row["sec"] is not None else float("nan"),
  2572. ),
  2573. )
  2574. for file_id, rows in raw.items():
  2575. meta = metas.get(file_id)
  2576. if meta is None or not rows:
  2577. continue
  2578. # wave_sample_one 只保留第一个周期,返回的数组本身就是该周期。
  2579. slices[file_id] = (
  2580. int(rows[0][0]),
  2581. int(rows[-1][0]) + 1,
  2582. np.asarray(rows, dtype=float),
  2583. )
  2584. # Per-slot period source: primary preferred, else the first selected
  2585. # point with a file at that slot (timestamps may differ by seconds).
  2586. source_by_slot: dict[int, str] = {}
  2587. for _slot, _point, device_point, _file_id in slot_sources:
  2588. if _slot not in source_by_slot:
  2589. source_by_slot[_slot] = device_point
  2590. for _slot, _point, device_point, _file_id in slot_sources:
  2591. if device_point == primary:
  2592. source_by_slot[_slot] = primary
  2593. for slot, point, device_point, source_id in slot_sources:
  2594. sample_time = point["sampleTime"]
  2595. is_slot_source = device_point == source_by_slot.get(slot, device_point)
  2596. cycle_bounds = slices.get(source_id)
  2597. cycle_start = 0
  2598. cycle_end = 0
  2599. slice_arr: np.ndarray | None = None
  2600. if cycle_bounds is not None:
  2601. cycle_start, cycle_end, slice_arr = cycle_bounds
  2602. cycle_len = len(slice_arr) if slice_arr is not None else 0
  2603. has_cycle = slice_arr is not None and 1 < cycle_len
  2604. if has_cycle:
  2605. angle: np.ndarray | None = None
  2606. volume: np.ndarray | None = None
  2607. point_volume_info: dict[str, Any] | None = None
  2608. stored_cycle = _stored_first_cycle(slice_arr)
  2609. detected = [stored_cycle] if stored_cycle is not None else []
  2610. if detected:
  2611. cycle = detected[0]
  2612. angle = cycle.angle
  2613. volume, point_volume_info = self._build_volume_vector(
  2614. len(slice_arr),
  2615. [cycle],
  2616. device_part + primary,
  2617. )
  2618. if is_slot_source and point_volume_info is not None:
  2619. volume_info = point_volume_info
  2620. for offset in range(cycle_len):
  2621. sample_index = int(slice_arr[offset, 0])
  2622. raw_value = float(slice_arr[offset, 1])
  2623. second = _safe_float(slice_arr[offset, 2])
  2624. angle360 = _safe_float(angle[offset]) if angle is not None and offset < len(angle) else None
  2625. volume_value = _safe_float(volume[offset]) if volume is not None and offset < len(volume) else None
  2626. x = slot + (sample_index - cycle_start) / cycle_len
  2627. series_data[device_point].append(
  2628. {
  2629. "value": [x, raw_value],
  2630. "x": x,
  2631. "rawValue": raw_value,
  2632. "sampleIndex": sample_index,
  2633. "waveFileId": source_id,
  2634. "sampleTime": sample_time,
  2635. "secondValue": second,
  2636. "volume": volume_value,
  2637. "angle360": angle360,
  2638. },
  2639. )
  2640. if is_slot_source:
  2641. if volume_value is not None:
  2642. volume_data.append(
  2643. {
  2644. "value": [x, volume_value],
  2645. "x": x,
  2646. "volume": volume_value,
  2647. "sampleIndex": sample_index,
  2648. "waveFileId": source_id,
  2649. "sampleTime": sample_time,
  2650. },
  2651. )
  2652. if angle360 is not None:
  2653. display_angle = angle360 if angle360 <= 180.0 else 360.0 - angle360
  2654. angle_data.append(
  2655. {
  2656. "value": [x, display_angle],
  2657. "x": x,
  2658. "angle": display_angle,
  2659. "sampleIndex": sample_index,
  2660. "waveFileId": source_id,
  2661. "sampleTime": sample_time,
  2662. },
  2663. )
  2664. if second is not None:
  2665. second_finite_count += 1
  2666. if second != 0:
  2667. second_non_zero_count += 1
  2668. second_min = second if second_min is None else min(second_min, second)
  2669. second_max = second if second_max is None else max(second_max, second)
  2670. second_series_data.append(
  2671. {
  2672. "value": [x, second],
  2673. "x": x,
  2674. "rawValue": second,
  2675. "sampleIndex": sample_index,
  2676. "waveFileId": source_id,
  2677. "sampleTime": sample_time,
  2678. },
  2679. )
  2680. if offset > 0:
  2681. prev = _safe_float(slice_arr[offset - 1, 2])
  2682. if second is not None and (prev is None or prev < 30) and second >= 30:
  2683. triggers.append(x)
  2684. cycles.append(
  2685. {
  2686. "id": f"{source_id}:1",
  2687. "waveFileId": source_id,
  2688. "periodNo": 1,
  2689. "pointIndex": slot,
  2690. "sampleTime": sample_time,
  2691. "startX": float(slot),
  2692. "endX": float(slot + 1),
  2693. "startSampleIndex": cycle_start,
  2694. "endSampleIndex": cycle_end - 1,
  2695. "sourceType": DEVICE_POINT_TO_TYPE[device_point],
  2696. "devicePoint": device_point,
  2697. "background": is_slot_source,
  2698. },
  2699. )
  2700. diagnostics.append(
  2701. {
  2702. "waveFileId": source_id,
  2703. "measurementType": DEVICE_POINT_TO_TYPE[device_point],
  2704. "devicePoint": device_point,
  2705. "sampleTime": sample_time,
  2706. "cycleStart": cycle_start,
  2707. "cycleEnd": cycle_end,
  2708. "cycleSampleCount": cycle_len,
  2709. },
  2710. )
  2711. for slot, point in enumerate(points):
  2712. for device_point in device_points:
  2713. file_info = point.get("files", {}).get(device_point)
  2714. if not file_info:
  2715. continue
  2716. file_id = int(file_info["id"] if isinstance(file_info, dict) else file_info)
  2717. metadata = metas.get(file_id)
  2718. cycle_start = metadata.get("cycle_start") if metadata else None
  2719. cycle_end = metadata.get("cycle_end") if metadata else None
  2720. files.append(
  2721. {
  2722. "id": file_id,
  2723. "pointIndex": slot,
  2724. "sampleTime": point["sampleTime"],
  2725. "devicePoint": device_point,
  2726. "measurementType": DEVICE_POINT_TO_TYPE[device_point],
  2727. "sampleCount": int(
  2728. (metadata.get("sample_count") if metadata else file_info.get("sampleCount") or 0) or 0,
  2729. ),
  2730. "sampleFrequencyHz": int(
  2731. (metadata.get("sample_frequency_hz") if metadata else file_info.get("sampleFrequencyHz") or 0) or 0,
  2732. ),
  2733. "pointName": str(
  2734. (metadata.get("point_name") if metadata else device_part + device_point)
  2735. or device_part + device_point,
  2736. ),
  2737. "rpm": float((metadata.get("rpm") if metadata else file_info.get("rpm") or 0) or 0),
  2738. "status": int(
  2739. (metadata.get("tspluse_status") if metadata else file_info.get("status") or 0) or 0,
  2740. ),
  2741. "fileName": str((metadata.get("file_name") if metadata else "") or ""),
  2742. "cycleStart": int(cycle_start) if cycle_start is not None else None,
  2743. "cycleEnd": int(cycle_end) if cycle_end is not None else None,
  2744. },
  2745. )
  2746. return self._assemble_first_cycle_window(
  2747. device_part, device_points, primary, points, series_data, second_series_data,
  2748. angle_data, volume_data, volume_info,
  2749. second_finite_count, second_non_zero_count, second_min, second_max,
  2750. cycles, triggers, files, diagnostics,
  2751. max(len(slot_sources) - len(cycles), 0),
  2752. )
  2753. @staticmethod
  2754. def _assemble_first_cycle_window(
  2755. device_part: str,
  2756. device_points: list[str],
  2757. primary: str,
  2758. points: list[dict[str, Any]],
  2759. series_data: dict[str, list[dict[str, Any]]],
  2760. second_series_data: list[dict[str, Any]],
  2761. angle_data: list[dict[str, Any]],
  2762. volume_data: list[dict[str, Any]],
  2763. volume_info: dict[str, Any] | None,
  2764. second_finite_count: int,
  2765. second_non_zero_count: int,
  2766. second_min: float | None,
  2767. second_max: float | None,
  2768. cycles: list[dict[str, Any]],
  2769. triggers: list[float],
  2770. files: list[dict[str, Any]],
  2771. diagnostics: list[dict[str, Any]],
  2772. missing_cycles: int = 0,
  2773. ) -> dict[str, Any]:
  2774. for device_point in series_data:
  2775. series_data[device_point].sort(key=lambda item: item["x"])
  2776. second_series_data.sort(key=lambda item: item["x"])
  2777. angle_data.sort(key=lambda item: item["x"])
  2778. volume_data.sort(key=lambda item: item["x"])
  2779. extents = {device_point: _series_extent(series_data[device_point]) for device_point in device_points}
  2780. return {
  2781. "devicePart": device_part,
  2782. "devicePoints": device_points,
  2783. "primaryPoint": primary,
  2784. "points": points,
  2785. "xMin": 0,
  2786. "xMax": len(points),
  2787. "series": [
  2788. {
  2789. "devicePoint": device_point,
  2790. "measurementType": DEVICE_POINT_TO_TYPE[device_point],
  2791. "color": MEASUREMENT_COLORS[DEVICE_POINT_TO_TYPE[device_point]],
  2792. "data": series_data[device_point],
  2793. "min": extents[device_point][0],
  2794. "max": extents[device_point][1],
  2795. }
  2796. for device_point in device_points
  2797. ],
  2798. "secondSeries": {
  2799. "name": "周期数据",
  2800. "color": "#f56c6c",
  2801. "sourceMeasurementType": DEVICE_POINT_TO_TYPE[primary],
  2802. "sourceDevicePoint": primary,
  2803. "data": second_series_data,
  2804. "finiteCount": second_finite_count,
  2805. "nonZeroCount": second_non_zero_count,
  2806. "min": second_min,
  2807. "max": second_max,
  2808. },
  2809. "angleSeries": {
  2810. "color": "#d59b2b",
  2811. "data": angle_data,
  2812. },
  2813. "volumeSeries": {
  2814. "color": "#4d9e6f",
  2815. "data": volume_data,
  2816. "info": volume_info,
  2817. },
  2818. "cycles": cycles,
  2819. "triggerXs": sorted(set(triggers)),
  2820. "files": files,
  2821. "diagnostics": diagnostics,
  2822. "firstCycleMode": True,
  2823. "firstCycleNotice": (
  2824. f"窗口内 {missing_cycles} 个文件没有可用的单周期采样数据(wave_sample_one),"
  2825. "多为停机(rpm=0)文件,无法绘制首周期曲线。"
  2826. if missing_cycles > 0
  2827. else None
  2828. ),
  2829. }
  2830. @staticmethod
  2831. def _build_volume_vector(
  2832. sample_count: int,
  2833. cycles: list[DetectedCycle],
  2834. point_name: str,
  2835. ) -> tuple[np.ndarray, dict[str, Any] | None]:
  2836. volume = np.full(sample_count, np.nan, dtype=float)
  2837. cylinder_name = next(
  2838. (name for name in CYLINDER_BORE_MM if name in point_name),
  2839. None,
  2840. )
  2841. if cylinder_name is None or not cycles:
  2842. return volume, None
  2843. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  2844. clearance = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  2845. crank_radius = PISTON_STROKE_MM / 2.0
  2846. piston_area = np.pi * (bore_mm / 2.0) ** 2
  2847. for cycle in cycles:
  2848. angle_rad = np.deg2rad(cycle.angle)
  2849. travel = (
  2850. crank_radius * (1.0 - np.cos(angle_rad))
  2851. + CONNECTING_ROD_LENGTH_MM
  2852. - np.sqrt(
  2853. CONNECTING_ROD_LENGTH_MM**2
  2854. - (crank_radius * np.sin(angle_rad)) ** 2,
  2855. )
  2856. )
  2857. volume[cycle.start_offset : cycle.end_offset] = (
  2858. clearance + piston_area * travel / 1_000_000.0
  2859. )
  2860. finite = volume[np.isfinite(volume)]
  2861. return volume, {
  2862. "cylinder": cylinder_name,
  2863. "boreMm": bore_mm,
  2864. "clearanceVolumeL": clearance,
  2865. "minVolumeL": float(np.min(finite)) if len(finite) else None,
  2866. "maxVolumeL": float(np.max(finite)) if len(finite) else None,
  2867. }
  2868. @staticmethod
  2869. def _build_period_detail(
  2870. metadata: dict[str, Any],
  2871. samples: np.ndarray,
  2872. period_number: int,
  2873. ) -> dict[str, Any]:
  2874. stored_cycle = _stored_first_cycle(samples) if metadata.get("single_cycle") else None
  2875. if stored_cycle is not None:
  2876. detected = [stored_cycle]
  2877. diagnostics = {"completeCycleCount": 1, "storedFirstCycle": 1}
  2878. else:
  2879. detected, diagnostics = detect_cycles(samples)
  2880. cycle = next((item for item in detected if item.number == period_number), None)
  2881. if cycle is None:
  2882. raise ValueError(f"没有找到周期 {period_number}")
  2883. # 保留 wave_sample_one 的完整周期原始采样点,不再插值成固定 360 点。
  2884. angles360 = np.asarray(cycle.angle, dtype=float)
  2885. pressure = np.asarray(cycle.signal, dtype=float)
  2886. display_angles = np.where(angles360 <= 180.0, angles360, 360.0 - angles360)
  2887. point_name = str(metadata.get("point_name") or "")
  2888. side = metadata.get("device_point")
  2889. if side not in PRESSURE_SIDES:
  2890. side = next((item for item in PRESSURE_SIDES if point_name.endswith(item)), None)
  2891. device_part = metadata.get("device_part")
  2892. if not device_part and side and point_name.endswith(str(side)):
  2893. device_part = point_name[: -len(str(side))]
  2894. cylinder_name = next(
  2895. (name for name in CYLINDER_BORE_MM if name in point_name),
  2896. None,
  2897. )
  2898. volume: np.ndarray | None = None
  2899. volume_info: dict[str, Any] | None = None
  2900. if cylinder_name:
  2901. bore_mm = CYLINDER_BORE_MM[cylinder_name]
  2902. clearance_volume = CLEARANCE_VOLUME_L_BY_BORE[bore_mm]
  2903. angle_rad = np.deg2rad(angles360)
  2904. crank_radius = PISTON_STROKE_MM / 2.0
  2905. piston_travel = (
  2906. crank_radius * (1.0 - np.cos(angle_rad))
  2907. + CONNECTING_ROD_LENGTH_MM
  2908. - np.sqrt(
  2909. CONNECTING_ROD_LENGTH_MM**2
  2910. - (crank_radius * np.sin(angle_rad)) ** 2,
  2911. )
  2912. )
  2913. piston_area = np.pi * (bore_mm / 2.0) ** 2
  2914. volume = clearance_volume + piston_area * piston_travel / 1_000_000.0
  2915. volume_info = {
  2916. "cylinder": cylinder_name,
  2917. "boreMm": bore_mm,
  2918. "clearanceVolumeL": clearance_volume,
  2919. "minVolumeL": float(np.min(volume)),
  2920. "maxVolumeL": float(np.max(volume)),
  2921. }
  2922. start_index = int(samples[cycle.start_offset, 0])
  2923. end_offset = min(cycle.end_offset, len(samples) - 1)
  2924. end_index = int(samples[max(cycle.end_offset - 1, cycle.start_offset), 0])
  2925. return {
  2926. "side": side,
  2927. "devicePart": device_part or "",
  2928. "sampleTime": _time_string(metadata.get("sample_time")),
  2929. "waveFile": {
  2930. "id": int(metadata["id"]),
  2931. "devicePart": device_part or "",
  2932. "devicePoint": side,
  2933. "pointName": point_name,
  2934. "measurementType": metadata.get("measurement_type"),
  2935. "sampleTime": _time_string(metadata.get("sample_time")),
  2936. "sampleFrequencyHz": int(metadata.get("sample_frequency_hz") or 0),
  2937. "sampleCount": int(metadata.get("sample_count") or len(samples)),
  2938. },
  2939. "period": {
  2940. "periodNo": cycle.number,
  2941. "startSampleIndex": start_index,
  2942. "endSampleIndex": end_index,
  2943. "sampleCount": int(cycle.end_offset - cycle.start_offset),
  2944. "triggerSampleIndices": [
  2945. int(samples[offset, 0])
  2946. for offset in cycle.trigger_offsets
  2947. if 0 <= offset < len(samples)
  2948. ],
  2949. },
  2950. "angles": display_angles.tolist(),
  2951. "angles360": angles360.tolist(),
  2952. "pressure": pressure.tolist(),
  2953. "volume": volume.tolist() if volume is not None else None,
  2954. "volumeInfo": volume_info,
  2955. "phases": [
  2956. {
  2957. "name": name,
  2958. "color": color,
  2959. "start": start,
  2960. "end": end,
  2961. }
  2962. for name, color, start, end in PHASES
  2963. ],
  2964. "diagnostics": diagnostics,
  2965. }
  2966. @staticmethod
  2967. def _load_for_window(
  2968. loader: Callable[..., tuple[dict[str, Any], np.ndarray]],
  2969. file_id: int,
  2970. measurement_type: str,
  2971. point_name: str,
  2972. sample_time: str,
  2973. load_cache: dict[int, tuple[dict[str, Any], np.ndarray]],
  2974. ) -> tuple[dict[str, Any], np.ndarray]:
  2975. if file_id not in load_cache:
  2976. if loader.__name__ == "_load_demo_wave":
  2977. load_cache[file_id] = loader(file_id, measurement_type, point_name, sample_time)
  2978. else:
  2979. load_cache[file_id] = loader(file_id)
  2980. return load_cache[file_id]
  2981. def _load_db_wave(self, file_id: int) -> tuple[dict[str, Any], np.ndarray]:
  2982. with get_connection() as connection:
  2983. with connection.cursor() as cursor:
  2984. cursor.execute(
  2985. """
  2986. SELECT id, device_part, device_point, point_name, measurement_type,
  2987. sample_frequency_hz, sample_count, sample_time, rpm, file_name,
  2988. tspluse_status, cycle_start, cycle_end
  2989. FROM wave_file
  2990. WHERE id = %s
  2991. """,
  2992. (file_id,),
  2993. )
  2994. metadata = cursor.fetchone()
  2995. if metadata is None:
  2996. raise ValueError(f"wave_file.id={file_id} 不存在")
  2997. # wave_sample_one 只保留第一个周期,直接读取该文件全部样本。
  2998. cursor.execute(
  2999. """
  3000. SELECT sample_index, signal_value, second_value
  3001. FROM wave_sample_one
  3002. WHERE wave_file_id = %s
  3003. ORDER BY sample_index ASC
  3004. """,
  3005. (file_id,),
  3006. )
  3007. rows = cursor.fetchall()
  3008. if not rows:
  3009. raise ValueError(f"wave_file.id={file_id} 没有采样数据")
  3010. metadata["single_cycle"] = True
  3011. samples = np.asarray(
  3012. [
  3013. (
  3014. float(row["sample_index"]),
  3015. float(row["signal_value"]),
  3016. float(row["second_value"]) if row["second_value"] is not None else np.nan,
  3017. )
  3018. for row in rows
  3019. ],
  3020. dtype=float,
  3021. )
  3022. return metadata, samples
  3023. @staticmethod
  3024. @lru_cache(maxsize=24)
  3025. def _demo_samples(file_id: int, measurement_type: str) -> np.ndarray:
  3026. count = DEMO_SAMPLE_COUNT
  3027. index = np.arange(count, dtype=float)
  3028. revolution = DEMO_REVOLUTION_SAMPLES
  3029. phase = (index % revolution) / revolution * 2 * np.pi
  3030. second = np.zeros(count, dtype=float)
  3031. for revolution_start in range(0, count, revolution):
  3032. for pulse in range(PULSES_PER_REVOLUTION):
  3033. pulse_start = revolution_start + int(round(pulse * revolution / PULSES_PER_REVOLUTION))
  3034. width = 22 if pulse == 0 else 8
  3035. pulse_end = min(count, pulse_start + width)
  3036. second[pulse_start:pulse_end] = 40.0
  3037. variation = (file_id % 17) / 17.0
  3038. if measurement_type == "压力":
  3039. signal = (
  3040. 4.2
  3041. + 1.8 * np.sin(phase - 0.4)
  3042. + 0.55 * np.sin(2 * phase + variation)
  3043. + 0.22 * np.sin(7 * phase)
  3044. )
  3045. signal += 0.2 * np.maximum(np.sin(phase - 0.2), 0) ** 5
  3046. elif measurement_type == "位移":
  3047. signal = 0.5 + 0.18 * np.cos(phase) + 0.035 * np.sin(3 * phase + variation)
  3048. else:
  3049. signal = 0.15 * np.sin(phase * 2 + variation) + 0.04 * np.sin(11 * phase)
  3050. signal += 0.018 * np.cos(index / 37.0)
  3051. return np.column_stack((index, signal, second))
  3052. def _load_demo_wave(
  3053. self,
  3054. file_id: int,
  3055. measurement_type: str,
  3056. point_name: str,
  3057. sample_time: str,
  3058. ) -> tuple[dict[str, Any], np.ndarray]:
  3059. samples = self._demo_samples(file_id, measurement_type)
  3060. metadata = {
  3061. "id": file_id,
  3062. "point_name": point_name,
  3063. "measurement_type": measurement_type,
  3064. "sample_frequency_hz": 25600,
  3065. "sample_count": len(samples),
  3066. "sample_time": sample_time,
  3067. "rpm": 998.0,
  3068. "file_name": f"demo-{file_id}.dat",
  3069. }
  3070. return metadata, samples
  3071. @staticmethod
  3072. def _demo_options() -> list[dict[str, Any]]:
  3073. end = DEMO_START + timedelta(minutes=5 * (DEMO_POINT_COUNT - 1))
  3074. return [
  3075. {
  3076. "devicePart": DEMO_DEVICE_PART,
  3077. "devicePoint": device_point,
  3078. "measurementType": DEVICE_POINT_TO_TYPE[device_point],
  3079. "minTime": _time_string(DEMO_START),
  3080. "maxTime": _time_string(end),
  3081. "fileCount": DEMO_POINT_COUNT,
  3082. }
  3083. for device_point in DEVICE_POINTS
  3084. ]
  3085. @staticmethod
  3086. def _demo_time_points(
  3087. device_part: str,
  3088. device_points: list[str],
  3089. start: datetime | None,
  3090. end: datetime | None,
  3091. ) -> list[dict[str, Any]]:
  3092. points = []
  3093. for index in range(DEMO_POINT_COUNT):
  3094. timestamp = DEMO_START + timedelta(minutes=5 * index)
  3095. if start and timestamp < start:
  3096. continue
  3097. if end and timestamp > end:
  3098. continue
  3099. files = {}
  3100. for device_point in device_points:
  3101. measurement_type = DEVICE_POINT_TO_TYPE[device_point]
  3102. files[device_point] = {
  3103. "id": DEMO_ID_BY_TYPE[measurement_type] + index,
  3104. "devicePoint": device_point,
  3105. "measurementType": measurement_type,
  3106. "sampleCount": DEMO_SAMPLE_COUNT,
  3107. "sampleFrequencyHz": 25600,
  3108. "rpm": 998.0,
  3109. }
  3110. points.append(
  3111. {
  3112. "index": len(points),
  3113. "sampleTime": _time_string(timestamp),
  3114. "files": files,
  3115. },
  3116. )
  3117. return points
  3118. data_service = DataService()