diff --git a/server/handlers/api/dxstats.py b/server/handlers/api/dxstats.py new file mode 100644 index 0000000..f96bcf8 --- /dev/null +++ b/server/handlers/api/dxstats.py @@ -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") diff --git a/server/webserver.py b/server/webserver.py index 39b4767..af259ca 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -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, diff --git a/templates/about.html b/templates/about.html index 6d72bc2..15ab16a 100644 --- a/templates/about.html +++ b/templates/about.html @@ -67,7 +67,7 @@

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.

- + {% end %} \ No newline at end of file diff --git a/templates/add_spot.html b/templates/add_spot.html index 626e685..4e7e625 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -69,8 +69,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/alerts.html b/templates/alerts.html index 5e27775..424bbce 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -56,8 +56,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/bands.html b/templates/bands.html index 13f19ac..c28dae8 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -62,9 +62,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index ca3ec9b..e8883b3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -46,10 +46,10 @@ crossorigin="anonymous"> - - - - + + + + diff --git a/templates/conditions.html b/templates/conditions.html index 38fd1b3..cf8ad03 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -138,8 +138,59 @@ - - +
+
+ DX Opportunities +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + {% for continent in ["EU", "NA", "SA", "AS", "AF", "OC", "AN"] %} + + + {% for band in ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"] %} + + {% end %} + + {% end %} + +
160m80m60m40m30m20m17m15m12m10m6m
{{ continent }}
+
+
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.
+
+
+ + + {% end %} \ No newline at end of file diff --git a/templates/map.html b/templates/map.html index 3f8ac25..cba0a09 100644 --- a/templates/map.html +++ b/templates/map.html @@ -70,9 +70,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/spots.html b/templates/spots.html index afb806a..17bdd33 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -87,9 +87,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/status.html b/templates/status.html index 91b741a..9ad2aa2 100644 --- a/templates/status.html +++ b/templates/status.html @@ -59,8 +59,8 @@ - - + + diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index d6382ed..bc0c963 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -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 diff --git a/webassets/js/conditions.js b/webassets/js/conditions.js index 8e03c7d..10dd7ce 100644 --- a/webassets/js/conditions.js +++ b/webassets/js/conditions.js @@ -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(); });