Add fe-map and iss sources, The Shape of Distance as published, contribution guide

- femap/: planar best-effort solver, build, result and page (fe-map.aethercosmology.com)
- satellites/: The Sync Test build, ISS elements, SatNOGS timings and page (iss.aethercosmology.com)
- src/handoff/The_Shape_of_Distance_PRESENTATION.html: inputs for both
- CONTRIBUTING.md; README and LICENSE-DATA cover all four sites; GitHub mirror for issues and pull requests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Toby
2026-10-30 18:42:34 -04:00
parent 2bf0bdccb5
commit ad8b889437
27 changed files with 4474 additions and 43 deletions
+459
View File
@@ -0,0 +1,459 @@
"""The Sync Test: ISS position and an imitated signal on four maps.
../.venv/bin/python build.py -> data/sync.json, site/index.html
Orbit: data/iss_25544_20261028.tle, fetched 2026-10-29 from
https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=TLE
(epoch 2026-10-28 14:18 UTC), propagated with SGP4 (python sgp4).
For every second of 16 orbits:
station SGP4 position -> Earth-fixed (GMST rotation) -> geodetic lat/lon
map speed speed of the point beneath the station across each flat map
(models.py: North-pole AE, dual-pole AE on 0N 0E, Mercator with its
east and west edges joined)
signal starts level with the station at each northbound equator
crossing and moves along the map's track at the measured speed:
7.07 km/s, the straight-line (level-flight) reading of the SatNOGS
recordings of 3 Oct 2026 (satnogs.py), which over a plane is also
the speed of the point beneath. lag = time since the station was
where the signal is.
On the globe the same recordings fit the published orbit (7.36 km/s at
altitude, x R/r on the ground); check_globe() confirms the signal stays level
there, as it must, since both come from the orbit.
"""
import json, math, os, sys
import numpy as np
from sgp4.api import Satrec, jday
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "..", "src")
sys.path.insert(0, SRC)
from models import R # 6371.0 km sphere shared with the cable survey
from build import land # Natural Earth 110m, rings of [lon, lat]
TLE = os.path.join(HERE, "data", "iss_25544_20261028.tle")
START = (2026, 10, 28, 15, 0, 0) # first northbound equator crossing after this
N_ORBITS = 16
MARGIN_S = 2 * 3600 # track after the last orbit, for a signal running ahead
STEP = 10 # s between samples sent to the page
G0 = 9.80665e-3 # km/s^2
OMEGA = 7.2921150e-5 # rad/s
WGS_A, WGS_F = 6378.137, 1 / 298.257223563
MIN_POP = 1_000_000
DEDUP_KM = 30 # merge districts of one city
SATNOGS = os.path.join(HERE, "data", "satnogs_20261003.json") # satnogs.py
F_UHF = 437.55e6 # Hz, the recorded downlink
C_KMS = 299_792.458
def gmst(jd):
t = (jd - 2451545.0) / 36525.0
s = 67310.54841 + (876600 * 3600 + 8640184.812866) * t + 0.093104 * t**2 - 6.2e-6 * t**3
return np.radians((s % 86400.0) / 240.0)
def propagate(sat, t0jd, t0fr, secs):
jd = np.full(secs.shape, t0jd)
fr = t0fr + secs / 86400.0
e, r, v = sat.sgp4_array(jd, fr)
assert not e.any(), "sgp4 error"
g = gmst(jd + fr)
c, s = np.cos(g), np.sin(g)
x = c * r[:, 0] + s * r[:, 1]
y = -s * r[:, 0] + c * r[:, 1]
z = r[:, 2]
vx = c * v[:, 0] + s * v[:, 1] + OMEGA * y
vy = -s * v[:, 0] + c * v[:, 1] - OMEGA * x
vz = v[:, 2]
return np.stack([x, y, z], 1), np.stack([vx, vy, vz], 1)
def geodetic(p):
x, y, z = p.T
e2 = WGS_F * (2 - WGS_F)
lon = np.degrees(np.arctan2(y, x))
rho = np.hypot(x, y)
lat = np.arctan2(z, rho * (1 - e2))
for _ in range(6):
n = WGS_A / np.sqrt(1 - e2 * np.sin(lat) ** 2)
h = rho / np.cos(lat) - n
lat = np.arctan2(z, rho * (1 - e2 * n / (n + h)))
return np.degrees(lat), lon, h
def unit(lat, lon):
p, l = np.radians(lat), np.radians(lon)
return np.stack([np.cos(p) * np.cos(l), np.cos(p) * np.sin(l), np.sin(p)], -1)
def to_latlon(u):
u = u / np.linalg.norm(u, axis=-1, keepdims=True)
return np.degrees(np.arcsin(np.clip(u[..., 2], -1, 1))), np.degrees(np.arctan2(u[..., 1], u[..., 0]))
# flat maps, vectorised forms of models.ae_xy / dualpole_xy / mercator_xy (km)
def ae_xy(lat, lon):
r = R * np.radians(90 - lat)
t = np.radians(lon)
return r * np.sin(t), -r * np.cos(t)
def dualpole_xy(lat, lon):
p, l = np.radians(lat), np.radians(lon)
c = np.arccos(np.clip(np.cos(p) * np.cos(l), -1, 1))
k = np.where(c < 1e-9, 1.0, c / np.where(c < 1e-9, 1.0, np.sin(c)))
return R * k * np.cos(p) * np.sin(l), R * k * np.sin(p)
def mercator_xy(lat, lon):
return R * np.radians(lon), R * np.log(np.tan(np.pi / 4 + np.radians(np.clip(lat, -85, 85)) / 2))
MAPS = {"ae": ae_xy, "dualpole": dualpole_xy, "mercator": mercator_xy}
WRAP = {"mercator": 2 * math.pi * R}
def step_lengths(f, u, wrap=None):
"""Length on the map of each 1-s step between unit vectors u[i] and u[i+1].
Steps longer than 20 km on the map (dual-pole rim) are subdivided along the
great circle until the pieces are short."""
lat, lon = to_latlon(u)
x, y = f(lat, lon)
dx, dy = np.diff(x), np.diff(y)
if wrap:
dx = (dx + wrap / 2) % wrap - wrap / 2
d = np.hypot(dx, dy)
for i in np.nonzero(d > 20)[0]:
n = int(min(20000, math.ceil(d[i] / 2)))
w = np.linspace(0, 1, n + 1)[:, None]
sub = u[i] * (1 - w) + u[i + 1] * w
la, lo = to_latlon(sub)
sx, sy = f(la, lo)
ddx = np.diff(sx)
if wrap:
ddx = (ddx + wrap / 2) % wrap - wrap / 2
d[i] = np.hypot(ddx, np.diff(sy)).sum()
return d
def main():
sn = json.load(open(SATNOGS))
v_sig = round(sn["v_kms"], 2) # straight-line reading of the recordings = speed in level flight over a plane
l1, l2 = [s.strip() for s in open(TLE).read().splitlines()[1:3]]
sat = Satrec.twoline2rv(l1, l2)
jd0, fr0 = jday(*START)
span = int(N_ORBITS * 93 * 60 + 3 * 3600)
secs = np.arange(span + 1, dtype=float)
pos, vel = propagate(sat, jd0, fr0, secs)
lat, lon, alt = geodetic(pos)
# northbound equator crossings (linear interpolation within the second)
up = np.nonzero((lat[:-1] < 0) & (lat[1:] >= 0))[0]
nodes = up + (-lat[up]) / (lat[up + 1] - lat[up])
assert len(nodes) >= N_ORBITS + 1
t0 = math.floor(nodes[0])
nodes = nodes[: N_ORBITS + 1] - t0
end = int(math.ceil(nodes[-1])) + MARGIN_S
sl = slice(t0, t0 + end + 1)
secs, pos, vel, lat, lon, alt = secs[sl] - t0, pos[sl], vel[sl], lat[sl], lon[sl], alt[sl]
u = unit(lat, lon)
vmeas = np.linalg.norm(vel, axis=1) # globe reading: km/s relative to the ground, at altitude
orbit_of = np.clip(np.searchsorted(nodes, secs, side="right") - 1, 0, N_ORBITS - 1)
inorb = secs < nodes[-1]
spans = [(int(math.ceil(nodes[k])), int(nodes[k + 1])) for k in range(N_ORBITS)]
# globe: point beneath on the 6371 km sphere (geocentric), and the ground speed the measured speed gives it (x R/r)
r = np.linalg.norm(pos, axis=1)
uc = pos / r[:, None]
vglobe = R * np.arccos(np.clip((uc[:-1] * uc[1:]).sum(1), -1, 1))
vglobe = np.concatenate([[vglobe[0]], (vglobe[:-1] + vglobe[1:]) / 2, [vglobe[-1]]])
aglobe = np.zeros_like(vglobe)
aglobe[5:-5] = (vglobe[10:] - vglobe[:-10]) / 10.0 / G0
vt = vel - (vel * uc).sum(1)[:, None] * uc
vg_ref = np.linalg.norm(vt, axis=1) * R / r
out = {"maps": {}}
stats = {}
for name, f in MAPS.items():
d = step_lengths(f, u, WRAP.get(name))
S = np.concatenate([[0.0], np.cumsum(d)]) # map km travelled
v = np.concatenate([[d[0]], (d[:-1] + d[1:]) / 2, [d[-1]]])
a = np.zeros_like(v)
a[5:-5] = (v[10:] - v[:-10]) / 10.0 / G0 # g, change of speed along the track
lag, gap = signal(S, secs, nodes, orbit_of, v_sig)
out["maps"][name] = {"S": S, "v": v, "a": a, "lag": lag, "gap": gap}
# restarted every minute instead of every orbit
sync = np.arange(0.0, nodes[-1], 60.0)
lag60, gap60 = signal(S, secs, sync, np.clip(np.searchsorted(sync, secs, side="right") - 1, 0, len(sync) - 1), v_sig)
ends = [np.interp(n - 1, secs, lag) for n in nodes[1:]]
stats[name] = {
"v_min": float(v[inorb].min()), "v_max": float(v[inorb].max()),
"a_max": float(np.abs(a[inorb]).max()),
"ratio_per_orbit": [float(v[a0:b0].max() / v[a0:b0].min()) for a0, b0 in spans],
"swing_per_orbit": [float(np.abs(np.diff(v[a0:b0])).sum()) for a0, b0 in spans],
"track_per_orbit": [float(S[b0] - S[a0]) for a0, b0 in spans],
"lag_end_mean": float(np.mean(ends)), "lag_end_min": float(np.min(ends)), "lag_end_max": float(np.max(ends)),
"lag_max": float(lag[inorb].max()), "lead_max": float(-lag[inorb].min()),
"gap_max": float(np.abs(gap[inorb]).max()),
"lag60_max": float(np.abs(lag60[inorb]).max()), "gap60_max": float(np.abs(gap60[inorb]).max()),
}
stats[name].update(one_orbit_after(S, secs, nodes, v_sig))
# dual-pole rim: closest approach to 0N 180E
rim = np.degrees(np.arccos(np.clip(-u[inorb][:, 0], -1, 1)))
stats["dualpole"]["rim_min_deg"] = float(rim.min())
# cities: station or signal above the horizon (mean altitude), XOR = out of sync
h_mean = float(alt[inorb].mean())
lam = math.acos(R / (R + h_mean))
cities = load_cities()
cu = unit(np.array([c[1] for c in cities]), np.array([c[2] for c in cities]))
cl = math.cos(lam)
idx = np.arange(0, int(nodes[-1]), STEP)
sees = (u[idx] @ cu.T) >= cl
worst = np.full(len(idx), 10**9)
for name in MAPS:
tau_i = idx - out["maps"][name]["lag"][idx]
uu = np.stack([np.interp(tau_i, secs, u[:, k]) for k in range(3)], 1)
bad = sees ^ ((uu @ cu.T) >= cl)
worst = np.minimum(worst, bad.sum(1))
stats[name]["cities_now_max"] = int(bad.sum(1).max())
# opening frame: within the first two orbits, at least 10 min before a restart and with cities out of
# sync on every map, the moment when the smallest lag over the three maps is largest
lagmin = np.min([out["maps"][n]["lag"][idx] for n in MAPS], axis=0)
nxt = nodes[np.minimum(orbit_of[idx] + 1, N_ORBITS)]
ok = (idx < nodes[2]) & (nxt - idx >= 600) & (worst >= 8)
t_init = int(idx[np.argmax(np.where(ok, lagmin, -1))])
stats["globe"] = {"v_min": float(vglobe[inorb].min()), "v_max": float(vglobe[inorb].max()),
"a_max": float(np.abs(aglobe[inorb]).max()),
"ratio_per_orbit": [float(vglobe[a0:b0].max() / vglobe[a0:b0].min()) for a0, b0 in spans],
"swing_per_orbit": [float(np.abs(np.diff(vglobe[a0:b0])).sum()) for a0, b0 in spans],
"track_per_orbit": [float(vglobe[a0:b0].sum()) for a0, b0 in spans],
"signal_drift_km_max": check_globe(secs, uc, vg_ref, nodes, orbit_of),
"ref_mean": float(vg_ref[inorb].mean())}
stats["measured"] = {"v_sig": v_sig, "v_min": float(vmeas[inorb].min()), "v_max": float(vmeas[inorb].max()),
"alt_mean": h_mean, "horizon_deg": math.degrees(lam), "horizon_km": R * lam,
"period_min": float(np.diff(nodes).mean() / 60), "signal_per_orbit_km": v_sig * float(np.diff(nodes).mean())}
stats["recorded"] = recorded_map_speeds(sn, lat, out, inorb)
stats["one_listener"] = one_listener(u, uc, lat, lon, h_mean)
stats["satnogs"] = {k: sn[k] for k in ("v_kms", "burst")} | {
"passes": {k: {x: q[x] for x in ("utc", "stations", "t0_rms_s", "v_kms", "offset_sd_median_hz")} for k, q in sn["passes"].items()}}
# page data: 10-s samples
k = np.arange(0, end + 1, STEP)
epoch = jd0 + fr0 + t0 / 86400.0
page = {
"tle": [l1, l2], "epoch_unix": round((epoch - 2440587.5) * 86400.0, 3), "step": STEP,
"nodes": [round(n, 2) for n in nodes.tolist()], "horizon_deg": round(math.degrees(lam), 3),
"alt_km": round(h_mean, 1), "t_init": t_init, "v_sig": v_sig, "vg_ref": round(stats["globe"]["ref_mean"], 4),
"lat": np.round(lat[k], 3).tolist(), "lon": np.round(lon[k], 3).tolist(),
"vmeas": np.round(vmeas[k] * 1000).astype(int).tolist(), # m/s
"vglobe": np.round(vglobe[k] * 1000).astype(int).tolist(), # m/s
"aglobe": np.round(aglobe[k] * 1000).astype(int).tolist(), # milli-g
"maps": {n: {"v": np.round(m["v"][k] * 1000).astype(int).tolist(), # m/s
"a": np.round(m["a"][k] * 1000).astype(int).tolist(), # milli-g
"S": np.round(m["S"][k], 1).tolist()} # map km travelled; the page derives the signal
for n, m in out["maps"].items()},
"cities": [list(c[:4]) for c in cities],
"stats": stats,
}
with open(os.path.join(HERE, "data", "sync.json"), "w") as fh:
json.dump(page, fh, separators=(",", ":"))
vals = copy_values(page, stats)
if not os.path.exists(os.path.join(HERE, "template.html")):
return
page["land"] = land()
tpl = open(os.path.join(HERE, "template.html"), encoding="utf-8").read()
d3 = open(os.path.join(SRC, "raw", "lib", "d3.min.js"), encoding="utf-8").read()
assert tpl.count("/*DATA*/null") == 1 and tpl.count("/*D3*/") == 1 and "</script" not in d3
unused = [k for k in vals if "{{" + k + "}}" not in tpl]
if unused:
print("not quoted on the page:", ", ".join(unused))
for key, val in vals.items():
tpl = tpl.replace("{{" + key + "}}", val)
assert "{{" not in tpl, tpl[tpl.index("{{"):][:40]
html = tpl.replace("/*DATA*/null", json.dumps(page, separators=(",", ":"))).replace("/*D3*/", d3)
with open(os.path.join(HERE, "site", "index.html"), "w", encoding="utf-8") as fh:
fh.write(html)
def signal(S, secs, sync, k, v_sig):
"""Source level with the station at each sync time, then moving along the map track at v_sig.
Returns lag (s since the station was where the signal is) and gap (map km, station ahead > 0)."""
Ssync = np.interp(sync[k], secs, S)
sig = Ssync + v_sig * (secs - sync[k])
return secs - np.interp(sig, S, secs), S - sig
def one_orbit_after(S, secs, nodes, v_sig):
"""Lag and gap one orbit after the signal is lined up, for every start time 1 min apart."""
P = float(np.diff(nodes).mean())
starts = np.arange(0.0, nodes[-1] - P, 60.0)
target = np.interp(starts, secs, S) + v_sig * P
lag = starts + P - np.interp(target, S, secs)
gap = np.interp(starts + P, secs, S) - target
return {"lag1_min": float(lag.min()), "lag1_max": float(lag.max()), "gap1_min": float(gap.min()), "gap1_max": float(gap.max())}
def load_cities():
"""GeoNames cities of MIN_POP or more; places within DEDUP_KM of a larger one are merged into it."""
rows = []
for row in open(os.path.join(SRC, "raw", "geonames", "cities15000.txt"), encoding="utf-8"):
c = row.rstrip("\n").split("\t")
if int(c[14] or 0) >= MIN_POP:
rows.append((c[2], round(float(c[4]), 3), round(float(c[5]), 3), c[8], int(c[14])))
rows.sort(key=lambda c: -c[4])
kept, ku = [], []
for c in rows:
cv = unit(np.array(c[1]), np.array(c[2]))
if ku and (np.array(ku) @ cv).max() >= math.cos(DEDUP_KM / R):
continue
kept.append(c); ku.append(cv)
return kept
def recorded_map_speeds(sn, lat, out, inorb):
"""Map speed at each SatNOGS receiver's closest approach (3 Oct 2026). On the North-pole AE and Mercator
maps the speed depends only on latitude and direction (northbound / southbound), so it is read from this
orbit's track at the same latitude and direction."""
north = np.gradient(lat) > 0
res = {}
for key, p in sn["passes"].items():
ca = sorted(p["ca"], key=lambda x: x[2])
nb = ca[-1][0] > ca[0][0] if abs(ca[-1][0] - ca[0][0]) > 1 else None
row = {"recorded_kms": p["v_kms"], "northbound": nb}
for name in ("ae", "mercator"):
v = out["maps"][name]["v"]
sp = []
for la, lo, t in ca:
if nb is None: # pass near its northern or southern limit: take both branches
m = inorb
else:
m = inorb & (north == nb)
i = np.nonzero(m)[0]
j = i[np.argmin(np.abs(lat[i] - la))]
sp.append(float(v[j]))
row[name] = {"mean": float(np.mean(sp)), "max": float(np.max(sp))}
res[key] = row
return res
def one_listener(u, uc, lat, lon, h):
"""The station over the North-pole AE plane, adjusting its own frequency so that one receiver
(VK4JBE, Brisbane) hears the shift the globe predicts, 2 min before its highest pass in this window.
Returns the error heard by neighbours at F_UHF."""
rx = (-27.492, 153.069)
ball = uc * (R + h)
dx, dy = ae_xy(lat, lon)
disc = np.stack([dx, dy, np.full_like(dx, h)], 1)
def rxpos(la, lo):
e = unit(np.array(la), np.array(lo))
q = ae_xy(np.array(la), np.array(lo))
return e * R, e, np.array([float(q[0]), float(q[1]), 0.0])
def shifts(la, lo):
P, e, Q = rxpos(la, lo)
fb = -F_UHF / C_KMS * np.gradient(np.linalg.norm(ball - P, axis=1))
fd = -F_UHF / C_KMS * np.gradient(np.linalg.norm(disc - Q, axis=1))
el = np.degrees(np.arcsin(np.clip(((ball - P) @ e) / np.linalg.norm(ball - P, axis=1), -1, 1)))
return fb, fd, el
fb, fd, el = shifts(*rx)
tca = int(np.argmax(el))
t = tca - 120
cor = fb[t] - fd[t]
res = {"rx": "VK4JBE, Brisbane", "max_el": float(el[tca]), "el": float(el[t]), "correction_hz": float(cor)}
for dk, brg, nm in ((100, 90, "east100"), (1000, 0, "north1000")):
p, l, dd, b = math.radians(rx[0]), math.radians(rx[1]), dk / R, math.radians(brg)
p2 = math.asin(math.sin(p) * math.cos(dd) + math.cos(p) * math.sin(dd) * math.cos(b))
l2 = l + math.atan2(math.sin(b) * math.sin(dd) * math.cos(p), math.cos(dd) - math.sin(p) * math.sin(p2))
fb2, fd2, el2 = shifts(math.degrees(p2), math.degrees(l2))
res[nm] = {"error_hz": float(fd2[t] + cor - fb2[t]), "el": float(el2[t])}
return res
def mmss(s):
s = int(round(s))
return f"{s // 60} min {s % 60:02d} s" if s >= 60 else f"{s} s"
def fmt_v(x):
return f"{x:,.0f}" if x >= 100 else f"{x:.2f}"
def rng(xs, f):
lo, hi = f(min(xs)), f(max(xs))
return lo if lo == hi else f"{lo}–{hi}"
def copy_values(page, st):
"""Numbers quoted in the page text, from this run."""
import datetime as dt
utc = lambda t: dt.datetime.fromtimestamp(page["epoch_unix"] + t, dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
m, ae, dp, me, gl, rec, one, sn = (st[k] for k in ("measured", "ae", "dualpole", "mercator", "globe", "recorded", "one_listener", "satnogs"))
l1 = page["tle"][0]
yy, doy = int(l1[18:20]), float(l1[20:32])
ep = dt.datetime(2000 + yy, 1, 1, tzinfo=dt.timezone.utc) + dt.timedelta(days=doy - 1)
b = sn["burst"]
rms = [q["t0_rms_s"] for q in sn["passes"].values()]
el = lambda km: f"{math.degrees(math.atan2(m['alt_mean'], km)):.1f}"
v = {
"V_SIG": f"{m['v_sig']:.2f}", "V_ORBIT_RANGE": f"{m['v_min']:.2f}–{m['v_max']:.2f}",
"ALT": f"{m['alt_mean']:.0f}", "PERIOD": f"{m['period_min']:.1f}",
"HORIZON_DEG": f"{m['horizon_deg']:.1f}", "HORIZON_KM": f"{m['horizon_km']:,.0f}",
"EL_HORIZON": el(m["horizon_km"]), "EL_10000": el(10000),
"GLOBE_V_RANGE": f"{gl['v_min']:.2f}–{gl['v_max']:.2f}", "GLOBE_DRIFT": f"{gl['signal_drift_km_max']:.2f}",
"N_CITIES": f"{len(page['cities'])}", "N_ORBITS": f"{len(page['nodes']) - 1}",
"START_UTC": utc(page["nodes"][0]), "EPOCH_UTC": ep.strftime("%Y-%m-%d %H:%M UTC"),
"SIG_TRACK": f"{round(m['signal_per_orbit_km'], -2):,.0f}",
"AE_LAG": f"{ae['lag_end_mean'] / 60:.0f} min", "ME_LAG": f"{me['lag_end_mean'] / 60:.0f} min",
"DP_LAG": f"{dp['lag_end_min'] / 60:.0f}–{dp['lag_end_max'] / 60:.0f} min",
"AE_LAG60": mmss(ae["lag60_max"]), "AE_GAP60": f"{round(ae['gap60_max'], -2):,.0f}",
"SN_RMS": rng(rms, lambda x: f"{x:.1f}"), "SN_N": f"{sum(q['stations'] for q in sn['passes'].values())}",
"SN_OFFSET": f"{np.median([q['offset_sd_median_hz'] for q in sn['passes'].values()]):.0f}",
"SN_V": f"{sn['v_kms']:.2f}",
"BURST_UTC": b["utc"].replace(" UTC", "")[11:] + " UTC on 3 October 2026", "BURST_N": f"{b['receivers']}",
"BURST_UP": f"{b['up']}", "BURST_DOWN": f"{b['down']}",
"BURST_MIN": f"−{abs(b['min_hz']) / 1000:.1f}", "BURST_MAX": f"+{b['max_hz'] / 1000:.1f}",
"REC_AE_SOUTH": rng([rec[k]["ae"]["mean"] for k in ("2026-10-03_SouthAmerica", "2026-10-03_Australia")], lambda x: f"{x:.0f}"),
"REC_AE_EU": f"{rec['2026-10-03_Europe']['ae']['mean']:.1f}",
"REC_V_SOUTH": rng([rec[k]["recorded_kms"] for k in ("2026-10-03_SouthAmerica", "2026-10-03_Australia")], lambda x: f"{x:.2f}"),
"REC_V_EU": f"{rec['2026-10-03_Europe']['recorded_kms']:.2f}",
"ONE_COR": f"{abs(one['correction_hz']) / 1000:.1f}", "ONE_E100": f"{abs(one['east100']['error_hz']):,.0f}",
"ONE_N1000": f"{abs(one['north1000']['error_hz']) / 1000:.1f}",
"DP_RIM": f"{dp['rim_min_deg']:.1f}",
"DP_VMAX": f"{dp['v_max']:,.0f}",
"AE_XSIG": f"{ae['v_max'] / m['v_sig']:.1f}",
}
for k, s in (("G", gl), ("AE", ae), ("DP", dp), ("ME", me)):
v[k + "_VR"] = f"{fmt_v(s['v_min'])}–{fmt_v(s['v_max'])}"
v[k + "_AMAX"] = f"{s['a_max']:,.0f}" if s["a_max"] >= 100 else f"{s['a_max']:.2f}"
v[k + "_RATIO"] = "×" + rng(s["ratio_per_orbit"], lambda x: f"{x:.2f}" if x < 10 else f"{x:.0f}")
v[k + "_SWING"] = rng(s["swing_per_orbit"], lambda x: f"{x:,.0f}" if x >= 10 else f"{x:.2f}")
v[k + "_TRACK"] = rng(s["track_per_orbit"], lambda x: f"{round(x, -2):,.0f}")
if k != "G":
v[k + "_LAGEND"] = (mmss(s["lag_end_mean"]) if s["lag_end_max"] - s["lag_end_min"] < 2
else f"{mmss(s['lag_end_min'])} – {mmss(s['lag_end_max'])}")
v[k + "_GAP"] = f"{s['gap_max']:,.0f}"
v[k + "_LAG1"] = f"{s['lag1_min'] / 60:.0f}–{s['lag1_max'] / 60:.0f} min"
v[k + "_GAP1"] = f"{round(s['gap1_min'], -2):,.0f}–{round(s['gap1_max'], -2):,.0f}"
v[k + "_LAG60"] = mmss(s["lag60_max"])
assert abs(gl["ratio_per_orbit"][0] - 1) < 0.01 and gl["signal_drift_km_max"] < 1
return v
def check_globe(secs, uc, vg, nodes, orbit_of):
"""Globe: a signal moving at the measured speed scaled to the ground (R/r), started level at each
crossing, against the station's point beneath on the sphere. Largest separation in km: zero up to
integration error, since both come from the same orbit."""
ang = np.arccos(np.clip((uc[:-1] * uc[1:]).sum(1), -1, 1))
S = np.concatenate([[0.0], np.cumsum(R * ang)])
M = np.concatenate([[0.0], np.cumsum((vg[:-1] + vg[1:]) / 2)])
m = secs < nodes[-1]
sig = np.interp(nodes[orbit_of], secs, S) + M - np.interp(nodes[orbit_of], secs, M)
return float(np.abs(S - sig)[m].max())
if __name__ == "__main__":
main()