mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-04-29 18:25:58 +00:00
Compare commits
2 Commits
ce99bbc6cf
...
2a5e0db5bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5e0db5bc | ||
|
|
1173af6a9d |
@@ -301,6 +301,7 @@ To navigate your way around the source code, this list may help.
|
||||
* `/data` - Data storage classes
|
||||
* `/spotproviders` - Classes providing spots by accessing the APIs of other services
|
||||
* `/alertproviders` - Classes providing alerts by accessing the APIs of other services
|
||||
* `/solarconditionsproviders` - Classes providing solar and propagation by accessing the APIs of other services
|
||||
* `/server` - Classes for running Spothole's own web server
|
||||
|
||||
*Templates*
|
||||
@@ -355,4 +356,6 @@ The software uses a number of Python libraries as listed in `requirements.txt`,
|
||||
|
||||
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
|
||||
|
||||
Amateur radio clusters, outdoor programmes, propagation data providers etc. are almost all volunteer-run services that make no or little profit, and are done for the love of amateur radio. Services like Spothole, which build on top of them, are truly standing on the shoulders of giants. None of this would have been possible without the hard work and dedication of many other people within the amaetur radio community.
|
||||
|
||||
The project's name was suggested by Harm, DK4HAA. Thanks!
|
||||
|
||||
@@ -128,7 +128,6 @@ spot-providers:
|
||||
sig: "TOTA"
|
||||
locations-csv: "datafiles/39c3-tota.csv"
|
||||
|
||||
|
||||
# Alert providers to use. Same setup as the spot providers list above.
|
||||
alert-providers:
|
||||
-
|
||||
@@ -160,6 +159,15 @@ alert-providers:
|
||||
name: "NG3K"
|
||||
enabled: true
|
||||
|
||||
|
||||
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
|
||||
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
|
||||
solar-condition-providers:
|
||||
-
|
||||
class: "HamQSL"
|
||||
name: "HamQSL"
|
||||
enabled: true
|
||||
|
||||
# Port to open the local web server on
|
||||
web-server-port: 8080
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class StatusReporter:
|
||||
"""Provides a timed update of the application's status data."""
|
||||
|
||||
def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts,
|
||||
alert_providers):
|
||||
alert_providers, solar_condition_providers):
|
||||
"""Constructor"""
|
||||
|
||||
self._status_data = status_data
|
||||
@@ -25,6 +25,7 @@ class StatusReporter:
|
||||
self._spot_providers = spot_providers
|
||||
self._alerts = alerts
|
||||
self._alert_providers = alert_providers
|
||||
self._solar_condition_providers = solar_condition_providers
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._startup_time = datetime.now(pytz.UTC)
|
||||
@@ -70,6 +71,11 @@ class StatusReporter:
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
self._alert_providers))
|
||||
self._status_data["solar_condition_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
self._solar_condition_providers))
|
||||
self._status_data["cleanup"] = {"status": self._cleanup_timer.status,
|
||||
"last_ran": self._cleanup_timer.last_cleanup_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if self._cleanup_timer.last_cleanup_time else 0}
|
||||
|
||||
180
data/solar_conditions.py
Normal file
180
data/solar_conditions.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Lookup tables for derived text descriptions.
|
||||
# Each threshold-based table is a list of (min_value, description) pairs in descending order;
|
||||
# the first entry whose threshold the value meets or exceeds is used.
|
||||
|
||||
BLACKOUT_DESCRIPTIONS = {
|
||||
"X": "Extreme HF radio blackout on entire sunlit side",
|
||||
"M": "Wide area HF radio blackout on sunlit side",
|
||||
"C": "Occasional loss of HF communications on sunlit side",
|
||||
"B": "No significant radio blackout",
|
||||
"A": "No impact",
|
||||
}
|
||||
|
||||
PROTON_FLUX_DESCRIPTIONS = [
|
||||
(1000000, "Complete HF blackout in polar regions"),
|
||||
(100000, "Partial HF blackout in polar regions"),
|
||||
(10000, "Degraded HF propagation in polar regions"),
|
||||
(1000, "Small effect on HF propagation in polar regions"),
|
||||
(100, "Minor effect on HF propagation in polar regions"),
|
||||
(10, "Very minor effect on HF propagation in polar regions"),
|
||||
(0, "No impact"),
|
||||
]
|
||||
|
||||
SOLAR_STORM_SCALES = [
|
||||
(100000, 5),
|
||||
(10000, 4),
|
||||
(1000, 3),
|
||||
(100, 2),
|
||||
(10, 1),
|
||||
(0, 0),
|
||||
]
|
||||
|
||||
GEOMAG_STORM_DESCRIPTIONS = [
|
||||
(9, "Solar storm. Complete HF blackout, S30+ noise"),
|
||||
(8, "Solar storm. HF sporadic only, S20-30 noise"),
|
||||
(7, "Solar storm. HF intermittent, S9-20 noise"),
|
||||
(6, "Solar storm. HF fading at higher latitudes, S6-9 noise"),
|
||||
(5, "Solar storm. HF fading at higher latitudes, S4-6 noise"),
|
||||
(4, "Active. Minor HF fading at higher latitudes, S2-3 noise"),
|
||||
(3, "Unsettled. Minor HF fading at higher latitudes, S2-3 noise"),
|
||||
(2, "Inactive. No impact, S0-2 noise"),
|
||||
(1, "Quiet. No impact, S0-2 noise"),
|
||||
(0, "Quiet. No impact, S0-2 noise"),
|
||||
]
|
||||
|
||||
GEOMAG_STORM_SCALES = [
|
||||
(9, 5),
|
||||
(8, 4),
|
||||
(7, 3),
|
||||
(6, 2),
|
||||
(5, 1),
|
||||
(0, 0),
|
||||
]
|
||||
|
||||
BAND_CONDITIONS_DESCRIPTIONS = [
|
||||
(200, "Reliable conditions on all bands including 6m"),
|
||||
(150, "Excellent conditions on all bands up to 10m, occasional 6m openings"),
|
||||
(120, "Fair to good conditions on all bands up to 10m"),
|
||||
(90, "Fair conditions on bands up to 15m"),
|
||||
(70, "Poor to fair conditions on bands up to 20m"),
|
||||
(0, "Bands above 40m unusable"),
|
||||
]
|
||||
|
||||
ELECTRON_FLUX_DESCRIPTIONS = [
|
||||
(1000, "Partial to complete HF blackout in polar regions"),
|
||||
(100, "Degraded HF propagation in polar regions"),
|
||||
(10, "Minor impact on HF in polar regions"),
|
||||
(0, "No impact"),
|
||||
]
|
||||
|
||||
|
||||
def _lookup_by_threshold(value, table, default=None):
|
||||
"""Return the description from a threshold table for the given numeric value.
|
||||
The table is a list of (min_value, description) pairs in descending order."""
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
for threshold, description in table:
|
||||
if value >= threshold:
|
||||
return description
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class HFBandCondition:
|
||||
"""Data class representing HF propagation conditions for certain bands and time of day."""
|
||||
|
||||
# Band name, e.g. "80m-40m", "20m-17m", "10m-6m"
|
||||
band: str = None
|
||||
# Time of day: "day" or "night"
|
||||
time: str = None
|
||||
# Propagation condition: "Good", "Fair", or "Poor"
|
||||
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
|
||||
class SolarConditions:
|
||||
"""Data class representing current solar and propagation conditions."""
|
||||
|
||||
# Time the data was last updated at the source, UTC seconds since UNIX epoch
|
||||
updated: float = None
|
||||
# Solar Flux Index (SFI)
|
||||
sfi: int = None
|
||||
# A-index (daily geomagnetic activity)
|
||||
a_index: int = None
|
||||
# K-index (3-hour geomagnetic activity)
|
||||
k_index: int = None
|
||||
# X-ray flux class, e.g. "B2.3", "C1.0"
|
||||
x_ray: str = None
|
||||
# Proton flux
|
||||
proton_flux: int = None
|
||||
# Electron flux
|
||||
electron_flux: int = None
|
||||
# Aurora activity level
|
||||
aurora: int = None
|
||||
# Latitude in degrees of the aurora boundary
|
||||
aurora_latitude: float = None
|
||||
# Sunspot count
|
||||
sunspots: int = None
|
||||
# Solar wind speed in km/s
|
||||
solar_wind: float = None
|
||||
# Interplanetary magnetic field strength in nT
|
||||
magnetic_field: float = None
|
||||
# Geomagnetic field condition, e.g. "Quiet", "Unsettled", "Active", "Storm"
|
||||
geomag_field: str = None
|
||||
# Geomagnetic background noise level, e.g. "S0", "S1", "S2"
|
||||
geomag_noise: str = None
|
||||
# HF band propagation conditions
|
||||
hf_conditions: list = None # list[HFBandCondition]
|
||||
# VHF propagation phenomena
|
||||
vhf_conditions: list = None # list[VHFCondition]
|
||||
|
||||
# Derived values (populated by infer_descriptions())
|
||||
# HF radio blackout risk, derived from x_ray
|
||||
blackout_desc: str = None
|
||||
# Solar radiation storm level description, derived from proton_flux
|
||||
proton_flux_desc: str = None
|
||||
# Solar radiation storm scale number (S0–S5), derived from proton_flux
|
||||
solar_storm_scale: int = None
|
||||
# Geomagnetic storm level description, derived from k_index
|
||||
geomag_storm_desc: str = None
|
||||
# Geomagnetic storm scale number (G0–G5), derived from k_index
|
||||
geomag_storm_scale: int = None
|
||||
# Overall HF band conditions summary, derived from sfi
|
||||
band_conditions_desc: str = None
|
||||
# Electron flux level, derived from electron_flux
|
||||
electron_flux_desc: str = None
|
||||
|
||||
def infer_descriptions(self):
|
||||
"""Populate derived text description fields from the current numeric/raw field values."""
|
||||
|
||||
# blackout_desc: use the X-ray flux class letter (first character of x_ray)
|
||||
if self.x_ray and len(self.x_ray) > 0:
|
||||
self.blackout_desc = BLACKOUT_DESCRIPTIONS.get(self.x_ray[0].upper())
|
||||
|
||||
self.proton_flux_desc = _lookup_by_threshold(self.proton_flux, PROTON_FLUX_DESCRIPTIONS)
|
||||
self.solar_storm_scale = _lookup_by_threshold(self.proton_flux, SOLAR_STORM_SCALES)
|
||||
self.geomag_storm_desc = _lookup_by_threshold(self.k_index, GEOMAG_STORM_DESCRIPTIONS)
|
||||
self.geomag_storm_scale = _lookup_by_threshold(self.k_index, GEOMAG_STORM_SCALES)
|
||||
self.band_conditions_desc = _lookup_by_threshold(self.sfi, BAND_CONDITIONS_DESCRIPTIONS)
|
||||
self.electron_flux_desc = _lookup_by_threshold(self.electron_flux, ELECTRON_FLUX_DESCRIPTIONS)
|
||||
|
||||
def to_json(self):
|
||||
"""JSON serialise"""
|
||||
|
||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 194 KiB |
28
server/handlers/api/solar_conditions.py
Normal file
28
server/handlers/api/solar_conditions.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
|
||||
|
||||
class APISolarConditionsHandler(tornado.web.RequestHandler):
|
||||
"""API request handler for /api/v1/solar"""
|
||||
|
||||
def initialize(self, solar_conditions, web_server_metrics):
|
||||
self._solar_conditions = solar_conditions
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
# Metrics
|
||||
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()
|
||||
|
||||
self.write(self._solar_conditions.to_json())
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
@@ -10,6 +10,7 @@ from server.handlers.api.addspot import APISpotHandler
|
||||
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
||||
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
|
||||
from server.handlers.api.options import APIOptionsHandler
|
||||
from server.handlers.api.solar_conditions import APISolarConditionsHandler
|
||||
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
||||
from server.handlers.api.status import APIStatusHandler
|
||||
from server.handlers.metrics import PrometheusMetricsHandler
|
||||
@@ -19,11 +20,12 @@ from server.handlers.pagetemplate import PageTemplateHandler
|
||||
class WebServer:
|
||||
"""Provides the public-facing web server."""
|
||||
|
||||
def __init__(self, spots, alerts, status_data, port):
|
||||
def __init__(self, spots, alerts, solar_conditions, status_data, port):
|
||||
"""Constructor"""
|
||||
|
||||
self._spots = spots
|
||||
self._alerts = alerts
|
||||
self._solar_conditions = solar_conditions
|
||||
self._sse_spot_queues = []
|
||||
self._sse_alert_queues = []
|
||||
self._status_data = status_data
|
||||
@@ -59,6 +61,8 @@ class WebServer:
|
||||
{"sse_spot_queues": self._sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/alerts/stream", APIAlertsStreamHandler,
|
||||
{"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/options", APIOptionsHandler,
|
||||
{"status_data": self._status_data, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/status", APIStatusHandler,
|
||||
|
||||
104
solarconditionsproviders/hamqsl.py
Normal file
104
solarconditionsproviders/hamqsl.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import logging
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytz
|
||||
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
|
||||
|
||||
POLL_INTERVAL = 3600 # 1 hour
|
||||
URL = "https://www.hamqsl.com/solarxml.php"
|
||||
|
||||
|
||||
class HamQSL(HTTPSolarConditionsProvider):
|
||||
"""Solar conditions provider using the HamQSL.com XML API (https://www.hamqsl.com/solarxml.php).
|
||||
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
if http_response.status_code != 200:
|
||||
logging.warning("HamQSL solar conditions API returned HTTP " + str(http_response.status_code))
|
||||
return None
|
||||
|
||||
root = ElementTree.fromstring(http_response.text)
|
||||
sd = root.find("solardata")
|
||||
if sd is None:
|
||||
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
|
||||
return None
|
||||
|
||||
# Some error checking functions in case the data is janky.
|
||||
|
||||
def text(tag, default=None):
|
||||
el = sd.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else default
|
||||
|
||||
def float_val(tag, default=None):
|
||||
try:
|
||||
return float(text(tag))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def int_val(tag, default=None):
|
||||
try:
|
||||
return int(text(tag))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
# Process HF band conditions
|
||||
hf_conditions = []
|
||||
calc = sd.find("calculatedconditions")
|
||||
if calc is not None:
|
||||
for band_el in calc.findall("band"):
|
||||
name = band_el.get("name")
|
||||
time = band_el.get("time")
|
||||
condition = band_el.text.strip() if band_el.text else None
|
||||
if name and time and condition:
|
||||
hf_conditions.append(HFBandCondition(band=name, time=time, condition=condition))
|
||||
|
||||
# Process VHF propagation conditions
|
||||
vhf_conditions = []
|
||||
vhf = sd.find("calculatedvhfconditions")
|
||||
if vhf is not None:
|
||||
for ph_el in vhf.findall("phenomenon"):
|
||||
vhf_conditions.append(VHFCondition(
|
||||
phenomenon=ph_el.get("name"),
|
||||
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.
|
||||
updated = None
|
||||
updated_str = text("updated")
|
||||
if updated_str:
|
||||
try:
|
||||
tz_abbr = updated_str.split()[-1]
|
||||
timezone = dateutil_tz.gettz(tz_abbr)
|
||||
if timezone is None:
|
||||
raise ValueError("Unknown timezone abbreviation: " + tz_abbr)
|
||||
dt = dateutil_parser.parse(updated_str, tzinfos={tz_abbr: timezone})
|
||||
updated = dt.astimezone(pytz.UTC).timestamp()
|
||||
except (ValueError, IndexError):
|
||||
logging.warning("HamQSL solar conditions API returned unrecognised timestamp format: " + updated_str)
|
||||
|
||||
# Return the data ready to be put into the solar conditions object.
|
||||
return {
|
||||
"updated": updated,
|
||||
"sfi": int_val("solarflux"),
|
||||
"a_index": int_val("aindex"),
|
||||
"k_index": int_val("kindex"),
|
||||
"x_ray": text("xray"),
|
||||
"sunspots": int_val("sunspots"),
|
||||
"proton_flux": int_val("protonflux"),
|
||||
"electron_flux": int_val("electonflux"),
|
||||
"aurora": int_val("aurora"),
|
||||
"aurora_latitude": float_val("latdegree"),
|
||||
"solar_wind": float_val("solarwind"),
|
||||
"magnetic_field": float_val("magneticfield"),
|
||||
"geomag_field": text("geomagfield"),
|
||||
"geomag_noise": text("signalnoise"),
|
||||
"hf_conditions": hf_conditions,
|
||||
"vhf_conditions": vhf_conditions
|
||||
}
|
||||
59
solarconditionsproviders/http_solar_conditions_provider.py
Normal file
59
solarconditionsproviders/http_solar_conditions_provider.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
||||
|
||||
|
||||
class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
|
||||
_http_response_to_solar_conditions() to parse the specific API response format."""
|
||||
|
||||
def __init__(self, provider_config, url, poll_interval):
|
||||
super().__init__(provider_config)
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
|
||||
def start(self):
|
||||
logging.info(
|
||||
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
|
||||
self._thread = Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def _run(self):
|
||||
while True:
|
||||
self._poll()
|
||||
if self._stop_event.wait(timeout=self._poll_interval):
|
||||
break
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
logging.debug("Polling " + self.name + " solar conditions API...")
|
||||
http_response = requests.get(self._url, headers=HTTP_HEADERS)
|
||||
new_data = self._http_response_to_solar_conditions(http_response)
|
||||
self.update_data(new_data)
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " solar conditions API.")
|
||||
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
"""Convert an HTTP response into solar conditions data. Returns a dict mapping SolarConditions field
|
||||
names to their new values, or None if the response could not be parsed. Only the fields returned will
|
||||
be updated on the shared SolarConditions object; any fields not included will be left unchanged."""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
41
solarconditionsproviders/solar_conditions_provider.py
Normal file
41
solarconditionsproviders/solar_conditions_provider.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
class SolarConditionsProvider:
|
||||
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
|
||||
propagation data."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
"""Constructor"""
|
||||
|
||||
self.name = provider_config["name"]
|
||||
self.enabled = provider_config["enabled"]
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
self._solar_conditions = None
|
||||
|
||||
def setup(self, solar_conditions):
|
||||
"""Set up the provider, giving it the solar conditions dict to update"""
|
||||
|
||||
self._solar_conditions = solar_conditions
|
||||
|
||||
def start(self):
|
||||
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
def stop(self):
|
||||
"""Stop any threads and prepare for application shutdown"""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
def update_data(self, new_data):
|
||||
"""Update the solar conditions object with new data"""
|
||||
|
||||
if new_data:
|
||||
for key, value in new_data.items():
|
||||
if hasattr(self._solar_conditions, key):
|
||||
setattr(self._solar_conditions, key, value)
|
||||
self._solar_conditions.infer_descriptions()
|
||||
27
spothole.py
27
spothole.py
@@ -8,6 +8,7 @@ import sys
|
||||
from diskcache import Cache
|
||||
|
||||
from core.cleanup import CleanupTimer
|
||||
from data.solar_conditions import SolarConditions
|
||||
from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN
|
||||
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
|
||||
from core.lookup_helper import lookup_helper
|
||||
@@ -17,10 +18,12 @@ from server.webserver import WebServer
|
||||
# Globals
|
||||
spots = Cache('cache/spots_cache')
|
||||
alerts = Cache('cache/alerts_cache')
|
||||
solar_conditions = SolarConditions()
|
||||
web_server = None
|
||||
status_data = {}
|
||||
spot_providers = []
|
||||
alert_providers = []
|
||||
solar_condition_providers = []
|
||||
cleanup_timer = None
|
||||
run = True
|
||||
|
||||
@@ -38,6 +41,9 @@ def shutdown(sig, frame):
|
||||
for ap in alert_providers:
|
||||
if ap.enabled:
|
||||
ap.stop()
|
||||
for scp in solar_condition_providers:
|
||||
if scp.enabled:
|
||||
scp.stop()
|
||||
cleanup_timer.stop()
|
||||
lookup_helper.stop()
|
||||
spots.close()
|
||||
@@ -61,6 +67,14 @@ def get_alert_provider_from_config(config_providers_entry):
|
||||
return provider_class(config_providers_entry)
|
||||
|
||||
|
||||
def get_solar_conditions_provider_from_config(config_providers_entry):
|
||||
"""Utility method to get a solar conditions provider based on the class specified in its config entry."""
|
||||
|
||||
module = importlib.import_module('solarconditionsproviders.' + config_providers_entry["class"].lower())
|
||||
provider_class = getattr(module, config_providers_entry["class"])
|
||||
return provider_class(config_providers_entry)
|
||||
|
||||
|
||||
# Main function
|
||||
if __name__ == '__main__':
|
||||
# Set up logging
|
||||
@@ -83,7 +97,7 @@ if __name__ == '__main__':
|
||||
lookup_helper.start()
|
||||
|
||||
# Set up web server
|
||||
web_server = WebServer(spots=spots, alerts=alerts, status_data=status_data, port=WEB_SERVER_PORT)
|
||||
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data, port=WEB_SERVER_PORT)
|
||||
|
||||
# Fetch, set up and start spot providers
|
||||
for entry in config["spot-providers"]:
|
||||
@@ -101,6 +115,14 @@ if __name__ == '__main__':
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
# Fetch, set up and start solar conditions providers
|
||||
for entry in config.get("solar-condition-providers", []):
|
||||
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
|
||||
for p in solar_condition_providers:
|
||||
p.setup(solar_conditions=solar_conditions)
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
# Set up timer to clear spot list of old data
|
||||
cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60)
|
||||
cleanup_timer.start()
|
||||
@@ -108,7 +130,8 @@ if __name__ == '__main__':
|
||||
# Set up status reporter
|
||||
status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server,
|
||||
cleanup_timer=cleanup_timer, spot_providers=spot_providers,
|
||||
alert_providers=alert_providers, run_interval=5)
|
||||
alert_providers=alert_providers,
|
||||
solar_condition_providers=solar_condition_providers, run_interval=5)
|
||||
status_reporter.start()
|
||||
|
||||
logging.info("Startup complete.")
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<h4 class="mt-4">What data sources are supported?</h4>
|
||||
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
|
||||
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p>
|
||||
<p>Spothole can retrieve solar and propagation condition data from <a href="https://www.hamqsl.com">HamQSL</a>.</p>
|
||||
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
|
||||
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
|
||||
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of one I've missed, please let me know!</p>
|
||||
@@ -61,12 +62,12 @@
|
||||
{% end %}
|
||||
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
|
||||
<h2 class="mt-4">Thanks</h2>
|
||||
<p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, and other online tools on which Spothole's data is based.</p>
|
||||
<p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, solar conditions and propagation modelling software, and other online tools on which Spothole's data is based. The vast majority of these are not profit-seeking and are made purely for the love of the hobby and to help others in the community. Spothole is standing on the shoulders of giants, who deserve a huge amount of thanks for all the work they put in.</p>
|
||||
<p>Spothole is also dependent on a number of Python libraries, in particular pyhamtools, and many JavaScript libraries, as well as the Font Awesome icon set and flag icons from the Noto Color Emoji set, and MIT-licenced GeoJSON files for CQ and ITU zones from HA8TKS.</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>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1774694366"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -69,8 +69,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/add-spot.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1774694366"></script>
|
||||
<script src="/js/add-spot.js?v=1774694366"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -56,8 +56,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/alerts.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1774694367"></script>
|
||||
<script src="/js/alerts.js?v=1774694367"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -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=1774598773"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774598773"></script>
|
||||
<script src="/js/bands.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1774694366"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774694366"></script>
|
||||
<script src="/js/bands.js?v=1774694366"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -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=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1774694366"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774694366"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774694366"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774694366"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
@@ -67,7 +67,7 @@
|
||||
<li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots"><i class="fa-solid fa-tower-cell"></i> Spots</a></li>
|
||||
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li>
|
||||
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
|
||||
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
|
||||
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-clock"></i> Upcoming</a></li>
|
||||
{% if allow_spotting %}
|
||||
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add Spot</a></li>
|
||||
{% end %}
|
||||
|
||||
@@ -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=1774598773"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774598773"></script>
|
||||
<script src="/js/map.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1774694367"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774694367"></script>
|
||||
<script src="/js/map.js?v=1774694367"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -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=1774598773"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774598773"></script>
|
||||
<script src="/js/spots.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1774694366"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774694366"></script>
|
||||
<script src="/js/spots.js?v=1774694366"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/status.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1774694366"></script>
|
||||
<script src="/js/status.js?v=1774694366"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,4 +1,5 @@
|
||||
<div class="d-inline-flex gap-1">
|
||||
<button id="filters-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
||||
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
||||
<button id="conditions-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleConditionsPanel();"><i class="fa-solid fa-sun"></i><span class="hideonmobile"> Conditions</span></button>
|
||||
<button id="filters-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i><span class="hideonmobile"> Filters</span></button>
|
||||
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span class="hideonmobile"> Display</span></button>
|
||||
</div>
|
||||
@@ -13,8 +13,13 @@ info:
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.2
|
||||
|
||||
* Added `/solar` endpoint for solar and propagation conditions.
|
||||
* Added `solar_condition_providers` array to the `/status` response.
|
||||
|
||||
### 1.1
|
||||
|
||||
|
||||
* Added Server-Sent Event API endpoints for spots and alerts.
|
||||
* Removed band colour and icon information from spots.
|
||||
* Moved activation_score from top-level in Spot and Alert to be part of the SIGRef
|
||||
@@ -23,7 +28,7 @@ info:
|
||||
license:
|
||||
name: The Unlicense
|
||||
url: https://unlicense.org/#the-unlicense
|
||||
version: v1.1
|
||||
version: v1.2
|
||||
servers:
|
||||
- url: https://spothole.app/api/v1
|
||||
paths:
|
||||
@@ -476,6 +481,11 @@ paths:
|
||||
description: An array of all the alert providers.
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertProviderStatus'
|
||||
solar_condition_providers:
|
||||
type: array
|
||||
description: An array of all the solar conditions providers.
|
||||
items:
|
||||
$ref: '#/components/schemas/SolarConditionsProviderStatus'
|
||||
|
||||
|
||||
/options:
|
||||
@@ -772,6 +782,22 @@ paths:
|
||||
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:
|
||||
@@ -1328,4 +1354,168 @@ components:
|
||||
ref_regex:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
description: Current solar and propagation conditions. All fields may be null if no provider has successfully fetched data yet.
|
||||
properties:
|
||||
updated:
|
||||
type: number
|
||||
description: Time that the data was last updated, UTC seconds since UNIX epoch
|
||||
example: 1759579508
|
||||
sfi:
|
||||
type: integer
|
||||
description: Solar Flux Index (SFI)
|
||||
example: 170
|
||||
a_index:
|
||||
type: integer
|
||||
description: Daily geomagnetic activity index
|
||||
example: 7
|
||||
k_index:
|
||||
type: integer
|
||||
description: 3-hour geomagnetic activity index, 0–9
|
||||
example: 2
|
||||
x_ray:
|
||||
type: string
|
||||
description: Current X-ray flux class
|
||||
example: "B2.3"
|
||||
proton_flux:
|
||||
type: integer
|
||||
description: Proton flux level
|
||||
example: 1
|
||||
electron_flux:
|
||||
type: integer
|
||||
description: Electron flux level
|
||||
example: 631
|
||||
aurora:
|
||||
type: integer
|
||||
description: Aurora activity level
|
||||
example: 5
|
||||
aurora_latitude:
|
||||
type: number
|
||||
description: Lowest latitude at which aurora should be visible
|
||||
example: 66.3
|
||||
sunspots:
|
||||
type: integer
|
||||
description: Sunspot count
|
||||
example: 87
|
||||
solar_wind:
|
||||
type: number
|
||||
description: Solar wind speed in km/s
|
||||
example: 356.6
|
||||
magnetic_field:
|
||||
type: number
|
||||
description: Interplanetary magnetic field strength in nT
|
||||
example: 2.5
|
||||
geomag_field:
|
||||
type: string
|
||||
description: Geomagnetic field condition summary
|
||||
example: "Active"
|
||||
geomag_noise:
|
||||
type: string
|
||||
description: Geomagnetic background noise level on HF, in S-units
|
||||
example: "S0"
|
||||
hf_conditions:
|
||||
type: array
|
||||
description: HF propagation condition assessments by band group and time of day
|
||||
items:
|
||||
$ref: '#/components/schemas/HFBandCondition'
|
||||
vhf_conditions:
|
||||
type: array
|
||||
description: VHF propagation condition assessments by phenomenon and location
|
||||
items:
|
||||
$ref: '#/components/schemas/VHFCondition'
|
||||
blackout_desc:
|
||||
type: string
|
||||
description: HF radio blackout risk description, derived from the X-ray flux class.
|
||||
example: "No significant radio blackout"
|
||||
proton_flux_desc:
|
||||
type: string
|
||||
description: Solar radiation storm level description, derived from proton flux.
|
||||
example: "No solar radiation storm"
|
||||
solar_storm_scale:
|
||||
type: integer
|
||||
description: Solar radiation storm scale number (S0-S5), derived from proton flux. S0 = none, S5 = extreme.
|
||||
minimum: 0
|
||||
maximum: 5
|
||||
example: 0
|
||||
geomag_storm_desc:
|
||||
type: string
|
||||
description: Geomagnetic storm level description, derived from K-index.
|
||||
example: "Quiet"
|
||||
geomag_storm_scale:
|
||||
type: integer
|
||||
description: Geomagnetic storm scale number (G0-G5), derived from K-index. G0 = none, G5 = extreme.
|
||||
minimum: 0
|
||||
maximum: 5
|
||||
example: 0
|
||||
band_conditions_desc:
|
||||
type: string
|
||||
description: Overall HF band conditions summary, derived from Solar Flux Index.
|
||||
example: "Fair to good conditions on all bands up to 10m"
|
||||
electron_flux_desc:
|
||||
type: string
|
||||
description: Electron flux impact description, derived from electron flux level.
|
||||
example: "No impact"
|
||||
|
||||
SolarConditionsProviderStatus:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The name of the provider.
|
||||
example: HamQSL
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether the provider is enabled or not.
|
||||
example: true
|
||||
status:
|
||||
type: string
|
||||
description: The status of the provider.
|
||||
example: OK
|
||||
last_updated:
|
||||
type: number
|
||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch. If this is zero, the provider has never updated.
|
||||
example: 1759579508
|
||||
@@ -3,6 +3,9 @@
|
||||
.navbar-nav .nav-link.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
.navbar-nav .nav-link i {
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
/* In embedded mode, hide header/footer/settings. "#header div" is kind of janky but for some reason if we hide the
|
||||
whole of #header, the map vertical sizing breaks. */
|
||||
|
||||
Reference in New Issue
Block a user