2 Commits

Author SHA1 Message Date
Ian Renton
2a5e0db5bc Add descriptions for solar conditions #92 2026-03-28 10:39:26 +00:00
Ian Renton
1173af6a9d Fetch solar conditions from HamQSL #92 2026-03-28 10:04:29 +00:00
22 changed files with 683 additions and 32 deletions

View File

@@ -301,6 +301,7 @@ To navigate your way around the source code, this list may help.
* `/data` - Data storage classes * `/data` - Data storage classes
* `/spotproviders` - Classes providing spots by accessing the APIs of other services * `/spotproviders` - Classes providing spots by accessing the APIs of other services
* `/alertproviders` - Classes providing alerts 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 * `/server` - Classes for running Spothole's own web server
*Templates* *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. 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! The project's name was suggested by Harm, DK4HAA. Thanks!

View File

@@ -128,7 +128,6 @@ spot-providers:
sig: "TOTA" sig: "TOTA"
locations-csv: "datafiles/39c3-tota.csv" locations-csv: "datafiles/39c3-tota.csv"
# Alert providers to use. Same setup as the spot providers list above. # Alert providers to use. Same setup as the spot providers list above.
alert-providers: alert-providers:
- -
@@ -160,6 +159,15 @@ alert-providers:
name: "NG3K" name: "NG3K"
enabled: true 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 # Port to open the local web server on
web-server-port: 8080 web-server-port: 8080

View File

