DX stats table. Closes #99

This commit is contained in:
Ian Renton
2026-03-29 10:12:25 +01:00
parent 44f38b8114
commit 4fe8dfc36a
13 changed files with 248 additions and 49 deletions

View File

@@ -0,0 +1,49 @@
import json
from collections import Counter
from datetime import datetime, timedelta
import pytz
import tornado
from core.prometheus_metrics_handler import api_requests_counter
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
CONTINENTS_SET = frozenset(CONTINENTS)
BANDS_SET = frozenset(BANDS)
class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/dxstats"""
def initialize(self, spots, web_server_metrics):
self._spots = spots
self._web_server_metrics = web_server_metrics
def get(self):
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter()
for key in self._spots.iterkeys():
spot = self._spots.get(key)
if spot is None:
continue
if not spot.time or spot.time < one_hour_ago:
continue
if spot.de_continent in CONTINENTS_SET and spot.dx_continent in CONTINENTS_SET and spot.band in BANDS_SET:
counts[spot.de_continent, spot.dx_continent, spot.band] += 1
result = {
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS}
for de in CONTINENTS
}
self.write(json.dumps(result))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")

View File

@@ -7,6 +7,7 @@ from tornado.web import StaticFileHandler
from core.utils import empty_queue
from server.handlers.api.addspot import APISpotHandler
from server.handlers.api.dxstats import APIDxStatsHandler
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
from server.handlers.api.options import APIOptionsHandler
@@ -63,6 +64,7 @@ class WebServer:
{"sse_alert_queues": self._sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/solar", APISolarConditionsHandler,
{"solar_conditions": self._solar_conditions, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/options", APIOptionsHandler,
{"status_data": self._status_data, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/status", APIStatusHandler,

View File

@@ -67,7 +67,7 @@
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/common.js?v=1774775545"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -69,8 +69,8 @@
</div>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/add-spot.js?v=1774772523"></script>
<script src="/js/common.js?v=1774775545"></script>
<script src="/js/add-spot.js?v=1774775545"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -56,8 +56,8 @@
</div>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/alerts.js?v=1774772523"></script>
<script src="/js/common.js?v=1774775545"></script>
<script src="/js/alerts.js?v=1774775545"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -62,9 +62,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/spotsbandsandmap.js?v=1774772523"></script>
<script src="/js/bands.js?v=1774772523"></script>
<script src="/js/common.js?v=1774775545"></script>
<script src="/js/spotsbandsandmap.js?v=1774775545"></script>
<script src="/js/bands.js?v=1774775545"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -46,10 +46,10 @@
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1774772523"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774772523"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774772523"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774772523"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1774775545"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774775545"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774775545"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774775545"></script>
</head>
<body>

View File

@@ -138,8 +138,59 @@
</div>
</div>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/conditions.js?v=1774772523"></script>
<div class="card mt-5">
<div class="card-header">
DX Opportunities
</div>
<div class="card-body">
<div class="mb-3">
<label for="dxstats-de-continent" class="form-label">Your continent:</label>
<select id="dxstats-de-continent" class="form-select storeable-select d-inline-block ms-2" style="width: auto;" oninput="dxStatsContientChanged();">
<option value="EU">Europe</option>
<option value="NA">North America</option>
<option value="SA">South America</option>
<option value="AS">Asia</option>
<option value="AF">Africa</option>
<option value="OC">Oceania</option>
<option value="AN">Antarctica</option>
</select>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead>
<tr>
<th></th>
<th>160m</th>
<th>80m</th>
<th>60m</th>
<th>40m</th>
<th>30m</th>
<th>20m</th>
<th>17m</th>
<th>15m</th>
<th>12m</th>
<th>10m</th>
<th>6m</th>
</tr>
</thead>
<tbody>
{% for continent in ["EU", "NA", "SA", "AS", "AF", "OC", "AN"] %}
<tr>
<td class="fw-bold">{{ continent }}</td>
{% for band in ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"] %}
<td id="dxstats-{{ continent }}-{{ band }}"></td>
{% end %}
</tr>
{% end %}
</tbody>
</table>
</div>
<div class="form-text mt-2">This table shows the number of spots in the past hour received in your continent, where the DX continent and band are as shown in the table. Bands with high numbers of spots are likely to be the best ones for making contact with the continent you want right now. Bear in mind that some bands and some continents are inherently much rarer than others.</div>
</div>
</div>
<script src="/js/common.js?v=1774775545"></script>
<script src="/js/conditions.js?v=1774775545"></script>
<script>$(document).ready(function() { $("#nav-link-conditions").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -70,9 +70,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/spotsbandsandmap.js?v=1774772523"></script>
<script src="/js/map.js?v=1774772523"></script>
<script src="/js/common.js?v=1774775545"></script>
<script src="/js/spotsbandsandmap.js?v=1774775545"></script>
<script src="/js/map.js?v=1774775545"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -87,9 +87,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/spotsbandsandmap.js?v=1774772523"></script>
<script src="/js/spots.js?v=1774772523"></script>
<script src="/js/common.js?v=1774775545"></script>
<script src="/js/spotsbandsandmap.js?v=1774775545"></script>
<script src="/js/spots.js?v=1774775545"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -59,8 +59,8 @@
</div>
</div>
<script src="/js/common.js?v=1774772523"></script>
<script src="/js/status.js?v=1774772523"></script>
<script src="/js/common.js?v=1774775545"></script>
<script src="/js/status.js?v=1774775545"></script>
<script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script>

View File

@@ -15,6 +15,7 @@ info:
### 1.2
* Added `/dxstats` endpoint for inter-continent DX spot statistics.
* Added `/solar` endpoint for solar and propagation conditions.
* Added `solar_condition_providers` array to the `/status` response.
@@ -406,6 +407,61 @@ paths:
$ref: '#/components/schemas/AlertStream'
/solar:
get:
tags:
- Propagation & DX
summary: Get solar and band conditions
description: Returns the current solar conditions and HF/VHF propagation condition summaries. This data is sourced from external providers (e.g. HamQSL) and updated periodically. All fields may be null if no provider has successfully fetched data yet.
operationId: solar
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/SolarConditions'
/dxstats:
get:
tags:
- Propagation & DX
summary: Get spot counts by continent and band
description: Returns a three-level nested object of spot counts from the current spot database, grouped by DE continent, then DX continent, then band. Only spots in the last hour are counted, regardless of what the server owner has set the spot expiry time to.
operationId: dxstats
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
description: Spot counts keyed by DE continent
additionalProperties:
type: object
description: Spot counts keyed by DX continent
additionalProperties:
type: object
description: Spot counts keyed by band
properties:
160m: { type: integer }
80m: { type: integer }
60m: { type: integer }
40m: { type: integer }
30m: { type: integer }
20m: { type: integer }
17m: { type: integer }
15m: { type: integer }
12m: { type: integer }
10m: { type: integer }
6m: { type: integer }
example:
EU:
NA: { 20m: 42, 17m: 7, 15m: 3, 10m: 0, 6m: 0, 160m: 0, 80m: 1, 60m: 0, 40m: 5, 30m: 2, 12m: 0 }
EU: { 20m: 18, 17m: 2, 15m: 0, 10m: 0, 6m: 1, 160m: 0, 80m: 4, 60m: 0, 40m: 9, 30m: 1, 12m: 0 }
/status:
get:
tags:
@@ -781,23 +837,6 @@ paths:
type: string
example: "Failed"
/solar:
get:
tags:
- General
summary: Get solar and propagation conditions
description: Returns the current solar conditions and HF/VHF propagation condition summaries. This data is sourced from external providers (e.g. HamQSL) and updated periodically. All fields may be null if no provider has successfully fetched data yet.
operationId: solar
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/SolarConditions'
components:
schemas:
Source:
@@ -1177,7 +1216,7 @@ components:
SpotStream:
type: object
description: A server-sent event containing a spot
required: [data]
required: [ data ]
properties:
data:
$ref: "#/components/schemas/Spot"
@@ -1278,7 +1317,7 @@ components:
AlertStream:
type: object
description: A server-sent event containing an alert
required: [data]
required: [ data ]
properties:
data:
$ref: "#/components/schemas/Alert"
@@ -1422,28 +1461,28 @@ components:
properties:
80m-40m-day:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
80m-40m-night:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
30m-20m-day:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
30m-20m-night:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
17m-15m-day:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
17m-15m-night:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
12m-10m-day:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
12m-10m-night:
type: string
enum: [Good, Fair, Poor]
enum: [ Good, Fair, Poor ]
vhf_conditions:
type: object
description: VHF propagation condition assessments, keyed by condition name

View File

@@ -1,3 +1,7 @@
// Cache for the full dxstats API response, so we can reload on the fly if the user changes the value of their continent
// in the select box
let dxStatsData = null;
// Load solar conditions
function loadSolarConditions() {
$.getJSON('/api/v1/solar', function(jsonData) {
@@ -104,7 +108,61 @@ function loadSolarConditions() {
});
}
// Take a normalised number 0-1 and generate a background colour for the DX stats cells
function dxStatsColor(t) {
const yellow = [255, 243, 205];
const green = [209, 231, 221];
if (t == 0.0) {
return "rgb(248, 215, 218)";
} else {
const ch = (i) => Math.round(yellow[i] + (green[i] - yellow[i]) * t);
return `rgb(${ch(0)}, ${ch(1)}, ${ch(2)})`;
}
}
// Render the DX stats table for the currently selected DE continent
function renderDxStats() {
if (!dxStatsData) { return; }
const deContinent = $('#dxstats-de-continent').val();
const deData = dxStatsData[deContinent];
if (!deData) { return; }
const cells = [];
Object.entries(deData).forEach(function([dxContinent, bands]) {
Object.entries(bands).forEach(function([band, count]) {
const cell = $('#dxstats-' + dxContinent + '-' + band);
cell.text(count);
cells.push({ cell, count });
});
});
const counts = cells.map(function(c) { return c.count; });
const min = Math.min(...counts);
const max = Math.max(...counts);
const range = max - min;
cells.forEach(function({ cell, count }) {
const t = range > 0 ? (count - min) / range : 0;
cell.css('background-color', dxStatsColor(t));
});
}
// Called when the DE continent select changes
function dxStatsContientChanged() {
saveSettings();
renderDxStats();
}
// Fetch DX stats from the API and render
function loadDxStats() {
$.getJSON('/api/v1/dxstats', function(jsonData) {
dxStatsData = jsonData;
renderDxStats();
});
}
// Startup
$(document).ready(function() {
loadSettings();
loadSolarConditions();
loadDxStats();
});