From df9a82cad3b7459855d45848ec745253202851f7 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Fri, 3 Apr 2026 17:10:36 +0100 Subject: [PATCH] Add fetching of NOAA 3-day forecast --- .idea/spothole.iml | 2 +- config-example.yml | 4 + data/solar_conditions.py | 2 + solarconditionsproviders/noaa3dayforecast.py | 97 ++++++++++++++++++++ templates/about.html | 2 +- templates/add_spot.html | 4 +- templates/alerts.html | 4 +- templates/bands.html | 6 +- templates/base.html | 8 +- templates/conditions.html | 4 +- templates/map.html | 6 +- templates/spots.html | 6 +- templates/status.html | 4 +- webassets/apidocs/openapi.yml | 11 +++ 14 files changed, 137 insertions(+), 23 deletions(-) create mode 100644 solarconditionsproviders/noaa3dayforecast.py diff --git a/.idea/spothole.iml b/.idea/spothole.iml index 8e8dfd8..06bd9e9 100644 --- a/.idea/spothole.iml +++ b/.idea/spothole.iml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/config-example.yml b/config-example.yml index aff2ef7..43b816a 100644 --- a/config-example.yml +++ b/config-example.yml @@ -175,6 +175,10 @@ solar-condition-providers: class: "HamQSL" name: "HamQSL" enabled: true + - + class: "NOAA3dayForecast" + name: "NOAA 3-day Forecast" + enabled: true # Port to open the local web server on web-server-port: 8080 diff --git a/data/solar_conditions.py b/data/solar_conditions.py index 0b24df1..7398fe5 100644 --- a/data/solar_conditions.py +++ b/data/solar_conditions.py @@ -131,6 +131,8 @@ class SolarConditions: hf_conditions: dict = None # VHF propagation conditions, keyed by condition name 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" + k_index_forecast: dict = None # Derived values (populated by infer_descriptions()) # HF radio blackout risk description, derived from x_ray diff --git a/solarconditionsproviders/noaa3dayforecast.py b/solarconditionsproviders/noaa3dayforecast.py new file mode 100644 index 0000000..6ffdbba --- /dev/null +++ b/solarconditionsproviders/noaa3dayforecast.py @@ -0,0 +1,97 @@ +import logging +import re +from datetime import datetime, timezone + +from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider + +POLL_INTERVAL = 3600 # 1 hour +URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt" + + +class NOAA3dayForecast(HTTPSolarConditionsProvider): + """Solar conditions provider using the NOAA 3-day forecast text file. Parses the NOAA forecast and populates + corresponding fields in the solar conditions object..""" + + 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("NOAA K-index forecast API returned HTTP " + str(http_response.status_code)) + return None + + lines = http_response.text.splitlines() + + # Find the "NOAA Kp index breakdown" section header + start_idx = None + for i, line in enumerate(lines): + if "NOAA Kp index breakdown" in line: + start_idx = i + break + + if start_idx is None: + logging.warning("NOAA K-index forecast: could not find 'NOAA Kp index breakdown' section") + return None + + # Extract the year from the header line, e.g. "NOAA Kp index breakdown Apr 2-Apr 4, 2026" + header_line = lines[start_idx] + year_match = re.search(r'\b(\d{4})\b', header_line) + if not year_match: + logging.warning("NOAA K-index forecast: could not extract year from: " + header_line) + return None + year = int(year_match.group(1)) + + # Parse the column date headers on the next line, e.g. " Apr 02 Apr 03 Apr 04" + if start_idx + 1 >= len(lines): + logging.warning("NOAA K-index forecast: missing date header line") + return None + + date_header_line = lines[start_idx + 2] + date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', date_header_line) + if not date_matches: + logging.warning("NOAA K-index forecast: could not parse date headers from: " + date_header_line) + return None + + column_dates = [] + for month_str, day_str in date_matches: + try: + column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date()) + except ValueError: + logging.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}") + return None + + # Parse each data row, e.g. "00-03UT 2.00 3.00 2.00" + k_index_forecast = {} + for line in lines[start_idx + 3:]: + time_match = re.match(r'^(\d{2})-(\d{2})UT\s+(.*)', line.strip()) + if not time_match: + if k_index_forecast: + break + continue + + start_hour = int(time_match.group(1)) + raw_values = time_match.group(3).split() + + for i, val in enumerate(raw_values): + if i >= len(column_dates): + break + # Discard bracketed values + if val.startswith('(') and val.endswith(')'): + continue + try: + kp = float(val) + except ValueError: + continue + + date = column_dates[i] + start_dt = datetime(date.year, date.month, date.day, start_hour, 0, 0, tzinfo=timezone.utc) + + # Key the data dict by start time + key = start_dt.timestamp() + k_index_forecast[key] = kp + + if not k_index_forecast: + logging.warning("NOAA K-index forecast: no data rows parsed") + return None + + return {"k_index_forecast": k_index_forecast} diff --git a/templates/about.html b/templates/about.html index e10cfe9..6001e9a 100644 --- a/templates/about.html +++ b/templates/about.html @@ -67,7 +67,7 @@

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.

- + {% end %} \ No newline at end of file diff --git a/templates/add_spot.html b/templates/add_spot.html index 6b2a6fc..a20a5ce 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -69,8 +69,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/alerts.html b/templates/alerts.html index 8a73bde..74e510b 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -56,8 +56,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/bands.html b/templates/bands.html index de1c047..954f6ed 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -62,9 +62,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 79c88ba..3ac7717 100644 --- a/templates/base.html +++ b/templates/base.html @@ -46,10 +46,10 @@ crossorigin="anonymous"> - - - - + + + + diff --git a/templates/conditions.html b/templates/conditions.html index 3980d60..e1b00b5 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -189,8 +189,8 @@ - - + + {% end %} \ No newline at end of file diff --git a/templates/map.html b/templates/map.html index a1a959e..a83f1a0 100644 --- a/templates/map.html +++ b/templates/map.html @@ -70,9 +70,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/spots.html b/templates/spots.html index 4f0e3b2..bc23d26 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -87,9 +87,9 @@ - - - + + + {% end %} \ No newline at end of file diff --git a/templates/status.html b/templates/status.html index 17837e2..ea3e918 100644 --- a/templates/status.html +++ b/templates/status.html @@ -59,8 +59,8 @@ - - + + diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index f31bf59..811d9c9 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -1507,6 +1507,17 @@ components: type: string description: Sporadic-E propagation condition on 2m for North America example: "Band Closed" + k_index_forecast: + 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. + additionalProperties: + type: integer + minimum: 0 + maximum: 9 + example: + "2026-04-02T00:00:00+00:00": 2 + "2026-04-02T03:00:00+00:00": 3 + "2026-04-02T06:00:00+00:00": 2 blackout_desc: type: string description: HF radio blackout risk description, derived from the X-ray flux class.