-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmap_exp.py
More file actions
583 lines (492 loc) · 21 KB
/
map_exp.py
File metadata and controls
583 lines (492 loc) · 21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
import os
import tkinter as tk
from tkinter import ttk
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from adif_file import adi # Assuming adif_file library is installed and available
import argparse # Import argparse
# --- Utility Functions (mostly unchanged) ---
def frequency_to_band(freq):
"""Converts frequency (MHz) string/float to amateur radio band name."""
try:
f = float(freq)
except (ValueError, TypeError):
return None
# Band definitions (lower, upper, name)
bands = [
(1.8, 2.0, "160m"),
(3.5, 4.0, "80m"),
(7.0, 7.3, "40m"),
(10.1, 10.15, "30m"),
(14.0, 14.35, "20m"),
(18.068, 18.168, "17m"),
(21.0, 21.45, "15m"),
(24.89, 24.99, "12m"),
(28.0, 29.7, "10m"),
# Add more bands if needed (e.g., VHF/UHF)
]
for low, high, name in bands:
if low <= f <= high:
return name
return None # Return None if frequency doesn't match known bands
def maidenhead_to_lat_lon(grid):
"""Converts Maidenhead grid locator string to latitude and longitude."""
grid = grid.strip().upper()
if len(grid) < 4:
return None, None # Basic validation
# Calculate longitude
lon = (ord(grid[0]) - ord("A")) * 20 - 180
lon += int(grid[2]) * 2
# Calculate latitude
lat = (ord(grid[1]) - ord("A")) * 10 - 90
lat += int(grid[3]) * 1
# Add precision for 6-character grids (subsquares)
if len(grid) >= 6:
lon += (ord(grid[4]) - ord("A") + 0.5) * (2 / 24)
lat += (ord(grid[5]) - ord("A") + 0.5) * (1 / 24)
else:
# Center of the 4-character grid square
lon += 1
lat += 0.5
return lat, lon
def grid_square_bounds(grid):
"""Calculates the bounding box (lat/lon) of a Maidenhead grid square."""
grid = grid.strip().upper()
if len(grid) < 4:
return None # Basic validation
# Calculate bottom-left corner longitude and latitude
lon0 = (ord(grid[0]) - ord("A")) * 20 - 180 + int(grid[2]) * 2
lat0 = (ord(grid[1]) - ord("A")) * 10 - 90 + int(grid[3]) * 1
# Determine width and height based on grid precision
if len(grid) >= 6:
lon0 += (ord(grid[4]) - ord("A")) * (2 / 24)
lat0 += (ord(grid[5]) - ord("A")) * (1 / 24)
width = 2 / 24
height = 1 / 24
else:
width = 2
height = 1
# Return bounds: min_lat, max_lat, min_lon, max_lon
return lat0, lat0 + height, lon0, lon0 + width
# --- ADIF Parsing ---
def parse_adif(file_path):
"""
Parses an ADIF file, extracts relevant QSO data, and performs necessary conversions.
Handles potential variations in the adif_file library API.
"""
try:
# Attempt loading using different potential methods of adif_file library
if hasattr(adi, "load"): # Newer versions might have 'load'
parsed = adi.load(file_path)
adif_data = parsed.get("RECORDS", []) # Safely get RECORDS
print(f"Parsed {len(adif_data)} ADIF records from {file_path}.")
except FileNotFoundError:
print(f"Error: ADIF file not found at {file_path}")
return None
except Exception as e:
print(f"Error parsing ADIF file: {e}")
return None
records = []
# Process each record efficiently
for record_raw in adif_data:
# Normalize keys to lowercase for consistent access
record = {k.lower(): v for k, v in record_raw.items()}
lat, lon = None, None
# Prioritize explicit LAT/LON fields
lat_str = record.get("lat")
lon_str = record.get("lon")
try:
if lat_str:
lat = float(lat_str)
except (ValueError, TypeError):
pass # Ignore conversion errors
try:
if lon_str:
lon = float(lon_str)
except (ValueError, TypeError):
pass # Ignore conversion errors
# Fallback to GRIDSQUARE if LAT/LON are missing or invalid
grid = record.get("my_gridsquare") # Remote station grid
if (lat is None or lon is None) and grid:
lat_g, lon_g = maidenhead_to_lat_lon(grid)
if lat_g is not None and lon_g is not None:
lat, lon = lat_g, lon_g
# Derive band from frequency if 'BAND' field is missing
band = record.get("band") or frequency_to_band(record.get("freq"))
# Extract distance, handling potential errors
distance = None
try:
dist_str = record.get("distance")
if dist_str:
distance = float(dist_str)
except (ValueError, TypeError):
pass
# Extract SNR (specifically from PSK Reporter field if present)
snr = None
try:
snr_str = record.get(
"app_pskrep_snr"
) # Common field for SNR in digital modes
if snr_str:
snr = float(snr_str)
except (ValueError, TypeError):
pass
# Append processed record if it has minimal required info (e.g., callsign)
if record.get("call"):
records.append(
{
"call": record.get("call"),
"band": band,
"time_on": record.get("time_on"), # Keep as string HHMMSS
"lat": lat,
"lon": lon,
"distance": distance,
"snr": snr,
"grid": grid,
# Add other fields as needed
}
)
print(f"Processed {len(records)} records with extracted data.")
return records
# --- Filtering ---
def filter_contacts(contacts_list, band=None, start_time=None, end_time=None):
"""Filters a list of contact dictionaries by band and/or time range."""
# Start with the full list
filtered_list = contacts_list
# Apply band filter if specified
if band:
# Use list comprehension for potentially faster filtering
filtered_list = [c for c in filtered_list if c.get("band") == band]
# Apply start time filter if specified
if start_time:
# Compare time strings directly (assuming HHMMSS format)
filtered_list = [
c for c in filtered_list if c.get("time_on") and c["time_on"] >= start_time
]
# Apply end time filter if specified
if end_time:
filtered_list = [
c for c in filtered_list if c.get("time_on") and c["time_on"] <= end_time
]
return filtered_list
# --- Plotting ---
def draw_maidenhead_grid(ax, resolution="major", **kwargs):
"""
Draws Maidenhead grid lines on a Cartopy axes object.
Resolutions: 'major' (10x20 deg), 'minor' (1x2 deg), 'sub' (very fine, potentially slow).
"""
# Define styles for different grid levels
styles = {
"major": {
"color": kwargs.pop("major_color", kwargs.pop("color", "blue")),
"linestyle": kwargs.pop("major_linestyle", kwargs.pop("linestyle", "--")),
"linewidth": kwargs.pop("major_linewidth", kwargs.pop("linewidth", 1.0)),
"zorder": kwargs.pop("major_zorder", 3), # Draw major grid on top
},
"minor": {
"color": kwargs.pop("minor_color", "gray"),
"linestyle": kwargs.pop("minor_linestyle", ":"),
"linewidth": kwargs.pop("minor_linewidth", 0.5),
"zorder": kwargs.pop("minor_zorder", 2),
},
"sub": {
"color": kwargs.pop("sub_color", "lightgray"),
"linestyle": kwargs.pop("sub_linestyle", ":"),
"linewidth": kwargs.pop("sub_linewidth", 0.2),
"zorder": kwargs.pop("sub_zorder", 1), # Draw sub grid below others
},
}
# Common transform for all lines
transform = ccrs.PlateCarree()
# Draw Major Grid (Fields: AA-RR, 00-99) - 20 degrees lon, 10 degrees lat
if resolution in ["major", "minor", "sub"]:
style = styles["major"]
lons = np.arange(-180, 181, 20)
lats = np.arange(-90, 91, 10)
ax.vlines(lons, -90, 90, transform=transform, clip_on=False, **style)
ax.hlines(lats, -180, 180, transform=transform, clip_on=False, **style)
# Draw Minor Grid (Squares: e.g., FN31) - 2 degrees lon, 1 degree lat
if resolution in ["minor", "sub"]:
style = styles["minor"]
lons = np.arange(-180, 180, 2)
lats = np.arange(-90, 90, 1)
# Avoid drawing lines that overlap with major grid for clarity if needed
# lons = np.setdiff1d(lons, np.arange(-180, 181, 20))
# lats = np.setdiff1d(lats, np.arange(-90, 91, 10))
ax.vlines(lons, -90, 90, transform=transform, clip_on=False, **style)
ax.hlines(lats, -180, 180, transform=transform, clip_on=False, **style)
# Draw Sub Grid (Subsquares: e.g., FN31ax) - 5 minutes lon, 2.5 minutes lat
if resolution == "sub":
style = styles["sub"]
lon_step = 5 / 60 # 5 minutes of longitude
lat_step = 2.5 / 60 # 2.5 minutes of latitude
lons = np.arange(-180, 180, lon_step)
lats = np.arange(-90, 90, lat_step)
# Avoid drawing lines that overlap with minor/major grid if needed
ax.vlines(lons, -90, 90, transform=transform, clip_on=False, **style)
ax.hlines(lats, -180, 180, transform=transform, clip_on=False, **style)
def plot_contacts_on_map(contacts_list, band_time, color_by="distance"):
"""
Plots contacts on a world map using Matplotlib and Cartopy.
Colors points by distance or SNR and includes dynamic statistics.
"""
if not contacts_list:
print("No contacts provided to plot.")
return
fig = plt.figure(figsize=(12, 9)) # Keep adjusted height
# Use add_subplot to ensure GeoAxes is created, then adjust layout
map_ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
# Adjust subplot parameters to make space below for the colorbar
fig.subplots_adjust(left=0.05, right=0.95, top=0.92, bottom=0.15)
map_ax.set_global() # This should now work on the GeoAxes object
map_ax.stock_img() # Add background land/ocean image
map_ax.add_feature(cfeature.COASTLINE, linewidth=0.8, zorder=4)
map_ax.add_feature(cfeature.BORDERS, linestyle=":", linewidth=0.6, zorder=4)
# Draw Maidenhead grid (e.g., major resolution)
draw_maidenhead_grid(
map_ax, # Pass the correct axes object
resolution="major", # Change resolution if needed ('minor', 'sub')
major_color="blue",
major_linestyle="--",
major_linewidth=0.8,
)
# --- Data Preparation for Plotting ---
# Pre-filter contacts with valid lat/lon and the chosen metric (distance/snr)
# This avoids processing invalid entries during plotting and stats updates.
plot_data = []
metric_key = color_by.lower() # 'distance' or 'snr'
for contact in contacts_list:
lat = contact.get("lat")
lon = contact.get("lon")
metric_val = contact.get(metric_key)
# Ensure lat, lon, and the chosen metric are valid numbers
if (
isinstance(lat, (int, float))
and isinstance(lon, (int, float))
and isinstance(metric_val, (int, float))
):
plot_data.append(
{
"lat": lat,
"lon": lon,
"metric": metric_val,
"distance": contact.get("distance"), # Keep both for stats
"snr": contact.get("snr"), # Keep both for stats
}
)
if not plot_data:
print("No contacts with valid coordinates and metric found to plot.")
plt.title("Contacts on World Map (0 plotted)")
plt.show()
return
# Convert plotting data to NumPy arrays for efficiency
lats_p = np.array([p["lat"] for p in plot_data])
lons_p = np.array([p["lon"] for p in plot_data])
cvals = np.array([p["metric"] for p in plot_data])
all_distances = np.array(
[
p["distance"]
for p in plot_data
if isinstance(p.get("distance"), (int, float))
]
)
all_snrs = np.array(
[p["snr"] for p in plot_data if isinstance(p.get("snr"), (int, float))]
)
# --- Scatter Plot ---
cb_label = "Distance (km)" if metric_key == "distance" else "SNR (dB)"
sc = map_ax.scatter( # Use map_ax here
lons_p,
lats_p,
c=cvals,
cmap="viridis",
s=30, # Smaller points?
transform=ccrs.Geodetic(), # Source data is lat/lon
zorder=5, # Plot points above grid lines and features
)
plot_count = len(lons_p)
# Add Colorbar below the map
# Create axes for the colorbar relative to the FIGURE, positioned in the space made by subplots_adjust
cbar_ax = fig.add_axes(
[0.15, 0.08, 0.7, 0.03]
) # [left, bottom, width, height] - Fine-tune if needed
cbar = plt.colorbar(sc, cax=cbar_ax, label=cb_label, orientation="horizontal")
# --- Dynamic Statistics Box ---
# Position the text box on the left side of the figure, relative to the figure
stats_text_obj = fig.text(
0.01, # Keep it on the far left
0.5, # Centered vertically in the figure
"", # x, y position (relative to figure)
transform=fig.transFigure, # Coordinates are relative to the figure
fontsize=8,
va="center", # Vertical alignment
bbox=dict(boxstyle="round,pad=0.5", fc="white", alpha=0.7), # Background box
)
stats_data = {
"lats": lats_p,
"lons": lons_p,
"distances": all_distances,
"snrs": all_snrs,
"plot_data": plot_data, # Keep original dicts if more info needed later
}
def update_stats(event=None):
"""Callback function to update statistics based on visible map area."""
# Get current map view limits (longitude, latitude) from map_ax
x0, x1 = map_ax.get_xlim()
y0, y1 = map_ax.get_ylim()
# --- Performance Optimization ---
# Filter the *already plotted* points based on the current view
# This is faster than iterating through the original 'contacts_list' again.
# Create boolean masks for points within the current longitude and latitude limits
lon_mask = (stats_data["lons"] >= x0) & (stats_data["lons"] <= x1)
lat_mask = (stats_data["lats"] >= y0) & (stats_data["lats"] <= y1)
# Combine masks to find points currently visible
visible_mask = lon_mask & lat_mask
# Extract data for visible points using the mask
visible_indices = np.where(visible_mask)[0]
count = len(visible_indices)
lines = [f"Visible Contacts: {count}"]
if count > 0:
# Efficiently get distance and SNR for visible points using the mask
# Need to handle cases where distance/snr might be None/NaN originally
# Let's re-extract from plot_data using visible_indices for safety
visible_dists = [
stats_data["plot_data"][i]["distance"]
for i in visible_indices
if isinstance(stats_data["plot_data"][i].get("distance"), (int, float))
]
visible_snrs = [
stats_data["plot_data"][i]["snr"]
for i in visible_indices
if isinstance(stats_data["plot_data"][i].get("snr"), (int, float))
]
# Calculate and format distance statistics if available
if visible_dists:
dists_np = np.array(visible_dists)
lines.append("\nDistance Stats (km):")
lines.append(f" Min: {np.min(dists_np):.1f}")
lines.append(f" Max: {np.max(dists_np):.1f}")
lines.append(f" Avg: {np.mean(dists_np):.1f}")
else:
lines.append("\nNo distance data in view.")
# Calculate and format SNR statistics if available
if visible_snrs:
snrs_np = np.array(visible_snrs)
lines.append("\nSNR Stats (dB):")
lines.append(f" Min: {np.min(snrs_np):.1f}")
lines.append(f" Max: {np.max(snrs_np):.1f}")
lines.append(f" Avg: {np.mean(snrs_np):.1f}")
lines.append(f" Std: {np.std(snrs_np):.1f}")
else:
lines.append("\nNo SNR data in view.")
# Update the text displayed in the statistics box
stats_text_obj.set_text("\n".join(lines))
fig.canvas.draw_idle() # Request redraw if text changed
# Connect the update function to map view change events on map_ax
map_ax.callbacks.connect("xlim_changed", update_stats)
map_ax.callbacks.connect("ylim_changed", update_stats)
# Call update_stats once initially to display stats for the full view
update_stats()
# Set title using suptitle, as before
fig.suptitle(
f"Contact Map ({plot_count} plotted) - Color by {color_by.capitalize()} - {band_time}",
y=0.96, # Adjust vertical position slightly if needed due to subplots_adjust
)
plt.show()
# --- GUI ---
# Modify run_gui to accept the file path
def run_gui(adif_file_path):
"""Sets up and runs the Tkinter GUI for filtering and plotting."""
# --- Load Data ---
print(f"Loading ADIF data from: {adif_file_path}")
contacts = parse_adif(adif_file_path)
if not contacts:
print("Failed to load or parse ADIF file. Exiting GUI.")
# Optionally show an error message in the GUI itself
root = tk.Tk()
root.title("Error")
tk.Label(
root,
text=f"Failed to load data from:\n{adif_file_path}\n\nCheck console for details.",
).pack(padx=20, pady=20)
ttk.Button(root, text="OK", command=root.destroy).pack(pady=10)
root.mainloop()
return
# Get unique bands present in the data for the dropdown
bands = sorted({c.get("band") for c in contacts if c.get("band")})
# --- Build GUI ---
root = tk.Tk()
# Title now reflects the loaded file
root.title(f"Contact Map Explorer - {os.path.basename(adif_file_path)}")
# Configure grid layout
root.columnconfigure(1, weight=1) # Allow entry column to expand
# Band Filter
tk.Label(root, text="Band:").grid(row=0, column=0, padx=5, pady=2, sticky="e")
band_var = tk.StringVar(value="") # Default to 'All' (empty string)
band_cb = ttk.Combobox(
root, textvariable=band_var, values=[""] + bands, state="readonly"
)
band_cb.grid(row=0, column=1, padx=5, pady=2, sticky="ew")
# Time Filters
tk.Label(root, text="Start Time (HHMMSS):").grid(
row=1, column=0, padx=5, pady=2, sticky="e"
)
start_entry = ttk.Entry(root)
start_entry.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
tk.Label(root, text="End Time (HHMMSS):").grid(
row=2, column=0, padx=5, pady=2, sticky="e"
)
end_entry = ttk.Entry(root)
end_entry.grid(row=2, column=1, padx=5, pady=2, sticky="ew")
# Color Mode Selector
tk.Label(root, text="Color by:").grid(row=3, column=0, padx=5, pady=2, sticky="e")
color_var = tk.StringVar(value="distance") # Default coloring
color_cb = ttk.Combobox(
root,
textvariable=color_var,
values=["distance", "snr"],
state="readonly",
width=10,
)
color_cb.grid(row=3, column=1, padx=5, pady=2, sticky="w") # Align left
# Plot Button Action
def on_plot():
band = band_var.get() or None # Use None if empty string
start = start_entry.get() or None
end = end_entry.get() or None
color_mode = color_var.get()
# Construct band_time string for the title
band_str = band if band else "All Bands"
time_str = f"{start if start else 'Start'}-{end if end else 'End'}"
plot_title_info = f"{band_str} ({time_str})"
print(f"Filtering: Band='{band}', Start='{start}', End='{end}'")
filtered = filter_contacts(contacts, band=band, start_time=start, end_time=end)
print(f"Plotting {len(filtered)} contacts, colored by {color_mode}.")
# Run plotting in a way that doesn't block the GUI entirely if possible
# (For complex plots, consider threading, but matplotlib might need main thread)
plot_contacts_on_map(filtered, band_time=plot_title_info, color_by=color_mode)
# Plot Button
plot_btn = ttk.Button(root, text="Plot Map", command=on_plot)
plot_btn.grid(row=4, column=0, columnspan=2, padx=5, pady=10)
# Start the Tkinter event loop
root.mainloop()
# --- Main Execution ---
if __name__ == "__main__":
# Set up argument parser
parser = argparse.ArgumentParser(
description="Visualize ADIF contact data on a map."
)
parser.add_argument("adif_file", help="Path to the ADIF file to process.")
# Parse arguments
args = parser.parse_args()
# Check if the file exists before proceeding
if not os.path.isfile(args.adif_file):
print(f"Error: File not found at {args.adif_file}")
exit(1)
print("Starting Contact Map Explorer GUI...")
# Pass the file path from arguments to the GUI function
run_gui(args.adif_file)
print("GUI closed.")