nmr_processor.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. """
  2. nmr_processor.py - чистый вычислительный модуль NMR-обработки сигнала.
  3. Пайплайн (из spec_combined.py, исправлено: fs берётся из JSON):
  4. raw int16 (base64)
  5. -> масштабирование (int16 * voltage_range / 32768)
  6. -> bandpass filter (Butterworth, sosfiltfilt zero-phase)
  7. -> IQ demodulation (x sin + j | cos при fc)
  8. -> LPF (Butterworth, sosfiltfilt)
  9. -> decimation
  10. -> zero-padding
  11. -> FFT + fftshift
  12. -> поиск пика (в отрицательных частотах), FWHM
  13. Нет зависимостей на FastAPI или GUI - только numpy/scipy.
  14. """
  15. from __future__ import annotations
  16. import base64
  17. import json
  18. from dataclasses import dataclass, field
  19. import numpy as np
  20. from scipy.signal import butter, sosfiltfilt, decimate
  21. _MAX_TRANSPORT_PTS = 8000 # максимум точек в JSON-ответе
  22. # -- Параметры ----------------------------------------------------------------
  23. @dataclass
  24. class NMRParams:
  25. """Параметры обработки NMR-сигнала."""
  26. center_freq: float = 2.95e6 # Гц - несущая (Larmor frequency)
  27. bandpass_bw: float = 0.1e6 # Гц - ширина полосового фильтра
  28. lp_cutoff: float = 0.6e6 # Гц - частота среза НЧФ
  29. dec_factor: int = 10 # коэффициент децимации
  30. zero_pad: int = 500_001 # количество нулей для zero-padding
  31. butter_order: int = 4 # порядок фильтра Баттерворта
  32. voltage_range: float = 5.0 # В - диапазон ADC (int16 -> V)
  33. averaging_num: int = 0 # averaging_num в JSON
  34. data_num: int = 0 # data_num в JSON
  35. channel_num: int = 1 # channel_num в JSON
  36. # -- Результат ----------------------------------------------------------------
  37. @dataclass
  38. class NMRResult:
  39. """Полный результат обработки одного измерения."""
  40. # Метаданные
  41. n_samples: int = 0
  42. sample_rate_hz: float = 0.0
  43. duration_ms: float = 0.0
  44. dec_factor_actual: int = 1
  45. # Сырой сигнал (до обработки)
  46. raw_t_ms: list = field(default_factory=list)
  47. raw_real: list = field(default_factory=list)
  48. raw_amplitude: list = field(default_factory=list)
  49. raw_freq_hz: list = field(default_factory=list)
  50. raw_magnitude: list = field(default_factory=list)
  51. raw_phase_rad: list = field(default_factory=list)
  52. # Демодулированный FID (после BPF -> demod -> LPF -> децимация)
  53. demod_t_ms: list = field(default_factory=list)
  54. demod_real: list = field(default_factory=list)
  55. demod_imag: list = field(default_factory=list)
  56. demod_amplitude: list = field(default_factory=list)
  57. # NMR-спектр (zero-pad + FFT + fftshift)
  58. freq_khz: list = field(default_factory=list)
  59. magnitude: list = field(default_factory=list)
  60. phase_rad: list = field(default_factory=list)
  61. # Пиковые метрики
  62. peak_freq_khz: float = 0.0
  63. peak_amplitude: float = 0.0
  64. fwhm_khz: float = 0.0
  65. freq_step_khz: float = 0.0
  66. # -- Загрузка hardware JSON ----------------------------------------------------
  67. def get_channel_value(value, channel_num: int, field_name: str):
  68. """Возвращает скалярное значение поля или значение для выбранного канала."""
  69. if isinstance(value, (list, tuple)):
  70. if channel_num < 0 or channel_num >= len(value):
  71. raise ValueError(
  72. f"Поле {field_name} не содержит значение для channel_num={channel_num}"
  73. )
  74. return value[channel_num]
  75. return value
  76. def decode_hardware_json(
  77. json_data: list | dict,
  78. params: NMRParams,
  79. ) -> tuple[np.ndarray, float]:
  80. """
  81. Извлекает сигнал и sample_rate из hardware JSON.
  82. Поддерживаемые форматы:
  83. * flat-list (fast-api-spectroscopy / spectrometer_service):
  84. [{averaging_num: 0, data_num: 0, measurement_rate: ...,
  85. channel_data: [{channel_num: 1, channel_data: "BASE64"}]}]
  86. * nested-dict (spec_combined.py):
  87. {averaging_num_0: [{data_num: 0, measurement_rate: ..., ...}]}
  88. sample_rate берётся из поля measurement_rate (ошибка в spec_combined.py
  89. была в том, что dt=100e-9 захардкожен; здесь используем реальный rate).
  90. Масштабирование: arr_int16 * voltage_range / 32768 -> float64 [V]
  91. """
  92. # Нормализуем к flat-list
  93. if isinstance(json_data, dict):
  94. key = f"averaging_num_{params.averaging_num}"
  95. items = json_data.get(key, [])
  96. else:
  97. items = json_data
  98. # Найти нужную запись
  99. measurement = None
  100. for item in items:
  101. avg = item.get("averaging_num", 0)
  102. dn = item.get("data_num", 0)
  103. if avg == params.averaging_num and dn == params.data_num:
  104. measurement = item
  105. break
  106. if measurement is None:
  107. raise ValueError(
  108. f"Измерение averaging_num={params.averaging_num}, "
  109. f"data_num={params.data_num} не найдено в JSON"
  110. )
  111. # Найти канал
  112. raw_b64 = None
  113. for ch in measurement.get("channel_data", []):
  114. if ch.get("channel_num") == params.channel_num:
  115. raw_b64 = ch.get("channel_data")
  116. break
  117. if raw_b64 is None:
  118. raise ValueError(f"Канал channel_num={params.channel_num} не найден")
  119. # Декодирование: base64 -> int16 -> float64
  120. points = get_channel_value(
  121. measurement.get("measurement_points"),
  122. params.channel_num,
  123. "measurement_points",
  124. )
  125. decoded = base64.b64decode(raw_b64.encode("utf-8"))
  126. arr = np.frombuffer(decoded, dtype=np.int16)
  127. if points is not None:
  128. arr = arr[:int(points)]
  129. signal = arr.astype(np.float64) * params.voltage_range / 32768.0
  130. # Частота дискретизации из JSON
  131. sample_rate = float(
  132. get_channel_value(
  133. measurement.get("measurement_rate", 8e6),
  134. params.channel_num,
  135. "measurement_rate",
  136. )
  137. )
  138. return signal, sample_rate
  139. # -- Вспомогательные функции ---------------------------------------------------
  140. def compute_fwhm(freq: np.ndarray, spectrum: np.ndarray, peak_idx: int) -> float:
  141. """Ширина пика на полувысоте (kHz или в единицах freq)."""
  142. half_max = spectrum[peak_idx] / 2.0
  143. left = peak_idx
  144. while left > 0 and spectrum[left] > half_max:
  145. left -= 1
  146. right = peak_idx
  147. while right < len(spectrum) - 1 and spectrum[right] > half_max:
  148. right += 1
  149. return float(freq[right] - freq[left])
  150. def _downsample(arr: np.ndarray, max_pts: int = _MAX_TRANSPORT_PTS) -> list:
  151. """Даунсэмплирует массив до max_pts точек и конвертирует в list[float]."""
  152. a = np.asarray(arr, dtype=float).flatten()
  153. if len(a) > max_pts:
  154. idx = np.linspace(0, len(a) - 1, max_pts, dtype=int)
  155. a = a[idx]
  156. return [float(x) for x in a]
  157. # -- Основной пайплайн ---------------------------------------------------------
  158. def process_nmr(
  159. signal: np.ndarray,
  160. sample_rate: float,
  161. params: NMRParams,
  162. ) -> NMRResult:
  163. """
  164. Полный NMR-пайплайн (аналог spec_combined.process_signal_2,
  165. с исправлениями: fs из аргумента, sosfiltfilt вместо sosfilt).
  166. signal - сигнал в [В], 1D float64
  167. sample_rate - частота дискретизации [Гц]
  168. params - параметры обработки
  169. Возвращает NMRResult со всеми массивами, даунсэмплированными до
  170. _MAX_TRANSPORT_PTS точек.
  171. """
  172. result = NMRResult()
  173. n_orig = len(signal)
  174. dt = 1.0 / sample_rate
  175. result.n_samples = n_orig
  176. result.sample_rate_hz = sample_rate
  177. result.duration_ms = round(n_orig * dt * 1e3, 4)
  178. # -- 1. Сырой сигнал --------------------------------------------------
  179. t_raw_ms = np.arange(n_orig) * dt * 1e3
  180. result.raw_t_ms = _downsample(t_raw_ms)
  181. result.raw_real = _downsample(signal)
  182. result.raw_amplitude = _downsample(np.abs(signal))
  183. raw_centered = signal - np.mean(signal)
  184. raw_n = len(raw_centered)
  185. if raw_n > 0:
  186. raw_fft = np.fft.rfft(raw_centered) * 2 / raw_n
  187. raw_freq_hz = np.fft.rfftfreq(raw_n, d=dt)
  188. result.raw_freq_hz = _downsample(raw_freq_hz)
  189. result.raw_magnitude = _downsample(np.abs(raw_fft))
  190. result.raw_phase_rad = _downsample(np.angle(raw_fft))
  191. # -- 2. Разбивка на точки_на_запись / n_записей -----------------------
  192. # Если сигнал содержит несколько повторений, разбиваем на матрицу
  193. # (аналогично spec_combined.py). Если одна запись - матрица (1, N).
  194. points_num = n_orig # всё как одна запись (стандартный случай)
  195. n_reps = int(n_orig / points_num)
  196. cutoff = n_reps * points_num
  197. ch = signal[:cutoff]
  198. ch = np.vstack(np.split(ch, n_reps)) # shape: (n_reps, points_num)
  199. # -- 3. Полосовой фильтр (BPF) ----------------------------------------
  200. fc = params.center_freq
  201. bw = params.bandpass_bw
  202. f_low = max(fc - bw / 2, 1.0)
  203. f_high = min(fc + bw / 2, sample_rate / 2 - 1.0)
  204. sos_bp = butter(params.butter_order, [f_low, f_high],
  205. btype="bandpass", fs=sample_rate, output="sos")
  206. ch_bp = sosfiltfilt(sos_bp, ch, axis=1)
  207. # -- 4. IQ-демодуляция ------------------------------------------------
  208. t_pts = np.arange(points_num) * dt
  209. demod_i = np.sin(2 * np.pi * fc * t_pts) # I - вещественная часть
  210. demod_q = np.cos(2 * np.pi * fc * t_pts) # Q - мнимая часть
  211. ch_real = ch_bp * demod_i
  212. ch_imag = ch_bp * demod_q
  213. # -- 5. НЧФ (LPF) -----------------------------------------------------
  214. lp_cut = min(params.lp_cutoff, sample_rate / 2 - 1.0)
  215. sos_lp = butter(params.butter_order, lp_cut,
  216. btype="lowpass", fs=sample_rate, output="sos")
  217. ch_real_f = sosfiltfilt(sos_lp, ch_real, axis=1)
  218. ch_imag_f = sosfiltfilt(sos_lp, ch_imag, axis=1)
  219. # -- 6. Децимация -----------------------------------------------------
  220. dec = int(params.dec_factor)
  221. if dec > 1:
  222. ch_real_d = decimate(ch_real_f, dec, axis=1)
  223. ch_imag_d = decimate(ch_imag_f, dec, axis=1)
  224. else:
  225. ch_real_d = ch_real_f
  226. ch_imag_d = ch_imag_f
  227. dec_actual = round(points_num / ch_real_d.shape[1])
  228. Td_new = dt * dec_actual
  229. result.dec_factor_actual = int(dec_actual)
  230. # -- 7. Комплексный FID (первая запись) -------------------------------
  231. complex_fid = ch_real_d[0] + 1j * ch_imag_d[0]
  232. n_fid = len(complex_fid)
  233. t_fid_ms = np.arange(n_fid) * Td_new * 1e3
  234. result.demod_t_ms = _downsample(t_fid_ms)
  235. result.demod_real = _downsample(complex_fid.real)
  236. result.demod_imag = _downsample(complex_fid.imag)
  237. result.demod_amplitude = _downsample(np.abs(complex_fid))
  238. # -- 8. Zero-padding --------------------------------------------------
  239. padded = np.pad(complex_fid, (0, max(0, params.zero_pad)), mode="constant")
  240. # -- 9. FFT + fftshift ------------------------------------------------
  241. spectrum = np.abs(np.fft.fftshift(np.fft.fft(padded)))
  242. n_sp = len(padded)
  243. BW = 1.0 / Td_new # полоса после децимации
  244. dff = BW / (n_sp - 1)
  245. freq_hz = np.arange(-BW / 2, BW / 2 + dff, dff)[:n_sp]
  246. freq_khz = freq_hz / 1e3
  247. phase = np.angle(np.fft.fftshift(np.fft.fft(padded)))
  248. result.freq_khz = _downsample(freq_khz)
  249. result.magnitude = _downsample(spectrum)
  250. result.phase_rad = _downsample(phase)
  251. result.freq_step_khz = float(dff / 1e3)
  252. # -- 10. Поиск пика (в отрицательных частотах) ------------------------
  253. neg_mask = freq_khz < 0
  254. if neg_mask.any():
  255. neg_spec = spectrum.copy()
  256. neg_spec[~neg_mask] = 0.0
  257. peak_idx = int(np.argmax(neg_spec))
  258. else:
  259. peak_idx = int(np.argmax(spectrum))
  260. result.peak_freq_khz = float(freq_khz[peak_idx])
  261. result.peak_amplitude = float(spectrum[peak_idx])
  262. result.fwhm_khz = compute_fwhm(freq_khz, spectrum, peak_idx)
  263. return result
  264. # -- Утилита для пакетной обработки -------------------------------------------
  265. def process_json_file(
  266. path: str,
  267. params: NMRParams,
  268. ) -> dict:
  269. """
  270. Загружает JSON-файл, декодирует и обрабатывает.
  271. Возвращает dict с полями NMRResult + 'filename', 'status', 'error'.
  272. """
  273. import os
  274. filename = os.path.basename(path)
  275. try:
  276. with open(path, encoding="utf-8") as fh:
  277. raw = json.load(fh)
  278. signal, fs = decode_hardware_json(raw, params)
  279. res = process_nmr(signal, fs, params)
  280. return {
  281. "filename": filename,
  282. "status": "ok",
  283. "error": None,
  284. "peak_freq_khz": res.peak_freq_khz,
  285. "fwhm_khz": res.fwhm_khz,
  286. "peak_amplitude": res.peak_amplitude,
  287. "freq_step_khz": res.freq_step_khz,
  288. }
  289. except Exception as exc:
  290. return {
  291. "filename": filename,
  292. "status": "error",
  293. "error": f"{type(exc).__name__}: {exc}",
  294. "peak_freq_khz": None,
  295. "fwhm_khz": None,
  296. "peak_amplitude": None,
  297. "freq_step_khz": None,
  298. }