diff --git a/data/solar_conditions.py b/data/solar_conditions.py index 7398fe5..9ab5e98 100644 --- a/data/solar_conditions.py +++ b/data/solar_conditions.py @@ -131,8 +131,14 @@ 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" + # NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period 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()) # HF radio blackout risk description, derived from x_ray diff --git a/solarconditionsproviders/noaa3dayforecast.py b/solarconditionsproviders/noaa3dayforecast.py index 6ffdbba..b74e23f 100644 --- a/solarconditionsproviders/noaa3dayforecast.py +++ b/solarconditionsproviders/noaa3dayforecast.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone 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" @@ -15,6 +15,68 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider): def __init__(self, provider_config): 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): if http_response.status_code != 200: 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") 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, + } diff --git a/templates/about.html b/templates/about.html index 6001e9a..8783c97 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 a20a5ce..dd1f73e 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 74e510b..c4c1bbc 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 954f6ed..b496f7e 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 3ac7717..eac3105 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 e1b00b5..c71ca44 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 a83f1a0..9ad1237 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 bc23d26..330927d 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 ea3e918..fa1d432 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 811d9c9..692022e 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -1509,15 +1509,61 @@ components: 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. + 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: - type: integer + type: number 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 + "1743638400.0": 4.0 + "1743649200.0": 5.67 + "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: type: string description: HF radio blackout risk description, derived from the X-ray flux class.