Skip to content

Commit b3ec394

Browse files
committed
waterfall updated
1 parent 750e746 commit b3ec394

1 file changed

Lines changed: 114 additions & 15 deletions

File tree

Lines changed: 114 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,153 @@
11
#! /usr/bin/python3
22
##--------------------------------------------------------------------\
33
# hackrfpy 'examples/waterfall_realtime.py'
4-
# Live waterfall from hackrf_sweep. Needs the [plotting] extra.
4+
# Live spectrum waterfall from hackrf_sweep. Needs the [plotting] extra.
5+
#
56
# Demonstrates consuming the sweep generator as frames arrive, with a
67
# clean shutdown that reaps hackrf_sweep on Ctrl-C (sweep_stream).
8+
#
9+
# Robust to real hackrf_sweep behavior: segments arrive OUT OF ORDER and a
10+
# sweep pass can be partial at the flush boundary, so every spectrum line is
11+
# conformed to a fixed width before being stacked into the image (a naive
12+
# np.array(history) crashes on the resulting ragged rows).
713
##--------------------------------------------------------------------\
814
import numpy as np
915
import matplotlib.pyplot as plt
16+
from matplotlib import colors
1017
from hackrfpy import HackRF
1118

12-
F_MIN, F_MAX = 88e6, 108e6 # FM broadcast band
13-
ROWS = 100
19+
# ---- what to watch -------------------------------------------------------
20+
F_MIN, F_MAX = 2.40e9, 2.50e9 # 2.4 GHz ISM (Wi-Fi / BT / microwave leak)
21+
ROWS = 160 # waterfall history depth (time axis)
22+
DB_FLOOR, DB_CEIL = -90, -20 # fixed color scale (dBFS); stable colors
23+
24+
# ---- aesthetic: a signals-monitor identity, not a default plot -----------
25+
# Palette drawn from spectrum-monitor / oscilloscope vernacular: near-black
26+
# instrument background, phosphor-cyan accents, signal mapped through a
27+
# perceptually-uniform heat map (turbo) so weak-to-strong reads intuitively.
28+
BG = "#0a0e14" # instrument black-blue
29+
PANEL = "#0d1320"
30+
GRID = "#1c2738"
31+
ACCENT = "#36e0c8" # phosphor cyan
32+
TEXT = "#9fb3c8" # cool grey-blue
33+
TEXTDIM = "#52617a"
34+
CMAP = "turbo" # perceptually uniform, high dynamic range
35+
36+
plt.rcParams.update({
37+
"figure.facecolor": BG,
38+
"axes.facecolor": PANEL,
39+
"savefig.facecolor": BG,
40+
"font.family": "monospace", # telemetry feel
41+
"text.color": TEXT,
42+
"axes.edgecolor": GRID,
43+
"axes.labelcolor": TEXT,
44+
"xtick.color": TEXTDIM,
45+
"ytick.color": TEXTDIM,
46+
"axes.linewidth": 0.8,
47+
})
1448

1549
h = HackRF()
16-
fig, ax = plt.subplots()
50+
51+
fig, ax = plt.subplots(figsize=(11, 6))
52+
try:
53+
fig.canvas.manager.set_window_title("hackrfpy :: live spectrum waterfall")
54+
except Exception:
55+
pass
56+
1757
img = None
58+
cbar = None
59+
width = None # locked to the first COMPLETE sweep's bin count
60+
history = []
1861

