mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-06-24 05:35:10 +00:00
Workaround to fetch ionosonde data from KC2G since the GIRO data source often seems to be down.
This commit is contained in:
@@ -187,6 +187,10 @@ solar-condition-providers:
|
|||||||
class: "GIROIonosonde"
|
class: "GIROIonosonde"
|
||||||
name: "GIRO Ionosonde Data"
|
name: "GIRO Ionosonde Data"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
-
|
||||||
|
class: "KC2GProp"
|
||||||
|
name: "KC2G Propagation Data"
|
||||||
|
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
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ class SolarConditions:
|
|||||||
blackout_forecast_r1r2: dict = None
|
blackout_forecast_r1r2: dict = None
|
||||||
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
|
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
|
||||||
blackout_forecast_r3_or_greater: dict = None
|
blackout_forecast_r3_or_greater: dict = None
|
||||||
# Ionosonde measurements from LGDC, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf,
|
# Ionosonde measurements, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf,
|
||||||
# band_states
|
# band_states. Populated by GIROIonosonde or KC2GProp providers.
|
||||||
ionosonde_data: dict = None
|
ionosonde_data: dict = None
|
||||||
|
|
||||||
# Derived values (populated by infer_descriptions())
|
# Derived values (populated by infer_descriptions())
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class WebServer:
|
|||||||
provider_classes = [type(p).__name__ for p in self._solar_condition_providers if p.enabled]
|
provider_classes = [type(p).__name__ for p in self._solar_condition_providers if p.enabled]
|
||||||
has_hamqsl = "HamQSL" in provider_classes
|
has_hamqsl = "HamQSL" in provider_classes
|
||||||
has_noaa_forecast = "NOAA3dayForecast" in provider_classes
|
has_noaa_forecast = "NOAA3dayForecast" in provider_classes
|
||||||
has_giro_ionosonde = "GIROIonosonde" in provider_classes
|
has_giro_ionosonde = "GIROIonosonde" in provider_classes or "KC2GProp" in provider_classes
|
||||||
page_opts = {"web_server_metrics": self.web_server_metrics, "has_hamqsl": has_hamqsl,
|
page_opts = {"web_server_metrics": self.web_server_metrics, "has_hamqsl": has_hamqsl,
|
||||||
"has_noaa_forecast": has_noaa_forecast, "has_giro_ionosonde": has_giro_ionosonde}
|
"has_noaa_forecast": has_noaa_forecast, "has_giro_ionosonde": has_giro_ionosonde}
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ from threading import Thread, Event
|
|||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS, BANDS
|
from core.constants import HTTP_HEADERS
|
||||||
|
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||||
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
||||||
|
|
||||||
POLL_INTERVAL = 3600 # 1 hour
|
POLL_INTERVAL = 3600 # 1 hour
|
||||||
STATIONS_INDEX = "datafiles/didbase-stations.csv"
|
STATIONS_INDEX = "datafiles/didbase-stations.csv"
|
||||||
LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues"
|
LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues"
|
||||||
HISTORY_HOURS = 24
|
HISTORY_HOURS = 24
|
||||||
HF_BANDS = [b for b in BANDS if b.is_ham_hf]
|
|
||||||
|
|
||||||
|
|
||||||
class GIROIonosonde(SolarConditionsProvider):
|
class GIROIonosonde(SolarConditionsProvider):
|
||||||
"""Solar conditions provider using ionosonde data from the GIRO Data Center.
|
"""Solar conditions provider using ionosonde data from the GIRO Data Center.
|
||||||
Queries foF2, MUF, and LUF measurements for all stations in datafiles/didbase-stations.csv."""
|
Queries foF2, MUF, and LUF measurements for all stations in datafiles/didbase-stations.csv.
|
||||||
|
Can run alongside KC2GProp: GIRO supplements KC2G's foF2/MUF data with LUF readings, and
|
||||||
|
stations from each source that the other does not cover are preserved."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
@@ -27,8 +29,6 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
|
|
||||||
def _load_stations(self):
|
def _load_stations(self):
|
||||||
"""Load the CSV file containing the list of URSIs and Station Names for currently active ionosondes."""
|
|
||||||
|
|
||||||
stations = []
|
stations = []
|
||||||
with open(STATIONS_INDEX, newline='') as f:
|
with open(STATIONS_INDEX, newline='') as f:
|
||||||
for row in csv.reader(f):
|
for row in csv.reader(f):
|
||||||
@@ -37,15 +37,19 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
return stations
|
return stations
|
||||||
|
|
||||||
def setup(self, solar_conditions, solar_conditions_cache):
|
def setup(self, solar_conditions, solar_conditions_cache):
|
||||||
"""Prepopulate the ionosonde_data map with known URSI and station names, so that the API exposes this structure
|
"""Pre-populate ionosonde_data with known station names for stations not already present,
|
||||||
even before we actually have any data in it."""
|
so the station dropdown is available before the first poll. Does not overwrite existing
|
||||||
|
entries so KC2G cache data is preserved."""
|
||||||
|
|
||||||
super().setup(solar_conditions, solar_conditions_cache)
|
super().setup(solar_conditions, solar_conditions_cache)
|
||||||
self.update_data({"ionosonde_data": {
|
existing = solar_conditions.ionosonde_data or {}
|
||||||
s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None, "luf": None,
|
new_entries = {
|
||||||
"band_states": None}
|
s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None,
|
||||||
for s in self._stations
|
"luf": None, "band_states": None}
|
||||||
}})
|
for s in self._stations if s["ursi"] not in existing
|
||||||
|
}
|
||||||
|
if new_entries:
|
||||||
|
self.update_data({"ionosonde_data": {**existing, **new_entries}})
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
@@ -63,9 +67,13 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
|
|
||||||
def _poll(self):
|
def _poll(self):
|
||||||
try:
|
try:
|
||||||
logging.debug(f"Polling GIRO ionosonde data...")
|
logging.debug("Polling GIRO ionosonde data...")
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
from_time = now - timedelta(hours=HISTORY_HOURS)
|
from_time = now - timedelta(hours=HISTORY_HOURS)
|
||||||
|
cutoff_ts = from_time.timestamp()
|
||||||
|
|
||||||
|
# Start from the existing ionosonde_data so stations provided by other providers
|
||||||
|
# (e.g. KC2GProp) are preserved for stations GIRO does not cover.
|
||||||
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
|
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
|
|
||||||
@@ -76,11 +84,28 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
name = station["name"]
|
name = station["name"]
|
||||||
try:
|
try:
|
||||||
fof2, muf, luf = self._fetch_station_data(ursi, from_time, now)
|
fof2, muf, luf = self._fetch_station_data(ursi, from_time, now)
|
||||||
if fof2 and muf:
|
if not fof2 or not muf:
|
||||||
band_states = self._compute_band_statess(fof2, muf, luf or {})
|
continue
|
||||||
ionosonde_data[ursi] = {"ursi": ursi, "name": name, "fof2": fof2, "muf": muf,
|
|
||||||
"luf": luf or None, "band_states": band_states}
|
# Merge GIRO's readings into any existing data for this station.
|
||||||
updated_count += 1
|
existing = ionosonde_data.get(ursi, {})
|
||||||
|
merged_fof2 = {**{float(t): v for t, v in (existing.get("fof2") or {}).items()}, **fof2}
|
||||||
|
merged_muf = {**{float(t): v for t, v in (existing.get("muf") or {}).items()}, **muf}
|
||||||
|
merged_luf = dict(luf) if luf else {}
|
||||||
|
|
||||||
|
merged_fof2 = {t: v for t, v in merged_fof2.items() if t >= cutoff_ts}
|
||||||
|
merged_muf = {t: v for t, v in merged_muf.items() if t >= cutoff_ts}
|
||||||
|
merged_luf = {t: v for t, v in merged_luf.items() if t >= cutoff_ts}
|
||||||
|
|
||||||
|
band_states = compute_band_states(merged_fof2, merged_muf, merged_luf)
|
||||||
|
ionosonde_data[ursi] = {
|
||||||
|
"ursi": ursi, "name": name,
|
||||||
|
"fof2": merged_fof2 or None,
|
||||||
|
"muf": merged_muf or None,
|
||||||
|
"luf": merged_luf or None,
|
||||||
|
"band_states": band_states,
|
||||||
|
}
|
||||||
|
updated_count += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})")
|
logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})")
|
||||||
|
|
||||||
@@ -91,7 +116,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception(f"Exception in GIRO Ionosonde data provider")
|
logging.exception("Exception in GIRO Ionosonde data provider")
|
||||||
self._stop_event.wait(timeout=1)
|
self._stop_event.wait(timeout=1)
|
||||||
|
|
||||||
def _fetch_station_data(self, ursi, from_time, to_time):
|
def _fetch_station_data(self, ursi, from_time, to_time):
|
||||||
@@ -105,40 +130,6 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
return None, None, None
|
return None, None, None
|
||||||
return self._parse_all(response.text)
|
return self._parse_all(response.text)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _latest(d):
|
|
||||||
"""Return the value with the highest timestamp key, or None if the dict is empty."""
|
|
||||||
return d[max(d.keys())] if d else None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _compute_band_statess(fof2_dict, muf_dict, luf_dict):
|
|
||||||
"""Compute HF band states from the latest foF2, MUF and LUF values.
|
|
||||||
|
|
||||||
States:
|
|
||||||
Closed if band frequency is below LUF (if known) or above MUF
|
|
||||||
Short if band frequency is >= LUF and < foF2 (good for NVIS)
|
|
||||||
Long if band frequency is >= foF2 and < MUF (good for DX)
|
|
||||||
"""
|
|
||||||
|
|
||||||
# We have a list of timestamped data for each value, but for this we only want the latest value
|
|
||||||
fof2 = GIROIonosonde._latest(fof2_dict)
|
|
||||||
muf = GIROIonosonde._latest(muf_dict)
|
|
||||||
luf = GIROIonosonde._latest(luf_dict)
|
|
||||||
if fof2 is None or muf is None:
|
|
||||||
return {}
|
|
||||||
band_states = {}
|
|
||||||
|
|
||||||
# Iterate over all ham HF bands, we don't care about the others at this point
|
|
||||||
for band in HF_BANDS:
|
|
||||||
freq = band.start_freq / 1000000
|
|
||||||
if freq > muf or (luf is not None and freq < luf):
|
|
||||||
band_states[band.name] = "Closed"
|
|
||||||
elif freq < fof2:
|
|
||||||
band_states[band.name] = "Short"
|
|
||||||
else:
|
|
||||||
band_states[band.name] = "Long"
|
|
||||||
return band_states
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_all(text):
|
def _parse_all(text):
|
||||||
"""Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
|
"""Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
|
||||||
|
|||||||
33
solarconditionsproviders/ionosonde_utils.py
Normal file
33
solarconditionsproviders/ionosonde_utils.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from core.constants import BANDS
|
||||||
|
|
||||||
|
HF_BANDS = [b for b in BANDS if b.is_ham_hf]
|
||||||
|
|
||||||
|
|
||||||
|
def _latest(d):
|
||||||
|
return d[max(d.keys())] if d else None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_band_states(fof2_dict, muf_dict, luf_dict):
|
||||||
|
"""Compute HF band states from the latest foF2, MUF and LUF values.
|
||||||
|
|
||||||
|
States:
|
||||||
|
Closed if band frequency is above MUF or below LUF (if known)
|
||||||
|
Short if band frequency is >= LUF and < foF2 (good for NVIS)
|
||||||
|
Long if band frequency is >= foF2 and < MUF (good for DX)
|
||||||
|
"""
|
||||||
|
|
||||||
|
fof2 = _latest(fof2_dict)
|
||||||
|
muf = _latest(muf_dict)
|
||||||
|
luf = _latest(luf_dict) if luf_dict else None
|
||||||
|
if fof2 is None or muf is None:
|
||||||
|
return {}
|
||||||
|
band_states = {}
|
||||||
|
for band in HF_BANDS:
|
||||||
|
freq = band.start_freq / 1_000_000
|
||||||
|
if freq > muf or (luf is not None and freq < luf):
|
||||||
|
band_states[band.name] = "Closed"
|
||||||
|
elif freq < fof2:
|
||||||
|
band_states[band.name] = "Short"
|
||||||
|
else:
|
||||||
|
band_states[band.name] = "Long"
|
||||||
|
return band_states
|
||||||
121
solarconditionsproviders/kc2gprop.py
Normal file
121
solarconditionsproviders/kc2gprop.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from threading import Thread, Event
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
|
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||||
|
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
||||||
|
|
||||||
|
POLL_INTERVAL = 900 # 15 minutes
|
||||||
|
KC2G_URL = "https://prop.kc2g.com/api/stations.json"
|
||||||
|
HISTORY_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
class KC2GProp(SolarConditionsProvider):
|
||||||
|
"""Solar conditions provider using ionosonde data from prop.kc2g.com. The API returns only the latest reading per
|
||||||
|
station, so this provider polls every 15 minutes and accumulates a 24-hour time series by merging each new reading
|
||||||
|
into the persisted ionosonde_data, producing the same data structure as GIROIonosonde.
|
||||||
|
|
||||||
|
Can run alongside GIROIonosonde: KC2G provides foF2/MUF with good reliability, while GIRO supplements with LUF
|
||||||
|
readings. Stations from each source that the other does not cover are preserved."""
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(provider_config)
|
||||||
|
self._thread = None
|
||||||
|
self._stop_event = Event()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
logging.info(f"Set up query of KC2G ionosonde data API every {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=POLL_INTERVAL):
|
||||||
|
break
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
try:
|
||||||
|
logging.debug("Polling KC2G ionosonde data...")
|
||||||
|
response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||||
|
if response.status_code != 200:
|
||||||
|
logging.warning(f"KC2G ionosonde API returned HTTP {response.status_code}")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff_ts = (now - timedelta(hours=HISTORY_HOURS)).timestamp()
|
||||||
|
|
||||||
|
# Start from existing ionosonde_data so the accumulated time series survives across polls and restarts and
|
||||||
|
# stations provided only by GIROIonosonde are not discarded
|
||||||
|
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for reading in response.json():
|
||||||
|
station = reading.get("station", {})
|
||||||
|
ursi = station.get("code")
|
||||||
|
name = station.get("name")
|
||||||
|
if not ursi or not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
time_str = reading.get("time")
|
||||||
|
if not time_str:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(time_str)
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
ts_float = ts.timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip readings outside our history window (some stations have months-old data)
|
||||||
|
if ts_float < cutoff_ts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
fof2_val = reading.get("fof2")
|
||||||
|
muf_val = reading.get("mufd")
|
||||||
|
if fof2_val is None and muf_val is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Merge this reading into the existing time series for the station.
|
||||||
|
existing = ionosonde_data.get(ursi, {})
|
||||||
|
fof2_dict = dict(existing.get("fof2") or {})
|
||||||
|
muf_dict = dict(existing.get("muf") or {})
|
||||||
|
# LUF is not available from KC2G; carry forward whatever GIRO has written.
|
||||||
|
luf_dict = {float(t): v for t, v in (existing.get("luf") or {}).items()}
|
||||||
|
|
||||||
|
fof2_dict[ts_float] = fof2_val
|
||||||
|
muf_dict[ts_float] = muf_val
|
||||||
|
|
||||||
|
# Trim all series to the 24-hour window.
|
||||||
|
fof2_dict = {t: v for t, v in fof2_dict.items() if t >= cutoff_ts}
|
||||||
|
muf_dict = {t: v for t, v in muf_dict.items() if t >= cutoff_ts}
|
||||||
|
luf_dict = {t: v for t, v in luf_dict.items() if t >= cutoff_ts}
|
||||||
|
|
||||||
|
band_states = compute_band_states(fof2_dict, muf_dict, luf_dict)
|
||||||
|
ionosonde_data[ursi] = {
|
||||||
|
"ursi": ursi,
|
||||||
|
"name": name,
|
||||||
|
"fof2": fof2_dict or None,
|
||||||
|
"muf": muf_dict or None,
|
||||||
|
"luf": luf_dict or None,
|
||||||
|
"band_states": band_states,
|
||||||
|
}
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
self.update_data({"ionosonde_data": ionosonde_data})
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.exception("Exception in KC2G ionosonde data provider")
|
||||||
|
self._stop_event.wait(timeout=1)
|
||||||
@@ -27,7 +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>, <a href="https://tilesontheair.com/">Tiles on the Air</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>, <a href="https://tilesontheair.com/">Tiles on the Air</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>, the <a href="https://www.swpc.noaa.gov/">NOAA Space Weather Prediction Center</a> and the <a href="https://giro.uml.edu/">Lowell GIRO Data Center</a>.</p>
|
<p>Spothole can retrieve solar and propagation condition data from <a href="https://www.hamqsl.com">HamQSL</a>, the <a href="https://www.swpc.noaa.gov/">NOAA Space Weather Prediction Center</a>, the <a href="https://giro.uml.edu/">Lowell GIRO Data Center</a> and <a href="https://prop.kc2g.com/">prop.kc2g.com</a> by KC2G.</p>
|
||||||
<p>Spothole can also perform lookups for callsign data on behalf of the user from <a href="https://qrz.com">QRZ.com</a> and <a href="https://hamqth.com">HamQTH</a>.</p>
|
<p>Spothole can also perform lookups for callsign data on behalf of the user from <a href="https://qrz.com">QRZ.com</a> and <a href="https://hamqth.com">HamQTH</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), Tiles on the Air, 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), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></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 %}
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></script>
|
||||||
<script src="/js/add-spot.js?v=1780733088"></script>
|
<script src="/js/add-spot.js?v=1780738158"></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 %}
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></script>
|
||||||
<script src="/js/alerts.js?v=1780733088"></script>
|
<script src="/js/alerts.js?v=1780738158"></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 %}
|
||||||
@@ -76,9 +76,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=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1780733088"></script>
|
<script src="/js/spotsbandsandmap.js?v=1780738158"></script>
|
||||||
<script src="/js/bands.js?v=1780733088"></script>
|
<script src="/js/bands.js?v=1780738158"></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 %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/css/style.css?v=1780733088" type="text/css">
|
<link rel="stylesheet" href="/css/style.css?v=1780738158" type="text/css">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||||
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
|
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
|
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1780733088"></script>
|
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1780738158"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1780733088"></script>
|
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1780738158"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1780733088"></script>
|
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1780738158"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -209,7 +209,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="ionosonde-chart" class="mt-3 mb-3 hideonmobile"></canvas>
|
<canvas id="ionosonde-chart" class="mt-3 mb-3 hideonmobile"></canvas>
|
||||||
<div class="form-text mt-2">Data from the <a href="https://lgdc.uml.edu/">Lowell GIRO Data Center</a>.</div>
|
<div class="form-text mt-2">Data from the <a href="https://lgdc.uml.edu/">Lowell GIRO Data Center</a> and/or <a href="https://prop.kc2g.com/">KC2G</a>.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -271,8 +271,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||||
<script src="/js/common.js?v=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></script>
|
||||||
<script src="/js/conditions.js?v=1780733088"></script>
|
<script src="/js/conditions.js?v=1780738158"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-conditions").addClass("active");
|
$("#nav-link-conditions").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
@@ -94,9 +94,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=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1780733088"></script>
|
<script src="/js/spotsbandsandmap.js?v=1780738158"></script>
|
||||||
<script src="/js/map.js?v=1780733088"></script>
|
<script src="/js/map.js?v=1780738158"></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 %}
|
||||||
@@ -104,9 +104,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=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1780733088"></script>
|
<script src="/js/spotsbandsandmap.js?v=1780738158"></script>
|
||||||
<script src="/js/spots.js?v=1780733088"></script>
|
<script src="/js/spots.js?v=1780738158"></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 %}
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1780733088"></script>
|
<script src="/js/common.js?v=1780738158"></script>
|
||||||
<script src="/js/status.js?v=1780733088"></script>
|
<script src="/js/status.js?v=1780738158"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -431,9 +431,9 @@ function renderIonosondeData() {
|
|||||||
const latestDate = moment.utc(maxTs * 1000);
|
const latestDate = moment.utc(maxTs * 1000);
|
||||||
$('#ionosonde-latest-time').text(latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')');
|
$('#ionosonde-latest-time').text(latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')');
|
||||||
}
|
}
|
||||||
$('#ionosonde-latest-luf').text(latestLuf !== null ? latestLuf.toFixed(2) + ' MHz' : 'N/A');
|
$('#ionosonde-latest-luf').text(latestLuf !== null ? latestLuf.toFixed(2) + ' MHz' : 'Unknown');
|
||||||
$('#ionosonde-latest-fof2').text(latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A');
|
$('#ionosonde-latest-fof2').text(latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'Unknown');
|
||||||
$('#ionosonde-latest-muf').text(latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A');
|
$('#ionosonde-latest-muf').text(latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'Unknown');
|
||||||
$('#ionosonde-stale-warning').toggle(maxTs !== null && (Date.now() / 1000 - maxTs) > 12 * 3600);
|
$('#ionosonde-stale-warning').toggle(maxTs !== null && (Date.now() / 1000 - maxTs) > 12 * 3600);
|
||||||
|
|
||||||
// Populate band state tables. There are actually two tables to populate, which is pretty janky, but allows us to
|
// Populate band state tables. There are actually two tables to populate, which is pretty janky, but allows us to
|
||||||
|
|||||||
Reference in New Issue
Block a user