diff --git a/README.md b/README.md index 439a166..8efdacd 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,11 @@ Legacy binary: `--bin` понимает mixed 8-байтный поток: - `0x000A,step,ch1_i16,ch2_i16` для CH1/CH2 из `kamil_adc` (`tty:/tmp/ttyADC_data`) +- `0x00A3,step,ch1_i16,ch2_i16` и `0x00A4,step,ch1_i16,ch2_i16` для DO1 LOW/HIGH tagged fast-tty - `0x001A,step,data_i16,0x0000` для логарифмического детектора Для `0x000A` сырая кривая строится как `ch1^2 + ch2^2`, а FFT рассчитывается от комплексного сигнала `ch1 + i*ch2`. +Для `0x00A3/0x00A4` tagged-режим определяется автоматически: LOW/HIGH отображаются раздельно в raw/aux/phase, а waterfall/FFT/B-scan скрываются. Для `0x001A` signed `data_i16` сначала переводится в В, затем raw отображается как `V`, а FFT рассчитывается от `exp(V)`. Параметр `--tty-range-v` применяется к обоим типам `--bin`-данных. diff --git a/rfg_adc_plotter/cli.py b/rfg_adc_plotter/cli.py index 362ff74..e4d179c 100644 --- a/rfg_adc_plotter/cli.py +++ b/rfg_adc_plotter/cli.py @@ -73,9 +73,12 @@ def build_parser() -> argparse.ArgumentParser: help=( "8-байтный бинарный протокол: либо legacy старт " "0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A и точки step,uint32(hi16,lo16),0x000A, " - "либо mixed поток 0x000A,step,ch1_i16,ch2_i16 и 0x001A,step,data_i16,0x0000. " + "либо mixed поток 0x000A,step,ch1_i16,ch2_i16; " + "0x00A3/0x00A4,step,ch1_i16,ch2_i16 (DO1 LOW/HIGH tagged); " + "и 0x001A,step,data_i16,0x0000. " "Для 0x000A: после парсинга int16 переводятся в В, " "сырая кривая = ch1^2+ch2^2 (В^2), FFT вход = ch1+i*ch2 (В). " + "Для 0x00A3/0x00A4: auto-detect tagged режим с раздельным отображением LOW/HIGH в raw. " "Для 0x001A: code_i16 переводится в В, raw = V, FFT вход = exp(V)" ), ) diff --git a/rfg_adc_plotter/gui/pyqtgraph_backend.py b/rfg_adc_plotter/gui/pyqtgraph_backend.py index 9277a7a..3b01411 100644 --- a/rfg_adc_plotter/gui/pyqtgraph_backend.py +++ b/rfg_adc_plotter/gui/pyqtgraph_backend.py @@ -8,7 +8,7 @@ import sys import threading import time from queue import Empty, Queue -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple import numpy as np @@ -67,6 +67,7 @@ TTY_RANGE_DEFAULT_V = 5.0 TTY_RANGE_MIN_V = 1e-6 TTY_RANGE_MAX_V = 1_000_000.0 LOGDET_EXP_INPUT_LIMIT = 80.0 +DO1_TAGGED_INFO_KEY = "_do1_tagged_payload" def sanitize_curve_data_for_display( @@ -491,6 +492,50 @@ def resolve_visible_aux_curves(aux_curves: SweepAuxCurves, enabled: bool) -> Swe return aux_1_arr, aux_2_arr +def compute_do1_tagged_aggregate( + raw_low: Optional[np.ndarray], + raw_high: Optional[np.ndarray], +) -> Optional[np.ndarray]: + """Build a merged raw series from LOW/HIGH tagged curves using nan-aware averaging.""" + if raw_low is None and raw_high is None: + return None + if raw_low is None: + return np.asarray(raw_high, dtype=np.float32).reshape(-1) + if raw_high is None: + return np.asarray(raw_low, dtype=np.float32).reshape(-1) + low_arr = np.asarray(raw_low, dtype=np.float32).reshape(-1) + high_arr = np.asarray(raw_high, dtype=np.float32).reshape(-1) + width = min(low_arr.size, high_arr.size) + if width <= 0: + return np.zeros((0,), dtype=np.float32) + low_arr = low_arr[:width] + high_arr = high_arr[:width] + out = np.full((width,), np.nan, dtype=np.float32) + low_valid = np.isfinite(low_arr) + high_valid = np.isfinite(high_arr) + both_valid = low_valid & high_valid + low_only = low_valid & (~high_valid) + high_only = high_valid & (~low_valid) + out[low_only] = low_arr[low_only] + out[high_only] = high_arr[high_only] + out[both_valid] = (low_arr[both_valid] + high_arr[both_valid]) * 0.5 + return out + + +def resolve_visible_do1_tagged_aux_curves( + aux_low: SweepAuxCurves, + aux_high: SweepAuxCurves, + enabled: bool, +) -> Tuple[SweepAuxCurves, SweepAuxCurves]: + """Return visible LOW/HIGH CH1/CH2 pairs for the DO1 tagged raw mode.""" + if not enabled: + return (None, None) + return ( + resolve_visible_aux_curves(aux_low, enabled=True), + resolve_visible_aux_curves(aux_high, enabled=True), + ) + + def compute_aux_phase_curve(aux_curves: SweepAuxCurves) -> Optional[np.ndarray]: """Compute phase-like curve atan2(aux_2, aux_1) for raw CH2/CH1 display.""" if aux_curves is None: @@ -508,6 +553,17 @@ def compute_aux_phase_curve(aux_curves: SweepAuxCurves) -> Optional[np.ndarray]: return phase +def compute_do1_tagged_phase_curves( + aux_low: SweepAuxCurves, + aux_high: SweepAuxCurves, +) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """Compute separate phase curves for DO1 LOW/HIGH tagged auxiliary data.""" + return ( + compute_aux_phase_curve(aux_low), + compute_aux_phase_curve(aux_high), + ) + + def sanitize_tty_voltage_range(range_v: float, default: float = TTY_RANGE_DEFAULT_V) -> float: """Return a finite positive full-scale voltage range for tty int16 conversion.""" try: @@ -810,6 +866,8 @@ def run_pyqtgraph(args) -> None: p_line = win.addPlot(row=0, col=0, title="Сырые данные") p_line.showGrid(x=True, y=True, alpha=0.3) curve = p_line.plot(pen=pg.mkPen((80, 120, 255), width=1)) + curve_raw_low = p_line.plot(pen=pg.mkPen((255, 90, 90), width=1)) + curve_raw_high = p_line.plot(pen=pg.mkPen((90, 220, 255), width=1)) p_line_aux_vb = None if bin_iq_power_mode: p_line_aux_vb = pg.ViewBox() @@ -823,15 +881,23 @@ def run_pyqtgraph(args) -> None: p_line_aux_vb = None curve_aux_1 = pg.PlotDataItem(pen=pg.mkPen((255, 170, 40), width=1)) curve_aux_2 = pg.PlotDataItem(pen=pg.mkPen((170, 70, 255), width=1)) + curve_aux_3 = pg.PlotDataItem(pen=pg.mkPen((255, 120, 20), width=1)) + curve_aux_4 = pg.PlotDataItem(pen=pg.mkPen((120, 60, 220), width=1)) if p_line_aux_vb is not None: p_line_aux_vb.addItem(curve_aux_1) p_line_aux_vb.addItem(curve_aux_2) + p_line_aux_vb.addItem(curve_aux_3) + p_line_aux_vb.addItem(curve_aux_4) else: p_line.addItem(curve_aux_1) p_line.addItem(curve_aux_2) + p_line.addItem(curve_aux_3) + p_line.addItem(curve_aux_4) else: curve_aux_1 = p_line.plot(pen=pg.mkPen((255, 170, 40), width=1)) curve_aux_2 = p_line.plot(pen=pg.mkPen((170, 70, 255), width=1)) + curve_aux_3 = p_line.plot(pen=pg.mkPen((255, 120, 20), width=1)) + curve_aux_4 = p_line.plot(pen=pg.mkPen((120, 60, 220), width=1)) curve_calib = p_line.plot(pen=pg.mkPen((220, 60, 60), width=1)) curve_norm = p_line.plot(pen=pg.mkPen((60, 180, 90), width=1)) p_line.setLabel("bottom", "ГГц") @@ -864,6 +930,7 @@ def run_pyqtgraph(args) -> None: p_line_phase = win.addPlot(row=2, col=0, title="Raw phase: atan(CH2/CH1)") p_line_phase.showGrid(x=True, y=True, alpha=0.3) curve_phase = p_line_phase.plot(pen=pg.mkPen((230, 180, 40), width=1)) + curve_phase_high = p_line_phase.plot(pen=pg.mkPen((80, 220, 220), width=1)) p_line_phase.setLabel("bottom", "ГГц") p_line_phase.setLabel("left", "рад") try: @@ -1312,18 +1379,23 @@ def run_pyqtgraph(args) -> None: if not isinstance(payload, dict): return None signal_kind = payload.get("signal_kind") - if signal_kind in {"bin_iq", "bin_logdet"}: + if signal_kind in {"bin_iq", "bin_logdet", "bin_iq_do1_tagged"}: return str(signal_kind) return None + def current_packet_is_do1_tagged(info: Optional[SweepInfo] = None) -> bool: + return get_signal_kind(info) == "bin_iq_do1_tagged" + def current_packet_is_complex() -> bool: - return bool(complex_sweep_mode) and get_signal_kind() != "bin_logdet" + signal_kind = get_signal_kind() + return bool(complex_sweep_mode) and signal_kind not in {"bin_logdet", "bin_iq_do1_tagged"} def refresh_signal_mode_labels() -> None: signal_kind = get_signal_kind() active_complex = current_packet_is_complex() is_logdet = signal_kind == "bin_logdet" is_bin_iq = signal_kind == "bin_iq" + is_do1_tagged = signal_kind == "bin_iq_do1_tagged" try: if is_logdet: @@ -1331,6 +1403,11 @@ def run_pyqtgraph(args) -> None: p_line.setLabel("left", "В") p_fft.setTitle("FFT: exp(V)") parsed_data_cb.setText("Сырые log-detector (В)") + elif is_do1_tagged: + p_line.setTitle("DO1 tagged raw: LOW/HIGH CH1^2 + CH2^2 (В^2)") + p_line.setLabel("left", "CH1^2 + CH2^2, В^2") + p_fft.setTitle("FFT") + parsed_data_cb.setText("DO1 tagged CH1/CH2 (В)") elif is_bin_iq: p_line.setTitle("Сырые CH1/CH2 (В) и CH1^2 + CH2^2 (В^2)") p_line.setLabel("left", "CH1^2 + CH2^2, В^2") @@ -1347,7 +1424,10 @@ def run_pyqtgraph(args) -> None: p_fft.setTitle("FFT") parsed_data_cb.setText("данные после парсинга") p_fft.setLabel("left", "Амплитуда" if active_complex else "дБ") - p_complex_calib.setVisible(bool(active_complex)) + p_img.setVisible(not is_do1_tagged) + p_fft.setVisible(not is_do1_tagged) + p_spec.setVisible(not is_do1_tagged) + p_complex_calib.setVisible((not is_do1_tagged) and bool(active_complex)) except Exception: pass @@ -1365,6 +1445,12 @@ def run_pyqtgraph(args) -> None: return False ch_1_v = convert_tty_i16_to_voltage(code_1_arr[:width], tty_range_v) ch_2_v = convert_tty_i16_to_voltage(code_2_arr[:width], tty_range_v) + runtime.full_do1_tagged_raw_low = None + runtime.full_do1_tagged_raw_high = None + runtime.full_do1_tagged_aux_low = None + runtime.full_do1_tagged_aux_high = None + runtime.full_do1_tagged_aux_low_codes = None + runtime.full_do1_tagged_aux_high_codes = None runtime.full_current_aux_curves = (ch_1_v, ch_2_v) runtime.full_current_fft_source = ch_1_v.astype(np.complex64) + (1j * ch_2_v.astype(np.complex64)) ch_1_v_f64 = ch_1_v.astype(np.float64, copy=False) @@ -1373,6 +1459,44 @@ def run_pyqtgraph(args) -> None: runtime.full_current_sweep_codes = None return True + def rebuild_do1_tagged_voltage_curves_from_codes() -> bool: + if (not bin_iq_power_mode) or runtime.full_do1_tagged_aux_low_codes is None or runtime.full_do1_tagged_aux_high_codes is None: + return False + try: + low_code_1, low_code_2 = runtime.full_do1_tagged_aux_low_codes + high_code_1, high_code_2 = runtime.full_do1_tagged_aux_high_codes + except Exception: + return False + low_code_1_arr = np.asarray(low_code_1, dtype=np.float32).reshape(-1) + low_code_2_arr = np.asarray(low_code_2, dtype=np.float32).reshape(-1) + high_code_1_arr = np.asarray(high_code_1, dtype=np.float32).reshape(-1) + high_code_2_arr = np.asarray(high_code_2, dtype=np.float32).reshape(-1) + width = min(low_code_1_arr.size, low_code_2_arr.size, high_code_1_arr.size, high_code_2_arr.size) + if width <= 0: + return False + + low_ch_1_v = convert_tty_i16_to_voltage(low_code_1_arr[:width], tty_range_v) + low_ch_2_v = convert_tty_i16_to_voltage(low_code_2_arr[:width], tty_range_v) + high_ch_1_v = convert_tty_i16_to_voltage(high_code_1_arr[:width], tty_range_v) + high_ch_2_v = convert_tty_i16_to_voltage(high_code_2_arr[:width], tty_range_v) + + low_ch_1_v_f64 = low_ch_1_v.astype(np.float64, copy=False) + low_ch_2_v_f64 = low_ch_2_v.astype(np.float64, copy=False) + high_ch_1_v_f64 = high_ch_1_v.astype(np.float64, copy=False) + high_ch_2_v_f64 = high_ch_2_v.astype(np.float64, copy=False) + raw_low = np.asarray((low_ch_1_v_f64 * low_ch_1_v_f64) + (low_ch_2_v_f64 * low_ch_2_v_f64), dtype=np.float32) + raw_high = np.asarray((high_ch_1_v_f64 * high_ch_1_v_f64) + (high_ch_2_v_f64 * high_ch_2_v_f64), dtype=np.float32) + runtime.full_do1_tagged_raw_low = raw_low + runtime.full_do1_tagged_raw_high = raw_high + runtime.full_do1_tagged_aux_low = (low_ch_1_v, low_ch_2_v) + runtime.full_do1_tagged_aux_high = (high_ch_1_v, high_ch_2_v) + runtime.full_current_aux_curves = None + runtime.full_current_aux_curves_codes = None + runtime.full_current_sweep_codes = None + runtime.full_current_fft_source = None + runtime.full_current_sweep_raw = compute_do1_tagged_aggregate(raw_low, raw_high) + return runtime.full_current_sweep_raw is not None and runtime.full_current_sweep_raw.size > 0 + def rebuild_logdet_voltage_curve_from_codes() -> bool: if (not bin_iq_power_mode) or runtime.full_current_sweep_codes is None: return False @@ -1380,6 +1504,12 @@ def run_pyqtgraph(args) -> None: if code_arr.size <= 0: return False sweep_raw_v, fft_input = build_logdet_voltage_fft_input(code_arr, tty_range_v) + runtime.full_do1_tagged_raw_low = None + runtime.full_do1_tagged_raw_high = None + runtime.full_do1_tagged_aux_low = None + runtime.full_do1_tagged_aux_high = None + runtime.full_do1_tagged_aux_low_codes = None + runtime.full_do1_tagged_aux_high_codes = None runtime.full_current_aux_curves = None runtime.full_current_aux_curves_codes = None runtime.full_current_sweep_raw = sweep_raw_v @@ -1390,6 +1520,8 @@ def run_pyqtgraph(args) -> None: signal_kind = get_signal_kind() if signal_kind == "bin_logdet": return rebuild_logdet_voltage_curve_from_codes() + if signal_kind == "bin_iq_do1_tagged": + return rebuild_do1_tagged_voltage_curves_from_codes() return rebuild_tty_voltage_curves_from_codes() def reset_background_state(*, clear_profile: bool = True) -> None: @@ -1427,6 +1559,7 @@ def run_pyqtgraph(args) -> None: if reset_ring: reset_ring_buffers() + tagged_mode = current_packet_is_do1_tagged() if runtime.full_current_freqs is None or runtime.full_current_sweep_raw is None: runtime.current_freqs = None runtime.current_sweep_raw = None @@ -1434,6 +1567,10 @@ def run_pyqtgraph(args) -> None: runtime.current_fft_input = None runtime.current_fft_complex = None runtime.current_aux_curves = None + runtime.current_do1_tagged_raw_low = None + runtime.current_do1_tagged_raw_high = None + runtime.current_do1_tagged_aux_low = None + runtime.current_do1_tagged_aux_high = None runtime.current_sweep_norm = None runtime.current_fft_mag = None runtime.current_fft_db = None @@ -1462,6 +1599,40 @@ def run_pyqtgraph(args) -> None: runtime.range_min_ghz, runtime.range_max_ghz, ) + if tagged_mode: + runtime.current_do1_tagged_raw_low = apply_working_range_to_signal( + runtime.full_current_freqs, + runtime.full_current_sweep_raw, + runtime.full_do1_tagged_raw_low, + runtime.range_min_ghz, + runtime.range_max_ghz, + ) + runtime.current_do1_tagged_raw_high = apply_working_range_to_signal( + runtime.full_current_freqs, + runtime.full_current_sweep_raw, + runtime.full_do1_tagged_raw_high, + runtime.range_min_ghz, + runtime.range_max_ghz, + ) + runtime.current_do1_tagged_aux_low = apply_working_range_to_aux_curves( + runtime.full_current_freqs, + runtime.full_current_sweep_raw, + runtime.full_do1_tagged_aux_low, + runtime.range_min_ghz, + runtime.range_max_ghz, + ) + runtime.current_do1_tagged_aux_high = apply_working_range_to_aux_curves( + runtime.full_current_freqs, + runtime.full_current_sweep_raw, + runtime.full_do1_tagged_aux_high, + runtime.range_min_ghz, + runtime.range_max_ghz, + ) + else: + runtime.current_do1_tagged_raw_low = None + runtime.current_do1_tagged_raw_high = None + runtime.current_do1_tagged_aux_low = None + runtime.current_do1_tagged_aux_high = None if runtime.current_sweep_raw.size == 0: if push_to_ring: @@ -1472,6 +1643,10 @@ def run_pyqtgraph(args) -> None: runtime.current_fft_input = None runtime.current_fft_complex = None runtime.current_aux_curves = None + runtime.current_do1_tagged_raw_low = None + runtime.current_do1_tagged_raw_high = None + runtime.current_do1_tagged_aux_low = None + runtime.current_do1_tagged_aux_high = None runtime.current_sweep_norm = None runtime.current_fft_mag = None runtime.current_fft_db = None @@ -1482,6 +1657,16 @@ def run_pyqtgraph(args) -> None: recompute_current_processed_sweep(push_to_ring=push_to_ring) def recompute_current_processed_sweep(push_to_ring: bool = False) -> None: + if current_packet_is_do1_tagged(): + runtime.current_sweep_norm = None + runtime.current_fft_source = None + runtime.current_fft_input = None + runtime.current_fft_complex = None + runtime.current_fft_mag = None + runtime.current_fft_db = None + runtime.current_distances = None + return + fft_source = runtime.current_fft_source if fft_source is None and runtime.current_sweep_raw is not None: fft_source = np.asarray(runtime.current_sweep_raw, dtype=np.float32) @@ -2207,8 +2392,76 @@ def run_pyqtgraph(args) -> None: runtime.full_current_aux_curves_codes = None runtime.full_current_sweep_codes = None runtime.full_current_fft_source = None + runtime.full_do1_tagged_raw_low = None + runtime.full_do1_tagged_raw_high = None + runtime.full_do1_tagged_aux_low = None + runtime.full_do1_tagged_aux_high = None + runtime.full_do1_tagged_aux_low_codes = None + runtime.full_do1_tagged_aux_high_codes = None signal_kind = get_signal_kind(info) - if signal_kind == "bin_logdet": + if signal_kind == "bin_iq_do1_tagged": + calibrated = calibrate_freqs( + { + "F": base_freqs, + "I": sweep, + } + ) + runtime.full_current_freqs = np.asarray(calibrated["F"], dtype=np.float64) + payload = info.get(DO1_TAGGED_INFO_KEY) if isinstance(info, dict) else None + if isinstance(payload, dict): + raw_low_payload = payload.get("raw_low") + raw_high_payload = payload.get("raw_high") + if raw_low_payload is not None: + runtime.full_do1_tagged_raw_low = np.asarray( + calibrate_freqs({"F": base_freqs, "I": raw_low_payload})["I"], + dtype=np.float32, + ) + if raw_high_payload is not None: + runtime.full_do1_tagged_raw_high = np.asarray( + calibrate_freqs({"F": base_freqs, "I": raw_high_payload})["I"], + dtype=np.float32, + ) + runtime.full_current_sweep_raw = compute_do1_tagged_aggregate( + runtime.full_do1_tagged_raw_low, + runtime.full_do1_tagged_raw_high, + ) + + aux_low_payload = payload.get("aux_low") + aux_high_payload = payload.get("aux_high") + if ( + bin_iq_power_mode + and isinstance(aux_low_payload, (tuple, list)) + and len(aux_low_payload) == 2 + and isinstance(aux_high_payload, (tuple, list)) + and len(aux_high_payload) == 2 + ): + try: + low_aux_1 = np.asarray( + calibrate_freqs({"F": base_freqs, "I": aux_low_payload[0]})["I"], + dtype=np.float32, + ) + low_aux_2 = np.asarray( + calibrate_freqs({"F": base_freqs, "I": aux_low_payload[1]})["I"], + dtype=np.float32, + ) + high_aux_1 = np.asarray( + calibrate_freqs({"F": base_freqs, "I": aux_high_payload[0]})["I"], + dtype=np.float32, + ) + high_aux_2 = np.asarray( + calibrate_freqs({"F": base_freqs, "I": aux_high_payload[1]})["I"], + dtype=np.float32, + ) + runtime.full_do1_tagged_aux_low_codes = (low_aux_1, low_aux_2) + runtime.full_do1_tagged_aux_high_codes = (high_aux_1, high_aux_2) + rebuild_do1_tagged_voltage_curves_from_codes() + except Exception: + runtime.full_do1_tagged_aux_low_codes = None + runtime.full_do1_tagged_aux_high_codes = None + if runtime.full_current_sweep_raw is None: + runtime.full_current_sweep_raw = np.asarray(calibrated["I"], dtype=np.float32) + runtime.full_current_fft_source = None + elif signal_kind == "bin_logdet": calibrated = calibrate_freqs( { "F": base_freqs, @@ -2242,7 +2495,7 @@ def run_pyqtgraph(args) -> None: runtime.full_current_aux_curves_codes = None runtime.full_current_fft_source = None - if runtime.full_current_fft_source is None: + if (signal_kind != "bin_iq_do1_tagged") and runtime.full_current_fft_source is None: calibrated = calibrate_freqs( { "F": base_freqs, @@ -2327,11 +2580,13 @@ def run_pyqtgraph(args) -> None: or last_bscan_refresh_stride <= 1 or (update_ticks % last_bscan_refresh_stride) == 0 ) + do1_tagged_now = current_packet_is_do1_tagged() if redraw_needed: refresh_signal_mode_labels() active_signal_kind = get_signal_kind() active_complex_mode = current_packet_is_complex() + active_do1_tagged = active_signal_kind == "bin_iq_do1_tagged" xs = resolve_curve_xs( runtime.current_sweep_raw.size if runtime.current_sweep_raw is not None @@ -2339,20 +2594,78 @@ def run_pyqtgraph(args) -> None: ) displayed_calib = None displayed_aux = resolve_visible_aux_curves(runtime.current_aux_curves, parsed_data_enabled) - displayed_phase = compute_aux_phase_curve(runtime.current_aux_curves) + displayed_tagged_aux_low, displayed_tagged_aux_high = resolve_visible_do1_tagged_aux_curves( + runtime.current_do1_tagged_aux_low, + runtime.current_do1_tagged_aux_high, + parsed_data_enabled, + ) + if active_do1_tagged: + displayed_phase, displayed_phase_high = compute_do1_tagged_phase_curves( + runtime.current_do1_tagged_aux_low, + runtime.current_do1_tagged_aux_high, + ) + else: + displayed_phase = compute_aux_phase_curve(runtime.current_aux_curves) + displayed_phase_high = None try: - p_line_phase.setVisible(displayed_phase is not None and displayed_phase.size > 0) + p_line_phase.setVisible( + (displayed_phase is not None and displayed_phase.size > 0) + or (displayed_phase_high is not None and displayed_phase_high.size > 0) + ) except Exception: pass - if runtime.current_sweep_raw is not None: + if active_do1_tagged: + curve.setData([], []) + if runtime.current_do1_tagged_raw_low is not None: + raw_low_x, raw_low_y = decimate_curve_for_display(xs, runtime.current_do1_tagged_raw_low) + raw_low_x, raw_low_y = sanitize_curve_data_for_display(raw_low_x, raw_low_y) + curve_raw_low.setData(raw_low_x, raw_low_y, autoDownsample=False) + else: + curve_raw_low.setData([], []) + if runtime.current_do1_tagged_raw_high is not None: + raw_high_x, raw_high_y = decimate_curve_for_display(xs, runtime.current_do1_tagged_raw_high) + raw_high_x, raw_high_y = sanitize_curve_data_for_display(raw_high_x, raw_high_y) + curve_raw_high.setData(raw_high_x, raw_high_y, autoDownsample=False) + else: + curve_raw_high.setData([], []) + elif runtime.current_sweep_raw is not None: raw_x, raw_y = decimate_curve_for_display(xs, runtime.current_sweep_raw) raw_x, raw_y = sanitize_curve_data_for_display(raw_x, raw_y) curve.setData(raw_x, raw_y, autoDownsample=False) + curve_raw_low.setData([], []) + curve_raw_high.setData([], []) else: curve.setData([], []) + curve_raw_low.setData([], []) + curve_raw_high.setData([], []) - if displayed_aux is not None: + if active_do1_tagged: + if displayed_tagged_aux_low is not None: + aux_low_1, aux_low_2 = displayed_tagged_aux_low + aux_low_width = min(xs.size, aux_low_1.size, aux_low_2.size) + low_x_1, low_y_1 = decimate_curve_for_display(xs[:aux_low_width], aux_low_1[:aux_low_width]) + low_x_2, low_y_2 = decimate_curve_for_display(xs[:aux_low_width], aux_low_2[:aux_low_width]) + low_x_1, low_y_1 = sanitize_curve_data_for_display(low_x_1, low_y_1) + low_x_2, low_y_2 = sanitize_curve_data_for_display(low_x_2, low_y_2) + curve_aux_1.setData(low_x_1, low_y_1, autoDownsample=False) + curve_aux_2.setData(low_x_2, low_y_2, autoDownsample=False) + else: + curve_aux_1.setData([], []) + curve_aux_2.setData([], []) + if displayed_tagged_aux_high is not None: + aux_high_1, aux_high_2 = displayed_tagged_aux_high + aux_high_width = min(xs.size, aux_high_1.size, aux_high_2.size) + high_x_1, high_y_1 = decimate_curve_for_display(xs[:aux_high_width], aux_high_1[:aux_high_width]) + high_x_2, high_y_2 = decimate_curve_for_display(xs[:aux_high_width], aux_high_2[:aux_high_width]) + high_x_1, high_y_1 = sanitize_curve_data_for_display(high_x_1, high_y_1) + high_x_2, high_y_2 = sanitize_curve_data_for_display(high_x_2, high_y_2) + curve_aux_3.setData(high_x_1, high_y_1, autoDownsample=False) + curve_aux_4.setData(high_x_2, high_y_2, autoDownsample=False) + else: + curve_aux_3.setData([], []) + curve_aux_4.setData([], []) + elif displayed_aux is not None: aux_1, aux_2 = displayed_aux aux_width = min(xs.size, aux_1.size, aux_2.size) aux_x_1, aux_y_1 = decimate_curve_for_display(xs[:aux_width], aux_1[:aux_width]) @@ -2361,9 +2674,13 @@ def run_pyqtgraph(args) -> None: aux_x_2, aux_y_2 = sanitize_curve_data_for_display(aux_x_2, aux_y_2) curve_aux_1.setData(aux_x_1, aux_y_1, autoDownsample=False) curve_aux_2.setData(aux_x_2, aux_y_2, autoDownsample=False) + curve_aux_3.setData([], []) + curve_aux_4.setData([], []) else: curve_aux_1.setData([], []) curve_aux_2.setData([], []) + curve_aux_3.setData([], []) + curve_aux_4.setData([], []) if displayed_phase is not None: phase_width = min(xs.size, displayed_phase.size) @@ -2372,8 +2689,18 @@ def run_pyqtgraph(args) -> None: curve_phase.setData(phase_x, phase_y, autoDownsample=False) else: curve_phase.setData([], []) + if displayed_phase_high is not None: + phase_high_width = min(xs.size, displayed_phase_high.size) + phase_high_x, phase_high_y = decimate_curve_for_display( + xs[:phase_high_width], + displayed_phase_high[:phase_high_width], + ) + phase_high_x, phase_high_y = sanitize_curve_data_for_display(phase_high_x, phase_high_y) + curve_phase_high.setData(phase_high_x, phase_high_y, autoDownsample=False) + else: + curve_phase_high.setData([], []) - if runtime.calib_envelope is not None: + if (not active_do1_tagged) and runtime.calib_envelope is not None: if runtime.current_sweep_raw is not None: displayed_calib = resample_envelope(runtime.calib_envelope, runtime.current_sweep_raw.size) xs_calib = xs[: displayed_calib.size] @@ -2386,7 +2713,7 @@ def run_pyqtgraph(args) -> None: else: curve_calib.setData([], []) - if runtime.current_sweep_norm is not None: + if (not active_do1_tagged) and runtime.current_sweep_norm is not None: norm_display = runtime.current_sweep_norm * norm_display_scale norm_x, norm_y = decimate_curve_for_display(xs[: norm_display.size], norm_display) norm_x, norm_y = sanitize_curve_data_for_display(norm_x, norm_y) @@ -2395,7 +2722,13 @@ def run_pyqtgraph(args) -> None: curve_norm.setData([], []) if fixed_ylim is None: - if active_signal_kind == "bin_iq": + if active_do1_tagged: + y_series = [ + runtime.current_do1_tagged_raw_low, + runtime.current_do1_tagged_raw_high, + runtime.current_sweep_raw, + ] + elif active_signal_kind == "bin_iq": y_series = [ runtime.current_sweep_raw, displayed_calib, @@ -2416,10 +2749,14 @@ def run_pyqtgraph(args) -> None: aux_limits = compute_auto_ylim( displayed_aux[0] if displayed_aux is not None else None, displayed_aux[1] if displayed_aux is not None else None, + displayed_tagged_aux_low[0] if displayed_tagged_aux_low is not None else None, + displayed_tagged_aux_low[1] if displayed_tagged_aux_low is not None else None, + displayed_tagged_aux_high[0] if displayed_tagged_aux_high is not None else None, + displayed_tagged_aux_high[1] if displayed_tagged_aux_high is not None else None, ) if aux_limits is not None: p_line_aux_vb.setYRange(aux_limits[0], aux_limits[1], padding=0) - phase_limits = compute_auto_ylim(displayed_phase) + phase_limits = compute_auto_ylim(displayed_phase, displayed_phase_high) if phase_limits is not None: p_line_phase.setYRange(phase_limits[0], phase_limits[1], padding=0) @@ -2457,10 +2794,12 @@ def run_pyqtgraph(args) -> None: curve_complex_calib_real.setData([], []) curve_complex_calib_imag.setData([], []) - sweep_for_fft = runtime.current_fft_input - if sweep_for_fft is None: + sweep_for_fft = None if active_do1_tagged else runtime.current_fft_input + if (not active_do1_tagged) and sweep_for_fft is None: sweep_for_fft = runtime.current_sweep_norm if runtime.current_sweep_norm is not None else runtime.current_sweep_raw - distance_axis = runtime.current_distances if runtime.current_distances is not None else runtime.ring.distance_axis + distance_axis = None if active_do1_tagged else ( + runtime.current_distances if runtime.current_distances is not None else runtime.ring.distance_axis + ) if sweep_for_fft is not None and sweep_for_fft.size > 0 and distance_axis is not None: if ( runtime.current_fft_mag is None @@ -2706,7 +3045,7 @@ def run_pyqtgraph(args) -> None: refresh_peak_params_label([]) runtime.plot_dirty = False - if changed and runtime.ring.ring is not None: + if changed and runtime.ring.ring is not None and (not do1_tagged_now): if refresh_heavy_views: disp = sanitize_image_for_display(runtime.ring.get_display_raw_decimated(RAW_WATERFALL_MAX_POINTS)) if disp is not None: @@ -2727,6 +3066,8 @@ def run_pyqtgraph(args) -> None: if redraw_needed or status_dirty: try: status_payload = dict(runtime.current_info) if runtime.current_info else {} + if status_payload: + status_payload = {k: v for k, v in status_payload.items() if not str(k).startswith("_")} if peak_calibrate_mode and runtime.current_peak_width is not None: status_payload["peak_w"] = runtime.current_peak_width if peak_calibrate_mode and runtime.current_peak_amplitude is not None: @@ -2771,7 +3112,7 @@ def run_pyqtgraph(args) -> None: except Exception: pass - if redraw_needed and runtime.ring.ring_fft is not None: + if redraw_needed and runtime.ring.ring_fft is not None and (not do1_tagged_now): if not refresh_bscan_views: log_debug_event( "suppressed_fft_image_refresh", diff --git a/rfg_adc_plotter/io/sweep_parser_core.py b/rfg_adc_plotter/io/sweep_parser_core.py index a36a3f9..411c231 100644 --- a/rfg_adc_plotter/io/sweep_parser_core.py +++ b/rfg_adc_plotter/io/sweep_parser_core.py @@ -5,12 +5,13 @@ from __future__ import annotations import math import time from collections import deque -from typing import List, Optional, Sequence, Set +from typing import Dict, List, Optional, Sequence, Set import numpy as np from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD, LOG_BASE, LOG_EXP_LIMIT, LOG_POSTSCALER, LOG_SCALER from rfg_adc_plotter.types import ( + Do1Level, ParserEvent, PointEvent, SignalKind, @@ -162,6 +163,14 @@ class LegacyBinaryParser: self._seen_points = False self._mode: Optional[str] = None self._current_signal_kind: Optional[SignalKind] = None + self._last_tagged_step_by_level: Dict[Do1Level, Optional[int]] = { + "low": None, + "high": None, + } + + def _reset_tagged_steps(self) -> None: + self._last_tagged_step_by_level["low"] = None + self._last_tagged_step_by_level["high"] = None @staticmethod def _u16_at(buf: bytearray, offset: int) -> int: @@ -172,6 +181,7 @@ class LegacyBinaryParser: self._last_step = None self._seen_points = False self._current_signal_kind = None + self._reset_tagged_steps() events.append(StartEvent(ch=int(ch))) def _emit_bin_start(self, events: List[ParserEvent], signal_kind: SignalKind) -> None: @@ -179,6 +189,7 @@ class LegacyBinaryParser: self._last_step = None self._seen_points = False self._current_signal_kind = signal_kind + self._reset_tagged_steps() events.append(StartEvent(ch=0, signal_kind=signal_kind)) def _emit_tty_start(self, events: List[ParserEvent]) -> None: @@ -187,6 +198,7 @@ class LegacyBinaryParser: def _emit_legacy_point(self, events: List[ParserEvent], step: int, value_word_hi: int, value_word_lo: int, ch: int) -> None: self._mode = "legacy" self._current_signal_kind = None + self._reset_tagged_steps() if self._seen_points and self._last_step is not None and step <= self._last_step: events.append(StartEvent(ch=int(ch))) self._seen_points = True @@ -194,7 +206,13 @@ class LegacyBinaryParser: value = u32_to_i32((int(value_word_hi) << 16) | int(value_word_lo)) events.append(PointEvent(ch=int(ch), x=int(step), y=float(value))) - def _prepare_bin_point(self, events: List[ParserEvent], step: int, signal_kind: SignalKind) -> None: + def _prepare_bin_point( + self, + events: List[ParserEvent], + step: int, + signal_kind: SignalKind, + do1_level: Optional[Do1Level] = None, + ) -> None: self._mode = "bin" if self._current_signal_kind != signal_kind: if self._seen_points: @@ -202,12 +220,28 @@ class LegacyBinaryParser: self._last_step = None self._seen_points = False self._current_signal_kind = signal_kind + self._reset_tagged_steps() + + if signal_kind == "bin_iq_do1_tagged": + level: Do1Level = "high" if do1_level == "high" else "low" + last_level_step = self._last_tagged_step_by_level[level] + if self._seen_points and last_level_step is not None and step <= last_level_step: + events.append(StartEvent(ch=0, signal_kind=signal_kind)) + self._last_step = None + self._seen_points = False + self._reset_tagged_steps() + self._seen_points = True + self._last_tagged_step_by_level[level] = int(step) + self._last_step = int(step) + return + if self._seen_points and self._last_step is not None and step <= self._last_step: events.append(StartEvent(ch=0, signal_kind=signal_kind)) self._last_step = None self._seen_points = False self._seen_points = True self._last_step = int(step) + self._reset_tagged_steps() def _emit_tty_point(self, events: List[ParserEvent], step: int, ch_1_word: int, ch_2_word: int) -> None: self._prepare_bin_point(events, step=int(step), signal_kind="bin_iq") @@ -223,6 +257,33 @@ class LegacyBinaryParser: ) ) + def _emit_tty_tagged_point( + self, + events: List[ParserEvent], + step: int, + ch_1_word: int, + ch_2_word: int, + do1_level: Do1Level, + ) -> None: + self._prepare_bin_point( + events, + step=int(step), + signal_kind="bin_iq_do1_tagged", + do1_level=do1_level, + ) + ch_1 = u16_to_i16(int(ch_1_word)) + ch_2 = u16_to_i16(int(ch_2_word)) + events.append( + PointEvent( + ch=0, + x=int(step), + y=tty_ch_pair_to_sweep(ch_1, ch_2), + aux=(float(ch_1), float(ch_2)), + signal_kind="bin_iq_do1_tagged", + do1_level=do1_level, + ) + ) + def _emit_logdet_point(self, events: List[ParserEvent], step: int, value_word: int) -> None: self._prepare_bin_point(events, step=int(step), signal_kind="bin_logdet") value = u16_to_i16(int(value_word)) @@ -249,6 +310,8 @@ class LegacyBinaryParser: is_tty_start = (w0 == 0x000A and w1 == 0xFFFF and w2 == 0xFFFF and w3 == 0xFFFF) is_legacy_point = (self._buf[6] == 0x0A and w0 != 0xFFFF) is_tty_point = (w0 == 0x000A and w1 != 0xFFFF) + is_tty_tagged_low_point = (w0 == 0x00A3 and w1 != 0xFFFF) + is_tty_tagged_high_point = (w0 == 0x00A4 and w1 != 0xFFFF) is_logdet_point = (w0 == 0x001A and w3 == 0x0000) if is_legacy_start: @@ -281,6 +344,26 @@ class LegacyBinaryParser: self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3)) del self._buf[:8] continue + if is_tty_tagged_low_point and (not is_legacy_point): + self._emit_tty_tagged_point( + events, + step=int(w1), + ch_1_word=int(w2), + ch_2_word=int(w3), + do1_level="low", + ) + del self._buf[:8] + continue + if is_tty_tagged_high_point and (not is_legacy_point): + self._emit_tty_tagged_point( + events, + step=int(w1), + ch_1_word=int(w2), + ch_2_word=int(w3), + do1_level="high", + ) + del self._buf[:8] + continue del self._buf[:1] continue @@ -289,6 +372,26 @@ class LegacyBinaryParser: self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3)) del self._buf[:8] continue + if is_tty_tagged_low_point: + self._emit_tty_tagged_point( + events, + step=int(w1), + ch_1_word=int(w2), + ch_2_word=int(w3), + do1_level="low", + ) + del self._buf[:8] + continue + if is_tty_tagged_high_point: + self._emit_tty_tagged_point( + events, + step=int(w1), + ch_1_word=int(w2), + ch_2_word=int(w3), + do1_level="high", + ) + del self._buf[:8] + continue if is_legacy_point and (not is_tty_point): self._emit_legacy_point( events, @@ -309,6 +412,28 @@ class LegacyBinaryParser: del self._buf[:8] continue + if is_tty_tagged_low_point and (not is_legacy_point): + self._emit_tty_tagged_point( + events, + step=int(w1), + ch_1_word=int(w2), + ch_2_word=int(w3), + do1_level="low", + ) + del self._buf[:8] + continue + + if is_tty_tagged_high_point and (not is_legacy_point): + self._emit_tty_tagged_point( + events, + step=int(w1), + ch_1_word=int(w2), + ch_2_word=int(w3), + do1_level="high", + ) + del self._buf[:8] + continue + if is_legacy_point and (not is_tty_point): self._emit_legacy_point( events, @@ -524,15 +649,34 @@ class SweepAssembler: self._ys: list[float] = [] self._aux_1: list[float] = [] self._aux_2: list[float] = [] + self._tagged_low_xs: list[int] = [] + self._tagged_low_ys: list[float] = [] + self._tagged_low_aux_1: list[float] = [] + self._tagged_low_aux_2: list[float] = [] + self._tagged_high_xs: list[int] = [] + self._tagged_high_ys: list[float] = [] + self._tagged_high_aux_1: list[float] = [] + self._tagged_high_aux_2: list[float] = [] self._cur_channel: Optional[int] = None self._cur_signal_kind: Optional[SignalKind] = None self._cur_channels: set[int] = set() + def _reset_tagged_current(self) -> None: + self._tagged_low_xs.clear() + self._tagged_low_ys.clear() + self._tagged_low_aux_1.clear() + self._tagged_low_aux_2.clear() + self._tagged_high_xs.clear() + self._tagged_high_ys.clear() + self._tagged_high_aux_1.clear() + self._tagged_high_aux_2.clear() + def _reset_current(self) -> None: self._xs.clear() self._ys.clear() self._aux_1.clear() self._aux_2.clear() + self._reset_tagged_current() self._cur_channel = None self._cur_signal_kind = None self._cur_channels.clear() @@ -567,6 +711,24 @@ class SweepAssembler: if last_idx < series.size - 1: series[last_idx + 1 :] = series[last_idx] + @staticmethod + def _nanmean_pair(primary: np.ndarray, secondary: np.ndarray) -> np.ndarray: + width = min(primary.size, secondary.size) + if width <= 0: + return np.zeros((0,), dtype=np.float32) + first = np.asarray(primary[:width], dtype=np.float32) + second = np.asarray(secondary[:width], dtype=np.float32) + out = np.full((width,), np.nan, dtype=np.float32) + first_valid = np.isfinite(first) + second_valid = np.isfinite(second) + both_valid = first_valid & second_valid + only_first = first_valid & (~second_valid) + only_second = second_valid & (~first_valid) + out[only_first] = first[only_first] + out[only_second] = second[only_second] + out[both_valid] = (first[both_valid] + second[both_valid]) * 0.5 + return out + def consume(self, event: ParserEvent) -> Optional[SweepPacket]: if isinstance(event, StartEvent): packet = self.finalize_current() @@ -598,7 +760,21 @@ class SweepAssembler: self._cur_channels.add(point_ch) self._xs.append(int(event.x)) self._ys.append(float(event.y)) - if event.aux is not None: + if self._cur_signal_kind == "bin_iq_do1_tagged": + level = "high" if event.do1_level == "high" else "low" + if level == "low": + self._tagged_low_xs.append(int(event.x)) + self._tagged_low_ys.append(float(event.y)) + if event.aux is not None: + self._tagged_low_aux_1.append(float(event.aux[0])) + self._tagged_low_aux_2.append(float(event.aux[1])) + else: + self._tagged_high_xs.append(int(event.x)) + self._tagged_high_ys.append(float(event.y)) + if event.aux is not None: + self._tagged_high_aux_1.append(float(event.aux[0])) + self._tagged_high_aux_2.append(float(event.aux[1])) + elif event.aux is not None: self._aux_1.append(float(event.aux[0])) self._aux_2.append(float(event.aux[1])) return packet @@ -613,13 +789,37 @@ class SweepAssembler: self._max_width = max(self._max_width, width) target_width = self._max_width if self._fancy else width - sweep = self._scatter(self._xs, self._ys, target_width) aux_curves: SweepAuxCurves = None - if self._aux_1 and self._aux_2 and len(self._aux_1) == len(self._xs): - aux_curves = ( - self._scatter(self._xs, self._aux_1, target_width), - self._scatter(self._xs, self._aux_2, target_width), - ) + do1_tagged_payload = None + if self._cur_signal_kind == "bin_iq_do1_tagged": + raw_low = self._scatter(self._tagged_low_xs, self._tagged_low_ys, target_width) + raw_high = self._scatter(self._tagged_high_xs, self._tagged_high_ys, target_width) + sweep = self._nanmean_pair(raw_low, raw_high) + aux_low = None + if self._tagged_low_aux_1 and self._tagged_low_aux_2 and len(self._tagged_low_aux_1) == len(self._tagged_low_xs): + aux_low = ( + self._scatter(self._tagged_low_xs, self._tagged_low_aux_1, target_width), + self._scatter(self._tagged_low_xs, self._tagged_low_aux_2, target_width), + ) + aux_high = None + if self._tagged_high_aux_1 and self._tagged_high_aux_2 and len(self._tagged_high_aux_1) == len(self._tagged_high_xs): + aux_high = ( + self._scatter(self._tagged_high_xs, self._tagged_high_aux_1, target_width), + self._scatter(self._tagged_high_xs, self._tagged_high_aux_2, target_width), + ) + do1_tagged_payload = { + "raw_low": raw_low, + "raw_high": raw_high, + "aux_low": aux_low, + "aux_high": aux_high, + } + else: + sweep = self._scatter(self._xs, self._ys, target_width) + if self._aux_1 and self._aux_2 and len(self._aux_1) == len(self._xs): + aux_curves = ( + self._scatter(self._xs, self._aux_1, target_width), + self._scatter(self._xs, self._aux_2, target_width), + ) n_valid_cur = int(np.count_nonzero(np.isfinite(sweep))) @@ -670,4 +870,6 @@ class SweepAssembler: "std": std, "dt_ms": dt_ms, } + if do1_tagged_payload is not None: + info["_do1_tagged_payload"] = do1_tagged_payload return (sweep, info, aux_curves) diff --git a/rfg_adc_plotter/io/sweep_reader.py b/rfg_adc_plotter/io/sweep_reader.py index f8e2c11..cd5dde4 100644 --- a/rfg_adc_plotter/io/sweep_reader.py +++ b/rfg_adc_plotter/io/sweep_reader.py @@ -50,7 +50,7 @@ def _looks_like_legacy_8byte_stream(data: bytes) -> bool: w0 = _u16le_at(buf, base) w1 = _u16le_at(buf, base + 2) w3 = _u16le_at(buf, base + 6) - if w0 == 0x000A and w1 != 0xFFFF: + if w0 in {0x000A, 0x00A3, 0x00A4} and w1 != 0xFFFF: matched_steps_tty.append(w1) elif w0 == 0x001A and w3 == 0x0000: matched_steps_logdet.append(w1) @@ -232,7 +232,8 @@ class SweepReader(threading.Thread): ) ) sys.stderr.write( - "[hint] parser_16_bit_x2: if source is 8-byte tty CH1/CH2 stream (0x000A,step,ch1,ch2), try --bin\n" + "[hint] parser_16_bit_x2: if source is 8-byte tty CH1/CH2 stream " + "(0x000A/0x00A3/0x00A4,step,ch1,ch2), try --bin\n" ) assembler = SweepAssembler(fancy=self._fancy, apply_inversion=False) return parser, assembler, [] @@ -358,7 +359,8 @@ class SweepReader(threading.Thread): and (now_s - self._started_at) >= _NO_PACKET_HINT_AFTER_S ): sys.stderr.write( - "[hint] parser_16_bit_x2 still has no sweeps; if source is tty CH1/CH2, rerun with --bin\n" + "[hint] parser_16_bit_x2 still has no sweeps; " + "if source is tty CH1/CH2 (0x000A/0x00A3/0x00A4), rerun with --bin\n" ) parser_hint_emitted = True time.sleep(0.0005) diff --git a/rfg_adc_plotter/state/runtime_state.py b/rfg_adc_plotter/state/runtime_state.py index 7731739..dc8f7af 100644 --- a/rfg_adc_plotter/state/runtime_state.py +++ b/rfg_adc_plotter/state/runtime_state.py @@ -24,6 +24,12 @@ class RuntimeState: full_current_fft_source: Optional[np.ndarray] = None full_current_aux_curves: SweepAuxCurves = None full_current_aux_curves_codes: SweepAuxCurves = None + full_do1_tagged_raw_low: Optional[np.ndarray] = None + full_do1_tagged_raw_high: Optional[np.ndarray] = None + full_do1_tagged_aux_low: SweepAuxCurves = None + full_do1_tagged_aux_high: SweepAuxCurves = None + full_do1_tagged_aux_low_codes: SweepAuxCurves = None + full_do1_tagged_aux_high_codes: SweepAuxCurves = None current_freqs: Optional[np.ndarray] = None current_distances: Optional[np.ndarray] = None current_sweep_raw: Optional[np.ndarray] = None @@ -31,6 +37,10 @@ class RuntimeState: current_fft_input: Optional[np.ndarray] = None current_fft_complex: Optional[np.ndarray] = None current_aux_curves: SweepAuxCurves = None + current_do1_tagged_raw_low: Optional[np.ndarray] = None + current_do1_tagged_raw_high: Optional[np.ndarray] = None + current_do1_tagged_aux_low: SweepAuxCurves = None + current_do1_tagged_aux_high: SweepAuxCurves = None current_sweep_norm: Optional[np.ndarray] = None current_fft_mag: Optional[np.ndarray] = None current_fft_db: Optional[np.ndarray] = None diff --git a/rfg_adc_plotter/types.py b/rfg_adc_plotter/types.py index 5bcc77c..5a8ef0a 100644 --- a/rfg_adc_plotter/types.py +++ b/rfg_adc_plotter/types.py @@ -9,7 +9,8 @@ import numpy as np Number = Union[int, float] -SignalKind = Literal["bin_iq", "bin_logdet"] +SignalKind = Literal["bin_iq", "bin_logdet", "bin_iq_do1_tagged"] +Do1Level = Literal["low", "high"] SweepInfo = Dict[str, Any] SweepData = Dict[str, np.ndarray] SweepAuxCurves = Optional[Tuple[np.ndarray, np.ndarray]] @@ -29,6 +30,7 @@ class PointEvent: y: float aux: Optional[Tuple[float, float]] = None signal_kind: Optional[SignalKind] = None + do1_level: Optional[Do1Level] = None ParserEvent: TypeAlias = Union[StartEvent, PointEvent] diff --git a/tests/test_cli.py b/tests/test_cli.py index dcd4984..8c49e03 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -46,6 +46,7 @@ class CliTests(unittest.TestCase): self.assertIn("--parser_16_bit_x2", proc.stdout) self.assertIn("--parser_complex_ascii", proc.stdout) self.assertIn("--opengl", proc.stdout) + self.assertIn("0x00A3/0x00A4", proc.stdout) def test_backend_mpl_reports_removal(self): proc = _run("-m", "rfg_adc_plotter.main", "/dev/null", "--backend", "mpl") diff --git a/tests/test_processing.py b/tests/test_processing.py index 798d9e1..45910a9 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -15,6 +15,8 @@ from rfg_adc_plotter.gui.pyqtgraph_backend import ( coalesce_packets_for_ui, compute_background_subtracted_bscan_levels, compute_aux_phase_curve, + compute_do1_tagged_aggregate, + compute_do1_tagged_phase_curves, convert_tty_i16_to_voltage, decimate_bscan_rows_for_display, decimate_curve_for_display, @@ -30,6 +32,7 @@ from rfg_adc_plotter.gui.pyqtgraph_backend import ( set_image_rect_if_ready, resolve_visible_fft_curves, resolve_visible_aux_curves, + resolve_visible_do1_tagged_aux_curves, ) from rfg_adc_plotter.processing.calibration import ( build_calib_envelope, @@ -316,6 +319,53 @@ class ProcessingTests(unittest.TestCase): self.assertEqual(phase.shape, expected.shape) self.assertTrue(np.allclose(phase, expected, atol=1e-6)) + def test_compute_do1_tagged_aggregate_nanmean_merges_low_and_high(self): + low = np.asarray([1.0, np.nan, 5.0, np.nan], dtype=np.float32) + high = np.asarray([3.0, 7.0, np.nan, np.nan], dtype=np.float32) + + merged = compute_do1_tagged_aggregate(low, high) + + self.assertIsNotNone(merged) + self.assertTrue(np.allclose(merged[:3], np.asarray([2.0, 7.0, 5.0], dtype=np.float32), equal_nan=True)) + self.assertTrue(np.isnan(merged[3])) + + def test_resolve_visible_do1_tagged_aux_curves_obeys_checkbox_state(self): + aux_low = ( + np.asarray([1.0, 2.0], dtype=np.float32), + np.asarray([3.0, 4.0], dtype=np.float32), + ) + aux_high = ( + np.asarray([5.0, 6.0], dtype=np.float32), + np.asarray([7.0, 8.0], dtype=np.float32), + ) + + hidden_low, hidden_high = resolve_visible_do1_tagged_aux_curves(aux_low, aux_high, enabled=False) + self.assertIsNone(hidden_low) + self.assertIsNone(hidden_high) + + visible_low, visible_high = resolve_visible_do1_tagged_aux_curves(aux_low, aux_high, enabled=True) + self.assertIsNotNone(visible_low) + self.assertIsNotNone(visible_high) + self.assertTrue(np.allclose(visible_low[0], aux_low[0])) + self.assertTrue(np.allclose(visible_high[1], aux_high[1])) + + def test_compute_do1_tagged_phase_curves_returns_two_independent_series(self): + aux_low = ( + np.asarray([1.0, 1.0], dtype=np.float32), + np.asarray([0.0, 1.0], dtype=np.float32), + ) + aux_high = ( + np.asarray([1.0, -1.0], dtype=np.float32), + np.asarray([1.0, 1.0], dtype=np.float32), + ) + + phase_low, phase_high = compute_do1_tagged_phase_curves(aux_low, aux_high) + + self.assertIsNotNone(phase_low) + self.assertIsNotNone(phase_high) + self.assertTrue(np.allclose(phase_low, np.asarray([0.0, np.pi / 4.0], dtype=np.float32), atol=1e-6)) + self.assertTrue(np.allclose(phase_high, np.asarray([np.pi / 4.0, 3.0 * np.pi / 4.0], dtype=np.float32), atol=1e-6)) + def test_decimate_curve_for_display_preserves_small_series(self): xs = np.linspace(3.3, 14.3, 64, dtype=np.float64) ys = np.linspace(-1.0, 1.0, 64, dtype=np.float32) diff --git a/tests/test_sweep_parser_core.py b/tests/test_sweep_parser_core.py index 6a920fe..576b3e7 100644 --- a/tests/test_sweep_parser_core.py +++ b/tests/test_sweep_parser_core.py @@ -87,6 +87,25 @@ def _pack_tty_point(step: int, ch1: int, ch2: int) -> bytes: ) +def _pack_tty_tagged_point(marker_word0: int, step: int, ch1: int, ch2: int) -> bytes: + return b"".join( + [ + _u16le(marker_word0), + _u16le(step), + _u16le(ch1), + _u16le(ch2), + ] + ) + + +def _pack_tty_tagged_low_point(step: int, ch1: int, ch2: int) -> bytes: + return _pack_tty_tagged_point(0x00A3, step, ch1, ch2) + + +def _pack_tty_tagged_high_point(step: int, ch1: int, ch2: int) -> bytes: + return _pack_tty_tagged_point(0x00A4, step, ch1, ch2) + + def _pack_logdet_point(step: int, value: int) -> bytes: return b"".join( [ @@ -189,6 +208,78 @@ class SweepParserCoreTests(unittest.TestCase): self.assertEqual(events[4].aux, (120.0, 80.0)) self.assertEqual(events[4].signal_kind, "bin_iq") + def test_legacy_binary_parser_accepts_tty_do1_tagged_stream(self): + parser = LegacyBinaryParser() + stream = b"".join( + [ + _pack_tty_start(), + _pack_tty_tagged_low_point(1, 100, 90), + _pack_tty_tagged_high_point(1, 120, 95), + ] + ) + + events = parser.feed(stream) + + self.assertEqual(len(events), 3) + self.assertIsInstance(events[0], StartEvent) + self.assertEqual(events[0].signal_kind, "bin_iq") + self.assertIsInstance(events[1], PointEvent) + self.assertEqual(events[1].signal_kind, "bin_iq_do1_tagged") + self.assertEqual(events[1].do1_level, "low") + self.assertEqual(events[1].x, 1) + self.assertEqual(events[1].aux, (100.0, 90.0)) + self.assertIsInstance(events[2], PointEvent) + self.assertEqual(events[2].signal_kind, "bin_iq_do1_tagged") + self.assertEqual(events[2].do1_level, "high") + self.assertEqual(events[2].x, 1) + self.assertEqual(events[2].aux, (120.0, 95.0)) + + def test_legacy_binary_parser_keeps_same_step_for_different_do1_levels_in_one_sweep(self): + parser = LegacyBinaryParser() + stream = b"".join( + [ + _pack_tty_start(), + _pack_tty_tagged_low_point(1, 100, 90), + _pack_tty_tagged_high_point(1, 120, 95), + _pack_tty_tagged_low_point(2, 130, 80), + _pack_tty_tagged_high_point(2, 140, 75), + ] + ) + + events = parser.feed(stream) + + start_events = [event for event in events if isinstance(event, StartEvent)] + self.assertEqual(len(start_events), 1) + self.assertEqual(start_events[0].signal_kind, "bin_iq") + point_levels = [event.do1_level for event in events if isinstance(event, PointEvent)] + self.assertEqual(point_levels, ["low", "high", "low", "high"]) + + def test_legacy_binary_parser_resets_tagged_stream_only_on_same_level_step_reset(self): + parser = LegacyBinaryParser() + stream = b"".join( + [ + _pack_tty_start(), + _pack_tty_tagged_low_point(1, 100, 90), + _pack_tty_tagged_high_point(1, 120, 95), + _pack_tty_tagged_low_point(2, 130, 80), + _pack_tty_tagged_high_point(2, 140, 75), + _pack_tty_tagged_low_point(1, 110, 85), + ] + ) + + events = parser.feed(stream) + + self.assertIsInstance(events[0], StartEvent) + self.assertIsInstance(events[1], PointEvent) + self.assertIsInstance(events[2], PointEvent) + self.assertIsInstance(events[3], PointEvent) + self.assertIsInstance(events[4], PointEvent) + self.assertIsInstance(events[5], StartEvent) + self.assertEqual(events[5].signal_kind, "bin_iq_do1_tagged") + self.assertIsInstance(events[6], PointEvent) + self.assertEqual(events[6].do1_level, "low") + self.assertEqual(events[6].x, 1) + def test_legacy_binary_parser_tty_mode_does_not_flip_to_legacy_on_ch2_low_byte_0x0a(self): parser = LegacyBinaryParser() stream = b"".join( @@ -377,6 +468,40 @@ class SweepParserCoreTests(unittest.TestCase): self.assertEqual(aux[0][1], 100.0) self.assertEqual(aux[1][2], 95.0) + def test_sweep_assembler_builds_tagged_payload_and_nanmean_aggregate(self): + assembler = SweepAssembler(fancy=False, apply_inversion=False) + self.assertIsNone(assembler.consume(StartEvent(ch=0, signal_kind="bin_iq_do1_tagged"))) + assembler.consume( + PointEvent( + ch=0, + x=1, + y=10.0, + aux=(100.0, 90.0), + signal_kind="bin_iq_do1_tagged", + do1_level="low", + ) + ) + assembler.consume( + PointEvent( + ch=0, + x=1, + y=20.0, + aux=(120.0, 95.0), + signal_kind="bin_iq_do1_tagged", + do1_level="high", + ) + ) + sweep, info, aux = assembler.finalize_current() + self.assertIsNone(aux) + self.assertEqual(info["signal_kind"], "bin_iq_do1_tagged") + self.assertAlmostEqual(float(sweep[1]), 15.0, places=6) + payload = info.get("_do1_tagged_payload") + self.assertIsInstance(payload, dict) + self.assertIn("raw_low", payload) + self.assertIn("raw_high", payload) + self.assertIn("aux_low", payload) + self.assertIn("aux_high", payload) + def test_sweep_assembler_splits_packet_on_channel_switch(self): assembler = SweepAssembler(fancy=False, apply_inversion=False) self.assertIsNone(assembler.consume(PointEvent(ch=1, x=1, y=10.0))) diff --git a/tests/test_sweep_reader.py b/tests/test_sweep_reader.py index 7e1eaea..716ed8b 100644 --- a/tests/test_sweep_reader.py +++ b/tests/test_sweep_reader.py @@ -66,6 +66,25 @@ def _pack_tty_point(step: int, ch1: int, ch2: int) -> bytes: ) +def _pack_tty_tagged_point(marker_word0: int, step: int, ch1: int, ch2: int) -> bytes: + return b"".join( + [ + _u16le(marker_word0), + _u16le(step), + _u16le(ch1), + _u16le(ch2), + ] + ) + + +def _pack_tty_tagged_low(step: int, ch1: int, ch2: int) -> bytes: + return _pack_tty_tagged_point(0x00A3, step, ch1, ch2) + + +def _pack_tty_tagged_high(step: int, ch1: int, ch2: int) -> bytes: + return _pack_tty_tagged_point(0x00A4, step, ch1, ch2) + + def _pack_logdet_point(step: int, value: int) -> bytes: return b"".join( [ @@ -166,6 +185,29 @@ class SweepReaderTests(unittest.TestCase): reader.join(timeout=1.0) stack.close() + def test_parser_16_bit_x2_falls_back_to_tty_do1_tagged_stream(self): + payload = bytearray() + while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 40): + payload += _pack_tty_start() + payload += _pack_tty_tagged_low(1, 100, 90) + payload += _pack_tty_tagged_high(1, 120, 95) + payload += _pack_tty_tagged_low(2, 110, 80) + payload += _pack_tty_tagged_high(2, 130, 70) + payload += _pack_tty_tagged_low(1, 105, 85) + + stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True) + try: + sweep, info, aux = queue.get(timeout=2.0) + self.assertEqual(info["signal_kind"], "bin_iq_do1_tagged") + self.assertIsNone(aux) + self.assertIn("_do1_tagged_payload", info) + self.assertGreaterEqual(sweep.shape[0], 2) + self.assertIn("fallback -> legacy", stderr.getvalue()) + finally: + stop_event.set() + reader.join(timeout=1.0) + stack.close() + def test_parser_16_bit_x2_keeps_true_complex_stream(self): payload = b"".join( [