-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathweb-ft.py
More file actions
425 lines (340 loc) · 13.8 KB
/
web-ft.py
File metadata and controls
425 lines (340 loc) · 13.8 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
"""
HamSCI Contesting and DXing Dashboard - Backend API Server
This Flask application serves as the backend for the HamSCI Personal Space Weather Station (PSWS)
Contesting and DXing Dashboard. It provides real-time HF propagation data for amateur radio operators
by fetching WSPR, FT8, and FT4 digital mode spots from a MongoDB database.
The application supports multiple filtering options including:
- Time-based filtering (last N minutes)
- Band filtering (160m through 2m)
- Country/continent filtering
- CQ zone and ITU zone filtering
- Mode filtering (WSPR/FT8/FT4)
Author: Owen Ruzanski (KD3ALD)
Organization: University of Scranton (W3USR), Frankford Radio Club
Project: HamSCI Personal Space Weather Station Dashboard Development
"""
from flask import Flask, jsonify, render_template, request
from pymongo import MongoClient
import maidenhead
from geopy.geocoders import Nominatim
from datetime import datetime, timedelta
app = Flask(__name__,static_url_path='', static_folder='static', template_folder='templates')
import geopandas as gpd
from shapely.geometry import shape, Point
import json
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Database Configuration
# Connect to MongoDB instance running WSPRDaemon database
# Database stores decoded WSPR, FT8, and FT4 spots from the PSWS receiver
MONGODB_HOST = os.getenv('MONGODB_HOST', 'localhost')
MONGODB_PORT = os.getenv('MONGODB_PORT', '27017')
MONGODB_USERNAME = os.getenv('MONGODB_USERNAME', 'admin')
MONGODB_PASSWORD = os.getenv('MONGODB_PASSWORD')
MONGODB_DATABASE = os.getenv('MONGODB_DATABASE', 'wspr_db')
if not MONGODB_PASSWORD:
raise ValueError("MONGODB_PASSWORD environment variable is not set. Please create a .env file (see .env.example)")
# Construct MongoDB connection URI
MONGODB_URI = f"mongodb://{MONGODB_USERNAME}:{MONGODB_PASSWORD}@{MONGODB_HOST}:{MONGODB_PORT}"
client = MongoClient(MONGODB_URI)
db = client[MONGODB_DATABASE]
collection = db['spots']
#world = gpd.read_file("https://raw.githubusercontent.com/johan/world.geo.json/master/countries.geo.json")
# Load CQ Zone GeoJSON data for geographic lookups
# CQ zones are amateur radio operating zones used for contests and awards (40 zones globally)
with open("static/js/cqzones.geojson") as f:
cq_data = json.load(f)
# Build list of (zone_number, shapely_geometry) tuples for point-in-polygon lookups
cq_zones = []
for feature in cq_data["features"]:
zone_num = feature["properties"]["cq_zone_number"]
geometry = shape(feature["geometry"])
cq_zones.append((zone_num, geometry))
def get_cq_zone(lat, lon):
"""
Determine which CQ zone contains a given geographic coordinate.
CQ zones are geographical regions defined by CQ Magazine for amateur radio
contesting and awards programs. There are 40 CQ zones worldwide.
Args:
lat (float): Latitude in decimal degrees (-90 to 90)
lon (float): Longitude in decimal degrees (-180 to 180)
Returns:
int: CQ zone number (1-40) if found, None otherwise
Example:
>>> get_cq_zone(40.7128, -74.0060) # New York City
5
"""
point = Point(lon, lat) # Shapely uses (lon, lat) order, not (lat, lon)
for zone_num, geometry in cq_zones:
if geometry.contains(point):
return zone_num
return None # Coordinate not inside any defined CQ zone
def frequency_to_band(freq):
"""
Convert a frequency in MHz to an amateur radio band designation.
Maps frequencies to standard amateur radio band names based on FCC amateur
radio frequency allocations. Used to categorize spots by band for display
and filtering purposes.
Args:
freq (float): Frequency in MHz (e.g., 14.097 for 20 meters)
Returns:
str: Band designation (e.g., "20m", "40m") or "Unknown" if not in an amateur band
Example:
>>> frequency_to_band(14.097)
'20m'
>>> frequency_to_band(7.074)
'40m'
"""
# Long wave bands
if 0.136 <= freq < 0.137:
return "2200m"
if 0.472 <= freq < 0.479:
return "630m"
# HF bands (most commonly used for DX and contesting)
if 1.8 <= freq < 2:
return "160m"
if 3.5 <= freq < 4:
return "80m"
if 5.2 <= freq < 5.5:
return "60m"
if 7.0 <= freq < 7.3:
return "40m"
if 10.1 <= freq < 10.15:
return "30m" # WARC band (no contests)
if 14.0 <= freq < 14.35:
return "20m"
if 18.068 <= freq < 18.168:
return "17m" # WARC band (no contests)
if 21.0 <= freq < 21.45:
return "15m"
if 24.89 <= freq < 24.99:
return "12m" # WARC band (no contests)
if 28.0 <= freq < 29.7:
return "10m"
# VHF/UHF bands
if 50.0 <= freq < 54.0:
return "6m"
if 144.0 <= freq < 148.0:
return "2m"
return "Unknown"
# Data Fetching Functions
def fetch_wspr_spots_tb(lastInterval=15):
"""
Fetch WSPR/FT8/FT4 spots for table display with regional aggregation.
Retrieves recent spots from MongoDB and returns simplified data optimized
for the table view, which groups spots by geographic region and band.
Includes CQ zone information for regional classification.
Args:
lastInterval (int): Number of minutes to look back from current time (default: 15)
Returns:
list[dict]: List of spot dictionaries containing:
- id: Document ID
- band: Amateur radio band (e.g., "20m")
- grid: Maidenhead grid square (6-character)
- time: Timestamp in "YYMMDD HHMM" format
- cq_zone: CQ zone number (1-40)
- mode: Digital mode ("wspr", "ft8", or "ft4")
Database Schema:
MongoDB documents contain:
- date: String in YYMMDD format (e.g., "260107" for January 7, 2026)
- time: String in HHMM format (e.g., "1430" for 14:30 UTC)
- grid: Maidenhead grid square of transmitter
- frequency: Frequency in MHz
- mode: Mode type (wspr/ft8/ft4)
"""
query = {}
# Build time-based query using lastInterval parameter
# Database stores timestamps as separate date and time strings (not datetime objects)
if lastInterval:
try:
minutes = int(lastInterval)
threshold = datetime.utcnow() - timedelta(minutes=minutes)
# Convert threshold to database format: YYMMDD and HHMM strings
threshold_date = f"{threshold.year % 100:02d}{threshold.month:02d}{threshold.day:02d}"
threshold_time = f"{threshold.hour:02d}{threshold.minute:02d}"
# Query for documents with date > threshold OR (date == threshold AND time >= threshold)
query = {"$or": [
{"date": {"$gt": threshold_date}},
{"date": threshold_date, "time": {"$gte": threshold_time}}
]}
except ValueError:
query = {} # Invalid interval, return all documents
# Fetch and sort spots (newest first)
docs = list(collection.find(query).sort([("date", -1), ("time", -1)]))
# Reverse to display chronologically (earliest to latest)
docs.reverse()
results = []
# Receiver location: FN21ni is KD3ALD station in New Jersey
rxlat, rxlon = maidenhead.to_location("FN21ni")
for doc in docs:
# Convert transmitter grid square to coordinates
txlat, txlon = maidenhead.to_location(doc.get("grid"))
# Lookup CQ zone for regional classification
zone = get_cq_zone(txlat, txlon)
# Convert frequency to band name
band = frequency_to_band(doc.get('frequency'))
results.append({
"id": f"${doc.get("_id")}",
"band": band,
"grid": f"{doc.get('grid')}",
"time": f"{doc.get('date')} {doc.get('time')}",
"cq_zone": zone,
"mode": f"{doc.get('mode')}",
})
return results
def fetch_wspr_spots(lastInterval=15):
"""
Fetch WSPR/FT8/FT4 spots for map display with full propagation details.
Retrieves recent spots from MongoDB and returns complete data for map visualization,
including both transmitter and receiver coordinates, signal quality metrics,
and propagation path information.
Args:
lastInterval (int): Number of minutes to look back from current time (default: 15)
Returns:
list[dict]: List of spot dictionaries containing:
- tx_sign: Transmitter callsign
- tx_lat, tx_lon: Transmitter coordinates (decimal degrees)
- rx_sign: Receiver callsign
- rx_lat, rx_lon: Receiver coordinates (decimal degrees)
- frequency: Frequency in MHz
- band: Amateur radio band
- mode: Digital mode (wspr/ft8/ft4)
- snr: Signal-to-noise ratio in dB
- drift: Frequency drift in Hz
- time: Timestamp in "YYMMDD HHMM" format
Notes:
- Invalid grid squares default to 0,0 coordinates (equator/prime meridian)
- Receiver location hardcoded to FN21ni (KD3ALD station)
- Client-side filtering handles band/country/zone filtering
"""
query = {}
# Build time-based MongoDB query
if lastInterval:
try:
minutes = int(lastInterval)
threshold = datetime.utcnow() - timedelta(minutes=minutes)
threshold_date = f"{threshold.year % 100:02d}{threshold.month:02d}{threshold.day:02d}"
threshold_time = f"{threshold.hour:02d}{threshold.minute:02d}"
query = {"$or": [
{"date": {"$gt": threshold_date}},
{"date": threshold_date, "time": {"$gte": threshold_time}}
]}
except ValueError:
query = {}
# Fetch spots from database
docs = list(collection.find(query).sort([("date", -1), ("time", -1)]))
# Reverse to display chronologically (earliest to latest)
docs.reverse()
results = []
# Receiver location: FN21ni grid square (northern New Jersey)
rxlat, rxlon = maidenhead.to_location("FN21ni")
for doc in docs:
# Attempt to convert transmitter grid to coordinates
try:
txlat, txlon = maidenhead.to_location(doc.get("grid"))
except Exception:
# Invalid grid square, use default coordinates (ocean/null island)
txlat, txlon = 0, 0
results.append({
"drift": doc.get("drift"),
"frequency": doc.get("frequency"),
"band": frequency_to_band(doc.get("frequency")),
"mode": doc.get("mode"),
"rx_lat": rxlat,
"rx_lon": rxlon,
"rx_sign": doc.get('rx_callsign'),
"snr": doc.get("snr"),
"time": f"{doc.get('date')} {doc.get('time')}",
"tx_lat": txlat,
"tx_lon": txlon,
"tx_sign": doc.get('callsign'),
})
return results
# Flask Route Definitions
# ----------------------
# These routes define the web application's API endpoints and page views
@app.route('/')
def home():
"""
Root route: Combined view with map and table side-by-side using iframes.
Returns:
HTML: Rendered both.html template showing dual-pane view
"""
return render_template("both.html")
@app.route('/map')
def map():
"""
Map view route: Interactive Leaflet map with spot visualization.
Displays TX-RX propagation paths with colored markers indicating band,
filtering controls, and real-time spot updates.
Returns:
HTML: Rendered index_ft.html template
"""
return render_template('index_ft.html')
@app.route('/display')
def display():
"""
Alternative display route (legacy).
Returns:
HTML: Rendered index_wcount.html template
"""
return render_template('index_wcount.html')
@app.route('/spots')
def spots():
"""
REST API endpoint: Fetch spots for map display.
Query Parameters:
lastInterval (str): Minutes to look back (default: "15")
band (str): Band filter (optional, client-side filtering preferred)
Returns:
JSON: Array of spot objects with full TX/RX details
Example:
GET /spots?lastInterval=30
Returns spots from the last 30 minutes
"""
# Only lastInterval is used server-side; other filters are applied client-side
lastInterval = request.args.get('lastInterval', '15')
band = request.args.get('band') # Currently unused but preserved for API compatibility
spots = fetch_wspr_spots(lastInterval=lastInterval)
return jsonify(spots)
@app.route('/tbspots')
def tbspots():
"""
REST API endpoint: Fetch spots for table display.
Optimized endpoint for table view with simplified data structure
including CQ zone information for regional aggregation.
Query Parameters:
lastInterval (str): Minutes to look back (default: "15")
band (str): Band filter (optional)
Returns:
JSON: Array of spot objects with band, grid, time, cq_zone, mode
Example:
GET /tbspots?lastInterval=15
Returns spots from the last 15 minutes formatted for table view
"""
lastInterval = request.args.get('lastInterval', '15')
band = request.args.get('band') # Currently unused
spots = fetch_wspr_spots_tb(lastInterval=lastInterval)
return jsonify(spots)
@app.route('/table')
def table():
"""
Table view route: Regional band activity aggregation table.
Displays spot counts organized by geographic region (based on CQ zones)
and band, useful for quick assessment of band openings.
Returns:
HTML: Rendered table_ft.html template
"""
return render_template("table_ft.html")
# Application Entry Point
if __name__ == '__main__':
"""
Start Flask development server.
Debug mode is enabled for development. For production deployment,
use a production WSGI server like gunicorn or uwsgi.
Example production command:
gunicorn -w 4 -b 0.0.0.0:5000 web-ft:app
"""
app.run(debug=True)