data_service.py 154 KB

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