Skip to content

Commit b7a33f3

Browse files
committed
realtime waterfall plot example
1 parent e2c333a commit b7a33f3

1 file changed

Lines changed: 304 additions & 2 deletions

File tree

README.md

Lines changed: 304 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,9 @@ else: # if port found and connected, then complete task(s) and disconnect
830830

831831
#### **Example 4: Plot a Waterfall using SCAN and Calculated Frequencies**
832832

833+
The first part of this example is a static report of the measurements taken over time. The time will vary a bit from the resolution. Data is collected and then displayed with matplotlib.
834+
835+
833836
```python
834837
# import tinySA library
835838
# (NOTE: check library path relative to script path)
@@ -997,9 +1000,308 @@ else: # if port found and connected, then complete task(s) and disconnect
9971000
tsa.disconnect()
9981001
```
9991002
<p align="center">
1000-
<img src="media/example4_waterfall_1.png" alt="Waterfall Plot for SCAN Data Over 50 Readings" height="350">
1003+
<img src="media/example4_waterfall_1.png" alt="Static Waterfall Plot for SCAN Data Over 50 Readings" height="350">
10011004
</p>
1002-
<p align="center">Waterfall Plot for SCAN Data Over 50 Readings</p>
1005+
<p align="center">Static Waterfall Plot for SCAN Data Over 50 Readings</p>
1006+
1007+
1008+
1009+
1010+
1011+
1012+
The second part of the example is a realtime waterfall plot with peak tracking and a sample of the last reading.
1013+
1014+
```python
1015+
# import tinySA library
1016+
# (NOTE: check library path relative to script path)
1017+
from src.tinySA_python import tinySA
1018+
1019+
# imports FOR THE EXAMPLE
1020+
import numpy as np
1021+
import matplotlib.pyplot as plt
1022+
import matplotlib.animation as animation
1023+
from collections import deque
1024+
import time
1025+
from datetime import datetime
1026+
import threading
1027+
import queue
1028+
1029+
def convert_data_to_arrays(start, stop, pts, data):
1030+
#Convert the raw tinySA data to frequency and power arrays.
1031+
# using the start and stop frequencies, and the number of points,
1032+
freq_arr = np.linspace(start, stop, pts) # note that the decimals might go out to many places.
1033+
# you can truncate this because its only used
1034+
# for plotting in this example
1035+
# As of the Jan. 2024 build in some data returned with SWEEP or SCAN calls there is error data.
1036+
# https://groups.io/g/tinysa/topic/tinasa_ultra_sweep_command/104194367
1037+
# this shows up as "-:.000000e+01".
1038+
# TEMP fix - replace the colon character with a -10. This puts the 'filled in' points around the noise floor.
1039+
# more advanced filtering should be applied for actual analysis.
1040+
1041+
data1 = bytearray(data.replace(b"-:.0", b"-10.0"))
1042+
1043+
# Get first value in each returned row (power in dBm)
1044+
try:
1045+
data_arr = [float(line.split()[0]) for line in data1.decode('utf-8').split('\n') if line.strip()]
1046+
except (ValueError, IndexError):
1047+
# If parsing fails, return zeros
1048+
data_arr = [0.0] * pts
1049+
1050+
# Ensure data array matches frequency array length
1051+
if len(data_arr) != pts:
1052+
# Pad or truncate to match expected points
1053+
# We do this to visualize what might be going wrong rather than outright throwing an error
1054+
# -100 is a very low noise floor, especially for a hand held device, so it's not a normal reading
1055+
if len(data_arr) < pts:
1056+
data_arr.extend([data_arr[-1] if data_arr else -100.0] * (pts - len(data_arr)))
1057+
else:
1058+
data_arr = data_arr[:pts]
1059+
1060+
return freq_arr, np.array(data_arr)
1061+
1062+
class LiveSpectrumPlotter:
1063+
def __init__(self, tsa, start, stop, pts, outmask, max_history=50):
1064+
self.tsa = tsa
1065+
self.start = start
1066+
self.stop = stop
1067+
self.pts = pts
1068+
self.outmask = outmask
1069+
self.max_history = max_history
1070+
1071+
# Data storage
1072+
self.freq_arr = None
1073+
self.power_history = deque(maxlen=max_history)
1074+
self.timestamps = deque(maxlen=max_history)
1075+
1076+
# Threading for data acquisition
1077+
self.data_queue = queue.Queue()
1078+
self.running = False
1079+
self.data_thread = None
1080+
1081+
# Current data for single-trace plots
1082+
self.current_power = None
1083+
1084+
# Twin axis reference for proper cleanup
1085+
self.ax3_twin = None
1086+
1087+
def data_acquisition_thread(self):
1088+
#Background thread for continuous data acquisition
1089+
while self.running:
1090+
try:
1091+
# Get scan data
1092+
data_bytes = self.tsa.scan(self.start, self.stop, self.pts, self.outmask)
1093+
1094+
# Convert to arrays
1095+
freq_arr, power_arr = convert_data_to_arrays(
1096+
self.start, self.stop, self.pts, data_bytes)
1097+
1098+
# Put data in queue for main thread
1099+
self.data_queue.put({
1100+
'freq': freq_arr,
1101+
'power': power_arr,
1102+
'timestamp': datetime.now()
1103+
})
1104+
1105+
time.sleep(0.2) # Small delay to prevent overwhelming the device
1106+
1107+
except Exception as e:
1108+
print(f"Data acquisition error: {e}")
1109+
time.sleep(0.5) # Wait a bit before retrying
1110+
continue
1111+
1112+
def start_acquisition(self):
1113+
#Start the data acquisition thread
1114+
self.running = True
1115+
self.data_thread = threading.Thread(target=self.data_acquisition_thread)
1116+
self.data_thread.daemon = True
1117+
self.data_thread.start()
1118+
1119+
def stop_acquisition(self):
1120+
#Stop the data acquisition thread
1121+
self.running = False
1122+
if self.data_thread:
1123+
self.data_thread.join()
1124+
1125+
def update_plots(self, frame):
1126+
#Update the matplotlib plots with new data
1127+
1128+
# Get all available data from queue
1129+
while not self.data_queue.empty():
1130+
try:
1131+
data = self.data_queue.get_nowait()
1132+
1133+
# Store frequency array (first time only)
1134+
if self.freq_arr is None:
1135+
self.freq_arr = data['freq']
1136+
1137+
# Update current data
1138+
self.current_power = data['power']
1139+
1140+
# Add to history
1141+
self.power_history.append(data['power'])
1142+
self.timestamps.append(data['timestamp'])
1143+
1144+
except queue.Empty:
1145+
break
1146+
1147+
# Clear plots
1148+
ax1.clear() # Waterfall
1149+
ax2.clear() # Live spectrum
1150+
ax3.clear() # Peak tracking
1151+
1152+
# Clear any existing twin axes completely
1153+
if hasattr(self, 'ax3_twin') and self.ax3_twin is not None:
1154+
self.ax3_twin.clear()
1155+
self.ax3_twin.remove()
1156+
self.ax3_twin = None
1157+
1158+
if self.freq_arr is not None and self.current_power is not None:
1159+
# Plot 1: Waterfall (left side - larger)
1160+
if len(self.power_history) > 1:
1161+
waterfall_data = np.array(list(self.power_history))
1162+
# Create time array in reverse order so newest (highest index) appears at top
1163+
time_arr = np.arange(len(waterfall_data))
1164+
freq_mesh, time_mesh = np.meshgrid(self.freq_arr, time_arr)
1165+
1166+
im = ax1.pcolormesh(freq_mesh/1e9, time_mesh, waterfall_data,
1167+
shading='nearest', cmap='viridis')
1168+
ax1.set_xlabel('Frequency (GHz)')
1169+
ax1.set_ylabel('Scan Number (newest at top)')
1170+
ax1.set_title('Spectrum History (Waterfall)')
1171+
1172+
# Add colorbar to waterfall plot
1173+
if not hasattr(self, 'colorbar_created'):
1174+
self.colorbar = plt.colorbar(im, ax=ax1, shrink=0.8)
1175+
self.colorbar.set_label('Power (dBm)')
1176+
self.colorbar_created = True
1177+
1178+
# Plot 2: Current Spectrum (top right)
1179+
ax2.plot(self.freq_arr/1e9, self.current_power, 'b-', linewidth=1.5)
1180+
ax2.set_xlabel('Frequency (GHz)')
1181+
ax2.set_ylabel('Power (dBm)')
1182+
ax2.set_title('Live Spectrum')
1183+
ax2.grid(True, alpha=0.3)
1184+
1185+
# Set reasonable y-axis limits
1186+
if len(self.current_power) > 0:
1187+
y_min = np.min(self.current_power) - 5
1188+
y_max = np.max(self.current_power) + 5
1189+
ax2.set_ylim(y_min, y_max)
1190+
1191+
# Plot 3: Peak tracking over time (bottom right)
1192+
if len(self.power_history) > 1:
1193+
peak_powers = [np.max(scan) for scan in self.power_history]
1194+
peak_freqs = [self.freq_arr[np.argmax(scan)]/1e9 for scan in self.power_history]
1195+
1196+
# Plot peak power over time
1197+
scan_numbers = list(range(len(peak_powers)))
1198+
1199+
# Create fresh twin axis for frequency (store reference for proper cleanup)
1200+
self.ax3_twin = ax3.twinx()
1201+
1202+
ax3.plot(scan_numbers, peak_powers, 'r-o', markersize=2,
1203+
label='Peak Power', linewidth=1.5)
1204+
self.ax3_twin.plot(scan_numbers, peak_freqs, 'g-s', markersize=2,
1205+
label='Peak Freq', linewidth=1.5)
1206+
1207+
ax3.set_xlabel('Scan Number')
1208+
ax3.set_ylabel('Peak Power (dBm)', color='r')
1209+
self.ax3_twin.set_ylabel('Peak Freq (GHz)', color='g')
1210+
ax3.set_title('Peak Tracking')
1211+
ax3.grid(True, alpha=0.3)
1212+
1213+
# Color the y-axis labels to match the lines
1214+
ax3.tick_params(axis='y', labelcolor='r', labelsize=8)
1215+
self.ax3_twin.tick_params(axis='y', labelcolor='g', labelsize=8)
1216+
1217+
# Force immediate redraw of the twin axis
1218+
self.ax3_twin.relim()
1219+
self.ax3_twin.autoscale_view()
1220+
1221+
# Add timestamp and scan info
1222+
if self.timestamps:
1223+
scan_count = len(self.timestamps)
1224+
time_str = self.timestamps[-1].strftime("%H:%M:%S")
1225+
fig.suptitle(f'Live tinySA Spectrum - {time_str} (Scan #{scan_count})',
1226+
fontsize=14)
1227+
1228+
1229+
if __name__ == "__main__":
1230+
# create a new tinySA object
1231+
tsa = tinySA()
1232+
# set the return message preferences
1233+
tsa.set_verbose(True)
1234+
tsa.set_error_byte_return(True)
1235+
1236+
# attempt to autoconnect
1237+
found_bool, connected_bool = tsa.autoconnect()
1238+
1239+
if not connected_bool:
1240+
print("ERROR: could not connect to port")
1241+
else:
1242+
try:
1243+
print("Starting live spectrum measurement...")
1244+
print("Close the plot window to stop measurement")
1245+
1246+
# Scan parameters
1247+
start = int(1e9) # 1 GHz
1248+
stop = int(3e9) # 3 GHz
1249+
pts = 200 # Reduced points for faster updates
1250+
outmask = 2 # get measured data
1251+
1252+
# Create plotter
1253+
plotter = LiveSpectrumPlotter(tsa, start, stop, pts, outmask, max_history=30)
1254+
1255+
# Set up the plot - 2x2 layout with waterfall taking left column
1256+
fig = plt.figure(figsize=(14, 10))
1257+
1258+
# Create grid layout: waterfall on left (spans 2 rows), two plots on right
1259+
gs = fig.add_gridspec(2, 2, width_ratios=[2, 1], height_ratios=[1, 1],
1260+
hspace=0.3, wspace=0.3)
1261+
1262+
ax1 = fig.add_subplot(gs[:, 0]) # Waterfall - spans both rows, left column
1263+
ax2 = fig.add_subplot(gs[0, 1]) # Live spectrum - top right
1264+
ax3 = fig.add_subplot(gs[1, 1]) # Peak tracking - bottom right
1265+
1266+
# Start data acquisition
1267+
plotter.start_acquisition()
1268+
1269+
# Create animation
1270+
ani = animation.FuncAnimation(fig, plotter.update_plots,
1271+
interval=300, blit=False)
1272+
1273+
# Show plot (this blocks until window is closed)
1274+
plt.show()
1275+
1276+
# Cleanup
1277+
plotter.stop_acquisition()
1278+
tsa.resume()
1279+
tsa.disconnect()
1280+
1281+
print("Live measurement stopped")
1282+
1283+
except KeyboardInterrupt:
1284+
print("\nMeasurement interrupted by user")
1285+
tsa.resume()
1286+
tsa.disconnect()
1287+
except Exception as e:
1288+
print(f"Error occurred: {e}")
1289+
tsa.resume()
1290+
tsa.disconnect()
1291+
1292+
```
1293+
1294+
1295+
<p align="center">
1296+
<img src="media/example5_waterfall_realtime.png" alt="Realtime Waterfall Plot for SCAN Data" height="350">
1297+
</p>
1298+
<p align="center">Realtime Waterfall Plot for SCAN Data</p>
1299+
1300+
1301+
1302+
1303+
1304+
10031305

10041306

10051307
### Saving SCAN Data to CSV

0 commit comments

Comments
 (0)