@@ -14,7 +14,7 @@ class StatusReporter:
"""Provides a timed update of the application's status data.""" """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, def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts,
alert_providers): alert_providers, solar_condition_providers):
"""Constructor""" """Constructor"""
self._status_data = status_data self._status_data = status_data
@@ -25,6 +25,7 @@ class StatusReporter:
self._spot_providers = spot_providers self._spot_providers = spot_providers
self._alerts = alerts self._alerts = alerts
self._alert_providers = alert_providers self._alert_providers = alert_providers
self._solar_condition_providers = solar_condition_providers
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC) self._startup_time = datetime.now(pytz.UTC)
@@ -70,6 +71,11 @@ class StatusReporter:
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0}, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
self._alert_providers)) 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, self._status_data["cleanup"] = {"status": self._cleanup_timer.status,
"last_ran": self._cleanup_timer.last_cleanup_time.replace( "last_ran": self._cleanup_timer.last_cleanup_time.replace(
tzinfo=pytz.UTC).timestamp() if self._cleanup_timer.last_cleanup_time else 0} tzinfo=pytz.UTC).timestamp() if self._cleanup_timer.last_cleanup_time else 0}

180
data/solar_conditions.py Normal file
View 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 (S0S5), 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 (G0G5), 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

View 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")

View File

@@ -10,6 +10,7 @@ from server.handlers.api.addspot import APISpotHandler
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
from server.handlers.api.solar_conditions import APISolarConditionsHandler
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from server.handlers.api.status import APIStatusHandler from server.handlers.api.status import APIStatusHandler
from server.handlers.metrics import PrometheusMetricsHandler from server.handlers.metrics import PrometheusMetricsHandler
@@ -19,11 +20,12 @@ from server.handlers.pagetemplate import PageTemplateHandler
class WebServer: class WebServer:
"""Provides the public-facing web server.""" """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""" """Constructor"""
self._spots = spots self._spots = spots
self._alerts = alerts self._alerts = alerts
self._solar_conditions = solar_conditions
self._sse_spot_queues = [] self._sse_spot_queues = []
self._sse_alert_queues = [] self._sse_alert_queues = []
self._status_data = status_data 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}), {"sse_spot_queues": self._sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/alerts/stream", APIAlertsStreamHandler, (r"/api/v1/alerts/stream", APIAlertsStreamHandler,
{"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,
{"solar_conditions": self._solar_conditions, "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

@@ -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
}

View 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")

View 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()

View File

@@ -8,6 +8,7 @@ import sys
from diskcache import Cache from diskcache import Cache
from core.cleanup import CleanupTimer from core.cleanup import CleanupTimer
from data.solar_conditions import SolarConditions
from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
@@ -17,10 +18,12 @@ from server.webserver import WebServer
# Globals # Globals
spots = Cache('cache/spots_cache') spots = Cache('cache/spots_cache')
alerts = Cache('cache/alerts_cache') alerts = Cache('cache/alerts_cache')
solar_conditions = SolarConditions()
web_server = None web_server = None
status_data = {} status_data = {}
spot_providers = [] spot_providers = []
alert_providers = [] alert_providers = []
solar_condition_providers = []
cleanup_timer = None cleanup_timer = None
run = True run = True
@@ -38,6 +41,9 @@ def shutdown(sig, frame):
for ap in alert_providers: for ap in alert_providers:
if ap.enabled: if ap.enabled:
ap.stop() ap.stop()
for scp in solar_condition_providers:
if scp.enabled:
scp.stop()
cleanup_timer.stop() cleanup_timer.stop()
lookup_helper.stop() lookup_helper.stop()
spots.close() spots.close()
@@ -61,6 +67,14 @@ def get_alert_provider_from_config(config_providers_entry):
return provider_class(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 # Main function
if __name__ == '__main__': if __name__ == '__main__':
# Set up logging # Set up logging
@@ -83,7 +97,7 @@ if __name__ == '__main__':
lookup_helper.start() lookup_helper.start()
# Set up web server # 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 # Fetch, set up and start spot providers
for entry in config["spot-providers"]: for entry in config["spot-providers"]:
@@ -101,6 +115,14 @@ if __name__ == '__main__':
if p.enabled: if p.enabled:
p.start() 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 # 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 = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60)
cleanup_timer.start() cleanup_timer.start()
@@ -108,7 +130,8 @@ if __name__ == '__main__':
# Set up status reporter # Set up status reporter
status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server, status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server,
cleanup_timer=cleanup_timer, spot_providers=spot_providers, 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() status_reporter.start()
logging.info("Startup complete.") logging.info("Startup complete.")

View File

@@ -27,6 +27,7 @@
<h4 class="mt-4">What data sources are supported?</h4> <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 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 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>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>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> <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 %} {% 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> <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> <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>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> <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=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> <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=1774598773"></script> <script src="/js/common.js?v=1774694366"></script>
<script src="/js/add-spot.js?v=1774598773"></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> <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=1774598773"></script> <script src="/js/common.js?v=1774694367"></script>
<script src="/js/alerts.js?v=1774598773"></script> <script src="/js/alerts.js?v=1774694367"></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=1774598773"></script> <script src="/js/common.js?v=1774694366"></script>
<script src="/js/spotsbandsandmap.js?v=1774598773"></script> <script src="/js/spotsbandsandmap.js?v=1774694366"></script>
<script src="/js/bands.js?v=1774598773"></script> <script src="/js/bands.js?v=1774694366"></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=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/utils.js?v=1774694366"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774694366"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774694366"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774694366"></script>
</head> </head>
<body> <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="/" 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="/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="/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 %} {% 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&nbsp;Spot</a></li> <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&nbsp;Spot</a></li>
{% 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=1774598773"></script> <script src="/js/common.js?v=1774694367"></script>
<script src="/js/spotsbandsandmap.js?v=1774598773"></script> <script src="/js/spotsbandsandmap.js?v=1774694367"></script>
<script src="/js/map.js?v=1774598773"></script> <script src="/js/map.js?v=1774694367"></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=1774598773"></script> <script src="/js/common.js?v=1774694366"></script>
<script src="/js/spotsbandsandmap.js?v=1774598773"></script> <script src="/js/spotsbandsandmap.js?v=1774694366"></script>
<script src="/js/spots.js?v=1774598773"></script> <script src="/js/spots.js?v=1774694366"></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

@@ -3,8 +3,8 @@
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div> <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/common.js?v=1774694366"></script>
<script src="/js/status.js?v=1774598773"></script> <script src="/js/status.js?v=1774694366"></script>
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -1,4 +1,5 @@
<div class="d-inline-flex gap-1"> <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>&nbsp;Filters</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">&nbsp;Conditions</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>&nbsp;Display</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">&nbsp;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">&nbsp;Display</span></button>
</div> </div>

View File

@@ -13,8 +13,13 @@ info:
## Changelog ## Changelog
### 1.2
* Added `/solar` endpoint for solar and propagation conditions.
* Added `solar_condition_providers` array to the `/status` response.
### 1.1 ### 1.1
* Added Server-Sent Event API endpoints for spots and alerts. * Added Server-Sent Event API endpoints for spots and alerts.
* Removed band colour and icon information from spots. * Removed band colour and icon information from spots.
* Moved activation_score from top-level in Spot and Alert to be part of the SIGRef * Moved activation_score from top-level in Spot and Alert to be part of the SIGRef
@@ -23,7 +28,7 @@ info:
license: license:
name: The Unlicense name: The Unlicense
url: https://unlicense.org/#the-unlicense url: https://unlicense.org/#the-unlicense
version: v1.1 version: v1.2
servers: servers:
- url: https://spothole.app/api/v1 - url: https://spothole.app/api/v1
paths: paths:
@@ -476,6 +481,11 @@ paths:
description: An array of all the alert providers. description: An array of all the alert providers.
items: items:
$ref: '#/components/schemas/AlertProviderStatus' $ref: '#/components/schemas/AlertProviderStatus'
solar_condition_providers:
type: array
description: An array of all the solar conditions providers.
items:
$ref: '#/components/schemas/SolarConditionsProviderStatus'
/options: /options:
@@ -772,6 +782,22 @@ paths:
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:
@@ -1328,4 +1354,168 @@ components:
ref_regex: ref_regex:
type: string type: string
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:
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, 09
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

View File

@@ -3,6 +3,9 @@
.navbar-nav .nav-link.active { .navbar-nav .nav-link.active {
font-weight: bold; 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 /* 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. */ whole of #header, the map vertical sizing breaks. */