"""Audits a sample of the corpus against the U.S. Naval Observatory's one-day API (doc 40 §5, A15). python3 tools/corpus/usno_audit.py Standard library only, so it runs with the Mac's own Python and installs nothing. USNO's answers are kept in ~/Developer/temp/dawngloaming-corpus/usno/ — a scratch folder, never this repository — so no USNO value enters the corpus, which stays Skyfield/DE440 alone. What comes back is only how far apart the two are. USNO gives times to the minute, sunrise and sunset at −0.8333°, civil twilight at −6°, and moonrise by its geocentric criterion — the same moment as the app's topocentric one (doc 40 §2). Each corpus event is matched with the nearest USNO event of the same kind on the same UTC day, asked for with tz=0. USNO answers for 1700–2100 only, so an event whose UTC day falls outside that — La Paz's last sunset of 2100 is on 1 January 2101 in UTC — is counted apart, and a request USNO refuses is reported with its reason rather than ending the run. Dedicated to the public domain under CC0 1.0 (https://creativecommons.org/publicdomain/zero/1.0/): to the extent possible under law, Lustig Fairchild Ltd. has waived all copyright and related rights to this script. Published from Bulgaria, with the almanac it audits (https://lustigfairchild.com/dawn-and-gloaming/almanac/). """ import argparse import datetime as dt import json import pathlib import time import urllib.error import urllib.parse import urllib.request ROOT = pathlib.Path(__file__).resolve().parents[2] CACHE = pathlib.Path.home() / "Developer" / "temp" / "dawngloaming-corpus" / "usno" API = "https://aa.usno.navy.mil/api/rstt/oneday" FIRST, LAST = dt.date(1700, 1, 1), dt.date(2100, 12, 31) # the years USNO answers for WHOLE = {"singapore", "makkah", "wellington", "la-paz"} SAMPLED = {"london", "tromso", "reykjavik", "mcmurdo"} PHENOMENA = { ("horizon", "RISING"): ("sundata", "Rise"), ("horizon", "SETTING"): ("sundata", "Set"), ("civil", "RISING"): ("sundata", "Begin Civil Twilight"), ("civil", "SETTING"): ("sundata", "End Civil Twilight"), ("moon", "RISING"): ("moondata", "Rise"), ("moon", "SETTING"): ("moondata", "Set"), } def usno(lat, lon, date): cached = CACHE / f"{lat:+.4f}_{lon:+.4f}_{date}.json" if cached.exists(): return json.loads(cached.read_text()) query = urllib.parse.urlencode({"date": date, "coords": f"{lat},{lon}", "tz": 0}) request = urllib.request.Request(f"{API}?{query}", headers={"User-Agent": "dawngloaming-corpus-audit"}) with urllib.request.urlopen(request, timeout=30) as response: data = json.load(response) cached.write_text(json.dumps(data)) time.sleep(0.5) return data def instants(data, date, group, phen): out = [] for item in data["properties"]["data"].get(group, []): if item.get("phen") == phen and item.get("time") and item["time"] != "null": hours, minutes = map(int, item["time"][:5].split(":")) out.append(dt.datetime.combine(date, dt.time(hours, minutes), dt.timezone.utc)) return out def sampled(row): date = dt.date.fromisoformat(row["date"]) return row["place"] in WHOLE or (row["place"] in SAMPLED and date.day in (1, 15)) def main(): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--corpus", type=pathlib.Path, default=ROOT / "Plan" / "almanac" / "corpus") args = parser.parse_args() CACHE.mkdir(parents=True, exist_ok=True) gaps, outside, refused = {}, 0, {} for path in sorted(args.corpus.glob("*.jsonl")): for row in map(json.loads, path.read_text(encoding="utf-8").splitlines()): if not sampled(row): continue lines = {"horizon": row["sun"]["horizon"], "civil": row["sun"]["civil"], "moon": row["moon"]["horizon"]} for key, line in lines.items(): for event in line.get("events", []): if event["certainty"] != "NORMAL": continue # asserted by state and order only (doc 40 §5.5) at = dt.datetime.fromisoformat(event["at"].replace("Z", "+00:00")) if not FIRST <= at.date() <= LAST: outside += 1 continue group, phen = PHENOMENA[(key, event["dir"])] request = (row["lat"], row["lon"], at.date().isoformat()) if request in refused: continue try: answer = usno(*request) except urllib.error.HTTPError as error: refused[request] = f"HTTP {error.code}: {error.read()[:300].decode('utf-8', 'replace')}" continue theirs = instants(answer, at.date(), group, phen) off = min((abs((t - at).total_seconds()) for t in theirs), default=None) gaps.setdefault((row["place"], key, event["dir"]), []).append((off, row["date"], event["at"])) lines = ["place line direction events worst (s) over 90 s unmatched"] for (place, key, direction), found in sorted(gaps.items()): matched = [g for g in found if g[0] is not None] worst = max((g[0] for g in matched), default=0) over = [g for g in matched if g[0] > 90] unmatched = len(found) - len(matched) lines.append(f"{place:13} {key:8} {direction:10} {len(found):6} {worst:10.0f} {len(over):10} {unmatched:10}") lines += [f" {date} {at}: {off:.0f} s" for off, date, at in over[:5]] lines.append(f"\n{outside} events outside USNO's 1700–2100, not asked for") lines.append(f"{len(refused)} requests refused by USNO") lines += [f" {lat}, {lon} {date}: {why}" for (lat, lon, date), why in list(refused.items())[:5]] report = "\n".join(lines) (CACHE.parent / "usno-audit.txt").write_text(report + "\n") print(report) if __name__ == "__main__": main()