From b99140e81e7ad6792c0e31f053d70b631ce9c81c Mon Sep 17 00:00:00 2001 From: Danny Jacobs Date: Fri, 3 Jul 2026 11:18:34 -0700 Subject: [PATCH 1/6] adding live view module --- daq/live_view.py | 947 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 947 insertions(+) create mode 100644 daq/live_view.py diff --git a/daq/live_view.py b/daq/live_view.py new file mode 100644 index 0000000..0541361 --- /dev/null +++ b/daq/live_view.py @@ -0,0 +1,947 @@ +#!/usr/bin/env python3 +""" +live_view.py – LiveViewWorker and a standalone test GUI with waterfall. + +Contains two things that will eventually live in separate files: + + LiveViewWorker + Wraps a lightweight TopBlock (no science file output), starts a + producer thread that pushes new spectra into a queue.Queue, and + exposes start() / stop() so the main GUI can swap it out when a + science observation begins. + + LiveViewApp + A minimal customtkinter window used to test LiveViewWorker end-to-end. + Has Start / Stop buttons and a matplotlib waterfall embedded via + FigureCanvasTkAgg. Polls the queue with self.after() — exactly the + pattern the main ChartApp will use. + +Run from the CHART repo root (with the chart venv active): + python daq/live_view.py + +What you should see: + • A window opens with a dark waterfall panel (initially empty / navy). + • Press Start → RTL-SDR opens, spectra begin scrolling down. + • The status bar shows live in/out frame rates. + • Press Stop → producer thread and flowgraph shut down cleanly. +""" + +import sys +import os +import queue +import threading +import time +import tempfile + +import numpy as np +import customtkinter +import matplotlib +matplotlib.use("TkAgg") # must be set before pyplot import +import matplotlib.pyplot as plt +from matplotlib.figure import Figure +from matplotlib.ticker import MaxNLocator +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) +import chart.blocks as blocks + +# runObservation lives alongside this file in daq/. Imported here so +# ScienceWorker can run a real observation in a background thread. +from freq_and_time_scan import runObservation + + +# --------------------------------------------------------------------------- +# SDR / display constants — adjust to match your hardware +# --------------------------------------------------------------------------- + +CENTER_FREQ = 1420.405e6 # Hz — hydrogen line +SAMP_RATE = 2.0e6 # Hz +VECLENGTH = 1024 # FFT bins +DISPLAY_INT = 20 # FFT frames averaged per display spectrum + # lower = faster / noisier + # higher = slower / smoother +POLL_INTERVAL = 0.05 # seconds between probe reads in producer thread + +# Science branch kept minimal — live view doesn't care about recording +# but TopBlock still needs these to build the graph. +# Set NINT large enough that the flowgraph never stops on its own during +# a live view session. At 2 MHz / 1024 bins this gives ~5 hours runtime. +_LIVE_INT_LENGTH = 50 +_LIVE_NINT = 700_000 + +# Waterfall display parameters +WATERFALL_ROWS = 200 # time history rows kept on screen + +# ms between queue-drain ticks (~30 Hz). This deliberately oversamples the +# producer, which emits ~15 spectra/s. A consumer running slower than about +# 2x the producer rate exhibits beat-frequency pauses: occasionally a tick +# finds zero new frames and the waterfall visibly stalls, recurring at the +# beat period (a few hundred ms). Oversampling collapses any missed-frame +# gap to one tick (~33 ms), below the threshold of visible jerkiness. +GUI_POLL_MS = 33 + +RESIZE_REDRAW_MS = 150 # ms between resize-background-redraw checks + # slower than GUI_POLL_MS so it doesn't compete + # with data drawing; fast enough to feel snappy +STATUS_UPDATE_S = 1.0 # seconds between status-bar rate refreshes + +# Maximum number of spectra the queue may hold. If the consumer ever falls +# behind (e.g. an expensive blit at a large window size), the producer drops +# the oldest frame rather than letting the queue grow without bound. At +# ~15 spectra/s a depth of 30 gives ~2 s of buffer before anything is dropped. +QUEUE_MAXDEPTH = 30 + +# Frequency axis (MHz) — used for x-axis labels +FREQ_MHZ = (CENTER_FREQ / 1e6 + + np.linspace(-SAMP_RATE / 2, SAMP_RATE / 2, VECLENGTH) / 1e6) + + +# --------------------------------------------------------------------------- +# LiveViewWorker +# --------------------------------------------------------------------------- + +class LiveViewWorker: + """ + Manages a lightweight TopBlock for diagnostic live display. + + The worker owns a queue.Queue. External code (the GUI) reads from it. + The worker owns the producer thread that pushes into it. + The GUI knows nothing about TopBlock or GNU Radio. + + Usage + ----- + q = queue.Queue(maxsize=...) + worker = LiveViewWorker(q) + worker.start() # opens SDR, begins pushing spectra + ... + worker.stop() # closes SDR, producer thread exits cleanly + + Designed so the main GUI can do: + if live_worker.is_running(): + live_worker.stop() + science_worker.start() + + Health counters (written only by the producer thread, read by the GUI; + int access is atomic in CPython so no lock is needed): + frames_produced – total spectra pushed since start() + spectra_dropped – spectra evicted because the queue was full + """ + + def __init__(self, spectrum_queue, + center_freq=CENTER_FREQ, + samp_rate=SAMP_RATE, + veclength=VECLENGTH, + display_int=DISPLAY_INT, + poll_interval=POLL_INTERVAL, + bias=False): + + self._queue = spectrum_queue + self._center_freq = center_freq + self._samp_rate = samp_rate + self._veclength = veclength + self._display_int = display_int + self._poll_interval = poll_interval + self._bias = bias + + self._tb = None + self._stop_event = None + self._thread = None + self._data_dir = tempfile.mkdtemp(prefix="chart_live_") + + self.frames_produced = 0 + self.spectra_dropped = 0 + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def start(self): + """Open the SDR and begin pushing spectra into the queue.""" + if self.is_running(): + return + + self.frames_produced = 0 + self.spectra_dropped = 0 + self._stop_event = threading.Event() + + self._tb = blocks.TopBlock( + c_freq=self._center_freq, + veclength=self._veclength, + samp_rate=self._samp_rate, + int_length=_LIVE_INT_LENGTH, + nint=_LIVE_NINT, + bias=self._bias, + data_dir=self._data_dir, + display_int_length=self._display_int, + ) + self._tb.start() + + self._thread = threading.Thread( + target=self._producer_loop, + daemon=True, + name="live-view-producer", + ) + self._thread.start() + + def stop(self): + """Signal the producer to stop, then shut down the flowgraph.""" + if not self.is_running(): + return + + self._stop_event.set() + self._thread.join(timeout=3.0) + + self._tb.stop() + self._tb.wait() + self._tb = None + + def is_running(self): + return self._thread is not None and self._thread.is_alive() + + # ------------------------------------------------------------------ + # Producer loop (runs in background thread) + # ------------------------------------------------------------------ + + def _producer_loop(self): + """Poll the GNU Radio probe; push new (c_freq, spectrum) tuples. + + Each queue item carries the current tuning so the consumer can keep + the waterfall's frequency axis in lockstep with the data. For the + live view the frequency is constant, but pushing the same tuple shape + as ScienceWorker keeps the queue contract uniform — the consumer never + has to know which worker is upstream. + + Drop-oldest strategy: if the queue is full, evict the oldest item + before inserting the new one. This keeps the display showing the + most recent data even when the consumer is briefly stalled, and + prevents unbounded queue growth. + """ + last_vec = None + + while not self._stop_event.is_set(): + vec = self._tb.get_display_spectrum() + + if vec is not None: + if last_vec is None or not np.array_equal(vec, last_vec): + if self._queue.full(): + try: + self._queue.get_nowait() + self.spectra_dropped += 1 + except queue.Empty: + pass + self._queue.put_nowait((self._center_freq, vec.copy())) + self.frames_produced += 1 + last_vec = vec + + time.sleep(self._poll_interval) + + +# --------------------------------------------------------------------------- +# ScienceWorker +# --------------------------------------------------------------------------- + +class ScienceWorker: + """ + Runs a real science observation (via runObservation) and taps its + TopBlock's display branch to feed the same spectrum queue the live + view uses. + + Same public interface as LiveViewWorker — start(), stop(), is_running(), + and the frames_produced / spectra_dropped counters — so the GUI consumer + treats both identically. This is the payoff of the source-agnostic queue: + the waterfall does not know or care which worker is feeding it. + + Internally, however, this is a two-thread arrangement: + + Thread A ("science"): runs runObservation(cfg, on_tb_ready=...). + This thread spends almost all its time blocked inside the GNU Radio + tb.wait() call for each frequency step. We cannot poll the probe + from here — it is frozen in C++ until each step completes. + + Thread B ("producer"): created when on_tb_ready fires, i.e. once the + TopBlock exists. It polls tb.get_display_spectrum() and feeds the + queue, exactly like LiveViewWorker's producer loop. The GNU Radio + flowgraph runs in its own C++ threads, so the probe keeps updating + while Thread A is blocked in wait() — Thread B reads it freely. + + Between frequency steps the flowgraph briefly stops and restarts (the + sweep loop calls tb.start()/tb.wait() per step). During that gap the + probe holds its last value; the dedup check below suppresses the stale + repeat, so the waterfall simply pauses at each retune rather than drawing + duplicate rows. That is the intended "watch it retune" behaviour. + + Note the cadence advantage: because the tap is *before* the science + integration, the producer can emit several spectra per science + integration — the live display updates faster than the recorder writes. + """ + + def __init__(self, cfg, spectrum_queue, + logger=print, + poll_interval=POLL_INTERVAL): + + self._cfg = cfg + self._queue = spectrum_queue + self._logger = logger + self._poll_interval = poll_interval + + self._tb = None # set by _on_tb_ready (in science thread) + self._tb_ready = threading.Event() + self._stop_event = None + self._science_thread = None + self._producer_thread = None + + self.frames_produced = 0 + self.spectra_dropped = 0 + + # ------------------------------------------------------------------ + # Public interface (mirrors LiveViewWorker) + # ------------------------------------------------------------------ + + def start(self): + """Begin the science observation and the display tap.""" + if self.is_running(): + return + + self.frames_produced = 0 + self.spectra_dropped = 0 + self._tb = None + self._tb_ready.clear() + self._stop_event = threading.Event() + + # Thread A: the science observation itself. + self._science_thread = threading.Thread( + target=self._science_loop, + daemon=True, + name="science-observation", + ) + self._science_thread.start() + + # Thread B: the producer. It waits for the TopBlock to exist (set by + # _on_tb_ready, called from Thread A) before it starts polling. + self._producer_thread = threading.Thread( + target=self._producer_loop, + daemon=True, + name="science-producer", + ) + self._producer_thread.start() + + def stop(self): + """Signal the observation to halt and wind down both threads. + + Always releases the TopBlock (and thus the SDR device), even if the + science thread already finished on its own. This is what prevents the + "Failed to set default samplerate" error on the next start — the + device must be fully released before a new TopBlock can open it. + """ + if self._stop_event is not None: + # Tell runObservation's sweep loop to halt at the next step + # boundary, and tell the producer loop to exit. + self._stop_event.set() + + # The producer exits promptly (it only sleeps poll_interval between + # polls). The science thread may take longer — it has to finish the + # current frequency step's tb.wait() before runSweep checks the + # stop_event. We join with generous timeouts. + if self._producer_thread is not None: + self._producer_thread.join(timeout=3.0) + if self._science_thread is not None: + self._science_thread.join(timeout=10.0) + + # Idempotent: the science thread's finally already released tb on a + # normal exit; this catches the case where stop() is the trigger. + self._release_tb() + + def is_running(self): + # "Running" means the science observation is still going. The producer + # is subordinate to it. + return self._science_thread is not None and self._science_thread.is_alive() + + def _release_tb(self): + """Stop the flowgraph and drop all references to the TopBlock. + + Releasing the osmosdr source closes the USB device. Must run before + any subsequent start() so the RTL-SDR is free to be reopened. Safe to + call more than once — the second call is a no-op. + """ + tb = self._tb + self._tb = None + if tb is not None: + try: + tb.stop() + tb.wait() + except Exception: + pass + # Brief settle so the USB device finishes releasing before a + # possible immediate restart. + time.sleep(0.3) + + # ------------------------------------------------------------------ + # Thread A: science observation + # ------------------------------------------------------------------ + + def _science_loop(self): + """Run the real observation. Blocks in tb.wait() per frequency step.""" + try: + runObservation( + self._cfg, + logger=self._logger, + stop_event=self._stop_event, + on_tb_ready=self._on_tb_ready, + ) + except Exception as e: + self._logger(f"ScienceWorker: observation error: {e}") + finally: + # Unblock the producer if it's still waiting for a TopBlock that + # will now never come, and signal it to stop. + self._tb_ready.set() + self._stop_event.set() + # Release the SDR device here so it's freed whether the sweep + # completed on its own or was halted. Without this, self._tb keeps + # the device open after a normal sweep completion, and the next + # start() fails with "Failed to set default samplerate". + self._release_tb() + + def _on_tb_ready(self, tb): + """Called from Thread A once the TopBlock exists. Hands it to B.""" + self._tb = tb + self._tb_ready.set() + + # ------------------------------------------------------------------ + # Thread B: producer (mirrors LiveViewWorker._producer_loop) + # ------------------------------------------------------------------ + + def _producer_loop(self): + """Wait for the TopBlock, then poll its display branch into the queue.""" + # Block until the science thread has built the TopBlock (or bailed). + # Timeout so we don't hang forever if startup fails. + if not self._tb_ready.wait(timeout=15.0): + self._logger("ScienceWorker: timed out waiting for TopBlock.") + return + + tb = self._tb + if tb is None: + # Observation failed before producing a TopBlock; nothing to tap. + return + + last_vec = None + + while not self._stop_event.is_set(): + # The science sweep may delete/replace tb on us at the very end; + # guard against it going away mid-loop. + tb = self._tb + if tb is None: + break + + vec = tb.get_display_spectrum() + + if vec is not None: + if last_vec is None or not np.array_equal(vec, last_vec): + if self._queue.full(): + try: + self._queue.get_nowait() + self.spectra_dropped += 1 + except queue.Empty: + pass + # tb.c_freq is the live tuning, updated by set_c_freq() on + # every sweep step — so the frequency travels with the data. + self._queue.put_nowait((tb.c_freq, vec.copy())) + self.frames_produced += 1 + last_vec = vec + + time.sleep(self._poll_interval) + + +# --------------------------------------------------------------------------- +# Waterfall panel (pure matplotlib / numpy — no GNU Radio dependency) +# --------------------------------------------------------------------------- + +class WaterfallPanel: + """ + A matplotlib-based waterfall embedded in a Tk widget via FigureCanvasTkAgg. + + Accepts one numpy array at a time via push(spectrum). Maintains a + rolling 2-D buffer and redraws efficiently using blit. + + The panel is intentionally ignorant of the data source. + + Resize handling + --------------- + Blit requires a valid pixel-level background snapshot. That snapshot + becomes stale whenever the figure is resized. Two design decisions keep + this from causing jank or queue backup: + + 1. The axes position is fixed with subplots_adjust() rather than + tight_layout(). tight_layout() reflows on every canvas.draw(), + which can move the axes bbox by a subpixel, silently breaking blit + and forcing matplotlib into a slow full-redraw path on every push(). + Fixed margins mean the axes position is invariant — blit stays valid. + + 2. canvas.draw() (the expensive full redraw needed after a resize) is + never called from push(). Instead, _on_resize() sets a dirty flag + and the LiveViewApp GUI loop calls redraw_background() on a slow + separate timer (RESIZE_REDRAW_MS). This fully decouples the + expensive redraw from the data arrival rate. + """ + + # dB range for the colour scale — adjust to match your noise floor + DB_LO = -5.0 + DB_HI = 25.0 + + # Maximum number of frequency-axis tick labels. matplotlib chooses "nice" + # round values up to this count, so the labels stay readable when the + # figure is narrow. Lower this if ticks overlap; raise it for finer marks. + MAX_XTICKS = 5 + + def __init__(self, parent_frame, n_rows=WATERFALL_ROWS, n_cols=VECLENGTH): + self._n_rows = n_rows + self._n_cols = n_cols + + # Rolling buffer: row 0 = newest, row n_rows-1 = oldest + # Initialised to DB_LO so the empty waterfall is a uniform dark colour. + self._buf = np.full((n_rows, n_cols), self.DB_LO, dtype=np.float32) + + # --- matplotlib figure ----------------------------------------- + # Construct the Figure via the object-oriented API, NOT plt.subplots(). + # A pyplot-created figure is registered in pyplot's global registry, + # so any later plt.show() elsewhere in the app (e.g. the post-run + # preview plot) would also pop up THIS embedded figure in its own + # window and could reassign its canvas — breaking blit with a + # 'FigureCanvasBase has no get_renderer' error. A directly-constructed + # Figure is invisible to pyplot and owned solely by our Tk canvas. + self._fig = Figure(figsize=(5, 4)) + self._ax = self._fig.add_subplot(111) + self._fig.patch.set_facecolor("#1a1a2e") + self._ax.set_facecolor("#1a1a2e") + + # imshow: (rows × cols), origin='upper' → row 0 at top (newest data) + self._im = self._ax.imshow( + self._buf, + aspect="auto", + origin="upper", + interpolation="nearest", + cmap="inferno", + vmin=self.DB_LO, + vmax=self.DB_HI, + extent=[FREQ_MHZ[0], FREQ_MHZ[-1], n_rows, 0], + ) + + self._ax.set_xlabel("Frequency (MHz)", color="#aaaacc") + self._ax.set_ylabel("Frames ago", color="#aaaacc") + self._ax.tick_params(colors="#aaaacc") + for spine in self._ax.spines.values(): + spine.set_edgecolor("#333355") + + # Cap the number of frequency-axis ticks so labels don't overlap when + # the figure is narrow. MaxNLocator picks nice round values; nbins is + # the *maximum* interval count, so the visible label count is roughly + # MAX_XTICKS. prune="both" drops the edge labels that tend to collide + # with the axis frame. + self._ax.xaxis.set_major_locator( + MaxNLocator(nbins=self.MAX_XTICKS, prune="both") + ) + + cbar = self._fig.colorbar(self._im, ax=self._ax, pad=0.01) + cbar.set_label("Power (dB, arb.)", color="#aaaacc") + cbar.ax.yaxis.set_tick_params(color="#aaaacc") + # Non-pyplot equivalent of plt.setp(...): set tick label colours directly. + for t in cbar.ax.yaxis.get_ticklabels(): + t.set_color("#aaaacc") + + # Fixed margins instead of tight_layout(). + # tight_layout() reflows on every canvas.draw(), potentially moving + # the axes bbox and silently breaking blit. Fixed margins are stable + # across resizes and leave enough room for axis labels and colorbar. + self._fig.subplots_adjust(left=0.1, right=0.99, top=0.97, bottom=0.12) + + # --- embed in Tk ----------------------------------------------- + self._canvas = FigureCanvasTkAgg(self._fig, master=parent_frame) + self._canvas_widget = self._canvas.get_tk_widget() + self._canvas_widget.pack(fill="both", expand=True) + + # Initial full draw to render axes/ticks/colorbar into the canvas, + # then capture the static background for subsequent blits. + self._canvas.draw() + self._bg = self._canvas.copy_from_bbox(self._fig.bbox) + + # Resize dirty flag — set by _on_resize(), cleared by + # redraw_background(). push() never checks or clears this flag; + # the GUI timer calls redraw_background() at RESIZE_REDRAW_MS cadence. + self._bg_dirty = False + self._canvas.mpl_connect("resize_event", self._on_resize) + + # ------------------------------------------------------------------ + + def _on_resize(self, event): + """Record that a resize occurred; defers the expensive redraw.""" + self._bg_dirty = True + + def set_center_freq(self, c_freq_hz): + """Retune the frequency axis to a new center frequency. + + Updates the image extent and x-limits so the axis labels reflect the + band currently being observed. Marks the blit background dirty so the + resize timer redraws the (now-changed) axis ticks; the waterfall image + itself keeps blitting normally in between. + + Called by the consumer only when the frequency actually changes, so + it's cheap — at most once per sweep step, not once per frame. + """ + f_lo = (c_freq_hz - SAMP_RATE / 2) / 1e6 + f_hi = (c_freq_hz + SAMP_RATE / 2) / 1e6 + self._im.set_extent([f_lo, f_hi, self._n_rows, 0]) + self._ax.set_xlim(f_lo, f_hi) + self._bg_dirty = True + + def redraw_background(self): + """ + Perform a full canvas redraw and recapture the blit background. + + Called by the GUI on a slow timer (RESIZE_REDRAW_MS) when dirty. + Never called from push() — keeping canvas.draw() off the data-arrival + hot path is what prevents queue backup and jank after resizing. + + Returns True if a redraw was performed, False if nothing was needed. + """ + if not self._bg_dirty: + return False + self._canvas.draw() + self._bg = self._canvas.copy_from_bbox(self._fig.bbox) + self._bg_dirty = False + return True + + def push(self, spectrum): + """ + Accept one new linear-power spectrum, convert to dB, update display. + + This method must stay fast — it runs in the GUI thread on every + queue poll tick. It does nothing except update the numpy buffer + and issue a blit. No canvas.draw() calls live here. + + Parameters + ---------- + spectrum : np.ndarray, float32, shape (n_cols,) + Linear power values from get_display_spectrum(). + """ + # Convert linear → dB, guarding against log(0) + db = 10.0 * np.log10(np.maximum(spectrum, 1e-12)) + + # Roll buffer: shift everything down one row, insert new row at top + self._buf = np.roll(self._buf, shift=1, axis=0) + self._buf[0, :] = db + + # Blit path — fast: update image data, restore background, draw image + self._im.set_data(self._buf) + self._canvas.restore_region(self._bg) + self._ax.draw_artist(self._im) + self._canvas.blit(self._fig.bbox) + self._canvas.flush_events() + + +# --------------------------------------------------------------------------- +# Standalone test GUI +# --------------------------------------------------------------------------- + +class LiveViewApp(customtkinter.CTk): + """ + Minimal test window for LiveViewWorker + WaterfallPanel. + + Layout + ------ + ┌─────────────────────────────────┐ + │ [Start] [Stop] status text │ ← control bar + ├─────────────────────────────────┤ + │ │ + │ WaterfallPanel │ ← fills remaining space + │ │ + └─────────────────────────────────┘ + """ + + def __init__(self): + super().__init__() + + customtkinter.set_appearance_mode("dark") + customtkinter.set_default_color_theme("blue") + + self.title("CHART – Live View (test)") + self.geometry("860x540") + + # Bounded queue — producer drops oldest frame when full rather than + # growing without bound. See QUEUE_MAXDEPTH for rationale. + self._spectrum_queue = queue.Queue(maxsize=QUEUE_MAXDEPTH) + self._worker = LiveViewWorker(self._spectrum_queue) + + # Consumer-side counter, plus the reference point used to compute + # in/out frame rates over a rolling STATUS_UPDATE_S window. + self._frames_drawn = 0 + self._rate_ref_time = None # monotonic time of last rate refresh + self._produced_at_ref = 0 # worker.frames_produced at that time + self._drawn_at_ref = 0 # self._frames_drawn at that time + + # Tracks the worker's running state across ticks so the GUI can notice + # when an observation finishes on its own (sweep complete) and reset + # the buttons even though _on_stop was never called. + self._worker_was_running = False + + # Current tuning, surfaced from the queue. _last_freq is what the + # waterfall axis is currently set to; _current_freq is shown in status. + self._last_freq = None + self._current_freq = None + + # after() ids stored so _on_close can cancel both before destroying. + self._poll_after_id = None + self._resize_after_id = None + + self._build_ui() + + # Data polling heartbeat — drains the spectrum queue every GUI_POLL_MS. + self._poll_after_id = self.after(GUI_POLL_MS, self._poll_queue) + + # Resize redraw timer — checks the dirty flag every RESIZE_REDRAW_MS + # and calls canvas.draw() only when needed. Runs on a separate, + # slower cadence from _poll_queue so a resize redraw never delays + # a data update. + self._resize_after_id = self.after(RESIZE_REDRAW_MS, self._check_resize) + + self.protocol("WM_DELETE_WINDOW", self._on_close) + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _build_ui(self): + self.rowconfigure(0, weight=0) # control bar — fixed height + self.rowconfigure(1, weight=1) # waterfall — expands + self.columnconfigure(0, weight=1) + + # --- Control bar --- + ctrl = customtkinter.CTkFrame(self, fg_color="transparent") + ctrl.grid(row=0, column=0, padx=10, pady=(8, 4), sticky="ew") + + self._btn_start = customtkinter.CTkButton( + ctrl, text="Start", width=100, corner_radius=0, + command=self._on_start, + ) + self._btn_start.grid(row=0, column=0, padx=(0, 6)) + + self._btn_stop = customtkinter.CTkButton( + ctrl, text="Stop", width=100, corner_radius=0, + state="disabled", + command=self._on_stop, + ) + self._btn_stop.grid(row=0, column=1, padx=(0, 16)) + + self._status = customtkinter.CTkLabel( + ctrl, text="Idle — press Start to open SDR", + text_color="#aaaacc", + ) + self._status.grid(row=0, column=2, sticky="w") + + # --- Waterfall frame --- + wf_frame = customtkinter.CTkFrame(self, corner_radius=0, + fg_color="#1a1a2e") + wf_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="nsew") + + self._waterfall = WaterfallPanel(wf_frame) + + # ------------------------------------------------------------------ + # Button callbacks + # ------------------------------------------------------------------ + + def _on_start(self): + self._btn_start.configure(state="disabled") + self._btn_stop.configure(state="normal") + self._status.configure(text="Opening SDR…") + self.update_idletasks() + + try: + self._worker.start() + except Exception as e: + self._status.configure(text=f"ERROR: {e}") + self._btn_start.configure(state="normal") + self._btn_stop.configure(state="disabled") + return + + # Reset consumer counters and the rate-measurement window + self._frames_drawn = 0 + self._rate_ref_time = None + self._produced_at_ref = 0 + self._drawn_at_ref = 0 + self._last_freq = None + self._current_freq = None + self._worker_was_running = True + self._status.configure(text="Running — waiting for first spectrum…") + + def _on_stop(self): + self._btn_stop.configure(state="disabled") + self._status.configure(text="Stopping…") + self.update_idletasks() + + self._worker.stop() + self._handle_worker_stopped(manual=True) + + def _handle_worker_stopped(self, manual): + """Reset the UI after the worker stops, for either reason. + + Called from _on_stop (manual Stop press) and from _poll_queue when it + detects the observation finished on its own. Flushes any residual + spectra, resets the buttons, and reports final status. + """ + self._flush_queue() + self._btn_start.configure(state="normal") + self._btn_stop.configure(state="disabled") + self._worker_was_running = False + + verb = "Stopped" if manual else "Finished" + self._status.configure( + text=f"{verb}. {self._frames_drawn} spectra drawn." + ) + + def _flush_queue(self): + """Discard all items currently in the spectrum queue.""" + while True: + try: + self._spectrum_queue.get_nowait() + except queue.Empty: + break + + # ------------------------------------------------------------------ + # Queue polling (GUI thread, via self.after) + # ------------------------------------------------------------------ + + def _poll_queue(self): + """ + Drain the queue and feed each spectrum to the waterfall. + + Runs in the GUI thread via self.after(), so it can touch widgets + directly. Reschedules itself unconditionally so it keeps running + whether or not the worker is active. When the worker is stopped, + any residual items are dropped rather than drawn. + + Each queue item is a (c_freq, spectrum) tuple. When the tuning + changes (a sweep step), the waterfall's frequency axis is retuned + to match before the spectrum is drawn, so axis and data stay in sync. + """ + worker_running = self._worker.is_running() + + # Detect the observation finishing on its own (sweep complete) so the + # GUI resets even though _on_stop was never called. + if self._worker_was_running and not worker_running: + self._handle_worker_stopped(manual=False) + self._worker_was_running = worker_running + + drained = 0 + while True: + try: + c_freq, spectrum = self._spectrum_queue.get_nowait() + except queue.Empty: + break + + if worker_running: + if c_freq != self._last_freq: + self._waterfall.set_center_freq(c_freq) + self._last_freq = c_freq + self._current_freq = c_freq + self._waterfall.push(spectrum) + self._frames_drawn += 1 + + # Cap frames per tick so a sudden backlog (e.g. after a resize + # stall) is worked off gradually instead of freezing the GUI + # for one long burst. Rarely reached: at 33 ms ticks and a + # ~65 ms producer, most ticks see 0 or 1 frames. + drained += 1 + if drained >= 5: + break + + if worker_running: + self._update_status() + + self._poll_after_id = self.after(GUI_POLL_MS, self._poll_queue) + + def _update_status(self): + """ + Refresh the status bar with in/out frame rates and queue health. + + Recomputes at most once per STATUS_UPDATE_S so the fps figures have + a stable measurement window and the label doesn't flicker. + + in fps : spectra the producer pushed (SDR → queue) + out fps : spectra the consumer drew (queue → waterfall) + Healthy operation has out ≈ in. Persistent out < in means the + consumer is falling behind and the queue will fill (watch dropped). + """ + now = time.monotonic() + + # First call this run — establish the reference point and wait. + if self._rate_ref_time is None: + self._rate_ref_time = now + self._produced_at_ref = self._worker.frames_produced + self._drawn_at_ref = self._frames_drawn + return + + elapsed = now - self._rate_ref_time + if elapsed < STATUS_UPDATE_S: + return + + produced = self._worker.frames_produced + in_fps = (produced - self._produced_at_ref) / elapsed + out_fps = (self._frames_drawn - self._drawn_at_ref) / elapsed + + dropped = self._worker.spectra_dropped + drop_str = f" | dropped: {dropped}" if dropped else "" + + freq_str = (f"{self._current_freq / 1e6:.3f} MHz" + if self._current_freq else "—") + + self._status.configure( + text=f"Running — {freq_str}" + f" | in: {in_fps:.1f} fps out: {out_fps:.1f} fps" + f" | queued: {self._spectrum_queue.qsize()}" + f" | drawn: {self._frames_drawn}{drop_str}" + ) + + # Advance the measurement window + self._rate_ref_time = now + self._produced_at_ref = produced + self._drawn_at_ref = self._frames_drawn + + def _check_resize(self): + """ + Low-frequency timer: trigger a background redraw if the figure was + resized since the last check. + + Keeping this separate from _poll_queue means an expensive canvas.draw() + never delays a data update — the two timers are independent. + """ + self._waterfall.redraw_background() + self._resize_after_id = self.after(RESIZE_REDRAW_MS, self._check_resize) + + # ------------------------------------------------------------------ + + def _on_close(self): + # Cancel both pending callbacks before stopping the worker or destroying + # the window. Without this, Tkinter tries to fire them on a destroyed + # widget and hangs with "invalid command name" errors. + if self._poll_after_id is not None: + self.after_cancel(self._poll_after_id) + self._poll_after_id = None + if self._resize_after_id is not None: + self.after_cancel(self._resize_after_id) + self._resize_after_id = None + + if self._worker.is_running(): + self._worker.stop() + + # The waterfall Figure is constructed via the OO API (not pyplot), so + # it is not in pyplot's registry and needs no plt.close(). Destroying + # the Tk window below tears down its canvas; the Figure is then GC'd. + + self.destroy() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + app = LiveViewApp() + app.mainloop() From 6012df5144d804427894a477fea03b3d1aba5ff0 Mon Sep 17 00:00:00 2001 From: Danny Jacobs Date: Fri, 3 Jul 2026 11:19:54 -0700 Subject: [PATCH 2/6] make the top block available outside runObservation --- daq/freq_and_time_scan.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/daq/freq_and_time_scan.py b/daq/freq_and_time_scan.py index b948c3b..953dfc1 100755 --- a/daq/freq_and_time_scan.py +++ b/daq/freq_and_time_scan.py @@ -138,7 +138,7 @@ def buildConfig(args, logger=print): return cfg -def runObservation(cfg, logger=print, stop_event=None): +def runObservation(cfg, logger=print, stop_event=None, on_tb_ready=None): def runSweep(tb): for freq in np.arange(cfg["freq_i"],cfg["freq_f"],cfg["df"]): #loops through each frequency step but not the final @@ -176,7 +176,13 @@ def runSweep(tb): logger(f"SDR error: {e}\nStopping collection!!!") raise - + # Hand the live TopBlock to an optional observer (e.g. a live-display + # producer thread). No-op when on_tb_ready is None, so CLI and existing + # GUI behaviour are unchanged. The same tb is reused for every frequency + # step in the sweep, so this fires exactly once per observation. + if on_tb_ready is not None: + on_tb_ready(tb) + try: os.remove(tb.data_file) #removes data file created when tb is created except FileNotFoundError: @@ -227,4 +233,4 @@ def main(): runObservation(cfg) if __name__ == "__main__": - main() \ No newline at end of file + main() From 38db1a6b92581ef1e1dccb1db0ad124d481d1fe5 Mon Sep 17 00:00:00 2001 From: Danny Jacobs Date: Fri, 3 Jul 2026 11:21:11 -0700 Subject: [PATCH 3/6] add the gnuradio plumbing for live viewing --- src/chart/blocks.py | 132 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/src/chart/blocks.py b/src/chart/blocks.py index 469bc05..e5c9ff3 100644 --- a/src/chart/blocks.py +++ b/src/chart/blocks.py @@ -3,6 +3,7 @@ import datetime import argparse import os +import threading from gnuradio import gr from gnuradio import blocks as grblocks from gnuradio import fft @@ -36,8 +37,13 @@ def get_times(self): class TopBlock(gr.top_block): """Class to collect RTL data and metadata.""" + # Default display integration length. GUI can override via + # set_display_int_length() before or after start(). + DEFAULT_DISPLAY_INT_LENGTH = 50 + def __init__(self, c_freq=50e6, veclength=1024, samp_rate=2e6, int_length=100, - nint=100, bias=False, data_dir=None, metadata=None): + nint=100, bias=False, data_dir=None, metadata=None, + display_int_length=None): """Initialize the collect top block. Parameters @@ -56,7 +62,11 @@ def __init__(self, c_freq=50e6, veclength=1024, samp_rate=2e6, int_length=100, Directory for data. Defaults to cwd. metadata : additional data collected during observation passed from GUI - + display_int_length : int, optional + Number of FFT frames averaged in the display branch. + Independent of int_length. Defaults to DEFAULT_DISPLAY_INT_LENGTH. + Controls update rate vs. noise floor visibility trade-off: + lower = faster/noisier, higher = slower/smoother. """ gr.top_block.__init__(self, "Collectrtldata") @@ -69,12 +79,18 @@ def __init__(self, c_freq=50e6, veclength=1024, samp_rate=2e6, int_length=100, self.int_length = int_length self.nint = nint self.bias = bias + self._display_int_length = ( + display_int_length + if display_int_length is not None + else self.DEFAULT_DISPLAY_INT_LENGTH + ) if data_dir is None: self.data_dir = os.getcwd() else: self.data_dir = data_dir # Initialize to null to avoid empty file self.set_filename() + ################################################## # Blocks ################################################## @@ -110,6 +126,26 @@ def __init__(self, c_freq=50e6, veclength=1024, samp_rate=2e6, int_length=100, self.blocks_file_sink_0.set_unbuffered(False) self.blocks_complex_to_mag_squared_0 = grblocks.complex_to_mag_squared(self.veclength) self.chart_meta_trig_py_ff_0 = meta_trig_py_ff(self.veclength) + + # ------------------------------------------------------------------ + # Display branch + # Taps off blocks_complex_to_mag_squared_0 — upstream of the science + # integration — so display settings are independent of science settings. + # + # blocks_complex_to_mag_squared_0 + # | | + # (science branch) (display branch) + # blocks_integrate_xx_0 blocks_integrate_xx_display + # | | + # meta_trig_py_ff_0 blocks_probe_signal_vf_0 + # | + # blocks_file_sink_0 + # ------------------------------------------------------------------ + self.blocks_integrate_xx_display = grblocks.integrate_ff( + self._display_int_length, self.veclength + ) + self.blocks_probe_signal_vf_0 = grblocks.probe_signal_vf(self.veclength) + ################################################## # Connections ################################################## @@ -118,14 +154,106 @@ def __init__(self, c_freq=50e6, veclength=1024, samp_rate=2e6, int_length=100, self.connect((self.blocks_head_0, 0), (self.blocks_stream_to_vector_0, 0)) self.connect((self.blocks_stream_to_vector_0, 0), (self.fft_vxx_0, 0)) self.connect((self.fft_vxx_0, 0), (self.blocks_complex_to_mag_squared_0, 0)) + + # Science branch (unchanged) self.connect((self.blocks_complex_to_mag_squared_0, 0), (self.blocks_integrate_xx_0, 0)) self.connect((self.blocks_integrate_xx_0, 0), (self.chart_meta_trig_py_ff_0, 0)) self.connect((self.chart_meta_trig_py_ff_0, 0), (self.blocks_file_sink_0, 0)) + # Display branch (fan-out from complex_to_mag_squared) + self.connect((self.blocks_complex_to_mag_squared_0, 0), + (self.blocks_integrate_xx_display, 0)) + self.connect((self.blocks_integrate_xx_display, 0), + (self.blocks_probe_signal_vf_0, 0)) + # Get start time self.start_time = time.time() + # ------------------------------------------------------------------ + # Display branch public API + # ------------------------------------------------------------------ + + def get_display_spectrum(self): + """Return the most recent display-averaged power spectrum. + + Returns a numpy float32 array of length veclength containing linear + power values (arbitrary units, not yet converted to dB). The array + is a copy — safe to hold onto after the flowgraph updates. + + Returns None if the flowgraph has not produced a vector yet (i.e. + fewer than display_int_length FFT frames have been processed since + the last start() call). + """ + vec = self.blocks_probe_signal_vf_0.level() + if vec is None: + return None + arr = np.array(vec, dtype=np.float32) + # probe_signal_vf initialises its internal buffer to all-zeros. + # An all-zero vector means no data has arrived yet. + if not np.any(arr): + return None + return arr + + def set_display_int_length(self, n): + """Change the display averaging depth while the flowgraph is running. + + Parameters + ---------- + n : int + Number of FFT frames to average. Must be >= 1. + Lower values give faster updates with more noise. + Higher values give slower updates with a smoother spectrum. + + Note: GNU Radio's integrate_ff does not support runtime changes to + integration length, so this method stops the flowgraph, rebuilds the + display branch blocks with the new length, reconnects, and restarts. + Only call this when the flowgraph is running — it handles start/stop + internally. The science branch and file sink are unaffected. + """ + n = max(1, int(n)) + if n == self._display_int_length: + return + + was_running = self.is_running() if hasattr(self, 'is_running') else False + + # GNU Radio top_block doesn't expose is_running() before GR 3.9. + # Safest to just stop unconditionally and swallow the error. + try: + self.stop() + self.wait() + except Exception: + pass + + # Disconnect old display branch + try: + self.disconnect((self.blocks_complex_to_mag_squared_0, 0), + (self.blocks_integrate_xx_display, 0)) + self.disconnect((self.blocks_integrate_xx_display, 0), + (self.blocks_probe_signal_vf_0, 0)) + except Exception: + pass + + # Rebuild with new integration length + self._display_int_length = n + self.blocks_integrate_xx_display = grblocks.integrate_ff(n, self.veclength) + + self.connect((self.blocks_complex_to_mag_squared_0, 0), + (self.blocks_integrate_xx_display, 0)) + self.connect((self.blocks_integrate_xx_display, 0), + (self.blocks_probe_signal_vf_0, 0)) + + if was_running: + self.start() + + @property + def display_int_length(self): + return self._display_int_length + + # ------------------------------------------------------------------ + # Existing methods (unchanged) + # ------------------------------------------------------------------ + def set_veclength(self, veclength): """Set vector length.""" self.veclength = veclength From 8899aa6972a6c61b747005bb1ffffe11bf87de57 Mon Sep 17 00:00:00 2001 From: Danny Jacobs Date: Fri, 3 Jul 2026 11:22:35 -0700 Subject: [PATCH 4/6] add matplotlib rc file that overrides system default config --- matplotlibrc | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 matplotlibrc diff --git a/matplotlibrc b/matplotlibrc new file mode 100644 index 0000000..e69de29 From 296b775cffcb4aabbaa58a04e78128396d50a016 Mon Sep 17 00:00:00 2001 From: Danny Jacobs Date: Fri, 3 Jul 2026 11:23:23 -0700 Subject: [PATCH 5/6] add conda/mamba environment specification yaml --- chart.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 chart.yaml diff --git a/chart.yaml b/chart.yaml new file mode 100644 index 0000000..5fbbd1a --- /dev/null +++ b/chart.yaml @@ -0,0 +1,21 @@ +name: CHART +channels: + - conda-forge + - defaults +dependencies: + - ipython + - jupyter + - ipympl + - numpy>=1.20 + - astropy + - matplotlib + - pandas + - ipywidgets + - scipy + - gnuradio + - gnuradio-osmosdr + - pip + - pip: + - customtkinter + - timezonefinder + From 6b097cd4e93eca6eb74e582776096538c272e216 Mon Sep 17 00:00:00 2001 From: Danny Jacobs Date: Fri, 24 Jul 2026 10:44:04 -0700 Subject: [PATCH 6/6] chart-observe now includes live view features --- daq/chart-observe.py | 595 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 585 insertions(+), 10 deletions(-) diff --git a/daq/chart-observe.py b/daq/chart-observe.py index be4fae2..d1b0d99 100644 --- a/daq/chart-observe.py +++ b/daq/chart-observe.py @@ -21,6 +21,19 @@ from freq_and_time_scan import buildConfig, runObservation from chart.azalt import pointing, azalt, gps, gimme_time, altitude_plot_data +# Live waterfall display. WaterfallPanel and the display constants are reused +# unchanged from the standalone live_view tool; only the producer wiring is +# re-implemented here because chart-observe already owns the science thread. +import queue +from live_view import ( + WaterfallPanel, + LiveViewWorker, + CENTER_FREQ, + GUI_POLL_MS, + RESIZE_REDRAW_MS, + QUEUE_MAXDEPTH, +) + class ChartApp(customtkinter.CTk): def __init__(self): @@ -31,6 +44,26 @@ def __init__(self): self.last_data_dir = None # stores last directory created for plotting function self.popup = None # allows checking for multiple date and time popup windows + # --- Live display state ------------------------------------------- + # The science thread (self.session) feeds spectra to the Display tab + # through this queue. A separate producer thread polls the GNU Radio + # probe once the TopBlock exists; the GUI drains the queue on a timer. + self.spectrum_queue = queue.Queue(maxsize=QUEUE_MAXDEPTH) + self.display_tb = None # live TopBlock, handed over by on_tb_ready + self.display_tb_ready = None # threading.Event, set when tb available + self.display_stop = None # threading.Event to halt the producer + self.display_producer = None # producer thread handle + self.waterfall = None # WaterfallPanel, built in buildDisplayTab + self.frames_drawn = 0 + self.display_last_freq = None # last freq the waterfall axis was set to + self.display_poll_id = None # after() id for the queue-drain loop + + # Monitor (standalone live view) state. Mutually exclusive with a + # science sweep — both drive the same waterfall through spectrum_queue, + # but only one can hold the SDR at a time. + self.monitor_worker = None # LiveViewWorker instance when active + self._last_display_state = "idle" # for detecting state transitions + #storage for stdout/stderr pipe required to capture GNU radio messages self._log_pipe_r = None self._log_pipe_w = None @@ -96,20 +129,68 @@ def buildWidgets(self): self.clock_label.grid(column=1, row=0, padx=2, pady=2, sticky="w") self.updateClock() + # Global recording indicator — always visible regardless of tab, so the + # user knows a scan is in progress even from the Configuration tab. + # Sits just under the clock. Driven by pollDisplayQueue. + self.record_indicator = customtkinter.CTkLabel( + self.clock_frame, text="\u25cf Idle", + text_color=("gray60", "gray50"), + ) + self.record_indicator.grid(column=0, row=1, columnspan=2, padx=2, + pady=(0, 2), sticky="e") + # defines what rows can expand + # row 0: top bar (fixed) row 1: body/tabview (expands) + # row 2: control strip (fixed) row 3: terminal (fixed) self.rowconfigure(0, weight=0) + self.rowconfigure(1, weight=1) self.rowconfigure(2, weight=0) - self.rowconfigure(1, weight=1) - self.rowconfigure(0, weight=0) + self.rowconfigure(3, weight=0) self.columnconfigure(0, weight=1) - self.scroll_frame = customtkinter.CTkScrollableFrame(self, corner_radius=0) - self.scroll_frame.grid(column=0, row=1, padx=10, pady=0, sticky="nsew") + # --- Body tabview (row 1) ----------------------------------------- + # Two tabs: "Configuration" holds the existing settings (the scroll + # frame, unchanged) and "Display" will hold the live waterfall. + # The tabview is the expanding region; the control strip and terminal + # below it stay persistent across both tabs. + self.tabview = customtkinter.CTkTabview(self, corner_radius=0) + self.tabview.grid(column=0, row=1, padx=10, pady=0, sticky="nsew") + self.tab_config = self.tabview.add("Configuration") + self.tab_display = self.tabview.add("Display") + + # The scroll frame now lives inside the Configuration tab. Keeping the + # name self.scroll_frame unchanged means every widget parented to it + # (buildEntries, buildSwitches, buildButtons, ...) works untouched — + # only the parent changed, from self to the tab. + self.scroll_frame = customtkinter.CTkScrollableFrame(self.tab_config, corner_radius=0) + self.scroll_frame.pack(fill="both", expand=True) self.scroll_frame.columnconfigure(1, weight=1) self.scroll_frame.columnconfigure(3, weight=1) + # --- Persistent control strip (row 2) ----------------------------- + # A fixed-height band that always stays visible (never scrolls), set + # off from the body above by a thin separator line. Start/Stop live + # here so the primary actions are reachable no matter what the body + # is showing. Colours come from the active theme so dark mode is + # honoured automatically. + self.control_strip = customtkinter.CTkFrame(self, corner_radius=0, + fg_color="transparent") + self.control_strip.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 2)) + # Column weights for the strip's contents are set in buildButtons, + # which owns the sweep-button / monitor-group layout. + + # Thin separator across the top of the strip: a 1px frame using a + # theme-aware colour tuple (light_mode, dark_mode) so it flips with + # the appearance mode. Spans both button columns. + self.control_separator = customtkinter.CTkFrame( + self.control_strip, height=1, corner_radius=0, + fg_color=("gray70", "gray30"), + ) + self.control_separator.grid(row=0, column=0, columnspan=6, + sticky="ew", pady=(0, 5)) + self.terminal = customtkinter.CTkTextbox(self, height=80, corner_radius=0, border_width=3, border_color="gray") - self.terminal.grid(row=2, column=0, sticky="ew", padx=10, pady=(0,10)) + self.terminal.grid(row=3, column=0, sticky="ew", padx=10, pady=(0,10)) self.terminal.configure(state="disabled") #widgets inside of the scroll frame is called with the following functions @@ -117,9 +198,22 @@ def buildWidgets(self): self.buildSwitches() self.buildButtons() + # Display tab contents (status line, parameter summary, waterfall) + self.buildDisplayTab() + # functions that start at runtime are here self.loadSettings() self.gpsDisable() + self.syncControlStates() # set initial sweep/monitor control states + + # Start the queue-drain loop for the live waterfall. Runs continuously; + # it simply finds an empty queue when no observation is active. + self.display_poll_id = self.after(GUI_POLL_MS, self.pollDisplayQueue) + + # Separate slow timer that redraws the waterfall's static background + # (axes/ticks/colorbar) after a resize or frequency-axis change. Kept + # off the data path so an expensive redraw never delays a spectrum. + self.display_resize_id = self.after(RESIZE_REDRAW_MS, self.checkDisplayResize) def buildEntries(self): @@ -209,6 +303,281 @@ def buildEntries(self): self.estimated_time_label = customtkinter.CTkLabel(self.scroll_frame, text="") self.estimated_time_label.grid(column=3, row=7, padx=10, pady=5, sticky="w") + # ====================================================================== + # Live Display tab + # ====================================================================== + + def buildDisplayTab(self): + """Build the Display tab: status line, parameter summary, waterfall. + + Layout: the tab is split left/right — a parameter summary panel on the + left and the waterfall on the right. The summary itself contains two + groups (observer info, SDR/scan settings); whether those groups stack + vertically (compact) or sit side by side (mirroring Configuration) is + controlled by a single knob below — see SETTINGS_PLACEMENT. + """ + tab = self.tab_display + + # === TUNABLE KNOBS ================================================ + # Width ratio of summary vs waterfall. weights are relative ints, so + # 1:2 gives the waterfall ~2/3 of the width. Bump WATERFALL_WEIGHT to + # 3 for ~3/4, etc. + SUMMARY_WEIGHT = 1 + WATERFALL_WEIGHT = 2 + + # How the two summary groups are arranged: + # "stacked" → settings below observer (compact, one narrow column) + # "side" → settings right of observer (two columns, mirrors the + # Configuration tab's geography) + SETTINGS_PLACEMENT = "stacked" + # ================================================================== + + # Outer grid: row 0 = status (full width), row 1 = body (expands). + tab.rowconfigure(0, weight=0) + tab.rowconfigure(1, weight=1) + tab.columnconfigure(0, weight=SUMMARY_WEIGHT) # summary panel + tab.columnconfigure(1, weight=WATERFALL_WEIGHT) # waterfall + + # --- Status line: indicator dot + state + tuning + rate ----------- + # Spans both columns across the top. + status_row = customtkinter.CTkFrame(tab, fg_color="transparent") + status_row.grid(row=0, column=0, columnspan=2, sticky="ew", + padx=6, pady=(6, 2)) + + # Unicode filled circle; colour flips green (active) / gray (idle). + self.display_dot = customtkinter.CTkLabel( + status_row, text="\u25cf", width=18, + text_color=("gray60", "gray50"), + ) + self.display_dot.grid(row=0, column=0, padx=(2, 6)) + + self.display_status = customtkinter.CTkLabel(status_row, text="Idle") + self.display_status.grid(row=0, column=1, sticky="w") + + # --- Parameter summary panel (left) ------------------------------- + # sticky="new" keeps the params hugging the top-left of their column + # rather than centering in the tall left cell. + summary = customtkinter.CTkFrame(tab, border_color="gray", + border_width=2, corner_radius=0) + summary.grid(row=1, column=0, sticky="new", padx=6, pady=(2, 6)) + summary.columnconfigure(0, weight=1) + + # Two groups, each its own frame. observer_frame always sits top-left; + # settings_frame placement is the knob above. + observer_frame = customtkinter.CTkFrame(summary, fg_color="transparent") + settings_frame = customtkinter.CTkFrame(summary, fg_color="transparent") + + observer_frame.grid(row=0, column=0, sticky="new", padx=4, pady=4) + if SETTINGS_PLACEMENT == "side": + settings_frame.grid(row=0, column=1, sticky="new", padx=4, pady=4) + summary.columnconfigure(1, weight=1) + else: # "stacked" + settings_frame.grid(row=1, column=0, sticky="new", padx=4, pady=4) + + # Read-only value labels stored in a dict so updateDisplaySummary can + # refresh them from the entry fields when an observation starts. + self._summary_values = {} + + def add_row(parent, r, key, label_text): + parent.columnconfigure(1, weight=1) + lbl = customtkinter.CTkLabel(parent, text=label_text, anchor="e") + lbl.grid(row=r, column=0, padx=(6, 4), pady=1, sticky="e") + val = customtkinter.CTkLabel(parent, text="—", anchor="w") + val.grid(row=r, column=1, padx=(4, 6), pady=1, sticky="w") + self._summary_values[key] = val + + observer_cfg = ( + ("observer", "Observer:"), + ("location", "Location:"), + ("latitude", "Latitude:"), + ("longitude", "Longitude:"), + ("altitude", "Altitude:"), + ("azimuth", "Azimuth:"), + ) + settings_cfg = ( + ("freq_i", "Start Freq (MHz):"), + ("freq_f", "Stop Freq (MHz):"), + ("int_time", "Integration (s):"), + ("nint", "Ints / step:"), + ) + for r, (key, text) in enumerate(observer_cfg): + add_row(observer_frame, r, key, text) + for r, (key, text) in enumerate(settings_cfg): + add_row(settings_frame, r, key, text) + + # --- Waterfall (right) -------------------------------------------- + wf_frame = customtkinter.CTkFrame(tab, corner_radius=0, + fg_color="#1a1a2e") + wf_frame.grid(row=1, column=1, sticky="nsew", padx=6, pady=(0, 6)) + self.waterfall = WaterfallPanel(wf_frame) + + def updateDisplaySummary(self): + """Copy the current entry-field values into the Display tab summary. + + Called when an observation starts, so the front panel reflects what is + actually being observed. Reads the same entries the Configuration tab + edits; shows a dash for blanks. + """ + def txt(entry): + v = entry.get().strip() + return v if v else "—" + + self._summary_values["observer"].configure(text=txt(self.observer_name_entry)) + self._summary_values["location"].configure(text=txt(self.location_entry)) + self._summary_values["latitude"].configure(text=txt(self.latitude_entry)) + self._summary_values["longitude"].configure(text=txt(self.longitude_entry)) + self._summary_values["altitude"].configure(text=txt(self.altitude_entry)) + self._summary_values["azimuth"].configure(text=txt(self.azimuth_entry)) + # Scan settings come from the validated values set in startCollection. + self._summary_values["freq_i"].configure(text=f"{self.freq_i:g}") + self._summary_values["freq_f"].configure(text=f"{self.freq_f:g}") + self._summary_values["int_time"].configure(text=f"{self.int_time:g}") + self._summary_values["nint"].configure(text=f"{self.nint:g}") + + # --- Display producer (taps the science TopBlock) --------------------- + + def onDisplayTbReady(self, tb): + """Callback handed to runObservation; receives the live TopBlock. + + Runs in the science thread. Stashes tb and unblocks the producer. + """ + self.display_tb = tb + if self.display_tb_ready is not None: + self.display_tb_ready.set() + + def displayProducerLoop(self): + """Poll the TopBlock's display branch, push (freq, spectrum) to queue. + + Mirrors the producer loop proven in live_view, but lives here because + chart-observe owns the science thread (self.session) directly. Runs in + its own thread so it can read the probe while the science thread is + blocked in tb.wait(). + """ + if not self.display_tb_ready.wait(timeout=15.0): + return + tb = self.display_tb + if tb is None: + return + + last_vec = None + while not self.display_stop.is_set(): + tb = self.display_tb + if tb is None: + break + vec = tb.get_display_spectrum() + if vec is not None: + if last_vec is None or not np.array_equal(vec, last_vec): + if self.spectrum_queue.full(): + try: + self.spectrum_queue.get_nowait() + except queue.Empty: + pass + self.spectrum_queue.put_nowait((tb.c_freq, vec.copy())) + last_vec = vec + time.sleep(0.05) + + def startDisplayProducer(self): + """Spin up the display tap for a newly started observation.""" + self.display_tb = None + self.display_tb_ready = threading.Event() + self.display_stop = threading.Event() + self.frames_drawn = 0 + self.display_last_freq = None + self.display_producer = threading.Thread( + target=self.displayProducerLoop, daemon=True, + name="display-producer") + self.display_producer.start() + + def stopDisplayProducer(self): + """Signal the display tap to stop and drop its TopBlock reference.""" + if self.display_stop is not None: + self.display_stop.set() + if self.display_producer is not None: + self.display_producer.join(timeout=2.0) + self.display_tb = None + + def pollDisplayQueue(self): + """Drain the spectrum queue into the waterfall (GUI thread, on timer). + + Always reschedules itself. Draws spectra from whichever producer is + active (science sweep or monitor) — the queue is source-agnostic. Keeps + the indicator, status line, and control enable/disable in sync with the + current instrument state. + """ + state = self.displayState() + active = state in ("sweep", "monitor") + + # Only draw when the Display tab is actually visible. Blitting to an + # unmapped canvas (user on the Configuration tab) can corrupt the saved + # background. We still drain the queue so it can't back up; the frames + # are simply discarded while hidden and drawing resumes on return. + display_visible = self.tabview.get() == "Display" + + drained = 0 + while True: + try: + c_freq, spectrum = self.spectrum_queue.get_nowait() + except queue.Empty: + break + if active and display_visible and self.waterfall is not None: + if c_freq != self.display_last_freq: + self.waterfall.set_center_freq(c_freq) + self.display_last_freq = c_freq + self.waterfall.push(spectrum) + self.frames_drawn += 1 + drained += 1 + if drained >= 5: + break + + # Re-sync the control widgets only when the state actually changes + # (e.g. a sweep finishing on its own), to avoid reconfiguring widgets + # every tick. + if state != self._last_display_state: + self.syncControlStates() + self._last_display_state = state + + # Indicator: green = recording sweep, blue = monitoring, gray = idle. + if state == "sweep": + freq_str = (f"{self.display_last_freq / 1e6:.3f} MHz" + if self.display_last_freq else "tuning…") + green = ("#2e7d32", "#66bb6a") + self.display_dot.configure(text_color=green) + self.display_status.configure( + text=f"Observing — {freq_str} | {self.frames_drawn} frames") + self.record_indicator.configure(text="\u25cf Recording", + text_color=green) + elif state == "monitor": + freq_str = (f"{self.display_last_freq / 1e6:.3f} MHz" + if self.display_last_freq else "tuning…") + blue = ("#1565c0", "#42a5f5") + self.display_dot.configure(text_color=blue) + self.display_status.configure( + text=f"Monitoring — {freq_str} | {self.frames_drawn} frames") + self.record_indicator.configure(text="\u25cf Monitoring", + text_color=blue) + else: # idle + gray = ("gray60", "gray50") + self.display_dot.configure(text_color=gray) + if self.frames_drawn > 0: + self.display_status.configure( + text=f"Idle — last run drew {self.frames_drawn} frames") + else: + self.display_status.configure(text="Idle") + self.record_indicator.configure(text="\u25cf Idle", text_color=gray) + + self.display_poll_id = self.after(GUI_POLL_MS, self.pollDisplayQueue) + + def checkDisplayResize(self): + """Slow timer: redraw the waterfall background when dirty. + + Only acts when the Display tab is visible — redrawing a hidden canvas + is wasted work and can touch an unmapped widget. Reschedules itself. + """ + if (self.waterfall is not None + and self.tabview.get() == "Display"): + self.waterfall.redraw_background() + self.display_resize_id = self.after(RESIZE_REDRAW_MS, self.checkDisplayResize) + def buildSwitches(self): self.default_switch = customtkinter.CTkSwitch(self.scroll_frame, text="Use Default Parameters", onvalue="on", offvalue="off", command=self.enableDefaults, corner_radius=0) self.default_switch.grid(column=2, row=1, padx=10, pady=10, sticky="w") @@ -217,11 +586,52 @@ def buildSwitches(self): self.bias_switch.grid(column=2, row=7, padx=10, pady=5, sticky="w") def buildButtons(self): - self.start_button = customtkinter.CTkButton(self.scroll_frame, text="Start", command=self.startCollection, corner_radius=0) - self.start_button.grid(column=2, row=9, padx=10, pady=3, sticky="ew") - - self.stop_button = customtkinter.CTkButton(self.scroll_frame, text="Stop", command=self.stopCollection, corner_radius=0) - self.stop_button.grid(column=3, row=9, padx=10, pady=3, sticky="ew") + # The persistent control strip holds two mutually-exclusive instrument + # controls side by side: the science sweep (a single toggle button) and + # the monitor (a live-view switch with its own tuning + LNA controls). + # Start Sweep always takes priority — starting it stops the monitor. + strip = self.control_strip + + # Columns: 0 sweep button | 1 divider | 2 "Monitor" switch | + # 3 "Tune" label | 4 freq entry | 5 LNA checkbox + strip.columnconfigure(0, weight=1) # sweep button expands + strip.columnconfigure(2, weight=0) + strip.columnconfigure(4, weight=0) + + # Sweep toggle — replaces the old Start/Stop pair. Text and colour + # reflect state; the command routes to the toggle handler. + self.sweep_button = customtkinter.CTkButton( + strip, text="Start Sweep", command=self.toggleSweep, corner_radius=0) + self.sweep_button.grid(column=0, row=1, padx=(0, 8), pady=2, sticky="ew") + + # Thin vertical divider between the sweep and monitor groups. + # Fixed height (not sticky="ns") so it acts as a visual rule without + # driving the strip's height. + divider = customtkinter.CTkFrame(strip, width=2, height=28, + corner_radius=0, + fg_color=("gray70", "gray30")) + divider.grid(column=1, row=1, padx=4, pady=2) + + # Monitor switch (live view, no recording). + self.monitor_switch = customtkinter.CTkSwitch( + strip, text="Monitor", command=self.toggleMonitor, + onvalue="on", offvalue="off", corner_radius=0) + self.monitor_switch.grid(column=2, row=1, padx=(8, 8), pady=2, sticky="w") + + # Monitor tuning entry, defaulting to the 21 cm line. + self.monitor_tune_label = customtkinter.CTkLabel(strip, text="Tune (MHz):") + self.monitor_tune_label.grid(column=3, row=1, padx=(4, 2), pady=2, sticky="e") + self.monitor_freq_entry = customtkinter.CTkEntry( + strip, width=80, corner_radius=0) + self.monitor_freq_entry.insert(0, "1420.4") + self.monitor_freq_entry.grid(column=4, row=1, padx=(0, 8), pady=2, sticky="w") + + # LNA power checkbox — kept in sync with the Configuration tab's + # Bias-T switch and firing the same warning. + self.monitor_lna_check = customtkinter.CTkCheckBox( + strip, text="LNA power", command=self.monitorLnaToggled, + onvalue="on", offvalue="off", corner_radius=0) + self.monitor_lna_check.grid(column=5, row=1, padx=(0, 2), pady=2, sticky="w") self.jupyter_upload_button = customtkinter.CTkButton(self.scroll_frame, text="Upload to Jupyter Hub", command=self.jupyter_upload, corner_radius=0) self.jupyter_upload_button.grid(column=2, row=10, padx=10, pady=3, sticky="new") @@ -869,6 +1279,136 @@ def enableDefaults(self): self.updateTimeEstimate() + # ====================================================================== + # Sweep / Monitor mutual-exclusion control + # ====================================================================== + + def displayState(self): + """Return the current instrument state: 'sweep', 'monitor', or 'idle'. + + Single source of truth for the indicator, the control enable/disable + logic, and the swap behaviour. Sweep takes priority: if a science + thread is alive we report 'sweep' regardless of anything else. + """ + if self.session is not None and self.session.is_alive(): + return "sweep" + if self.monitor_worker is not None and self.monitor_worker.is_running(): + return "monitor" + return "idle" + + def toggleSweep(self): + """Sweep button handler. Start Sweep always takes priority. + + - idle → start the sweep + - monitor → stop the monitor (release the SDR), then start the sweep + - sweep → stop the sweep + """ + state = self.displayState() + if state == "sweep": + self.stopCollection() + return + + # Starting a sweep: if the monitor is up, take the device from it first. + if state == "monitor": + self.stopMonitor() + + self.startCollection() + + def toggleMonitor(self): + """Monitor switch handler. + + Only reachable when no sweep is running (the switch is disabled during + a sweep). Turns the standalone live view on or off. + """ + if self.monitor_switch.get() == "on": + self.startMonitor() + else: + self.stopMonitor() + + def startMonitor(self): + """Start the standalone live view at the chosen tuning frequency.""" + # Parse the tuning entry; fall back to the 21 cm line on bad input. + try: + freq_mhz = float(self.monitor_freq_entry.get()) + except ValueError: + messagebox.showerror("Invalid Frequency", + "Monitor tuning must be numeric (MHz).") + self.monitor_switch.deselect() + return + + bias = self.monitor_lna_check.get() == "on" + + self.frames_drawn = 0 + self.display_last_freq = None + self.monitor_worker = LiveViewWorker( + self.spectrum_queue, + center_freq=freq_mhz * 1e6, + bias=bias, + ) + try: + self.monitor_worker.start() + except Exception as e: + self.log(f"Monitor failed to start: {e}") + self.monitor_worker = None + self.monitor_switch.deselect() + return + + self.log(f"Monitor started at {freq_mhz:.3f} MHz" + f"{' with LNA power' if bias else ''}.") + self.syncControlStates() + self.tabview.set("Display") + + def stopMonitor(self): + """Stop the standalone live view and release the SDR.""" + if self.monitor_worker is not None: + self.monitor_worker.stop() + self.monitor_worker = None + self.log("Monitor stopped.") + # Reflect the off state on the switch (covers the swap case, where the + # sweep button — not the switch — triggered the stop). + self.monitor_switch.deselect() + self.syncControlStates() + + def monitorLnaToggled(self): + """LNA checkbox handler — keep the Bias-T switch in sync and warn. + + The monitor's LNA power and the Configuration tab's Bias-T are the same + physical setting, so toggling one updates the other and fires the same + warning the Configuration switch does. + """ + if self.monitor_lna_check.get() == "on": + self.bias_switch.select() + else: + self.bias_switch.deselect() + self.biasTwarn() + + def syncControlStates(self): + """Enable/disable the control widgets to match the current state. + + - sweep : monitor switch + tuning + LNA all disabled (device busy) + - monitor : tuning + LNA disabled (can't change mid-run); switch stays + enabled so it can be turned off; sweep button stays enabled + so it can perform the swap + - idle : everything enabled + """ + state = self.displayState() + + if state == "sweep": + self.sweep_button.configure(text="Stop Sweep") + self.monitor_switch.configure(state="disabled") + self.monitor_freq_entry.configure(state="disabled") + self.monitor_lna_check.configure(state="disabled") + elif state == "monitor": + self.sweep_button.configure(text="Start Sweep") + self.monitor_switch.configure(state="normal") + self.monitor_freq_entry.configure(state="disabled") + self.monitor_lna_check.configure(state="disabled") + else: # idle + self.sweep_button.configure(text="Start Sweep") + self.monitor_switch.configure(state="normal") + self.monitor_freq_entry.configure(state="normal") + self.monitor_lna_check.configure(state="normal") + def startCollection(self): # checks for default switch and returns either default values or text in the entry boxes @@ -992,14 +1532,28 @@ def startCollection(self): cfg = buildConfig(args, self.log) self.last_data_dir = cfg["data_dir"] self.stop_event = threading.Event() + + # Populate the Display tab's read-only summary from the entered values, + # then start the display tap that will feed the waterfall. + self.updateDisplaySummary() + self.startDisplayProducer() + self.session = threading.Thread( target=runObservation, args=(cfg, self.log, self.stop_event), + kwargs={"on_tb_ready": self.onDisplayTbReady}, daemon=True ) self.session.start() time.sleep(0.2) #wait for GNU Radio logs self.stopLogCapture() + + # Bring the user to the live view now that data is flowing. Start was + # reachable from either tab (it lives in the persistent strip), so this + # is a convenience, not a required navigation. + self.tabview.set("Display") + self.syncControlStates() + self.plotObservation() def stopCollection(self): @@ -1013,6 +1567,10 @@ def stopCollection(self): if self.stop_event: self.stop_event.set() + # Stop the display tap as well; the science thread will wind down at + # the next frequency-step boundary on its own. + self.stopDisplayProducer() + self.log("Stopping observation...\nWaiting for current frequency scan to finish...") def plotObservation(self): @@ -1027,6 +1585,10 @@ def plotObservation(self): self.after(1000, self.plotObservation) else: + # Observation finished on its own — wind down the display tap too. + # Idempotent: harmless if stopCollection already stopped it. + self.stopDisplayProducer() + if not self.last_data_dir: self.log("No observation data found.") return @@ -1131,6 +1693,15 @@ def biasTwarn(self): # displays a warning message if BiasT is enabled # !!! This function does not enable BiasT. Instead the startCollection() function passes along the value for the switch, which is then enabled when the data collection is started. + # Keep the monitor's LNA-power checkbox in sync with this switch (they + # are the same physical setting). select()/deselect() are programmatic + # and do not re-fire monitorLnaToggled, so there is no recursion. + if hasattr(self, "monitor_lna_check"): + if self.bias_switch.get() == "on": + self.monitor_lna_check.select() + else: + self.monitor_lna_check.deselect() + if self.bias_switch.get() == "on": messagebox.showwarning('WARNING', 'Only have this on if you know FOR SURE the BIAS-T is being used. \nIf you are following the CHART tutorial with the recommended LNA, it should be ON') @@ -1142,6 +1713,10 @@ def onClose(self): # defines a safe shutdown procedure that closes all subprocesses before exiting the GUI + # Release the SDR if the monitor is running. + if self.monitor_worker is not None and self.monitor_worker.is_running(): + self.monitor_worker.stop() + if self.jupyter_proc is not None: self.jupyter_proc.terminate() self.destroy()