7 Commits

Author SHA1 Message Date
Ian Renton
7de3cdc49c Fix links 2026-03-29 11:01:16 +01:00
Ian Renton
6f0101a861 DX stats table #99 2026-03-29 10:57:34 +01:00
Ian Renton
4fe8dfc36a DX stats table. Closes #99 2026-03-29 10:12:25 +01:00
Ian Renton
44f38b8114 Complete solar weather table #92 2026-03-29 09:22:03 +01:00
Ian Renton
5de5a7ffdf Solar weather table #92 2026-03-29 09:00:59 +01:00
Ian Renton
ed1f9e5b06 Simplify API for band conditions #92 2026-03-29 08:31:36 +01:00
Ian Renton
11d71629ce Propagation conditions page #92 2026-03-29 08:13:43 +01:00
16 changed files with 554 additions and 170 deletions

View File

@@ -6,9 +6,9 @@ from dataclasses import dataclass
# the first entry whose threshold the value meets or exceeds is used. # the first entry whose threshold the value meets or exceeds is used.
BLACKOUT_DESCRIPTIONS = { BLACKOUT_DESCRIPTIONS = {
"X": "Extreme HF radio blackout on entire sunlit side", "X": "Wide area HF radio blackout across sunlit side",
"M": "Wide area HF radio blackout on sunlit side", "M": "Occasional loss of HF communications on sunlit side",
"C": "Occasional loss of HF communications on sunlit side", "C": "Low absorption of HF signals on sunlit side",
"B": "No significant radio blackout", "B": "No significant radio blackout",
"A": "No impact", "A": "No impact",
} }
@@ -33,16 +33,16 @@ SOLAR_STORM_SCALES = [
] ]
GEOMAG_STORM_DESCRIPTIONS = [ GEOMAG_STORM_DESCRIPTIONS = [
(9, "Solar storm. Complete HF blackout, S30+ noise"), (9, "Complete HF blackout"),
(8, "Solar storm. HF sporadic only, S20-30 noise"), (8, "HF sporadic only"),
(7, "Solar storm. HF intermittent, S9-20 noise"), (7, "HF intermittent"),
(6, "Solar storm. HF fading at higher latitudes, S6-9 noise"), (6, "HF fading at higher latitudes"),
(5, "Solar storm. HF fading at higher latitudes, S4-6 noise"), (5, "HF fading at higher latitudes"),
(4, "Active. Minor HF fading at higher latitudes, S2-3 noise"), (4, "Minor HF fading at higher latitudes"),
(3, "Unsettled. Minor HF fading at higher latitudes, S2-3 noise"), (3, "Minor HF fading at higher latitudes"),
(2, "Inactive. No impact, S0-2 noise"), (2, "No impact"),
(1, "Quiet. No impact, S0-2 noise"), (1, "No impact"),
(0, "Quiet. No impact, S0-2 noise"), (0, "No impact"),
] ]
GEOMAG_STORM_SCALES = [ GEOMAG_STORM_SCALES = [
@@ -95,18 +95,6 @@ class HFBandCondition:
condition: str = None condition: str = None
@dataclass
class VHFCondition:
"""Data class representing a VHF propagation condition."""
# Phenomenon name, e.g. "E-Skip", "Sporadic E"
phenomenon: str = None
# Geographic location this applies to, e.g. "Europe", "N America"
location: str = None
# Condition description, e.g. "Band Closed", "Enhanced", "Good"
condition: str = None
@dataclass @dataclass
class SolarConditions: class SolarConditions:
"""Data class representing current solar and propagation conditions.""" """Data class representing current solar and propagation conditions."""
@@ -139,13 +127,13 @@ class SolarConditions:
geomag_field: str = None geomag_field: str = None
# Geomagnetic background noise level, e.g. "S0", "S1", "S2" # Geomagnetic background noise level, e.g. "S0", "S1", "S2"
geomag_noise: str = None geomag_noise: str = None
# HF band propagation conditions # HF band propagation conditions, keyed by "{band}-{time}" e.g. "80m-40m-day"
hf_conditions: list = None # list[HFBandCondition] hf_conditions: dict = None
# VHF propagation phenomena # VHF propagation conditions, keyed by condition name
vhf_conditions: list = None # list[VHFCondition] vhf_conditions: dict = None
# Derived values (populated by infer_descriptions()) # Derived values (populated by infer_descriptions())
# HF radio blackout risk, derived from x_ray # HF radio blackout risk description, derived from x_ray
blackout_desc: str = None blackout_desc: str = None
# Solar radiation storm level description, derived from proton_flux # Solar radiation storm level description, derived from proton_flux
proton_flux_desc: str = None proton_flux_desc: str = None
@@ -157,7 +145,7 @@ class SolarConditions:
geomag_storm_scale: int = None geomag_storm_scale: int = None
# Overall HF band conditions summary, derived from sfi # Overall HF band conditions summary, derived from sfi
band_conditions_desc: str = None band_conditions_desc: str = None
# Electron flux level, derived from electron_flux # Electron flux description, derived from electron_flux
electron_flux_desc: str = None electron_flux_desc: str = None
def infer_descriptions(self): def infer_descriptions(self):

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 core.utils import empty_queue
from server.handlers.api.addspot import APISpotHandler 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.alerts import APIAlertsHandler, APIAlertsStreamHandler
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
from server.handlers.api.options import APIOptionsHandler 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}), {"sse_alert_queues": self._sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/solar", APISolarConditionsHandler, (r"/api/v1/solar", APISolarConditionsHandler,
{"solar_conditions": self._solar_conditions, "web_server_metrics": self.web_server_metrics}), {"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, (r"/api/v1/options", APIOptionsHandler,
{"status_data": self._status_data, "web_server_metrics": self.web_server_metrics}), {"status_data": self._status_data, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/status", APIStatusHandler, (r"/api/v1/status", APIStatusHandler,

View File

@@ -4,7 +4,7 @@ from xml.etree import ElementTree
import pytz import pytz
from dateutil import parser as dateutil_parser, tz as dateutil_tz from dateutil import parser as dateutil_parser, tz as dateutil_tz
from data.solar_conditions import HFBandCondition, VHFCondition
from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider
POLL_INTERVAL = 3600 # 1 hour POLL_INTERVAL = 3600 # 1 hour
@@ -48,7 +48,7 @@ class HamQSL(HTTPSolarConditionsProvider):
return default return default
# Process HF band conditions # Process HF band conditions
hf_conditions = [] hf_conditions = {}
calc = sd.find("calculatedconditions") calc = sd.find("calculatedconditions")
if calc is not None: if calc is not None:
for band_el in calc.findall("band"): for band_el in calc.findall("band"):
@@ -56,18 +56,15 @@ class HamQSL(HTTPSolarConditionsProvider):
time = band_el.get("time") time = band_el.get("time")
condition = band_el.text.strip() if band_el.text else None condition = band_el.text.strip() if band_el.text else None
if name and time and condition: if name and time and condition:
hf_conditions.append(HFBandCondition(band=name, time=time, condition=condition)) hf_conditions[f"{name}-{time}"] = condition
# Process VHF propagation conditions # Process VHF propagation conditions
vhf_conditions = [] vhf_map = {}
vhf = sd.find("calculatedvhfconditions") vhf = sd.find("calculatedvhfconditions")
if vhf is not None: if vhf is not None:
for ph_el in vhf.findall("phenomenon"): for ph_el in vhf.findall("phenomenon"):
vhf_conditions.append(VHFCondition( key = (ph_el.get("name"), ph_el.get("location"))
phenomenon=ph_el.get("name"), vhf_map[key] = ph_el.text.strip() if ph_el.text else None
location=ph_el.get("location"),
condition=ph_el.text.strip() if ph_el.text else None
))
# Parse the "updated" timestamp string (format: "28 Mar 2026 0949 GMT") to UTC epoch seconds. # Parse the "updated" timestamp string (format: "28 Mar 2026 0949 GMT") to UTC epoch seconds.
updated = None updated = None
@@ -97,8 +94,14 @@ class HamQSL(HTTPSolarConditionsProvider):
"aurora_latitude": float_val("latdegree"), "aurora_latitude": float_val("latdegree"),
"solar_wind": float_val("solarwind"), "solar_wind": float_val("solarwind"),
"magnetic_field": float_val("magneticfield"), "magnetic_field": float_val("magneticfield"),
"geomag_field": text("geomagfield"), "geomag_field": (lambda v: "Unsettled" if v == "Unsettld" else v)(text("geomagfield").title()),
"geomag_noise": text("signalnoise"), "geomag_noise": text("signalnoise"),
"hf_conditions": hf_conditions, "hf_conditions": hf_conditions,
"vhf_conditions": vhf_conditions "vhf_conditions": {
"vhf_aurora_northern_hemi": vhf_map.get(("vhf-aurora", "northern_hemi")).title().replace("Lat Aur", "Latitude"),
"es_2m_europe": vhf_map.get(("E-Skip", "europe")),
"es_4m_europe": vhf_map.get(("E-Skip", "europe_4m")),
"es_6m_europe": vhf_map.get(("E-Skip", "europe_6m")),
"es_2m_na": vhf_map.get(("E-Skip", "north_america")),
},
} }

