main.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737
  1. import os
  2. import logging
  3. import matplotlib
  4. matplotlib.use('Agg') # headless backend - must be set before any pyplot import
  5. from fastapi import FastAPI, Form
  6. from fastapi import HTTPException
  7. from fastapi.responses import FileResponse
  8. from fastapi.responses import JSONResponse
  9. from scipy.fft import fft
  10. from scipy.signal import butter, sosfilt, decimate
  11. from state import create_session, get_session, update_session
  12. EXPORT_DIR = "exported_plots"
  13. os.makedirs(EXPORT_DIR, exist_ok=True)
  14. app = FastAPI()
  15. logger = logging.getLogger("uvicorn.error")
  16. from fastapi import UploadFile, File
  17. from uuid import uuid4
  18. import json
  19. import base64
  20. import numpy as np
  21. import scipy.fft as fft
  22. import matplotlib.pyplot as plt
  23. def get_app_version() -> str:
  24. try:
  25. with open("VERSION", "r", encoding="utf-8") as version_file:
  26. file_version = version_file.read().strip()
  27. if file_version:
  28. return file_version
  29. except OSError:
  30. pass
  31. env_version = os.getenv("APP_VERSION")
  32. if env_version:
  33. return env_version
  34. return "unknown"
  35. @app.get("/health")
  36. def health():
  37. return {"status": "ok"}
  38. @app.on_event("startup")
  39. async def log_app_version() -> None:
  40. logger.info("Starting srv-spectroscopy version=%s", get_app_version())
  41. class DataDecoder:
  42. def __init__(self, filename):
  43. self.data = ''
  44. with open(filename, 'r') as file:
  45. self.data = file.read()
  46. self.structed_data = json.loads(self.data)
  47. def getRawData(self, averaging_num=0, data_num=0, channel_num=0):
  48. for items in self.structed_data:
  49. if (items['averaging_num'] == averaging_num and items['data_num'] == data_num):
  50. for channel_data in items['channel_data']:
  51. if (channel_data['channel_num'] == channel_num):
  52. return channel_data['channel_data']
  53. return None
  54. def getDataDecoded(self, averaging_num=0, data_num=0, channel_num=0, points=10):
  55. rawData = self.getRawData(averaging_num, data_num, channel_num)
  56. edata = base64.b64decode(rawData.encode('utf-8'))
  57. arr = np.frombuffer(edata, dtype=np.int16, count=points, offset=0)
  58. return arr
  59. def getDataScaled(self, averaging_num=0, data_num=0, channel_num=0, points=10, range=5.0):
  60. decodedData = self.getDataDecoded(averaging_num, data_num, channel_num, points)
  61. return range * decodedData / 32768
  62. def getDataRate(self, averaging_num=0, data_num=0):
  63. for items in self.structed_data:
  64. if (items['averaging_num'] == averaging_num and items['data_num'] == data_num):
  65. return items['measurement_rate']
  66. def getDataSpectrum(self, averaging_num=0, data_num=0, channel_num=0, points=10, range=5.0, zero_fill=0,
  67. first_idx=0, last_idx=10):
  68. scaledData = self.getDataScaled(averaging_num, data_num, channel_num, points, range)
  69. zerofilledData = np.append(scaledData[first_idx:last_idx], np.zeros(zero_fill))
  70. rate = self.getDataRate()
  71. transferedData = fft.rfft(zerofilledData) * 2 / (last_idx - first_idx)
  72. freqs = fft.rfftfreq((last_idx - first_idx) + zero_fill, 1 / rate)
  73. spectrum = np.abs(transferedData)
  74. phases = np.angle(transferedData)
  75. spect_dict = {'freqs': freqs,
  76. 'phases': phases,
  77. 'spectrum': spectrum}
  78. return spect_dict
  79. def getDataPoints(self, averaging_num=0, data_num=0):
  80. for items in self.structed_data:
  81. if (items['averaging_num'] == averaging_num and items['data_num'] == data_num):
  82. return items['measurement_points']
  83. @app.post("/upload/")
  84. async def upload_json(file: UploadFile = File(...)):
  85. try:
  86. contents = await file.read()
  87. session_id = str(uuid4())
  88. filename = f"uploaded_{session_id}.json"
  89. path = os.path.join(EXPORT_DIR, filename)
  90. with open(path, "wb") as f:
  91. f.write(contents)
  92. decoder = DataDecoder(path)
  93. points = decoder.getDataPoints()
  94. if not points:
  95. raise HTTPException(status_code=400, detail="measurement_points not found")
  96. points = points[0]
  97. rate = decoder.getDataRate()
  98. if not rate:
  99. raise HTTPException(status_code=400, detail="measurement_rate not found")
  100. ch0 = decoder.getDataScaled(channel_num=0, points=points)
  101. ch1 = decoder.getDataScaled(channel_num=1, points=points)
  102. time = np.arange(len(ch0)) / rate
  103. session_id=create_session({
  104. "decoder_path": path,
  105. "ch0": ch0,
  106. "ch1": ch1,
  107. "time": time,
  108. "dt": 1 / rate,
  109. "rate": rate
  110. })
  111. return {"session_id": session_id}
  112. except Exception as e:
  113. import traceback
  114. traceback.print_exc()
  115. raise HTTPException(status_code=500, detail=f"Upload failed: {type(e).__name__}: {str(e)}")
  116. @app.post("/filter/")
  117. def apply_filters(session_id: str = Form(...), dt: float = Form(...),
  118. center_freq: float = Form(...),
  119. lower_freq: float = Form(...),
  120. higher_freq: float = Form(...),
  121. low_freq: float = Form(...)):
  122. state = get_session(session_id)
  123. if not state:
  124. raise HTTPException(status_code=404, detail="Session not found")
  125. # df = state["raw"]
  126. def round_sig(x, sig=4):
  127. if x == 0:
  128. return 0
  129. from math import log10, floor
  130. return round(x, sig - int(floor(log10(abs(x)))) - 1)
  131. # dt = round_sig((df['Время'][1] - df['Время'][0]) / (1000), 4)
  132. # # dt=df['Время'][1]-df['Время'][0]
  133. # fs = 1 / dt
  134. # target = df['Канал A'].to_numpy()
  135. # ch1 = df['Канал B'].to_numpy()
  136. time = state["raw"]["time"]
  137. ch0 = np.array(state["raw"]["ch0"])
  138. ch1 = np.array(state["raw"]["ch1"])
  139. dt = round_sig(time[1] - time[0], 4)
  140. target = ch0
  141. fs = 1 / dt
  142. points_num = len(target)
  143. n = int(len(ch1) / points_num)
  144. ch1 = np.vstack(np.split(ch1[:n * points_num], n))
  145. target = np.vstack(np.split(target[:n * points_num], n))
  146. bp = butter(4, [lower_freq, higher_freq], 'bp', fs=fs, output='sos')
  147. lp = butter(4, low_freq, 'lowpass', fs=fs, output='sos')
  148. ch1_f = sosfilt(bp, ch1, axis=1)
  149. target_f = sosfilt(bp, target, axis=1)
  150. update_session(session_id, "dt", dt)
  151. update_session(session_id, "center_freq", center_freq)
  152. update_session(session_id, "real", np.sin(2 * np.pi * center_freq * np.arange(0, points_num * dt, dt)))
  153. update_session(session_id, "imag", np.cos(2 * np.pi * center_freq * np.arange(0, points_num * dt, dt)))
  154. real_part = np.sin(2 * np.pi * center_freq * np.arange(0, ch1.shape[1] * dt, dt))
  155. imag_part = np.cos(2 * np.pi * center_freq * np.arange(0, ch1.shape[1] * dt, dt))
  156. ch1_real = ch1_f * real_part
  157. ch1_imag = ch1_f * imag_part
  158. ch1_real_f = sosfilt(lp, ch1_real, axis=1)
  159. ch1_imag_f = sosfilt(lp, ch1_imag, axis=1)
  160. ch1_complex = ch1_real_f + 1j * ch1_imag_f
  161. update_session(session_id, "ch1_f", ch1_complex)
  162. update_session(session_id, "dt", dt)
  163. return {"status": "filtered"}
  164. @app.post("/fft/")
  165. def compute_fft(session_id: str = Form(...),
  166. coef_dec_1: int = Form(10),
  167. coef_dec_2: int = Form(2),
  168. coef_dec_3: int = Form(1),
  169. coef_dec_4: int = Form(1)):
  170. state = get_session(session_id)
  171. if not state or "ch1_f" not in state:
  172. raise HTTPException(status_code=400, detail="Not filtered yet")
  173. try:
  174. signal = state["ch1_f"]
  175. decimated = decimate(signal, coef_dec_1, axis=1)
  176. decimated = decimate(decimated, coef_dec_2, axis=1)
  177. decimated = decimate(decimated, coef_dec_3, axis=1)
  178. decimated = decimate(decimated, coef_dec_4, axis=1)
  179. print(coef_dec_1)
  180. # decimated = decimate(decimated, 2, axis=1)
  181. slice_ = decimated[0][2500:]
  182. Td_new = state["dt"] * 10
  183. sp_row = np.abs(fft.fftshift(fft.fft(slice_)))
  184. BW = 1 / Td_new
  185. dff = BW / (len(slice_) - 1)
  186. freq = np.arange(-BW / 2, BW / 2 + dff, dff)[:len(sp_row)] / 1000
  187. update_session(session_id, "fft", {
  188. "fid": slice_.real.tolist(),
  189. "spectrum": sp_row.tolist(),
  190. "frequency": freq.tolist()
  191. })
  192. # print(len(decimated[0]))
  193. except Exception as e:
  194. import traceback
  195. traceback.print_exc()
  196. raise HTTPException(status_code=500, detail=f"Bad coeff: {type(e).__name__}: {str(e)}")
  197. return {"status": "fft done"}
  198. @app.get("/result/")
  199. def get_result(session_id: str):
  200. state = get_session(session_id)
  201. if not state or "fft" not in state:
  202. raise HTTPException(status_code=400, detail="FFT not yet computed")
  203. return JSONResponse(state["fft"])
  204. @app.post("/export/")
  205. def export_plots(session_id: str = Form(...)):
  206. state = get_session(session_id)
  207. if not state or "fft" not in state:
  208. raise HTTPException(status_code=400, detail="FFT not computed")
  209. fid = state["fft"]["fid"]
  210. spectrum = state["fft"]["spectrum"]
  211. freq = state["fft"]["frequency"]
  212. fid_path = os.path.join(EXPORT_DIR, f"{session_id}_fid.png")
  213. spectrum_path = os.path.join(EXPORT_DIR, f"{session_id}_spectrum.png")
  214. # print(session_id)
  215. # print("DONE")
  216. # ========== FID plot ==========
  217. plt.figure(figsize=(8, 4))
  218. plt.plot(fid)
  219. plt.title("FID")
  220. plt.xlabel("Samples")
  221. plt.ylabel("Amplitude")
  222. plt.grid()
  223. plt.tight_layout()
  224. plt.savefig(fid_path)
  225. plt.close()
  226. # ========== Spectrum plot =========
  227. plt.figure(figsize=(8, 4))
  228. plt.plot(freq, spectrum)
  229. plt.title("Spectrum")
  230. plt.xlabel("Frequency (kHz)")
  231. plt.ylabel("Magnitude")
  232. plt.grid()
  233. plt.tight_layout()
  234. plt.savefig(spectrum_path)
  235. plt.close()
  236. return {
  237. "fid_plot": f"/download/{session_id}_fid.png",
  238. "spectrum_plot": f"/download/{session_id}_spectrum.png"
  239. }
  240. @app.post("/plot-raw/")
  241. def plot_raw(session_id: str = Form(...)):
  242. state = get_session(session_id)
  243. if not state:
  244. raise HTTPException(status_code=404, detail="Session not found")
  245. ch0 = state["raw"]["ch0"]
  246. ch1 = state["raw"]["ch1"]
  247. fig, axs = plt.subplots(2, 1, figsize=(10, 5), sharex=True)
  248. axs[0].plot(ch0)
  249. axs[0].set_title("Канал A (весь сигнал)")
  250. axs[0].grid()
  251. axs[1].plot(ch1)
  252. axs[1].set_title("Канал B (весь сигнал)")
  253. axs[1].grid()
  254. plt.tight_layout()
  255. plot_path = os.path.join(EXPORT_DIR, f"{session_id}_raw_channels.png")
  256. plt.savefig(plot_path)
  257. plt.close()
  258. return {
  259. "raw_plot": f"/download/{session_id}_raw_channels.png"
  260. }
  261. @app.get("/download/{filename}")
  262. def download_file(filename: str):
  263. file_path = os.path.join(EXPORT_DIR, filename)
  264. if not os.path.exists(file_path):
  265. raise HTTPException(status_code=404, detail="File not found")
  266. return FileResponse(file_path, media_type="image/png", filename=filename)
  267. @app.post("/export-raw-data/")
  268. def export_raw_data(session_id: str = Form(...)):
  269. state = get_session(session_id)
  270. if not state:
  271. raise HTTPException(status_code=404, detail="Session not found")
  272. signal = state["raw"]["ch1"] # сырые данные без демодуляции и децимации
  273. print(signal)
  274. if isinstance(signal, np.ndarray):
  275. signal = signal.tolist()
  276. return JSONResponse(content={
  277. "status": "raw signal",
  278. "data": signal,
  279. "path": session_id
  280. })
  281. @app.post("/export-filter-data/")
  282. def export_filter_data(session_id: str = Form(...),
  283. center_freq: float = Form(...),
  284. lower_freq: float = Form(...),
  285. higher_freq: float = Form(...),
  286. low_freq: float = Form(...)):
  287. state = get_session(session_id)
  288. if not state:
  289. raise HTTPException(status_code=404, detail="Session not found")
  290. def round_sig(x, sig=4):
  291. if x == 0:
  292. return 0
  293. from math import log10, floor
  294. return round(x, sig - int(floor(log10(abs(x)))) - 1)
  295. time = state["raw"]["time"]
  296. ch0 = np.array(state["raw"]["ch0"])
  297. ch1 = np.array(state["raw"]["ch1"])
  298. dt = round_sig(time[1] - time[0], 4)
  299. target = ch0
  300. fs = 1 / dt
  301. points_num = len(target)
  302. n = int(len(ch1) / points_num)
  303. ch1 = np.vstack(np.split(ch1[:n * points_num], n))
  304. target = np.vstack(np.split(target[:n * points_num], n))
  305. bp = butter(4, [lower_freq, higher_freq], 'bp', fs=fs, output='sos')
  306. lp = butter(4, low_freq, 'lowpass', fs=fs, output='sos')
  307. ch1_f = sosfilt(bp, ch1, axis=1)
  308. target_f = sosfilt(bp, target, axis=1)
  309. update_session(session_id, "dt", dt)
  310. update_session(session_id, "center_freq", center_freq)
  311. update_session(session_id, "real", np.sin(2 * np.pi * center_freq * np.arange(0, points_num * dt, dt)))
  312. update_session(session_id, "imag", np.cos(2 * np.pi * center_freq * np.arange(0, points_num * dt, dt)))
  313. real_part = np.sin(2 * np.pi * center_freq * np.arange(0, ch1.shape[1] * dt, dt))
  314. imag_part = np.cos(2 * np.pi * center_freq * np.arange(0, ch1.shape[1] * dt, dt))
  315. ch1_real = ch1_f * real_part
  316. ch1_imag = ch1_f * imag_part
  317. ch1_real_f = sosfilt(lp, ch1_real, axis=1)
  318. ch1_imag_f = sosfilt(lp, ch1_imag, axis=1)
  319. ch1_complex = ch1_real_f + 1j * ch1_imag_f
  320. update_session(session_id, "ch1_f", ch1_complex)
  321. update_session(session_id, "dt", dt)
  322. # return JSONResponse(state["ch1_f"], state["dt"])
  323. return JSONResponse(content={
  324. "status": "filtered signal",
  325. "signal_real": np.array(ch1_complex).real.tolist(),
  326. "signal_imag": np.array(ch1_complex).imag.tolist(),
  327. "time_data_signal":dt.tolist(),
  328. "path": session_id
  329. })
  330. @app.post("/export-decdem-data/")
  331. def export_filter_data(session_id: str = Form(...)):
  332. state = get_session(session_id)
  333. if not state:
  334. raise HTTPException(status_code=400, detail="Session not found")
  335. if not state["ch1_f"]:
  336. raise HTTPException(status_code=404, detail="Do filter before")
  337. signal = state["ch1_f"]
  338. decimated = decimate(signal, 10, axis=1)
  339. decimated = decimate(decimated, 2, axis=1)
  340. update_session(session_id, "decimated", decimated)
  341. # return JSONResponse(decimated)
  342. return JSONResponse(content={
  343. "status": "dec and dem signal",
  344. "signal_real": np.array(decimated).real.tolist(),
  345. "signal_imag": np.array(decimated).imag.tolist(),
  346. "path": session_id
  347. })
  348. @app.post("/export-position-freq/")
  349. def position_frequency_axis(session_id: str = Form(...)):
  350. state = get_session(session_id)
  351. if not state:
  352. raise HTTPException(status_code=400, detail="Session not found")
  353. if not state["decimated"]:
  354. raise HTTPException(status_code=404, detail="Do decimate before")
  355. signal = state["decimated"]
  356. N = len(signal)
  357. fft_signal = np.fft.fft(signal)
  358. amplitude = np.abs(fft_signal)[:N//2] # только положительные частоты
  359. # индекс максимальной амплитуды
  360. peak_index = np.argmax(amplitude)
  361. peak_frequency = peak_index / N
  362. return JSONResponse(content={
  363. "status": "peak position",
  364. "peak max amplitude in freq": peak_frequency,
  365. "path": session_id
  366. })
  367. @app.post("/export-FWHM/")
  368. def find_FWHM(session_id: str = Form(...)):
  369. state = get_session(session_id)
  370. if not state:
  371. raise HTTPException(status_code=400, detail="Session not found")
  372. if not state["decimated"]:
  373. raise HTTPException(status_code=404, detail="Do decimate before")
  374. signal = state["decimated"]
  375. N = len(signal)
  376. fft_signal = np.fft.fft(signal)
  377. amplitude = np.abs(fft_signal)[:N//2] # положительные частоты
  378. max_amplitude = np.max(amplitude)
  379. half_max = max_amplitude / 2
  380. indices_above_half_max = np.where(amplitude >= half_max)[0]
  381. if len(indices_above_half_max) < 2:
  382. raise HTTPException(status_code=200, detail="Bad signal, not found FWHM")
  383. FWHM_index_range = indices_above_half_max[-1] - indices_above_half_max[0]
  384. FWHM_normalized = FWHM_index_range / N
  385. return JSONResponse(content={
  386. "status": "FWHM",
  387. "width at half height": FWHM_normalized,
  388. "path": session_id
  389. })
  390. @app.post("/export-max-amplitude-freq/")
  391. def max_amplitude_time_domain(session_id: str = Form(...)):
  392. state = get_session(session_id)
  393. if not state:
  394. raise HTTPException(status_code=400, detail="Session not found")
  395. if not state["decimated"]:
  396. raise HTTPException(status_code=404, detail="Do decimate before")
  397. signal = state["decimated"]
  398. max_amplitude = np.max(np.abs(signal))
  399. return JSONResponse(content={
  400. "status": "max amplitude freq",
  401. "max amplitude": max_amplitude,
  402. "path": session_id
  403. })
  404. # -- Stateless NMR-анализ (новые endpoints) -----------------------------------
  405. import asyncio
  406. import uuid as _uuid
  407. from nmr_processor import NMRParams, decode_hardware_json, process_nmr, process_json_file
  408. # batch_id -> {status, done, total, results, summary}
  409. _batch_tasks: dict[str, dict] = {}
  410. def _make_params(
  411. center_freq: float,
  412. bandpass_bw: float,
  413. lp_cutoff: float,
  414. dec_factor: int,
  415. zero_pad: int,
  416. butter_order: int,
  417. voltage_range: float,
  418. averaging_num: int,
  419. data_num: int,
  420. channel_num: int,
  421. ) -> NMRParams:
  422. return NMRParams(
  423. center_freq=center_freq,
  424. bandpass_bw=bandpass_bw,
  425. lp_cutoff=lp_cutoff,
  426. dec_factor=dec_factor,
  427. zero_pad=zero_pad,
  428. butter_order=butter_order,
  429. voltage_range=voltage_range,
  430. averaging_num=averaging_num,
  431. data_num=data_num,
  432. channel_num=channel_num,
  433. )
  434. @app.post("/analyze/")
  435. async def analyze_endpoint(
  436. file: UploadFile = File(...),
  437. center_freq: float = Form(2.95e6),
  438. bandpass_bw: float = Form(0.1e6),
  439. lp_cutoff: float = Form(0.6e6),
  440. dec_factor: int = Form(10),
  441. zero_pad: int = Form(500_001),
  442. butter_order: int = Form(4),
  443. voltage_range: float = Form(5.0),
  444. averaging_num: int = Form(0),
  445. data_num: int = Form(0),
  446. channel_num: int = Form(1),
  447. ):
  448. """
  449. Stateless NMR analysis.
  450. Загружает hardware JSON (flat-list или nested-dict формат),
  451. применяет полный NMR-пайплайн (BPF -> IQ-demod -> LPF -> децимация ->
  452. zero-pad -> FFT) и возвращает все данные в одном ответе.
  453. Все массивы даунсэмплированы до 8 000 точек для эффективного транспорта.
  454. """
  455. contents = await file.read()
  456. try:
  457. json_data = json.loads(contents)
  458. except json.JSONDecodeError as exc:
  459. raise HTTPException(status_code=422, detail=f"Invalid JSON: {exc}")
  460. params = _make_params(
  461. center_freq, bandpass_bw, lp_cutoff, dec_factor, zero_pad,
  462. butter_order, voltage_range, averaging_num, data_num, channel_num,
  463. )
  464. try:
  465. signal, sample_rate = await asyncio.to_thread(
  466. decode_hardware_json, json_data, params
  467. )
  468. result = await asyncio.to_thread(process_nmr, signal, sample_rate, params)
  469. except ValueError as exc:
  470. raise HTTPException(status_code=422, detail=str(exc))
  471. except Exception as exc:
  472. import traceback
  473. traceback.print_exc()
  474. raise HTTPException(status_code=500, detail=f"{type(exc).__name__}: {exc}")
  475. return {
  476. "metadata": {
  477. "n_samples": result.n_samples,
  478. "sample_rate_hz": result.sample_rate_hz,
  479. "duration_ms": result.duration_ms,
  480. "dec_factor_actual": result.dec_factor_actual,
  481. },
  482. "raw_time": {
  483. "t_ms": result.raw_t_ms,
  484. "real": result.raw_real,
  485. "amplitude": result.raw_amplitude,
  486. },
  487. "raw_spectrum": {
  488. "freq_hz": result.raw_freq_hz,
  489. "magnitude": result.raw_magnitude,
  490. "phase_rad": result.raw_phase_rad,
  491. },
  492. "demod_time": {
  493. "t_ms": result.demod_t_ms,
  494. "real": result.demod_real,
  495. "imag": result.demod_imag,
  496. "amplitude": result.demod_amplitude,
  497. },
  498. "spectrum": {
  499. "freq_khz": result.freq_khz,
  500. "magnitude": result.magnitude,
  501. "phase_rad": result.phase_rad,
  502. },
  503. "metrics": {
  504. "peak_freq_khz": result.peak_freq_khz,
  505. "peak_amplitude": result.peak_amplitude,
  506. "fwhm_khz": result.fwhm_khz,
  507. "freq_step_khz": result.freq_step_khz,
  508. },
  509. }
  510. async def _run_batch(batch_id: str, folder_path: str, params: NMRParams) -> None:
  511. """Фоновая задача: обработать все *.json в folder_path."""
  512. import glob as _glob
  513. files: list[str] = sorted({
  514. p
  515. for pat in ("*.json", "*.JSON")
  516. for p in _glob.glob(os.path.join(folder_path, pat))
  517. })
  518. _batch_tasks[batch_id]["total"] = len(files)
  519. for idx, fpath in enumerate(files):
  520. entry = await asyncio.to_thread(process_json_file, fpath, params)
  521. _batch_tasks[batch_id]["results"].append(entry)
  522. _batch_tasks[batch_id]["done"] = idx + 1
  523. # Сводная статистика
  524. ok = [r for r in _batch_tasks[batch_id]["results"] if r["status"] == "ok"]
  525. n_err = len(files) - len(ok)
  526. if ok:
  527. freqs = [r["peak_freq_khz"] for r in ok]
  528. fwhms = [r["fwhm_khz"] for r in ok]
  529. summary = {
  530. "mean_peak_freq_khz": float(np.mean(freqs)),
  531. "std_peak_freq_khz": float(np.std(freqs)),
  532. "mean_fwhm_khz": float(np.mean(fwhms)),
  533. "n_ok": len(ok),
  534. "n_error": n_err,
  535. }
  536. else:
  537. summary = {
  538. "mean_peak_freq_khz": None,
  539. "std_peak_freq_khz": None,
  540. "mean_fwhm_khz": None,
  541. "n_ok": 0,
  542. "n_error": n_err,
  543. }
  544. _batch_tasks[batch_id]["summary"] = summary
  545. _batch_tasks[batch_id]["status"] = "completed"
  546. @app.post("/batch/")
  547. async def batch_endpoint(
  548. folder_path: str = Form(...),
  549. center_freq: float = Form(2.95e6),
  550. bandpass_bw: float = Form(0.1e6),
  551. lp_cutoff: float = Form(0.6e6),
  552. dec_factor: int = Form(10),
  553. zero_pad: int = Form(500_001),
  554. butter_order: int = Form(4),
  555. voltage_range: float = Form(5.0),
  556. averaging_num: int = Form(0),
  557. data_num: int = Form(0),
  558. channel_num: int = Form(1),
  559. ):
  560. """
  561. Запустить пакетный NMR-анализ всех *.json файлов в папке folder_path.
  562. Возвращает batch_id. Статус и результаты - GET /batch/{batch_id}.
  563. """
  564. if not os.path.isdir(folder_path):
  565. raise HTTPException(
  566. status_code=422, detail=f"Not a directory: {folder_path!r}"
  567. )
  568. batch_id = _uuid.uuid4().hex[:12]
  569. params = _make_params(
  570. center_freq, bandpass_bw, lp_cutoff, dec_factor, zero_pad,
  571. butter_order, voltage_range, averaging_num, data_num, channel_num,
  572. )
  573. _batch_tasks[batch_id] = {
  574. "status": "processing",
  575. "done": 0,
  576. "total": 0,
  577. "results": [],
  578. "summary": {},
  579. }
  580. asyncio.create_task(_run_batch(batch_id, folder_path, params))
  581. return {"batch_id": batch_id, "status": "accepted"}
  582. @app.get("/batch/{batch_id}")
  583. def get_batch_endpoint(batch_id: str):
  584. """
  585. Получить статус и результаты пакетного анализа.
  586. status: "processing" | "completed"
  587. progress.done - файлов обработано
  588. progress.total - всего файлов
  589. results - список {filename, status, peak_freq_khz, fwhm_khz, ...}
  590. summary - {mean_peak_freq_khz, std_peak_freq_khz, mean_fwhm_khz, n_ok, n_error}
  591. """
  592. entry = _batch_tasks.get(batch_id)
  593. if entry is None:
  594. raise HTTPException(status_code=404, detail=f"Batch '{batch_id}' not found")
  595. return {
  596. "status": entry["status"],
  597. "progress": {"done": entry["done"], "total": entry["total"]},
  598. "results": entry["results"],
  599. "summary": entry["summary"],
  600. }