#!/usr/bin/env python3
"""The counts behind section 6 of https://factchecked.science/qianlong-1770-storm/ (Fact-Checked Science, 27 October
2026): days with Kp 9 around the episode's 1948 mark, the ranking of solar cycles by their peaks, and the largest
sunspot groups in the Greenwich record. Standard library only.
It fetches three public files by URL (about 11 MB in all, over verified HTTPS) and reads them in memory as data,
nothing in them is run; or give it your own copies of the three files, in this order:
python3 -I counts.py [Kp_ap_Ap_SN_F107_since_1932.txt cyclesminmax.html RGO_NOAA1874_2013.zip] [out.json]
Data:
Kp: GFZ Helmholtz Centre for Geosciences, Geomagnetic Observatory Niemegk. Matzka, J., et al. (2021), Space
Weather 19, e2020SW002641, https://doi.org/10.1029/2020SW002641; data https://doi.org/10.5880/Kp.0001, CC BY 4.0.
File https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt. Its sunspot columns (CC BY-NC) are not used.
Cycles: WDC-SILSO, Royal Observatory of Belgium, Brussels, table of cycle minima and maxima (13-month smoothed
sunspot number, version 2.0), https://www.sidc.be/SILSO/cyclesminmax, CC BY-NC 4.0, used with credit on a
non-commercial site.
Groups: NASA Marshall Space Flight Center's compilation of the Royal Greenwich Observatory (1874-1976) and USAF/NOAA
(1977-2013) sunspot-group records, https://solarscience.msfc.nasa.gov/greenwch.shtml, file
https://solarscience.msfc.nasa.gov/greenwch/RGO_NOAA1874_2013.zip, format
https://solarscience.msfc.nasa.gov/greenwch/format.txt. A US government work.
Rules, as in our verification (fixed before the counts were first run):
Kp is given in thirds: 9- = 8.667, 9o = 9.000. NOAA's G5 ("Extreme") is Kp 9o; "a Kp 9 day" is a UT day with at
least one 3-hour value of 9o. Days whose highest value is 9- are counted separately (NOAA puts 9- in G4).
Only definitive values are used (the file's last column is 2); days with missing values are skipped.
Solar cycles run from the month of one minimum in SILSO's table to the month before the next. The cycles are
ranked by the maximum in the same table.
A sunspot group's size is its largest daily corrected whole-spot area (columns 41-44, millionths of the visible
hemisphere), the group being keyed by its year file and Greenwich number. The ranking uses the RGO years
1874-1976; NASA notes that the USAF/NOAA areas after 1976 run low and multiplies them by 1.4, so those are
listed apart, for comparison.
It prints the numbers the page uses, and writes them as JSON if a file name is given last.
"""
import hashlib
import html
import io
import json
import re
import sys
import urllib.request
import zipfile
from collections import defaultdict
URLS = ["https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt",
"https://www.sidc.be/SILSO/cyclesminmax",
"https://solarscience.msfc.nasa.gov/greenwch/RGO_NOAA1874_2013.zip"]
KP9, KP9M = 8.99, 8.66
def get(src):
if src.startswith("https://"):
req = urllib.request.Request(src, headers={"User-Agent": "factchecked.science counts.py"})
with urllib.request.urlopen(req, timeout=300) as r:
data = r.read()
else:
with open(src, "rb") as f:
data = f.read()
print(f"read {src}: {len(data):,} bytes, SHA-256 {hashlib.sha256(data).hexdigest()}")
return data
def cycles_table(raw):
t = raw.decode("utf-8", errors="replace")
cells = [html.unescape(re.sub(r"<[^>]+>", "", c)).strip() for c in re.findall(r"(?is)
]*>(.*?) | ", t)]
rows = []
for i in range(0, len(cells) - 6, 7):
c = cells[i:i + 7]
if c[0].isdigit():
rows.append({"cycle": int(c[0]), "min": (int(c[1]), int(c[2])), "max": (int(c[4]), int(c[5])),
"max_SN": float(c[6].replace(",", "."))})
if [r["cycle"] for r in rows] != list(range(1, len(rows) + 1)) or len(rows) < 25:
sys.exit("SILSO's table did not parse as expected; stopping")
for k, r in enumerate(sorted(rows, key=lambda r: -r["max_SN"])):
r["rank"] = k + 1
return rows
def cycle_of(y, m, rows):
c = None
for r in rows:
if (y, m) >= r["min"]:
c = r["cycle"]
return c
def kp_counts(raw, rows):
by_year9, by_year9m, year_max = defaultdict(int), defaultdict(int), {}
by_cyc9, by_cyc9m = defaultdict(int), defaultdict(int)
days9 = []
last = None
for line in raw.decode("ascii", errors="replace").splitlines():
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 int(p[-1]) < 2 or any(k < 0 for k in kps):
continue
last = f"{y:04d}-{m:02d}-{d:02d}"
mx = max(kps)
year_max[y] = max(year_max.get(y, 0.0), mx)
c = cycle_of(y, m, rows)
if mx >= KP9:
by_year9[y] += 1
by_cyc9[c] += 1
days9.append(last)
elif mx >= KP9M:
by_year9m[y] += 1
by_cyc9m[c] += 1
return by_year9, by_year9m, year_max, by_cyc9, by_cyc9m, days9, last
def thirds(v):
"""Kp in the usual notation: 8.333 -> 8+, 8.667 -> 9-, 9.0 -> 9o."""
n = round(v * 3)
base, rem = divmod(n, 3)
return f"{base}o" if rem == 0 else f"{base}+" if rem == 1 else f"{base + 1}-"
def groups(raw):
best = {}
with zipfile.ZipFile(io.BytesIO(raw)) as z:
names = sorted(n for n in z.namelist() if n.lower().endswith(".txt"))
for n in names:
for line in z.read(n).decode("ascii", errors="replace").splitlines():
if len(line) < 44:
continue
try:
y, m, d = int(line[0:4]), int(line[4:6]), int(line[6:8])
grp, area = line[12:20].strip(), int(line[40:44])
except ValueError:
continue
if grp and area > 0 and ((n, grp) not in best or area > best[(n, grp)][0]):
best[(n, grp)] = (area, f"{y:04d}-{m:02d}-{d:02d}", y)
rgo = sorted(((a, dt) for (a, dt, y) in best.values() if y <= 1976), reverse=True)
noaa = sorted(((a, dt) for (a, dt, y) in best.values() if y >= 1977), reverse=True)
return len(names), rgo, noaa
def main():
sys.stdout.reconfigure(encoding="utf-8")
args = sys.argv[1:]
outp = args.pop() if len(args) in (1, 4) else None
srcs = args if len(args) == 3 else URLS
kp_raw, cyc_raw, zip_raw = (get(s) for s in srcs)
out = {}
rows = cycles_table(cyc_raw)
rank = {r["cycle"]: r["rank"] for r in rows}
print(f"\nSolar cycles ranked by their smoothed maxima ({len(rows)} cycles, SILSO):")
for c, what in ((2, "the 1770 storm"), (10, "the 1859 storm"), (14, "the 1903 and 1909 storms"), (18, "1948")):
r = rows[c - 1]
print(f" cycle {c:2d} ({what}): maximum {r['max_SN']} in {r['max'][0]}-{r['max'][1]:02d}, {rank[c]} of {len(rows)}")
out["cycle_rank"] = {str(r["cycle"]): [r["rank"], r["max_SN"], list(r["max"])] for r in rows}
y9, y9m, ymax, c9, c9m, days9, last = kp_counts(kp_raw, rows)
complete = [c for c in range(17, 25)] # complete cycles since the record began in 1932
mean9 = sum(c9[c] for c in complete) / len(complete)
both = {c: c9[c] + c9m[c] for c in complete}
order = sorted(complete, key=lambda c: -both[c])
print(f"\nKp (GFZ, definitive values to {last}):")
print(f" 1948: {y9[1948]} days with Kp 9, {y9m[1948]} more at 9-; its highest value was {thirds(ymax[1948])}")
print(f" cycle 18: {c9[18]} days with Kp 9 ({', '.join(d for d in days9 if cycle_of(int(d[:4]), int(d[5:7]), rows) == 18)});"
f" {both[18]} counting the days at 9-")
print(f" cycles 17-24, days with Kp 9: " + ", ".join(f"{c}: {c9[c]}" for c in complete) + f"; mean {mean9:.2f}")
print(f" cycles 17-24, days at 9- or 9: " + ", ".join(f"{c}: {both[c]}" for c in order)
+ f"; mean {sum(both.values()) / len(both):.2f}")
out["kp"] = {"definitive_to": last, "1948": {"kp9_days": y9[1948], "kp9minus_only_days": y9m[1948],
"highest": ymax[1948]}, "kp9_days_by_cycle": {str(c): c9[c] for c in complete},
"kp9_or_9minus_days_by_cycle": {str(c): both[c] for c in complete}, "mean_kp9_days_17_24": mean9,
"kp9_days": days9}
nfiles, rgo, noaa = groups(zip_raw)
c18 = [r for r in rows if r["cycle"] in (18, 19)]
lo, hi = c18[0]["min"], c18[1]["min"]
print(f"\nLargest sunspot groups, RGO 1874-1976 ({nfiles} year files), millionths of the visible hemisphere:")
for i, (a, dt) in enumerate(rgo[:8]):
inside = lo <= (int(dt[:4]), int(dt[5:7])) < hi
print(f" {i + 1}. {a:5d} on {dt}" + (" (cycle 18)" if inside else ""))
print(" largest after 1976 (USAF/NOAA), as reported and x1.4: "
+ ", ".join(f"{a} ({a * 1.4:.0f}) on {dt}" for a, dt in noaa[:3]))
out["groups"] = {"rgo_top8": rgo[:8], "usaf_noaa_top3": noaa[:3]}
if outp:
with open(outp, "w", encoding="utf-8", newline="\n") as f:
json.dump(out, f, indent=1)
print("wrote", outp)
if __name__ == "__main__":
main()