View File

@@ -22,42 +22,43 @@ class ParksNPeaks(HTTPSpotProvider):
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
# Iterate through source data # Iterate through source data
for source_spot in http_response.json(): if http_response and http_response != "":
# Convert to our spot format for source_spot in http_response.json():
spot = Spot(source=self.name, # Convert to our spot format
source_id=source_spot["actID"], spot = Spot(source=self.name,
dx_call=source_spot["actCallsign"].upper(), source_id=source_spot["actID"],
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None, dx_call=source_spot["actCallsign"].upper(),
# typo exists in API de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if ( # typo exists in API
source_spot["actFreq"] != "") else None, freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
# Seen PNP spots with empty frequency, and with comma-separated thousands digits source_spot["actFreq"] != "") else None,
mode=source_spot["actMode"].upper(), # Seen PNP spots with empty frequency, and with comma-separated thousands digits
comment=source_spot["actComments"], mode=source_spot["actMode"].upper(),
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace( comment=source_spot["actComments"],
tzinfo=pytz.UTC).timestamp()) time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(
tzinfo=pytz.UTC).timestamp())
# Extract a de_call if it's in the comment but not in the "actSpoter" field # Extract a de_call if it's in the comment but not in the "actSpoter" field
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment) m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
if not spot.de_call and m: if not spot.de_call and m:
spot.de_call = m.group(1) spot.de_call = m.group(1)
# Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a # Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a
# defined set of references # defined set of references
sig = source_spot["actClass"].upper() sig = source_spot["actClass"].upper()
sig_ref = source_spot["actSiteID"] sig_ref = source_spot["actSiteID"]
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "": if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "":
spot.sig = sig spot.sig = sig
spot.sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())] spot.sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())]
# Free text location is not present in all spots, so only add it if it's set # Free text location is not present in all spots, so only add it if it's set
if "actLocation" in source_spot and source_spot["actLocation"] != "": if "actLocation" in source_spot and source_spot["actLocation"] != "":
spot.sig_refs[0].name = source_spot["actLocation"] spot.sig_refs[0].name = source_spot["actLocation"]
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]: if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:
logging.warning("PNP spot found with sig " + sig + ", developer needs to add support for this!") logging.warning("PNP spot found with sig " + sig + ", developer needs to add support for this!")
# Add new spot to the list # Add new spot to the list
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots

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> <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> </div>
<script src="/js/common.js?v=1774699418"></script> <script src="/js/common.js?v=1774778476"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,25 +3,91 @@
<div class="card mt-5"> <div class="card mt-5">
<div class="card-header"> <div class="card-header">
Propagation Propagation Conditions
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 row-cols-md-2 g-3"> <div class="row row-cols-1 row-cols-md-2 g-3">
<div class="col"> <div class="col">
<div class="card h-100"> <div class="card h-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">HF Conditions</h5> <h5 class="card-title">HF</h5>
<table class="table table-sm mt-2">
<thead>
<tr>
<th>Band</th>
<th>Day</th>
<th>Night</th>
</tr>
</thead>
<tbody>
<tr>
<td>80-40m</td>
<td id="hf-conditions-80m-40m-day"></td>
<td id="hf-conditions-80m-40m-night"></td>
</tr>
<tr>
<td>30-20m</td>
<td id="hf-conditions-30m-20m-day"></td>
<td id="hf-conditions-30m-20m-night"></td>
</tr>
<tr>
<td>17-15m</td>
<td id="hf-conditions-17m-15m-day"></td>
<td id="hf-conditions-17m-15m-night"></td>
</tr>
<tr>
<td>12-10m</td>
<td id="hf-conditions-12m-10m-day"></td>
<td id="hf-conditions-12m-10m-night"></td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<div class="col"> <div class="col">
<div class="card h-100"> <div class="card h-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">VHF Conditions</h5> <h5 class="card-title">VHF</h5>
<table class="table table-sm mt-2">
<thead>
<tr>
<th>Propagation Mode</th>
<th>Condition</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sporadic-E 6m (Europe)</td>
<td id="vhf-conditions-es_6m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 4m (Europe)</td>
<td id="vhf-conditions-es_4m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 2m (Europe)</td>
<td id="vhf-conditions-es_2m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 2m (North America)</td>
<td id="vhf-conditions-es_2m_na"></td>
</tr>
<tr>
<td>Aurora (Northern Hemisphere)</td>
<td id="vhf-conditions-vhf_aurora_northern_hemi"></td>
</tr>
<tr>
<td>Aurora Minimum Latitude</td>
<td id="vhf-conditions-aurora-lat"></td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="form-text mt-3">Data from <a href="https://hamqsl.com">HamQSL.com</a>.</div>
</div> </div>
</div> </div>
@@ -30,12 +96,101 @@
Solar Weather Solar Weather
</div> </div>
<div class="card-body"> <div class="card-body">
Coming soon! <div class="row border-bottom align-items-start me-0">
<div class="col-12 col-md-2 py-2 fw-bold">Solar Flux</div>
<div id="sw-solar-flux-vals" class="col-12 col-md-3 py-2">
<span class="me-3">SFI: <strong id="sw-sfi"></strong></span>
<span>Sunspots: <strong id="sw-sunspots"></strong></span>
</div>
<div id="sw-solar-flux-desc" class="col-12 col-md-7 py-2"></div>
</div>
<div class="row border-bottom align-items-start me-0">
<div class="col-12 col-md-2 py-2 fw-bold">Geomagnetic</div>
<div id="sw-geomag-vals" class="col-12 col-md-3 py-2">
<span class="me-3">K: <strong id="sw-k-index"></strong></span>
<span class="me-3">A: <strong id="sw-a-index"></strong></span>
<span class="me-3"><strong>G</strong><strong id="sw-geomag-storm-scale"></strong></span>
<span>Noise: <strong id="sw-geomag-noise"></strong></span>
</div>
<div id="sw-geomag-desc" class="col-12 col-md-7 py-2">
<span id="sw-geomag-field"></span>. <span id="sw-geomag-storm-desc"></span>
</div>
</div>
<div class="row border-bottom align-items-start me-0">
<div class="col-12 col-md-2 py-2 fw-bold">X-ray Flux</div>
<div id="sw-xray-vals" class="col-12 col-md-3 py-2"><strong id="sw-x-ray"></strong></div>
<div id="sw-xray-desc" class="col-12 col-md-7 py-2"></div>
</div>
<div class="row border-bottom align-items-start me-0">
<div class="col-12 col-md-2 py-2 fw-bold">Proton Flux</div>
<div id="sw-proton-vals" class="col-12 col-md-3 py-2">
<span class="me-3"><strong id="sw-proton-flux"></strong> pfu</span>
<span class="me-3"><strong>S</strong><strong id="sw-solar-storm-scale"></strong></span>
</div>
<div id="sw-proton-desc" class="col-12 col-md-7 py-2"></div>
</div>
<div class="row border-bottom align-items-start me-0">
<div class="col-12 col-md-2 fw-bold py-2">Electron Flux</div>
<div id="sw-electron-vals" class="col-12 col-md-3 py-2"><strong id="sw-electron-flux"></strong> efu</div>
<div id="sw-electron-desc" class="col-12 col-md-7 py-2"></div>
</div>
<div class="form-text mt-3">Data from <a href="https://hamqsl.com">HamQSL.com</a>.</div>
</div> </div>
</div> </div>
<script src="/js/common.js?v=1774699418"></script> <div class="card mt-5">
<script src="/js/conditions.js?v=1774699418"></script> <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=1774778476"></script>
<script src="/js/conditions.js?v=1774778476"></script>
<script>$(document).ready(function() { $("#nav-link-conditions").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-conditions").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ info:
### 1.2 ### 1.2
* Added `/dxstats` endpoint for inter-continent DX spot statistics.
* Added `/solar` endpoint for solar and propagation conditions. * Added `/solar` endpoint for solar and propagation conditions.
* Added `solar_condition_providers` array to the `/status` response. * Added `solar_condition_providers` array to the `/status` response.
@@ -406,6 +407,61 @@ paths:
$ref: '#/components/schemas/AlertStream' $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: /status:
get: get:
tags: tags:
@@ -781,23 +837,6 @@ paths:
type: string type: string
example: "Failed" 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: components:
schemas: schemas:
Source: Source:
@@ -1177,7 +1216,7 @@ components:
SpotStream: SpotStream:
type: object type: object
description: A server-sent event containing a spot description: A server-sent event containing a spot
required: [data] required: [ data ]
properties: properties:
data: data:
$ref: "#/components/schemas/Spot" $ref: "#/components/schemas/Spot"
@@ -1278,7 +1317,7 @@ components:
AlertStream: AlertStream:
type: object type: object
description: A server-sent event containing an alert description: A server-sent event containing an alert
required: [data] required: [ data ]
properties: properties:
data: data:
$ref: "#/components/schemas/Alert" $ref: "#/components/schemas/Alert"
@@ -1356,47 +1395,6 @@ components:
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this. description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
example: "[A-Z]{2}\\-\\d+" example: "[A-Z]{2}\\-\\d+"
HFBandCondition:
type: object
description: HF propagation conditions for a group of bands at a particular time of day.
properties:
band:
type: string
description: Band group, e.g. "80m-40m", "30m-20m", "17m-15m", "10m-6m". As provided by HamQSL.
example: "80m-40m"
time:
type: string
description: Time of day these conditions apply to. As provided by HamQSL.
enum:
- day
- night
example: day
condition:
type: string
description: Propagation condition assessment. As provided by HamQSL.
enum:
- Good
- Fair
- Poor
example: Good
VHFCondition:
type: object
description: A VHF propagation phenomenon and its current condition.
properties:
phenomenon:
type: string
description: The name of the propagation phenomenon, e.g. "E-Skip", "vhf-aurora". As provided by HamQSL.
example: "E-Skip"
location:
type: string
description: The geographic region this condition applies to, e.g. "europe", "north_america", "northern_hemi". As provided by HamQSL.
example: "europe"
condition:
type: string
description: The current condition for this phenomenon and location.
example: "Band Closed"
SolarConditions: SolarConditions:
type: object type: object
description: Current solar and propagation conditions. All fields may be null if no provider has successfully fetched data yet. description: Current solar and propagation conditions. All fields may be null if no provider has successfully fetched data yet.
@@ -1458,15 +1456,57 @@ components:
description: Geomagnetic background noise level on HF, in S-units description: Geomagnetic background noise level on HF, in S-units
example: "S0" example: "S0"
hf_conditions: hf_conditions:
type: array type: object
description: HF propagation condition assessments by band group and time of day description: HF propagation condition assessments, keyed by "{band}-{time}" e.g. "80m-40m-day"
items: properties:
$ref: '#/components/schemas/HFBandCondition' 80m-40m-day:
type: string
enum: [ Good, Fair, Poor ]
80m-40m-night:
type: string
enum: [ Good, Fair, Poor ]
30m-20m-day:
type: string
enum: [ Good, Fair, Poor ]
30m-20m-night:
type: string
enum: [ Good, Fair, Poor ]
17m-15m-day:
type: string
enum: [ Good, Fair, Poor ]
17m-15m-night:
type: string
enum: [ Good, Fair, Poor ]
12m-10m-day:
type: string
enum: [ Good, Fair, Poor ]
12m-10m-night:
type: string
enum: [ Good, Fair, Poor ]
vhf_conditions: vhf_conditions:
type: array type: object
description: VHF propagation condition assessments by phenomenon and location description: VHF propagation condition assessments, keyed by condition name
items: properties:
$ref: '#/components/schemas/VHFCondition' vhf_aurora_northern_hemi:
type: string
description: VHF aurora propagation condition for the northern hemisphere
example: "Band Closed"
es_2m_europe:
type: string
description: Sporadic-E propagation condition on 2m for Europe
example: "Band Closed"
es_4m_europe:
type: string
description: Sporadic-E propagation condition on 4m for Europe
example: "Band Closed"
es_6m_europe:
type: string
description: Sporadic-E propagation condition on 6m for Europe
example: "Band Closed"
es_2m_na:
type: string
description: Sporadic-E propagation condition on 2m for North America
example: "Band Closed"
blackout_desc: blackout_desc:
type: string type: string
description: HF radio blackout risk description, derived from the X-ray flux class. description: HF radio blackout risk description, derived from the X-ray flux class.

View File

@@ -1,11 +1,157 @@
// 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 // Load solar conditions
function loadSolarConditions() { function loadSolarConditions() {
$.getJSON('/api/v1/solar', function(jsonData) { $.getJSON('/api/v1/solar', function(jsonData) {
// HF
const hfConditionClass = { 'Good': 'table-success', 'Fair': 'table-warning', 'Poor': 'table-danger' };
if (jsonData.hf_conditions) {
Object.entries(jsonData.hf_conditions).forEach(function([key, condition]) {
const cell = $('#hf-conditions-' + key);
cell.text(condition);
cell.addClass(hfConditionClass[condition]);
});
}
// VHF
if (jsonData.vhf_conditions) {
Object.entries(jsonData.vhf_conditions).forEach(function([key, condition]) {
const cell = $('#vhf-conditions-' + key);
cell.text(condition);
let vhfClass;
if (condition === 'Band Closed') {
vhfClass = 'table-danger';
} else if (condition.includes('High')) {
vhfClass = 'table-warning';
} else {
vhfClass = 'table-success';
}
cell.addClass(vhfClass);
});
}
if (jsonData.aurora_latitude !== null && jsonData.aurora_latitude !== undefined) {
$('#vhf-conditions-aurora-lat').text(jsonData.aurora_latitude + '°');
}
// Solar Weather
const swFields = {
'sfi': 'sw-sfi',
'sunspots': 'sw-sunspots',
'band_conditions_desc': 'sw-solar-flux-desc',
'k_index': 'sw-k-index',
'a_index': 'sw-a-index',
'geomag_field': 'sw-geomag-field',
'geomag_storm_scale': 'sw-geomag-storm-scale',
'geomag_storm_desc': 'sw-geomag-storm-desc',
'geomag_noise': 'sw-geomag-noise',
'x_ray': 'sw-x-ray',
'blackout_desc': 'sw-xray-desc',
'proton_flux': 'sw-proton-flux',
'solar_storm_scale': 'sw-solar-storm-scale',
'proton_flux_desc': 'sw-proton-desc',
'electron_flux': 'sw-electron-flux',
'electron_flux_desc': 'sw-electron-desc',
};
Object.entries(swFields).forEach(function([field, id]) {
const val = jsonData[field];
if (val !== null && val !== undefined) {
$('#' + id).text(val);
}
});
// Solar Weather - colouring
function applySwClass(valsId, descId, cls) {
$('#' + valsId).addClass(cls);
$('#' + descId).addClass(cls);
}
const sfi = jsonData.sfi;
if (sfi !== null && sfi !== undefined) {
applySwClass('sw-solar-flux-vals', 'sw-solar-flux-desc',
sfi > 150 ? 'bg-success-subtle' : sfi > 90 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
const kIndex = jsonData.k_index;
if (kIndex !== null && kIndex !== undefined) {
applySwClass('sw-geomag-vals', 'sw-geomag-desc',
kIndex < 5 ? 'bg-success-subtle' : kIndex < 7 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
const xRay = jsonData.x_ray;
if (xRay) {
const letter = xRay[0].toUpperCase();
const xRayClass = (letter === 'X') ? 'bg-danger-subtle'
: (letter === 'M') ? 'bg-warning-subtle'
: 'bg-success-subtle';
applySwClass('sw-xray-vals', 'sw-xray-desc', xRayClass);
}
const protonFlux = jsonData.proton_flux;
if (protonFlux !== null && protonFlux !== undefined) {
applySwClass('sw-proton-vals', 'sw-proton-desc',
protonFlux <= 100 ? 'bg-success-subtle' : protonFlux <= 10000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
const electronFlux = jsonData.electron_flux;
if (electronFlux !== null && electronFlux !== undefined) {
applySwClass('sw-electron-vals', 'sw-electron-desc',
electronFlux <= 100 ? 'bg-success-subtle' : electronFlux <= 1000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
});
}
// 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;
const cls = t === 0 ? 'table-danger' : t < 0.05 ? 'table-warning' : 'table-success';
cell.removeClass('table-danger table-warning table-success').addClass(cls);
});
}
// 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 // Startup
$(document).ready(function() { $(document).ready(function() {
loadSettings();
loadSolarConditions(); loadSolarConditions();
}); loadDxStats();
});