258 lines
10 KiB
Python
Executable File
258 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regenerates data/gfz-drivers.bin, the solar and geomagnetic record the model reads.
|
|
|
|
Usage: scripts/fetch-drivers.py [path to a local copy of the source file]
|
|
|
|
The record is committed as a compact binary blob and embedded with include_bytes!, so a
|
|
caller needs no network, no data directory and nothing to parse at startup. The blob is
|
|
numbers only; its provenance is written alongside it in data/gfz-drivers.txt, which the
|
|
crate compiles in as a string so that a program can print where its weather came from.
|
|
|
|
Source: GFZ Potsdam's combined Kp/ap/Ap/SN/F10.7 record, under CC BY 4.0. Chosen over
|
|
Celestrak's SW-All.csv for two reasons: it states a license where Celestrak states none,
|
|
which matters for a file committed into an MIT crate, and Celestrak's runs decades past
|
|
today on its own predictions, which are not measurements.
|
|
|
|
The sunspot column is dropped, being the one part of the GFZ file under CC BY-NC 4.0, and
|
|
so is the flux adjusted to one astronomical unit: NRLMSISE-00 asks for the flux at the
|
|
Earth's actual distance from the Sun, which is the observed column.
|
|
|
|
Unlike the coefficient tables, this record is meant to change — it gains a row a day — so
|
|
there is no digest pinned in the crate. What is recorded instead is the digest of what was
|
|
downloaded, with the date and the coverage, in the provenance file.
|
|
"""
|
|
|
|
import hashlib
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
|
|
URL = "https://kp.gfz-potsdam.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt"
|
|
ATTRIBUTION = (
|
|
"GFZ German Research Centre for Geosciences and Geomagnetic Observatory Niemegk, "
|
|
"CC BY 4.0"
|
|
)
|
|
MISSING = -1.0
|
|
|
|
# Geomagnetic indices reach back to 1932 and the solar flux to February 1947, but the early
|
|
# flux record is sporadic: 105 missing days in 1947 and one hole of 44. From 1957 it is all
|
|
# but complete, which is where this record starts and where the other published driver
|
|
# files start too.
|
|
START = date(1957, 1, 1)
|
|
|
|
# A run of unmeasured flux days this long is refused rather than interpolated.
|
|
LONGEST_FILL = 7
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
OUT = ROOT / "data" / "gfz-drivers.bin"
|
|
NOTE = ROOT / "data" / "gfz-drivers.txt"
|
|
|
|
# Modified Julian Day zero. The record is indexed by MJD because a UT day is not always
|
|
# 86400 seconds and MJD still increments by exactly one across a leap second.
|
|
MJD_EPOCH = date(1858, 11, 17)
|
|
|
|
MAGIC = b"NRLMSISEDRV"
|
|
VERSION = 1
|
|
|
|
# Field positions in the source, which is whitespace-aligned with 28 columns.
|
|
YEAR, MONTH, DAY = 0, 1, 2
|
|
AP_FIRST, AP_LAST = 15, 22
|
|
AP_DAILY = 23
|
|
F107_OBSERVED = 25
|
|
DEFINITIVE = 27
|
|
|
|
|
|
def source_text(argv):
|
|
if len(argv) > 1:
|
|
raw = Path(argv[1]).read_bytes()
|
|
print(f"reading {argv[1]}")
|
|
else:
|
|
print(f"downloading {URL}")
|
|
raw = subprocess.run(
|
|
["curl", "-sS", "--fail", "--max-time", "180", "-L", URL],
|
|
check=True,
|
|
capture_output=True,
|
|
).stdout
|
|
return raw.decode(), hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def parse(text):
|
|
"""Daily rows from START onward, with the flux left as None where unmeasured."""
|
|
rows = []
|
|
for line in text.splitlines():
|
|
if line.startswith("#") or not line.strip():
|
|
continue
|
|
fields = line.split()
|
|
if len(fields) != 28:
|
|
raise SystemExit(f"expected 28 fields, found {len(fields)}: {line[:60]}")
|
|
|
|
when = date(int(fields[YEAR]), int(fields[MONTH]), int(fields[DAY]))
|
|
if when < START:
|
|
continue
|
|
|
|
observed = float(fields[F107_OBSERVED])
|
|
rows.append(
|
|
{
|
|
"date": when,
|
|
"ap": [int(float(f)) for f in fields[AP_FIRST : AP_LAST + 1]],
|
|
"ap_daily": int(float(fields[AP_DAILY])),
|
|
"flux": None if observed <= MISSING else observed,
|
|
"definitive": int(fields[DEFINITIVE]) == 1,
|
|
"filled": False,
|
|
}
|
|
)
|
|
|
|
if not rows:
|
|
raise SystemExit(f"no rows at or after {START}")
|
|
|
|
# The record is addressed by day, so a missing date would shift everything after it.
|
|
for earlier, later in zip(rows, rows[1:]):
|
|
if later["date"] - earlier["date"] != timedelta(days=1):
|
|
raise SystemExit(f"missing day between {earlier['date']} and {later['date']}")
|
|
|
|
return rows
|
|
|
|
|
|
def trim(rows):
|
|
"""Drops unmeasured days at either end.
|
|
|
|
START is a round date rather than a measurement and the flux happens not to have been
|
|
taken on it. The far end matters more: an unmeasured day there would have to be
|
|
extrapolated, and refusing to evaluate past the record is precisely so that nothing is.
|
|
"""
|
|
measured = [index for index, row in enumerate(rows) if row["flux"] is not None]
|
|
if not measured:
|
|
raise SystemExit("no measured solar flux at all")
|
|
return rows[measured[0] : measured[-1] + 1]
|
|
|
|
|
|
def fill(rows):
|
|
"""Interpolates the days the flux was not measured, marks them, counts them.
|
|
|
|
The gaps are isolated — one or two days, five at the very worst — and the flux moves a
|
|
few percent over that, so a straight line between the measurements either side is a
|
|
fair reconstruction. A long gap is not, and is refused.
|
|
"""
|
|
measured = [index for index, row in enumerate(rows) if row["flux"] is not None]
|
|
filled = 0
|
|
for before, after in zip(measured, measured[1:]):
|
|
span = after - before
|
|
if span == 1:
|
|
continue
|
|
if span - 1 >= LONGEST_FILL:
|
|
raise SystemExit(
|
|
f"{span - 1} unmeasured days from {rows[before + 1]['date']}, too many "
|
|
f"to interpolate"
|
|
)
|
|
for offset in range(1, span):
|
|
weight = offset / span
|
|
rows[before + offset]["flux"] = (
|
|
rows[before]["flux"] * (1.0 - weight) + rows[after]["flux"] * weight
|
|
)
|
|
rows[before + offset]["filled"] = True
|
|
filled += 1
|
|
return filled
|
|
|
|
|
|
def mjd(when):
|
|
return when.toordinal() - MJD_EPOCH.toordinal()
|
|
|
|
|
|
def encode(rows, generated):
|
|
"""The blob: a fixed header, then twenty-one bytes a day, little-endian throughout.
|
|
|
|
The flux is stored in tenths because that is the precision the source states it to, so
|
|
nothing is lost and a float is not needed. ap and Ap are whole numbers on the Kp scale,
|
|
which reaches 400.
|
|
"""
|
|
blob = bytearray()
|
|
blob += MAGIC
|
|
blob += struct.pack("<BiI", VERSION, mjd(rows[0]["date"]), len(rows))
|
|
blob += struct.pack("<I", int(generated.strftime("%Y%m%d")))
|
|
|
|
for row in rows:
|
|
tenths = round(row["flux"] * 10.0)
|
|
if not 0 <= tenths <= 0xFFFF:
|
|
raise SystemExit(f"{row['date']}: flux {row['flux']} does not fit")
|
|
values = [row["ap_daily"], *row["ap"]]
|
|
for value in values:
|
|
if not 0 <= value <= 0xFFFF:
|
|
raise SystemExit(f"{row['date']}: ap {value} does not fit")
|
|
flags = (1 if row["definitive"] else 0) | (0 if row["filled"] else 2)
|
|
blob += struct.pack("<H", tenths)
|
|
blob += struct.pack("<9H", *values)
|
|
blob += struct.pack("<B", flags)
|
|
return bytes(blob)
|
|
|
|
|
|
def note(rows, digest, generated, filled):
|
|
"""The provenance, which the crate compiles in as a string."""
|
|
definitive = sum(1 for row in rows if row["definitive"])
|
|
highest = max(rows, key=lambda row: row["flux"])
|
|
lowest = min(rows, key=lambda row: row["flux"])
|
|
dropouts = [row for row in rows if row["flux"] < 60.0 and not row["filled"]]
|
|
peaks = [row for row in rows if row["flux"] > 400.0 and not row["filled"]]
|
|
return f"""The solar and geomagnetic record NRLMSISE-00 reads.
|
|
|
|
Source {URL}
|
|
Attribution {ATTRIBUTION}
|
|
Downloaded {generated.isoformat()}
|
|
Digest sha256 {digest}
|
|
of the file as downloaded, not of the blob next to this note.
|
|
|
|
Coverage {rows[0]['date'].isoformat()} to {rows[-1]['date'].isoformat()}, \
|
|
{len(rows)} days
|
|
Modified Julian Day {mjd(rows[0]['date'])} to {mjd(rows[-1]['date'])}
|
|
The last day a model can be evaluated on is forty days earlier than the last
|
|
day held, because the 81-day flux average the model wants is centered.
|
|
|
|
Flux The observed 10.7 cm radio flux, at the Earth's actual distance from the Sun,
|
|
which is what the model asks for. The flux adjusted to one astronomical unit
|
|
is not kept. {filled} of {len(rows)} days were not measured and are
|
|
interpolated from the days either side; they are marked in the blob and
|
|
reported by Day::flux_measured.
|
|
|
|
Geomagnetic The daily mean Ap and the eight three-hour ap indices. {definitive} of
|
|
{len(rows)} days are definitive rather than preliminary, which Day::definitive
|
|
reports.
|
|
|
|
Extremes The flux runs from {lowest['flux']} on {lowest['date'].isoformat()} to \
|
|
{highest['flux']} on {highest['date'].isoformat()}.
|
|
Both ends are in the source and are passed through unaltered. The high end is
|
|
real: the daily flux includes radio bursts, so a large flare puts it several
|
|
times above the solar-maximum background, and there are {len(peaks)} measured
|
|
days above 400. The low end looks like instrument trouble rather than the Sun —
|
|
{len(dropouts)} measured days fall below 60, each an isolated day between
|
|
neighbors around 70, and both are in early March. Whether to smooth them is a
|
|
decision for whoever is using the record, not for the record.
|
|
|
|
Dropped The sunspot number, which is the one part of the source under CC BY-NC 4.0,
|
|
and the adjusted flux.
|
|
|
|
Regenerate scripts/fetch-drivers.py
|
|
"""
|
|
|
|
|
|
def main():
|
|
text, digest = source_text(sys.argv)
|
|
rows = trim(parse(text))
|
|
filled = fill(rows)
|
|
generated = date.today()
|
|
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUT.write_bytes(encode(rows, generated))
|
|
NOTE.write_text(note(rows, digest, generated, filled))
|
|
|
|
print(f"digest {digest}")
|
|
print(f"interpolated {filled} unmeasured days of {len(rows)}")
|
|
print(
|
|
f"wrote {OUT.relative_to(ROOT)}: {len(rows)} days, "
|
|
f"{rows[0]['date']} to {rows[-1]['date']}, {OUT.stat().st_size / 1e3:.0f} KB"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|