#!/usr/bin/env python3
"""The numbers behind the 89-year arithmetic, cycle 26 and the cyclone on "S0 News, October 27, 2026".
Fact-Checked Science, sections 4, 5 and 7 ("Our analysis"). Standard library only.
Run it
python3 -I record_numbers.py [folder]
The folder (default: the script's own) must hold, or will receive (about 11 MB in all):
Kp_ap_Ap_SN_F107_since_1932.txt https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt
Kp from Geomagnetic Observatory Niemegk, GFZ Helmholtz Centre for Geosciences, CC BY 4.0: Matzka, Stolle,
Yamazaki, Bronkalla and Morschhauser (2021), Space Weather, https://doi.org/10.1029/2020SW002641; data
https://doi.org/10.5880/Kp.0001. The file grows daily; its newest values are nowcasts. Its sunspot numbers
(CC BY-NC 4.0) are not used.
cyclesminmax.html https://www.sidc.be/SILSO/cyclesminmax
WDC-SILSO, Royal Observatory of Belgium, Brussels: the dates and smoothed sunspot numbers of every cycle's
minimum and maximum. CC BY-NC 4.0 (non-commercial use, with credit).
RGO_NOAA1874_2013.zip https://solarscience.msfc.nasa.gov/greenwch/RGO_NOAA1874_2013.zip
Royal Greenwich Observatory (1874-1976) and USAF/NOAA (from 1977) sunspot-group records, compiled by
D. Hathaway at NASA's Marshall Space Flight Center. Read inside the ZIP; nothing is extracted.
What it prints
1. Kp: the days whose highest three-hour Kp was 9o (NOAA's G5) or 9- (G4), per solar cycle; the highest Kp of
1948 and its days; the Kp 9 days of cycle 18; the mean for the complete cycles 17-24. Cycles start in the
month of the smoothed minimum in SILSO's table.
2. SILSO: the cycles ranked by smoothed maximum; the mean cycle length and the span of 8 consecutive cycles;
where the storms of 1770 and 1859, and the year 1948, fall in their cycles.
3. Greenwich: the five largest sunspot groups of 1874-1976 by peak corrected area (millionths of the Sun's
hemisphere, MSH), and the largest after 1976, also multiplied by 1.4, Hathaway's correction for the change of
source.
4. Typed-in values: Yuan et al.'s (2026) recurrence intervals as chances per decade and per 11-year cycle; the
cyclone's forecast landfall times in UTC, IST and Arizona time; the days left in 2026.
"""
import datetime as dt
import math
import os
import re
import sys
import urllib.request
import zipfile
from collections import defaultdict
FILES = {
"Kp_ap_Ap_SN_F107_since_1932.txt": "https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt",
"cyclesminmax.html": "https://www.sidc.be/SILSO/cyclesminmax",
"RGO_NOAA1874_2013.zip": "https://solarscience.msfc.nasa.gov/greenwch/RGO_NOAA1874_2013.zip",
}
def fetch(folder, name):
path = os.path.join(folder, name)
if not os.path.exists(path):
print("downloading", FILES[name])
req = urllib.request.Request(FILES[name], headers={"User-Agent": "factchecked.science record_numbers.py"})
with urllib.request.urlopen(req, timeout=300) as r, open(path, "wb") as f:
f.write(r.read())
return path
def frac_year(y, m, d=15):
start = dt.date(y, 1, 1)
return y + (dt.date(y, m, d) - start).days / (dt.date(y + 1, 1, 1) - start).days
def silso_cycles(path):
raw = open(path, encoding="utf-8", errors="replace").read()
cells = [re.sub(r"<[^>]+>", "", c).strip() for c in re.findall(r"
]*>(.*?) | ", raw, flags=re.S | re.I)]
cyc, i = {}, 0
while i + 6 < len(cells):
ch = cells[i:i + 7]
if re.fullmatch(r"\d{1,2}", ch[0]) and re.fullmatch(r"\d{4}", ch[1]):
cyc[int(ch[0])] = {"min": (int(ch[1]), int(ch[2])), "max": (int(ch[4]), int(ch[5])),
"sn": float(ch[6].replace(",", "."))}
i += 7
else:
i += 1
return cyc
def main():
sys.stdout.reconfigure(encoding="utf-8")
folder = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__))
cyc = silso_cycles(fetch(folder, "cyclesminmax.html"))
starts = sorted((v["min"], k) for k, v in cyc.items())
def cycle_of(y, m):
c = None
for (yy, mm), k in starts:
if (y, m) >= (yy, mm):
c = k
return c
# 1. Kp
per = defaultdict(lambda: [0, 0])
k9_18, y1948, ndays, last = [], [], 0, None
for line in open(fetch(folder, "Kp_ap_Ap_SN_F107_since_1932.txt"), encoding="utf-8", errors="replace"):
if line.startswith("#") or not line.strip():
continue
p = line.split()
y, m, d = int(p[0]), int(p[1]), int(p[2])
kps = [float(x) for x in p[7:15]]
if any(k < 0 for k in kps):
continue
ndays += 1
last = f"{y}-{m:02d}-{d:02d}"
k = max(kps)
c = cycle_of(y, m)
if k >= 8.99:
per[c][0] += 1
if c == 18:
k9_18.append(f"{y}-{m:02d}-{d:02d}")
elif k > 8.6:
per[c][1] += 1
if y == 1948:
y1948.append((k, f"{y}-{m:02d}-{d:02d}"))
print(f"1. Kp, {ndays} days to {last}")
for c in range(17, max(cyc) + 1):
print(f" cycle {c}{' (incomplete)' if c == max(cyc) else ''}: {per[c][0]} at Kp 9o, {per[c][1]} more at 9-")
full = [c for c in range(17, 25)]
print(f" mean for cycles 17-24: {sum(per[c][0] for c in full) / len(full):.2f} Kp 9o days per cycle")
print(f" cycle 18's Kp 9o days: {', '.join(k9_18)}")
top = max(k for k, _ in y1948)
names = {8.333: "8+", 8.667: "9-", 9.0: "9o", 8.0: "8o", 7.667: "8-"}
print(f" 1948: highest Kp {names.get(round(top, 3), top)} on {', '.join(dd for k, dd in y1948 if k == top)}; "
f"days at 9o: {sum(1 for k, _ in y1948 if k >= 8.99)}; at 9-: {sum(1 for k, _ in y1948 if 8.6 < k < 8.99)}")
# 2. SILSO
ranked = sorted(cyc, key=lambda k: -cyc[k]["sn"])
print("\n2. Sunspot cycles by smoothed maximum (SILSO):")
print(" " + "; ".join(f"{i}. cycle {k} {cyc[k]['sn']:.1f} ({cyc[k]['max'][0]}-{cyc[k]['max'][1]:02d})"
for i, k in enumerate(ranked, 1)))
mins = {k: frac_year(*cyc[k]["min"]) for k in cyc}
mean = (mins[max(cyc)] - mins[min(cyc)]) / (max(cyc) - min(cyc))
spans = [mins[k + 8] - mins[k] for k in cyc if k + 8 in cyc]
print(f" mean cycle length {mean:.2f} years; 8 cycles = {8 * mean:.1f} years; "
f"8 consecutive cycles span {min(spans):.1f}-{max(spans):.1f} years")
for t, label in ((frac_year(1770, 9, 17), "the Qianlong storm, 17 Sep 1770"),
(frac_year(1859, 9, 2), "the Carrington storm, 2 Sep 1859"), (1948.5, "mid-1948")):
k = max(c for c in cyc if mins[c] <= t)
mx = frac_year(*cyc[k]["max"])
print(f" {label}: cycle {k}, {t - mx:+.2f} years from its maximum ({cyc[k]['max'][0]}-{cyc[k]['max'][1]:02d}, "
f"{cyc[k]['sn']:.1f}, rank {ranked.index(k) + 1} of {len(cyc)})")
# 3. Greenwich
best = {}
with zipfile.ZipFile(fetch(folder, "RGO_NOAA1874_2013.zip")) as z:
for n in sorted(x for x in z.namelist() if x.lower().endswith(".txt")):
for raw in z.read(n).decode("latin-1").splitlines():
if len(raw) < 44 or not raw[:4].strip().isdigit():
continue
try:
y, mo, d, g, a = int(raw[0:4]), int(raw[4:6]), int(raw[6:8]), raw[12:20].strip(), int(raw[40:44])
except ValueError:
continue
key = (y, g) if y <= 1976 else ("NOAA", g, y // 10)
if a > best.get(key, (0, ""))[0]:
best[key] = (a, f"{y}-{mo:02d}-{d:02d}")
rgo = sorted(((a, w) for k, (a, w) in best.items() if k[0] != "NOAA"), reverse=True)[:5]
noaa = max((a, w) for k, (a, w) in best.items() if k[0] == "NOAA")
print("\n3. Largest sunspot groups, RGO 1874-1976 (peak corrected area, MSH): "
+ "; ".join(f"{a:,} on {w}" for a, w in rgo))
print(f" largest after 1976 (USAF/NOAA): {noaa[0]:,} MSH on {noaa[1]}, x1.4 = {noaa[0] * 1.4:,.0f}")
# 4. Typed-in values
print("\n4. Yuan et al. (2026), recurrence intervals as chances (Poisson):")
for rec, label in ((14000, "1770-size, central 14,000 years"), (2000, "1770-size, short end about 2,000 years"),
(1400, "1859-size, central 1,400 years")):
print(f" {label}: {100 * (1 - math.exp(-10 / rec)):.2f}% a decade, {100 * (1 - math.exp(-11 / rec)):.2f}% an 11-year cycle")
utc, ist = dt.timezone.utc, dt.timezone(dt.timedelta(hours=5, minutes=30))
az = dt.timezone(dt.timedelta(hours=-7))
up = dt.datetime(2026, 10, 27, 12, 22, 43, tzinfo=utc)
print(f" the upload: {up:%d %b %H:%M} UTC = {up.astimezone(ist):%d %b %H:%M} IST = {up.astimezone(az):%d %b %H:%M} Arizona")
for label, t in (("IMD, 21:30 IST update: from", dt.datetime(2026, 10, 28, 2, 0, tzinfo=ist)),
(" to", dt.datetime(2026, 10, 28, 4, 0, tzinfo=ist)),
("RSMC advisory 12: by", dt.datetime(2026, 10, 28, 6, 0, tzinfo=utc))):
print(f" {label} {t.astimezone(utc):%d %b %H:%M} UTC, {t.astimezone(ist):%d %b %H:%M} IST, "
f"{t.astimezone(az):%d %b %H:%M} Arizona ({(t - up).total_seconds() / 3600:.1f} h after the upload)")
d0 = dt.date(2026, 10, 27)
print(f" days after Oct 27 to the end of 2026: {(dt.date(2026, 12, 31) - d0).days}")
if __name__ == "__main__":
main()