mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-04-29 18:25:58 +00:00
Add fetching of NOAA 3-day forecast
This commit is contained in:
@@ -131,8 +131,14 @@ class SolarConditions:
|
|||||||
hf_conditions: dict = None
|
hf_conditions: dict = None
|
||||||
# VHF propagation conditions, keyed by condition name
|
# VHF propagation conditions, keyed by condition name
|
||||||
vhf_conditions: dict = None
|
vhf_conditions: dict = None
|
||||||
# NOAA Kp index 3-day forecast, keyed by ISO 8601 interval string e.g. "2026-04-02T00:00:00+00:00/2026-04-02T03:00:00+00:00"
|
# NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period
|
||||||
k_index_forecast: dict = None
|
k_index_forecast: dict = None
|
||||||
|
# NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
|
||||||
|
solar_storm_forecast: dict = None
|
||||||
|
# NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC
|
||||||
|
blackout_forecast_r1r2: dict = None
|
||||||
|
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
|
||||||
|
blackout_forecast_r3_or_greater: dict = None
|
||||||
|
|
||||||
# Derived values (populated by infer_descriptions())
|
# Derived values (populated by infer_descriptions())
|
||||||
# HF radio blackout risk description, derived from x_ray
|
# HF radio blackout risk description, derived from x_ray
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider
|
from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider
|
||||||
|
|
||||||
POLL_INTERVAL = 3600 # 1 hour
|
POLL_INTERVAL = 3600
|
||||||
URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt"
|
URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt"
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,68 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
|||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
super().__init__(provider_config, URL, POLL_INTERVAL)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_percentage_table(lines, section_header, year):
|
||||||
|
"""Find and parse a forecast table using percentages, identified by section_header. This is common to the lookup
|
||||||
|
of the solar storm and radio blackout forecast parsing."""
|
||||||
|
start_idx = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if section_header in line:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None:
|
||||||
|
logging.warning(f"NOAA 3-day forecast: could not find '{section_header}' section")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the date header line — the first line within the next few that contains month+day patterns
|
||||||
|
date_header_idx = None
|
||||||
|
for j in range(start_idx + 1, min(start_idx + 6, len(lines))):
|
||||||
|
if re.search(r'[A-Za-z]{3}\s+\d{2}', lines[j]):
|
||||||
|
date_header_idx = j
|
||||||
|
break
|
||||||
|
if date_header_idx is None:
|
||||||
|
logging.warning(f"NOAA 3-day forecast: could not find date header after '{section_header}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', lines[date_header_idx])
|
||||||
|
if not date_matches:
|
||||||
|
logging.warning(f"NOAA 3-day forecast: no dates in header: {lines[date_header_idx]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
column_timestamps = []
|
||||||
|
for month_str, day_str in date_matches:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc)
|
||||||
|
column_timestamps.append(dt.timestamp())
|
||||||
|
except ValueError:
|
||||||
|
logging.warning(f"NOAA 3-day forecast: could not parse date: {month_str} {day_str} {year}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse data rows: each non-empty line should have a text label and percentage values
|
||||||
|
result = {}
|
||||||
|
for line in lines[date_header_idx + 1:]:
|
||||||
|
line_stripped = line.strip()
|
||||||
|
if not line_stripped:
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
pct_matches = list(re.finditer(r'\b(\d+)%', line_stripped))
|
||||||
|
if not pct_matches:
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
# Row label is everything before the first percentage value
|
||||||
|
row_label = line_stripped[:line_stripped.index(pct_matches[0].group())].strip()
|
||||||
|
row_data = {}
|
||||||
|
for j, match in enumerate(pct_matches):
|
||||||
|
if j >= len(column_timestamps):
|
||||||
|
break
|
||||||
|
row_data[column_timestamps[j]] = int(match.group(1))
|
||||||
|
if row_data:
|
||||||
|
result[row_label] = row_data
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
def _http_response_to_solar_conditions(self, http_response):
|
def _http_response_to_solar_conditions(self, http_response):
|
||||||
if http_response.status_code != 200:
|
if http_response.status_code != 200:
|
||||||
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
|
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
|
||||||
@@ -94,4 +156,23 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
|||||||
logging.warning("NOAA K-index forecast: no data rows parsed")
|
logging.warning("NOAA K-index forecast: no data rows parsed")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {"k_index_forecast": k_index_forecast}
|
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
|
||||||
|
solar_storm_forecast = None
|
||||||
|
radiation_table = self._parse_percentage_table(lines, "Solar Radiation Storm Forecast", year)
|
||||||
|
if radiation_table:
|
||||||
|
solar_storm_forecast = radiation_table.get("S1 or greater")
|
||||||
|
|
||||||
|
# Parse Radio Blackout Forecast (two rows: "R1-R2" and "R3 or greater")
|
||||||
|
blackout_forecast_r1r2 = None
|
||||||
|
blackout_forecast_r3_or_greater = None
|
||||||
|
blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year)
|
||||||
|
if blackout_table:
|
||||||
|
blackout_forecast_r1r2 = blackout_table.get("R1-R2")
|
||||||
|
blackout_forecast_r3_or_greater = blackout_table.get("R3 or greater")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"k_index_forecast": k_index_forecast,
|
||||||
|
"solar_storm_forecast": solar_storm_forecast,
|
||||||
|
"blackout_forecast_r1r2": blackout_forecast_r1r2,
|
||||||
|
"blackout_forecast_r3_or_greater": blackout_forecast_r3_or_greater,
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></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=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></script>
|
||||||
<script src="/js/add-spot.js?v=1775232636"></script>
|
<script src="/js/add-spot.js?v=1775233379"></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 %}
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></script>
|
||||||
<script src="/js/alerts.js?v=1775232636"></script>
|
<script src="/js/alerts.js?v=1775233379"></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 %}
|
||||||
@@ -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=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1775232636"></script>
|
<script src="/js/spotsbandsandmap.js?v=1775233379"></script>
|
||||||
<script src="/js/bands.js?v=1775232636"></script>
|
<script src="/js/bands.js?v=1775233379"></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 %}
|
||||||
@@ -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=1775232636"></script>
|
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1775233379"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1775232636"></script>
|
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1775233379"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1775232636"></script>
|
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1775233379"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1775232636"></script>
|
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1775233379"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -189,8 +189,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></script>
|
||||||
<script src="/js/conditions.js?v=1775232636"></script>
|
<script src="/js/conditions.js?v=1775233379"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-conditions").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-conditions").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -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=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1775232636"></script>
|
<script src="/js/spotsbandsandmap.js?v=1775233379"></script>
|
||||||
<script src="/js/map.js?v=1775232636"></script>
|
<script src="/js/map.js?v=1775233379"></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 %}
|
||||||
@@ -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=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1775232636"></script>
|
<script src="/js/spotsbandsandmap.js?v=1775233379"></script>
|
||||||
<script src="/js/spots.js?v=1775232636"></script>
|
<script src="/js/spots.js?v=1775233379"></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=1775232636"></script>
|
<script src="/js/common.js?v=1775233379"></script>
|
||||||
<script src="/js/status.js?v=1775232636"></script>
|
<script src="/js/status.js?v=1775233379"></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>
|
||||||
|
|||||||
@@ -1509,15 +1509,61 @@ components:
|
|||||||
example: "Band Closed"
|
example: "Band Closed"
|
||||||
k_index_forecast:
|
k_index_forecast:
|
||||||
type: object
|
type: object
|
||||||
description: NOAA Kp index 3-day forecast. Keys UNIX time in seconds representing the start of the relevant 3-hour UTC period. Values are the forecast Kp index (0–9) for that period.
|
description: >
|
||||||
|
NOAA Kp index 3-day forecast. Keys are UNIX timestamps (UTC seconds since epoch) for the
|
||||||
|
start of each 3-hour period. Values are the forecast Kp index (0–9) for that period.
|
||||||
|
Only forecast values are included; observed actuals (shown in parentheses in the source
|
||||||
|
data) are discarded.
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: integer
|
type: number
|
||||||
minimum: 0
|
minimum: 0
|
||||||
maximum: 9
|
maximum: 9
|
||||||
example:
|
example:
|
||||||
"2026-04-02T00:00:00+00:00": 2
|
"1743638400.0": 4.0
|
||||||
"2026-04-02T03:00:00+00:00": 3
|
"1743649200.0": 5.67
|
||||||
"2026-04-02T06:00:00+00:00": 2
|
"1743660000.0": 3.67
|
||||||
|
solar_storm_forecast:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
NOAA Solar Radiation Storm forecast — probability (%) of S1 or greater events per day.
|
||||||
|
Keys are UNIX timestamps (UTC seconds since epoch) for the start of each forecast day.
|
||||||
|
Values are integer percentages (0–100).
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
example:
|
||||||
|
"1743638400.0": 50
|
||||||
|
"1743724800.0": 50
|
||||||
|
"1743811200.0": 25
|
||||||
|
blackout_forecast_r1r2:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
NOAA Radio Blackout forecast — probability (%) of R1–R2 (Minor–Moderate) blackout events
|
||||||
|
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
||||||
|
forecast day. Values are integer percentages (0–100).
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
example:
|
||||||
|
"1743638400.0": 55
|
||||||
|
"1743724800.0": 55
|
||||||
|
"1743811200.0": 55
|
||||||
|
blackout_forecast_r3_or_greater:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
NOAA Radio Blackout forecast — probability (%) of R3 or greater (Strong–Extreme) blackout
|
||||||
|
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
||||||
|
forecast day. Values are integer percentages (0–100).
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 100
|
||||||
|
example:
|
||||||
|
"1743638400.0": 25
|
||||||
|
"1743724800.0": 25
|
||||||
|
"1743811200.0": 25
|
||||||
blackout_desc:
|
blackout_desc:
|
||||||
type: string
|
type: string
|
||||||
description: HF radio blackout risk description, derived from the X-ray flux class.
|
description: HF radio blackout risk description, derived from the X-ray flux class.
|
||||||
|
|||||||
Reference in New Issue
Block a user