This commit is contained in:
awe
2026-02-25 20:20:40 +03:00
parent 267ddedb19
commit f1652d072e
12 changed files with 1823 additions and 404 deletions

View File

@ -1,21 +1,20 @@
"""Состояние приложения: текущие свипы и настройки калибровки/нормировки."""
import os
from queue import Empty, Queue
from typing import Any, Dict, Mapping, Optional
from typing import Any, Mapping, Optional
import numpy as np
from rfg_adc_plotter.processing.normalizer import (
build_calib_envelopes,
normalize_by_calib,
normalize_by_envelope,
from rfg_adc_plotter.processing.pipeline import (
DEFAULT_BACKGROUND_PATH,
DEFAULT_CALIB_ENVELOPE_PATH,
SweepPreprocessor,
)
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepInfo, SweepPacket
CALIB_ENVELOPE_PATH = "calib_envelope.npy"
BACKGROUND_PATH = "background.npy"
CALIB_ENVELOPE_PATH = DEFAULT_CALIB_ENVELOPE_PATH
BACKGROUND_PATH = DEFAULT_BACKGROUND_PATH
def format_status(data: Mapping[str, Any]) -> str:
@ -39,11 +38,7 @@ def format_status(data: Mapping[str, Any]) -> str:
class AppState:
"""Весь изменяемый GUI-state: текущие данные, калибровка, настройки.
Методы drain_queue и set_calib_enabled заменяют одноимённые closures
с nonlocal из оригинального кода.
"""
"""Весь изменяемый GUI-state: текущие данные + pipeline предобработки."""
def __init__(self, norm_type: str = "projector"):
self.current_sweep_pre_exp: Optional[np.ndarray] = None
@ -51,116 +46,265 @@ class AppState:
self.current_sweep_processed: Optional[np.ndarray] = None
self.current_sweep_raw: Optional[np.ndarray] = None
self.current_sweep_norm: Optional[np.ndarray] = None
self.last_calib_sweep: Optional[np.ndarray] = None
self.current_info: Optional[SweepInfo] = None
self.calib_enabled: bool = False
self.norm_type: str = norm_type
# "live" — нормировка по текущему ch0-свипу; "file" — по огибающей из файла
self.calib_mode: str = "live"
self.calib_file_envelope: Optional[np.ndarray] = None
# Вычет фона
self.background: Optional[np.ndarray] = None
self.background_enabled: bool = False
self.norm_type: str = str(norm_type).strip().lower()
self.preprocessor = SweepPreprocessor(norm_type=self.norm_type)
self._last_sweep_for_ring: Optional[np.ndarray] = None
self._last_stage_trace: tuple[str, ...] = tuple()
def _normalize(self, raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
if self.calib_mode == "file" and self.calib_file_envelope is not None:
return normalize_by_envelope(raw, self.calib_file_envelope)
return normalize_by_calib(raw, calib, self.norm_type)
def configure_capture_import(self, *, fancy: Optional[bool] = None, logscale: Optional[bool] = None):
self.preprocessor.set_capture_parse_options(fancy=fancy, logscale=logscale)
def save_calib_envelope(self, path: str = CALIB_ENVELOPE_PATH) -> bool:
"""Вычислить огибающую из last_calib_sweep и сохранить в файл.
# ---- Свойства pipeline (для совместимости с GUI) ----
@property
def calib_enabled(self) -> bool:
return self.preprocessor.calib_enabled
Возвращает True при успехе.
"""
if self.last_calib_sweep is None:
return False
try:
_lower, upper = build_calib_envelopes(self.last_calib_sweep)
np.save(path, upper)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось сохранить огибающую: {exc}\n")
return False
@property
def calib_mode(self) -> str:
return self.preprocessor.calib_mode
def load_calib_envelope(self, path: str = CALIB_ENVELOPE_PATH) -> bool:
"""Загрузить огибающую из файла.
@property
def calib_file_envelope(self) -> Optional[np.ndarray]:
return self.preprocessor.calib_file_envelope
Возвращает True при успехе.
"""
if not os.path.isfile(path):
return False
try:
env = np.load(path)
self.calib_file_envelope = np.asarray(env, dtype=np.float32)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось загрузить огибающую: {exc}\n")
return False
@property
def last_calib_sweep(self) -> Optional[np.ndarray]:
return self.preprocessor.last_calib_sweep
@property
def background(self) -> Optional[np.ndarray]:
return self.preprocessor.background
@property
def background_enabled(self) -> bool:
return self.preprocessor.background_enabled
@property
def calib_source_type(self) -> str:
return self.preprocessor.calib_external_source_type
@property
def background_source_type(self) -> str:
return self.preprocessor.background_source_type
@property
def calib_reference_summary(self):
return self.preprocessor.calib_reference_summary
@property
def background_reference_summary(self):
return self.preprocessor.background_reference_summary
@property
def last_reference_error(self) -> str:
return self.preprocessor.last_reference_error
@property
def calib_envelope_path(self) -> str:
return self.preprocessor.calib_envelope_path
@property
def background_path(self) -> str:
return self.preprocessor.background_path
# ---- Управление файлами калибровки/фона ----
def set_calib_envelope_path(self, path: str):
self.preprocessor.set_calib_envelope_path(path)
self._refresh_current_processed()
def set_background_path(self, path: str):
self.preprocessor.set_background_path(path)
self._refresh_current_processed()
def has_calib_envelope_file(self) -> bool:
return self.preprocessor.has_calib_envelope_file()
def has_background_file(self) -> bool:
return self.preprocessor.has_background_file()
def save_calib_envelope(self, path: Optional[str] = None) -> bool:
return self.preprocessor.save_calib_envelope(path)
def load_calib_reference(self, path: Optional[str] = None) -> bool:
ok = self.preprocessor.load_calib_reference(path)
if ok:
self._refresh_current_processed()
return ok
def load_calib_envelope(self, path: Optional[str] = None) -> bool:
return self.load_calib_reference(path)
def set_calib_mode(self, mode: str):
"""Переключить режим калибровки: 'live' или 'file'."""
self.calib_mode = mode
self.preprocessor.set_calib_mode(mode)
self._refresh_current_processed()
def save_background(self, path: str = BACKGROUND_PATH) -> bool:
"""Сохранить текущий sweep_for_ring как фоновый спектр.
def save_background(self, path: Optional[str] = None) -> bool:
return self.preprocessor.save_background(self._last_sweep_for_ring, path)
Сохраняет последний свип, который был записан в ринг-буфер
(нормированный, если калибровка включена, иначе сырой).
Возвращает True при успехе.
"""
if self._last_sweep_for_ring is None:
return False
try:
np.save(path, self._last_sweep_for_ring)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось сохранить фон: {exc}\n")
return False
def load_background_reference(self, path: Optional[str] = None) -> bool:
ok = self.preprocessor.load_background_reference(path)
if ok:
self._refresh_current_processed()
return ok
def load_background(self, path: str = BACKGROUND_PATH) -> bool:
"""Загрузить фоновый спектр из файла.
Возвращает True при успехе.
"""
if not os.path.isfile(path):
return False
try:
bg = np.load(path)
self.background = np.asarray(bg, dtype=np.float32)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось загрузить фон: {exc}\n")
return False
def load_background(self, path: Optional[str] = None) -> bool:
return self.load_background_reference(path)
def set_background_enabled(self, enabled: bool):
"""Включить/выключить вычет фона."""
self.background_enabled = enabled
self.preprocessor.set_background_enabled(enabled)
self._refresh_current_processed()
def set_calib_enabled(self, enabled: bool):
"""Включить/выключить режим калибровки, пересчитать norm-свип."""
self.calib_enabled = enabled
if self.calib_enabled and self.current_sweep_raw is not None:
if self.calib_mode == "file" and self.calib_file_envelope is not None:
self.current_sweep_norm = normalize_by_envelope(
self.current_sweep_raw, self.calib_file_envelope
)
elif self.calib_mode == "live" and self.last_calib_sweep is not None:
self.current_sweep_norm = self._normalize(
self.current_sweep_raw, self.last_calib_sweep
)
else:
self.current_sweep_norm = None
self.preprocessor.set_calib_enabled(enabled)
self._refresh_current_processed()
# ---- Вспомогательные методы для UI ----
def _current_channel(self) -> Optional[int]:
if not isinstance(self.current_info, dict):
return None
try:
return int(self.current_info.get("ch", 0))
except Exception:
return 0
def _apply_result_to_current(self, result) -> None:
self._last_stage_trace = tuple(result.stage_trace)
if result.is_calibration_reference:
self.current_sweep_norm = None
elif result.calibration_applied or result.background_applied:
self.current_sweep_norm = result.processed_sweep
else:
self.current_sweep_norm = None
self.current_sweep_processed = (
self.current_sweep_norm if self.current_sweep_norm is not None else self.current_sweep_raw
self.current_sweep_processed = result.processed_sweep
self._last_sweep_for_ring = result.processed_sweep
def _refresh_current_processed(self):
if self.current_sweep_raw is None:
self.current_sweep_norm = None
self.current_sweep_processed = None
self._last_stage_trace = tuple()
return
ch = self._current_channel() or 0
result = self.preprocessor.process(self.current_sweep_raw, ch, update_references=False)
self._apply_result_to_current(result)
def format_pipeline_status(self) -> str:
"""Краткое описание pipeline для UI: от распарсенного свипа до IFFT."""
ch = self._current_channel()
if ch is None:
ch_txt = "?"
else:
ch_txt = str(ch)
reader_stage = "log-exp" if self.current_sweep_pre_exp is not None else "linear"
if ch == 0:
file_calib_applies = (
self.calib_enabled
and self.calib_mode == "file"
and self.calib_file_envelope is not None
)
if self.calib_enabled and self.calib_mode == "file":
calib_stage = self.format_calib_source_status()
else:
calib_stage = "calib[off]"
if not self.background_enabled:
bg_stage = "bg[off]"
elif self.background_source_type == "capture_raw":
if self.background is None:
bg_stage = (
"bg[capture(raw->calib):missing]"
if file_calib_applies
else "bg[capture(raw):missing]"
)
else:
bg_stage = "bg[capture(raw->calib)]" if file_calib_applies else "bg[capture(raw)]"
elif self.background_source_type == "npy_processed":
bg_stage = "bg[npy]" if self.background is not None else "bg[npy:missing]"
else:
bg_stage = "bg[sub]" if self.background is not None else "bg[missing]"
return (
f"pipeline ch{ch_txt}: parsed -> {reader_stage} -> raw -> "
f"live-calib-ref -> {calib_stage} -> {bg_stage} -> ring -> IFFT(dB)"
)
calib_stage = self.format_calib_source_status()
bg_stage = self.format_background_source_status()
return (
f"pipeline ch{ch_txt}: parsed -> {reader_stage} -> raw -> "
f"{calib_stage} -> {bg_stage} -> ring -> IFFT(dB)"
)
def _format_summary(self, summary) -> str:
if summary is None:
return ""
parts: list[str] = []
if getattr(summary, "sweeps_valid", 0) or getattr(summary, "sweeps_total", 0):
parts.append(f"valid:{summary.sweeps_valid}/{summary.sweeps_total}")
if getattr(summary, "dominant_width", None) is not None:
parts.append(f"w:{summary.dominant_width}")
chs = getattr(summary, "channels_seen", tuple())
if chs:
parts.append("chs:" + ",".join(str(v) for v in chs))
warns = getattr(summary, "warnings", tuple())
if warns:
parts.append(f"warn:{warns[0]}")
return " ".join(parts)
def format_calib_source_status(self) -> str:
if not self.calib_enabled:
return "calib[off]"
if self.calib_mode == "live":
return "calib[live]" if self.last_calib_sweep is not None else "calib[live:no-ref]"
if self.calib_file_envelope is None:
return "calib[file:missing]"
if self.calib_source_type == "capture":
return "calib[capture]"
if self.calib_source_type == "npy":
return "calib[npy]"
return "calib[file]"
def format_background_source_status(self) -> str:
if not self.background_enabled:
return "bg[off]"
src = self.background_source_type
if src == "capture_raw":
if self.calib_enabled:
can_map = (
(self.calib_mode == "file" and self.calib_file_envelope is not None)
or (self.calib_mode == "live" and self.last_calib_sweep is not None)
)
if not can_map:
return "bg[capture(raw->calib):missing]"
if self.background is None:
return "bg[capture(raw->calib):missing]"
return "bg[capture(raw->calib)]" if self.calib_enabled else "bg[capture(raw)]"
if src == "npy_processed":
return "bg[npy]" if self.background is not None else "bg[npy:missing]"
if self.background is not None:
return "bg[sub]"
return "bg[missing]"
def format_reference_status(self) -> str:
parts: list[str] = []
calib_s = self._format_summary(self.calib_reference_summary)
if calib_s:
parts.append(f"calib[{calib_s}]")
bg_s = self._format_summary(self.background_reference_summary)
if bg_s:
parts.append(f"bg[{bg_s}]")
if self.last_reference_error:
parts.append(f"err:{self.last_reference_error}")
return " | ".join(parts)
def format_stage_trace(self) -> str:
if not self._last_stage_trace:
return ""
return " -> ".join(self._last_stage_trace)
def drain_queue(self, q: "Queue[SweepPacket]", ring: RingBuffer) -> int:
"""Вытащить все ожидающие свипы из очереди, обновить state и ring.
@ -173,49 +317,23 @@ class AppState:
except Empty:
break
drained += 1
self.current_sweep_raw = s
self.current_sweep_post_exp = s
self.current_info = info
pre_exp = info.get("pre_exp_sweep") if isinstance(info, dict) else None
self.current_sweep_pre_exp = pre_exp if isinstance(pre_exp, np.ndarray) else None
ch = 0
try:
ch = int(info.get("ch", 0)) if isinstance(info, dict) else 0
except Exception:
ch = 0
# Канал 0 — опорный (калибровочный) свип
if ch == 0:
self.last_calib_sweep = s
self.save_calib_envelope()
self.current_sweep_norm = None
sweep_for_ring = s
self._last_sweep_for_ring = sweep_for_ring
else:
can_normalize = self.calib_enabled and (
(self.calib_mode == "file" and self.calib_file_envelope is not None)
or (self.calib_mode == "live" and self.last_calib_sweep is not None)
)
if can_normalize:
calib_ref = self.last_calib_sweep if self.last_calib_sweep is not None else s
self.current_sweep_norm = self._normalize(s, calib_ref)
sweep_for_ring = self.current_sweep_norm
else:
self.current_sweep_norm = None
sweep_for_ring = s
result = self.preprocessor.process(s, ch, update_references=True)
self._apply_result_to_current(result)
# Вычет фона (в том же домене что и sweep_for_ring)
if self.background_enabled and self.background is not None and ch != 0:
w = min(sweep_for_ring.size, self.background.size)
sweep_for_ring = sweep_for_ring.copy()
sweep_for_ring[:w] -= self.background[:w]
self.current_sweep_norm = sweep_for_ring
self._last_sweep_for_ring = sweep_for_ring
self.current_sweep_processed = sweep_for_ring
ring.ensure_init(s.size)
ring.push(sweep_for_ring)
ring.push(result.processed_sweep)
return drained
def format_channel_label(self) -> str:

View File

@ -6,14 +6,10 @@ from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import (
FFT_LEN,
FREQ_SPAN_GHZ,
IFFT_LEN,
SWEEP_LEN,
WF_WIDTH,
ZEROS_LOW,
ZEROS_MID,
)
from rfg_adc_plotter.processing.fourier import build_ifft_time_axis_ns, compute_ifft_db_profile
class RingBuffer:
@ -51,10 +47,8 @@ class RingBuffer:
self.ring = np.full((self.max_sweeps, self.width), np.nan, dtype=np.float32)
self.ring_time = np.full((self.max_sweeps,), np.nan, dtype=np.float64)
self.ring_fft = np.full((self.max_sweeps, self.fft_bins), np.nan, dtype=np.float32)
# Временная ось IFFT: шаг dt = 1/(FREQ_SPAN_GHZ*1e9), переведём в нс
self.fft_time_axis = (
np.arange(IFFT_LEN, dtype=np.float64) / (FREQ_SPAN_GHZ * 1e9) * 1e9
).astype(np.float32)
# Временная ось IFFT вынесена в processing.fourier для явного pipeline.
self.fft_time_axis = build_ifft_time_axis_ns()
self.head = 0
# Обновляем x_shared если пришёл свип большего размера
if self.x_shared is None or sweep_width > self.x_shared.size:
@ -75,29 +69,7 @@ class RingBuffer:
self._push_fft(s)
def _push_fft(self, s: np.ndarray):
bins = self.ring_fft.shape[1] # = IFFT_LEN = 1953
if s is None or s.size == 0:
fft_row = np.full((bins,), np.nan, dtype=np.float32)
else:
# 1. Взять первые SWEEP_LEN отсчётов (остаток — нули если свип короче)
sig = np.zeros(SWEEP_LEN, dtype=np.float32)
take = min(int(s.size), SWEEP_LEN)
seg = np.nan_to_num(s[:take], nan=0.0).astype(np.float32, copy=False)
sig[:take] = seg
# 2. Собрать двусторонний спектр:
# [ZEROS_LOW нулей | ZEROS_MID нулей | SWEEP_LEN данных]
# = [-14.3..-3.2 ГГц | -3.2..+3.2 ГГц | +3.2..+14.3 ГГц]
data = np.zeros(IFFT_LEN, dtype=np.complex64)
data[ZEROS_LOW + ZEROS_MID:] = sig
# 3. ifftshift + ifft → временной профиль
spec = np.fft.ifftshift(data)
result = np.fft.ifft(spec)
# 4. Амплитуда в дБ
mag = np.abs(result).astype(np.float32)
fft_row = (20.0 * np.log10(mag + 1e-9)).astype(np.float32)
fft_row = compute_ifft_db_profile(s)
prev_head = (self.head - 1) % self.ring_fft.shape[0]
self.ring_fft[prev_head, :] = fft_row