analysis_tab.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. from __future__ import annotations
  2. import json
  3. import math
  4. import os
  5. from typing import Any, Callable, Dict, Optional, Tuple
  6. import requests
  7. from PyQt6.QtCore import QRectF, Qt
  8. from PyQt6.QtGui import QPainter, QPixmap
  9. from PyQt6.QtWidgets import (
  10. QFileDialog,
  11. QGridLayout,
  12. QGroupBox,
  13. QHBoxLayout,
  14. QLabel,
  15. QLineEdit,
  16. QMessageBox,
  17. QPushButton,
  18. QPlainTextEdit,
  19. QVBoxLayout,
  20. QWidget,
  21. )
  22. from config import HTTP_TIMEOUT_SEC
  23. from mainui.clients import LoggedAnalysisApi
  24. from mainui.widgets import InteractiveImageView
  25. from mainui.worker import run_in_thread
  26. class AnalysisTabMixin:
  27. def _analysis_base_url(self) -> str:
  28. return (self.analysis.base_url or "").rstrip("/")
  29. def _analysis_api(self) -> LoggedAnalysisApi:
  30. base = self._analysis_base_url()
  31. self.analysis.base_url = base
  32. return LoggedAnalysisApi(base_url=base, timeout=HTTP_TIMEOUT_SEC)
  33. def _init_analysis_panel(self) -> None:
  34. root_layout = self.spec.layout()
  35. if root_layout is None:
  36. root_layout = QVBoxLayout(self.spec)
  37. self.spec.setLayout(root_layout)
  38. gb = QGroupBox("Analysis service (REST)")
  39. gl = QGridLayout(gb)
  40. self.leAnalysisFile = QLineEdit("")
  41. self.leAnalysisFile.setPlaceholderText("Select CSV to upload...")
  42. btnBrowse = QPushButton("Browse...")
  43. btnBrowse.clicked.connect(self.analysis_pick_file)
  44. gl.addWidget(QLabel("File:"), 0, 0)
  45. gl.addWidget(self.leAnalysisFile, 0, 1, 1, 4)
  46. gl.addWidget(btnBrowse, 0, 5)
  47. self.leSessionId = QLineEdit("")
  48. self.leSessionId.setReadOnly(True)
  49. self.leSessionId.setPlaceholderText("auto (after upload)")
  50. btnUpload = QPushButton("Upload")
  51. btnUpload.clicked.connect(self.analysis_upload)
  52. gl.addWidget(QLabel("Session:"), 1, 0)
  53. gl.addWidget(self.leSessionId, 1, 1, 1, 4)
  54. gl.addWidget(btnUpload, 1, 5)
  55. # filter params
  56. # Defaults aligned with test_spectrum/test.py
  57. self.leDt = QLineEdit("1.125e-7")
  58. self.leCenter = QLineEdit("2.97e6")
  59. self.leLo = QLineEdit("2.92e6")
  60. self.leHi = QLineEdit("3.02e6")
  61. self.leLow = QLineEdit("600e3")
  62. gl.addWidget(QLabel("dt (s)"), 2, 0)
  63. gl.addWidget(self.leDt, 2, 1)
  64. gl.addWidget(QLabel("center (Hz)"), 2, 2)
  65. gl.addWidget(self.leCenter, 2, 3)
  66. gl.addWidget(QLabel("lo/hi/low (Hz)"), 2, 4)
  67. w_f = QWidget()
  68. hl = QHBoxLayout()
  69. hl.setContentsMargins(0, 0, 0, 0)
  70. for le in (self.leLo, self.leHi, self.leLow):
  71. le.setFixedWidth(90)
  72. hl.addWidget(le)
  73. w_f.setLayout(hl)
  74. gl.addWidget(w_f, 2, 5)
  75. btnFilter = QPushButton("Filter")
  76. btnFilter.clicked.connect(self.analysis_filter)
  77. gl.addWidget(btnFilter, 3, 5)
  78. # fft params
  79. self.leC1 = QLineEdit("")
  80. self.leC2 = QLineEdit("")
  81. self.leC3 = QLineEdit("")
  82. self.leC4 = QLineEdit("")
  83. self.leC1.setPlaceholderText("10")
  84. self.leC2.setPlaceholderText("2")
  85. self.leC3.setPlaceholderText("1")
  86. self.leC4.setPlaceholderText("1")
  87. gl.addWidget(QLabel("FFT dec coefs c1..c4"), 3, 0)
  88. w_fft = QWidget()
  89. hl2 = QHBoxLayout()
  90. hl2.setContentsMargins(0, 0, 0, 0)
  91. for le in (self.leC1, self.leC2, self.leC3, self.leC4):
  92. le.setFixedWidth(55)
  93. hl2.addWidget(le)
  94. w_fft.setLayout(hl2)
  95. gl.addWidget(w_fft, 3, 1, 1, 4)
  96. btnFFT = QPushButton("FFT")
  97. btnFFT.clicked.connect(self.analysis_fft)
  98. gl.addWidget(btnFFT, 4, 5)
  99. btnResult = QPushButton("Get result")
  100. btnPlotRaw = QPushButton("Plot raw")
  101. btnExport = QPushButton("Export plots")
  102. btnResult.clicked.connect(self.analysis_result)
  103. btnPlotRaw.clicked.connect(self.analysis_plot_raw)
  104. btnExport.clicked.connect(self.analysis_export)
  105. gl.addWidget(btnResult, 4, 0)
  106. gl.addWidget(btnPlotRaw, 4, 1)
  107. gl.addWidget(btnExport, 4, 2)
  108. self.pteAnalysisLog = QPlainTextEdit()
  109. self.pteAnalysisLog.setReadOnly(True)
  110. self.pteAnalysisLog.setMaximumBlockCount(2000)
  111. gl.addWidget(self.pteAnalysisLog, 5, 0, 1, 6)
  112. root_layout.insertWidget(0, gb)
  113. self.lblAnalysisPing = QLabel("* Analysis service: unknown")
  114. self.lblAnalysisPing.setStyleSheet("color: #808080;")
  115. root_layout.addWidget(self.lblAnalysisPing)
  116. self.lblRawImg = self._ensure_image_view(self.spec.timePlotContainer, "RAW")
  117. self.lblSpecImg = self._ensure_image_view(self.spec.spectrumPlotContainer, "SPEC")
  118. self.lblExtraImg = self._ensure_image_view(self.spec.demodPlotContainer, "EXTRA")
  119. if hasattr(self.spec, "gbTimePlot"):
  120. self.spec.gbTimePlot.setMinimumHeight(460)
  121. if hasattr(self.spec, "gbSpectrumPlot"):
  122. self.spec.gbSpectrumPlot.setMinimumHeight(460)
  123. if hasattr(self.spec, "gbDemodPlot"):
  124. self.spec.gbDemodPlot.setMinimumHeight(380)
  125. if hasattr(self.spec, "gbTimeDomain"):
  126. self.spec.gbTimeDomain.setVisible(False)
  127. if hasattr(self.spec, "gbDemod"):
  128. self.spec.gbDemod.setVisible(False)
  129. if hasattr(self.spec, "btnPlotOrigSpec"):
  130. self.spec.btnPlotOrigSpec.setVisible(False)
  131. if hasattr(self.spec, "btnPlotDemodSpec"):
  132. self.spec.btnPlotDemodSpec.setVisible(False)
  133. if hasattr(self.spec, "gbDemodPlot"):
  134. self.spec.gbDemodPlot.setVisible(False)
  135. if hasattr(self.spec, "btnDemodulate"):
  136. self.spec.btnDemodulate.clicked.connect(self._show_demod_plot)
  137. if hasattr(self.spec, "vlFreq"):
  138. row1 = QHBoxLayout()
  139. row2 = QHBoxLayout()
  140. self.btnExportRawData = QPushButton("Raw data")
  141. self.btnExportFilterData = QPushButton("Filter data")
  142. self.btnExportDecDemData = QPushButton("DecDem data")
  143. self.btnPeakFreq = QPushButton("Peak freq")
  144. self.btnFwhm = QPushButton("FWHM")
  145. self.btnMaxAmp = QPushButton("Max amp")
  146. self.btnExportDecDemData.setEnabled(False)
  147. self.btnPeakFreq.setEnabled(False)
  148. self.btnFwhm.setEnabled(False)
  149. self.btnMaxAmp.setEnabled(False)
  150. self.btnExportDecDemData.setToolTip("Disabled: endpoint is broken in current spectrum.py backend")
  151. self.btnPeakFreq.setToolTip("Disabled: depends on broken /export-decdem-data/")
  152. self.btnFwhm.setToolTip("Disabled: depends on broken /export-decdem-data/")
  153. self.btnMaxAmp.setToolTip("Disabled: depends on broken /export-decdem-data/")
  154. row1.addWidget(self.btnExportRawData)
  155. row1.addWidget(self.btnExportFilterData)
  156. row1.addWidget(self.btnExportDecDemData)
  157. row2.addWidget(self.btnPeakFreq)
  158. row2.addWidget(self.btnFwhm)
  159. row2.addWidget(self.btnMaxAmp)
  160. self.spec.vlFreq.addLayout(row1)
  161. self.spec.vlFreq.addLayout(row2)
  162. self.btnExportRawData.clicked.connect(self.analysis_export_raw_data)
  163. self.btnExportFilterData.clicked.connect(self.analysis_export_filter_data)
  164. self.btnExportDecDemData.clicked.connect(self.analysis_export_decdem_data)
  165. self.btnPeakFreq.clicked.connect(self.analysis_export_position_freq)
  166. self.btnFwhm.clicked.connect(self.analysis_export_fwhm)
  167. self.btnMaxAmp.clicked.connect(self.analysis_export_max_amplitude_freq)
  168. self.lblSpectrumMetrics = QLabel("Spectrum metrics: -")
  169. self.lblSpectrumMetrics.setStyleSheet("color: #505050;")
  170. root_layout.addWidget(self.lblSpectrumMetrics)
  171. self.spec_log(f"[ANALYSIS] ready. base={self.analysis.base_url}")
  172. self.analysis_ping_timer.start()
  173. self._ping_analysis_service()
  174. def _ensure_image_view(self, container: QWidget, title: str) -> InteractiveImageView:
  175. lay = container.layout()
  176. if lay is None:
  177. lay = QVBoxLayout(container)
  178. container.setLayout(lay)
  179. view = InteractiveImageView(title=title, parent=container)
  180. lay.addWidget(view)
  181. return view
  182. def _show_demod_plot(self) -> None:
  183. if hasattr(self.spec, "gbDemodPlot"):
  184. self.spec.gbDemodPlot.setVisible(True)
  185. def _to_float_list(self, value: Any) -> list[float]:
  186. if isinstance(value, (list, tuple)):
  187. out: list[float] = []
  188. for v in value:
  189. try:
  190. fv = float(v)
  191. if math.isfinite(fv):
  192. out.append(fv)
  193. except Exception:
  194. continue
  195. return out
  196. try:
  197. fv = float(value)
  198. return [fv] if math.isfinite(fv) else []
  199. except Exception:
  200. return []
  201. def _render_line_plot(
  202. self,
  203. view: InteractiveImageView,
  204. title: str,
  205. y_data: list[float],
  206. x_data: Optional[list[float]] = None,
  207. ) -> None:
  208. if not y_data:
  209. view.set_placeholder(f"{title}: no numeric data")
  210. return
  211. n = len(y_data)
  212. if x_data is None or len(x_data) != n:
  213. x_data = [float(i) for i in range(n)]
  214. max_points = 4000
  215. if n > max_points:
  216. step = int(math.ceil(n / max_points))
  217. y_data = y_data[::step]
  218. x_data = x_data[::step]
  219. xmin, xmax = min(x_data), max(x_data)
  220. ymin, ymax = min(y_data), max(y_data)
  221. if xmax <= xmin:
  222. xmax = xmin + 1.0
  223. if ymax <= ymin:
  224. ymax = ymin + 1.0
  225. w, h, m = 1200, 420, 32
  226. pix = QPixmap(w, h)
  227. pix.fill(Qt.GlobalColor.white)
  228. p = QPainter(pix)
  229. p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
  230. p.setPen(Qt.GlobalColor.black)
  231. p.drawRect(m, m, w - 2 * m, h - 2 * m)
  232. plot_w = float(w - 2 * m)
  233. plot_h = float(h - 2 * m)
  234. last = None
  235. p.setPen(Qt.GlobalColor.blue)
  236. for x, y in zip(x_data, y_data):
  237. px = m + (x - xmin) * plot_w / (xmax - xmin)
  238. py = m + (ymax - y) * plot_h / (ymax - ymin)
  239. if last is not None:
  240. p.drawLine(int(last[0]), int(last[1]), int(px), int(py))
  241. last = (px, py)
  242. p.setPen(Qt.GlobalColor.darkGray)
  243. p.drawText(QRectF(m, 4, w - 2 * m, 22), title)
  244. p.end()
  245. view.set_pixmap(pix)
  246. def _set_spectrum_metrics(self, text: str) -> None:
  247. if hasattr(self, "lblSpectrumMetrics"):
  248. self.lblSpectrumMetrics.setText(f"Spectrum metrics: {text}")
  249. def _set_analysis_ping(self, ok: Optional[bool], msg: str = "") -> None:
  250. if not hasattr(self, "lblAnalysisPing"):
  251. return
  252. base = self._analysis_base_url()
  253. if ok is None:
  254. self.lblAnalysisPing.setText(f"* Analysis service: checking ({base})")
  255. self.lblAnalysisPing.setStyleSheet("color: #808080;")
  256. elif ok:
  257. self.lblAnalysisPing.setText(f"* Analysis service: online ({base})")
  258. self.lblAnalysisPing.setStyleSheet("color: #2e7d32;")
  259. else:
  260. tail = f" - {msg}" if msg else ""
  261. self.lblAnalysisPing.setText(f"* Analysis service: offline ({base}){tail}")
  262. self.lblAnalysisPing.setStyleSheet("color: #c62828;")
  263. def _ping_analysis_service(self) -> None:
  264. self._set_analysis_ping(None)
  265. base = self._analysis_base_url()
  266. def job() -> Tuple[bool, str]:
  267. try:
  268. r = requests.get(base, timeout=2.0)
  269. return True, f"HTTP {r.status_code}"
  270. except Exception as e:
  271. return False, str(e)
  272. def ok(result: Tuple[bool, str]) -> None:
  273. state, msg = result
  274. self._set_analysis_ping(state, msg if not state else "")
  275. def err(msg: str) -> None:
  276. self._set_analysis_ping(False, msg)
  277. run_in_thread(self, job, ok, err)
  278. def analysis_pick_file(self) -> None:
  279. path, _ = QFileDialog.getOpenFileName(
  280. self,
  281. "Select file for analysis",
  282. "",
  283. "Data (*.csv *.json);;CSV (*.csv);;JSON (*.json);;All files (*)",
  284. )
  285. if not path:
  286. return
  287. self.leAnalysisFile.setText(path)
  288. self.analysis.last_uploaded_path = path
  289. self.spec_log(f"[ANALYSIS] file selected: {path}")
  290. def _get_analysis_upload_path(self) -> Optional[str]:
  291. path = self.leAnalysisFile.text().strip() or self.analysis.last_uploaded_path
  292. if not path or not os.path.exists(path):
  293. QMessageBox.warning(self, "Analysis", "Select a valid file to upload.")
  294. return None
  295. if os.path.getsize(path) == 0:
  296. QMessageBox.warning(self, "Analysis", "Selected file is empty.")
  297. return None
  298. if path.lower().endswith(".json"):
  299. try:
  300. with open(path, "r", encoding="utf-8") as f:
  301. json.load(f)
  302. except Exception as e:
  303. QMessageBox.warning(self, "Analysis", f"Invalid JSON file: {e}")
  304. return None
  305. return path
  306. def _ensure_session_id(self, on_ready: Callable[[str], None]) -> None:
  307. sid = (self.analysis.session_id or self.leSessionId.text().strip())
  308. if sid:
  309. on_ready(str(sid))
  310. return
  311. path = self._get_analysis_upload_path()
  312. if not path:
  313. return
  314. def job() -> str:
  315. api = self._analysis_api()
  316. return api.upload(path)
  317. def ok(session_id: str) -> None:
  318. self.analysis.session_id = session_id
  319. self.leSessionId.setText(session_id)
  320. self.spec_log(f"[ANALYSIS] uploaded. session_id={session_id}")
  321. on_ready(session_id)
  322. def err(msg: str) -> None:
  323. self.spec_log(f"[ANALYSIS] upload error: {msg}")
  324. self.spec_log("[ANALYSIS] session_id missing, uploading automatically...")
  325. run_in_thread(self, job, ok, err)
  326. def analysis_upload(self) -> None:
  327. path = self._get_analysis_upload_path()
  328. if not path:
  329. return
  330. def job() -> str:
  331. api = self._analysis_api()
  332. return api.upload(path)
  333. def ok(session_id: str) -> None:
  334. self.analysis.session_id = session_id
  335. self.leSessionId.setText(session_id)
  336. self.spec_log(f"[ANALYSIS] uploaded. session_id={session_id}")
  337. def err(msg: str) -> None:
  338. self.spec_log(f"[ANALYSIS] upload error: {msg}")
  339. self.spec_log("[ANALYSIS] uploading...")
  340. run_in_thread(self, job, ok, err)
  341. def analysis_filter(self) -> None:
  342. try:
  343. dt = float(self.leDt.text().strip())
  344. center = float(self.leCenter.text().strip())
  345. lo = float(self.leLo.text().strip())
  346. hi = float(self.leHi.text().strip())
  347. low = float(self.leLow.text().strip())
  348. except Exception:
  349. QMessageBox.warning(self, "Analysis", "Invalid filter parameters.")
  350. return
  351. def proceed(sid: str) -> None:
  352. def job() -> str:
  353. api = self._analysis_api()
  354. api.filter(session_id=sid, dt=dt, center=center, lo=lo, hi=hi, low=low)
  355. return "ok"
  356. def ok(_res: str) -> None:
  357. self.spec_log("[ANALYSIS] filter done.")
  358. def err(msg: str) -> None:
  359. self.spec_log(f"[ANALYSIS] filter error: {msg}")
  360. self.spec_log("[ANALYSIS] filtering...")
  361. run_in_thread(self, job, ok, err)
  362. self._ensure_session_id(proceed)
  363. def analysis_fft(self) -> None:
  364. c1s = self.leC1.text().strip()
  365. c2s = self.leC2.text().strip()
  366. c3s = self.leC3.text().strip()
  367. c4s = self.leC4.text().strip()
  368. c1: Optional[int] = None
  369. c2: Optional[int] = None
  370. c3: Optional[int] = None
  371. c4: Optional[int] = None
  372. if any((c1s, c2s, c3s, c4s)):
  373. if not all((c1s, c2s, c3s, c4s)):
  374. QMessageBox.warning(self, "Analysis", "Fill all FFT coefficients or leave all empty.")
  375. return
  376. try:
  377. c1 = int(c1s)
  378. c2 = int(c2s)
  379. c3 = int(c3s)
  380. c4 = int(c4s)
  381. except Exception:
  382. QMessageBox.warning(self, "Analysis", "Invalid FFT coefficients.")
  383. return
  384. def proceed(sid: str) -> None:
  385. def job() -> str:
  386. api = self._analysis_api()
  387. api.fft(session_id=sid, c1=c1, c2=c2, c3=c3, c4=c4)
  388. return "ok"
  389. def ok(_res: str) -> None:
  390. self.spec_log("[ANALYSIS] fft done.")
  391. def err(msg: str) -> None:
  392. self.spec_log(f"[ANALYSIS] fft error: {msg}")
  393. self.spec_log("[ANALYSIS] fft...")
  394. run_in_thread(self, job, ok, err)
  395. self._ensure_session_id(proceed)
  396. def analysis_result(self) -> None:
  397. def proceed(sid: str) -> None:
  398. def job() -> Dict[str, Any]:
  399. api = self._analysis_api()
  400. return api.result(session_id=sid)
  401. def ok(data: Dict[str, Any]) -> None:
  402. self.spec_log("[ANALYSIS] result:")
  403. self.spec_log(json.dumps(data, ensure_ascii=False, indent=2)[:5000])
  404. def err(msg: str) -> None:
  405. self.spec_log(f"[ANALYSIS] result error: {msg}")
  406. run_in_thread(self, job, ok, err)
  407. self._ensure_session_id(proceed)
  408. def analysis_export(self) -> None:
  409. def proceed(sid: str) -> None:
  410. def job() -> Dict[str, Any]:
  411. api = self._analysis_api()
  412. return api.export_plots(session_id=sid)
  413. def ok(data: Dict[str, Any]) -> None:
  414. self.spec_log("[ANALYSIS] export response received.")
  415. self.spec_log(json.dumps(data, ensure_ascii=False, indent=2)[:5000])
  416. self._try_render_images_from_response(data)
  417. def err(msg: str) -> None:
  418. self.spec_log(f"[ANALYSIS] export error: {msg}")
  419. run_in_thread(self, job, ok, err)
  420. self._ensure_session_id(proceed)
  421. def analysis_plot_raw(self) -> None:
  422. def proceed(sid: str) -> None:
  423. def job() -> Dict[str, Any]:
  424. api = self._analysis_api()
  425. return api.plot_raw(session_id=sid)
  426. def ok(data: Dict[str, Any]) -> None:
  427. self.spec_log("[ANALYSIS] plot-raw response received.")
  428. self.spec_log(json.dumps(data, ensure_ascii=False, indent=2)[:5000])
  429. self._try_render_images_from_response(data)
  430. def err(msg: str) -> None:
  431. self.spec_log(f"[ANALYSIS] plot-raw error: {msg}")
  432. run_in_thread(self, job, ok, err)
  433. self._ensure_session_id(proceed)
  434. def _run_filter_fft_and_then(self, sid: str, on_ready: Callable[[], None]) -> None:
  435. try:
  436. dt = float(self.leDt.text().strip())
  437. center = float(self.leCenter.text().strip())
  438. lo = float(self.leLo.text().strip())
  439. hi = float(self.leHi.text().strip())
  440. low = float(self.leLow.text().strip())
  441. except Exception:
  442. QMessageBox.warning(self, "Analysis", "Invalid filter parameters.")
  443. return
  444. c1s = self.leC1.text().strip()
  445. c2s = self.leC2.text().strip()
  446. c3s = self.leC3.text().strip()
  447. c4s = self.leC4.text().strip()
  448. c1 = c2 = c3 = c4 = None
  449. if any([c1s, c2s, c3s, c4s]):
  450. if not all([c1s, c2s, c3s, c4s]):
  451. QMessageBox.warning(self, "Analysis", "Fill all FFT coefficients or leave all empty.")
  452. return
  453. try:
  454. c1, c2, c3, c4 = int(c1s), int(c2s), int(c3s), int(c4s)
  455. except Exception:
  456. QMessageBox.warning(self, "Analysis", "Invalid FFT coefficients.")
  457. return
  458. def job() -> None:
  459. api = self._analysis_api()
  460. api.filter(session_id=sid, dt=dt, center=center, lo=lo, hi=hi, low=low)
  461. api.fft(session_id=sid, c1=c1, c2=c2, c3=c3, c4=c4)
  462. def ok(_: Any) -> None:
  463. self.spec_log("[ANALYSIS] filter+fft ready.")
  464. on_ready()
  465. def err(msg: str) -> None:
  466. self.spec_log(f"[ANALYSIS] filter+fft error: {msg}")
  467. self.spec_log("[ANALYSIS] running filter+fft before metric...")
  468. run_in_thread(self, job, ok, err)
  469. def analysis_export_raw_data(self) -> None:
  470. def proceed(sid: str) -> None:
  471. def job() -> Dict[str, Any]:
  472. api = self._analysis_api()
  473. return api.export_raw_data(session_id=sid)
  474. def ok(data: Dict[str, Any]) -> None:
  475. y = self._to_float_list(data.get("data"))
  476. self._render_line_plot(self.lblRawImg, "Raw signal", y)
  477. self.spec_log(f"[ANALYSIS] export-raw-data done. points={len(y)}")
  478. def err(msg: str) -> None:
  479. self.spec_log(f"[ANALYSIS] export-raw-data error: {msg}")
  480. run_in_thread(self, job, ok, err)
  481. self._ensure_session_id(proceed)
  482. def analysis_export_filter_data(self) -> None:
  483. def proceed(sid: str) -> None:
  484. try:
  485. center = float(self.leCenter.text().strip())
  486. lo = float(self.leLo.text().strip())
  487. hi = float(self.leHi.text().strip())
  488. low = float(self.leLow.text().strip())
  489. except Exception:
  490. QMessageBox.warning(self, "Analysis", "Invalid filter frequencies.")
  491. return
  492. def job() -> Dict[str, Any]:
  493. api = self._analysis_api()
  494. return api.export_filter_data(
  495. session_id=sid,
  496. center_freq=center,
  497. lower_freq=lo,
  498. higher_freq=hi,
  499. low_freq=low,
  500. )
  501. def ok(data: Dict[str, Any]) -> None:
  502. y = self._to_float_list(data.get("signal_real"))
  503. x = self._to_float_list(data.get("time_data_signal"))
  504. x_use = x if len(x) == len(y) else None
  505. self._render_line_plot(self.lblRawImg, "Filtered signal (real)", y, x_use)
  506. self.spec_log(f"[ANALYSIS] export-filter-data done. points={len(y)}")
  507. def err(msg: str) -> None:
  508. self.spec_log(f"[ANALYSIS] export-filter-data error: {msg}")
  509. run_in_thread(self, job, ok, err)
  510. self._ensure_session_id(proceed)
  511. def analysis_export_decdem_data(self) -> None:
  512. def proceed(sid: str) -> None:
  513. def job() -> Dict[str, Any]:
  514. api = self._analysis_api()
  515. return api.export_decdem_data(session_id=sid)
  516. def ok(data: Dict[str, Any]) -> None:
  517. y = self._to_float_list(data.get("signal_real"))
  518. self._render_line_plot(self.lblExtraImg, "Dec/Dem signal (real)", y)
  519. self._show_demod_plot()
  520. self.spec_log(f"[ANALYSIS] export-decdem-data done. points={len(y)}")
  521. def err(msg: str) -> None:
  522. self.spec_log(f"[ANALYSIS] export-decdem-data error: {msg}")
  523. run_in_thread(self, job, ok, err)
  524. self._ensure_session_id(proceed)
  525. def analysis_export_position_freq(self) -> None:
  526. def proceed(sid: str) -> None:
  527. self._run_metric_export(
  528. sid=sid,
  529. api_call=lambda api: api.export_position_freq(session_id=sid),
  530. value_key="peak max amplitude in freq",
  531. metric_prefix="peak freq",
  532. log_name="export-position-freq",
  533. )
  534. self._ensure_session_id(proceed)
  535. def analysis_export_fwhm(self) -> None:
  536. def proceed(sid: str) -> None:
  537. self._run_metric_export(
  538. sid=sid,
  539. api_call=lambda api: api.export_fwhm(session_id=sid),
  540. value_key="width at half height",
  541. metric_prefix="FWHM",
  542. log_name="export-FWHM",
  543. )
  544. self._ensure_session_id(proceed)
  545. def analysis_export_max_amplitude_freq(self) -> None:
  546. def proceed(sid: str) -> None:
  547. self._run_metric_export(
  548. sid=sid,
  549. api_call=lambda api: api.export_max_amplitude_freq(session_id=sid),
  550. value_key="max amplitude",
  551. metric_prefix="max amp",
  552. log_name="export-max-amplitude-freq",
  553. )
  554. self._ensure_session_id(proceed)
  555. def _run_metric_export(
  556. self,
  557. *,
  558. sid: str,
  559. api_call: Callable[[LoggedAnalysisApi], Dict[str, Any]],
  560. value_key: str,
  561. metric_prefix: str,
  562. log_name: str,
  563. ) -> None:
  564. def run_metric() -> None:
  565. def job() -> Dict[str, Any]:
  566. return api_call(self._analysis_api())
  567. def ok(data: Dict[str, Any]) -> None:
  568. val = data.get(value_key, "n/a")
  569. self._set_spectrum_metrics(f"{metric_prefix}={val}")
  570. self.spec_log(f"[ANALYSIS] {log_name}: {val}")
  571. def err(msg: str) -> None:
  572. self.spec_log(f"[ANALYSIS] {log_name} error: {msg}")
  573. run_in_thread(self, job, ok, err)
  574. self._run_filter_fft_and_then(sid, run_metric)
  575. def _try_render_images_from_response(self, data: Dict[str, Any]) -> None:
  576. if not isinstance(data, dict):
  577. return
  578. urls = []
  579. for k, v in data.items():
  580. if isinstance(v, str) and v.startswith("/"):
  581. urls.append((k, v))
  582. elif isinstance(v, dict):
  583. for k2, v2 in v.items():
  584. if isinstance(v2, str) and v2.startswith("/"):
  585. urls.append((f"{k}.{k2}", v2))
  586. if not urls:
  587. self.spec_log("[ANALYSIS] No image urls found in response.")
  588. return
  589. targets = [
  590. (self.lblRawImg, "RAW"),
  591. (self.lblSpecImg, "SPEC"),
  592. (self.lblExtraImg, "EXTRA"),
  593. ]
  594. for (lbl, tag), (name, url) in zip(targets, urls[:3]):
  595. self.spec_log(f"[ANALYSIS] rendering {name}: {url}")
  596. self._download_and_show_image(url, lbl, tag)
  597. def _download_and_show_image(self, rel_url: str, target_lbl: InteractiveImageView, tag: str) -> None:
  598. def job() -> bytes:
  599. api = self._analysis_api()
  600. return api.download_bytes(rel_url)
  601. def ok(content: bytes) -> None:
  602. target_lbl.set_image_bytes(content)
  603. def err(msg: str) -> None:
  604. target_lbl.set_placeholder(f"{tag}: download error")
  605. self.spec_log(f"[ANALYSIS] download error: {msg}")
  606. run_in_thread(self, job, ok, err)