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.