19-
# A single hackrf_sweep pass over a wide band arrives as MANY rows (one per
20-
# ~5 MHz tuning segment) that share a timestamp. We assemble all rows with the
21-
# same timestamp into one spectrum line, then flush when the timestamp changes.
62+
# assemble all rows sharing a timestamp into one spectrum line
2263
sweep_bins = {}
2364
last_time = None
24-
history = []
65+
sweeps_seen = 0
66+
67+
68+
def assemble_line():
69+
# concatenate segments low->high (real hackrf_sweep emits them out of
70+
# order, so sort by hz_low) into one spectrum row
71+
return np.concatenate([sweep_bins[k] for k in sorted(sweep_bins)])
72+
73+
74+
def conform(line, w):
75+
# make a line exactly w wide: truncate if long, edge-pad if short. Real
76+
# sweeps occasionally yield a partial pass at the flush boundary; this
77+
# keeps the image rectangular instead of crashing np.array on ragged rows.
78+
if len(line) == w:
79+
return line
80+
if len(line) > w:
81+
return line[:w]
82+
return np.pad(line, (0, w - len(line)), mode="edge")
83+
84+
85+
def style_axes():
86+
band = f"{F_MIN/1e6:.0f}\u2013{F_MAX/1e6:.0f} MHz"
87+
ax.set_title(f"LIVE SPECTRUM \u2014 {band}",
88+
color=ACCENT, fontsize=13, fontweight="bold",
89+
loc="left", pad=12, family="monospace")
90+
ax.set_xlabel("frequency (MHz)", fontsize=9)
91+
ax.set_ylabel("time (newest at top)", fontsize=9)
92+
# frequency ticks across the band
93+
xt = np.linspace(0, width - 1, 6)
94+
xl = [f"{(F_MIN + (F_MAX - F_MIN) * (t / max(width - 1, 1)))/1e6:.0f}"
95+
for t in xt]
96+
ax.set_xticks(xt)
97+
ax.set_xticklabels(xl)
98+
ax.set_yticks([])
99+
for s in ax.spines.values():
100+
s.set_color(GRID)
25101

26102

27103
def flush_line():
28-
# concatenate the assembled segments low->high into one spectrum row
29-
global history, img
30-
line = np.concatenate([sweep_bins[k] for k in sorted(sweep_bins)])
104+
global history, img, cbar, width
105+
line = assemble_line()
31106
sweep_bins.clear()
32-
history.append(line)
107+
108+
if width is None:
109+
# lock the image width to the first complete sweep we see
110+
width = len(line)
111+
112+
history.append(conform(line, width))
33113
history = history[-ROWS:]
34-
arr = np.array(history)
114+
arr = np.array(history) # now guaranteed rectangular
115+
arr = arr[::-1] # newest row on top
116+
35117
if img is None:
36-
img = ax.imshow(arr, aspect="auto", cmap="viridis")
118+
img = ax.imshow(arr, aspect="auto", cmap=CMAP,
119+
norm=colors.Normalize(vmin=DB_FLOOR, vmax=DB_CEIL),
120+
interpolation="nearest", origin="upper",
121+
extent=[0, width, 0, len(arr)])
122+
cbar = fig.colorbar(img, ax=ax, pad=0.01, fraction=0.046)
123+
cbar.set_label("power (dBFS)", color=TEXT, fontsize=9)
124+
cbar.ax.yaxis.set_tick_params(color=TEXTDIM)
125+
cbar.outline.set_edgecolor(GRID)
126+
plt.setp(plt.getp(cbar.ax, "yticklabels"), color=TEXTDIM)
127+
style_axes()
128+
fig.tight_layout()
37129
else:
38130
img.set_data(arr)
39-
img.set_clim(arr.min(), arr.max())
131+
img.set_extent([0, width, 0, len(arr)])
132+
40133
plt.pause(0.001)
41134

42135

136+
print(f"[*] live waterfall {F_MIN/1e6:.0f}-{F_MAX/1e6:.0f} MHz "
137+
f"-- close the window or Ctrl-C to stop")
138+
43139
# sweep_stream guarantees hackrf_sweep is reaped on exit, including the
44140
# KeyboardInterrupt that ends a live waterfall. Without the context manager a
45141
# Ctrl-C could orphan the child sweep process.
46142
try:
47143
with h.sweep_stream(F_MIN, F_MAX) as rows:
48144
for row in rows:
49145
if last_time is not None and row["time"] != last_time and sweep_bins:
146+
sweeps_seen += 1
50147
flush_line()
51148
sweep_bins[row["hz_low"]] = np.array(row["db"])
52149
last_time = row["time"]
53150
except KeyboardInterrupt:
54151
pass # context manager has already reaped the child on the way out
152+
153+
print("[*] stopped")

0 commit comments

Comments
 (0)