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()
+3
View File
@@ -0,0 +1,3 @@
ISS (ZARYA)
1 25544U 98067A 26301.59601386 .00005724 00000+0 11161-3 0 9990
2 25544 51.6336 359.9178 0006994 320.4528 39.5949 15.49090321592436
+336
View File
@@ -0,0 +1,336 @@
{
"source": "The Shape of Distance, Listening to the ISS (https://distance.aethercosmology.com/#doppler)",
"passes": {
"2026-10-03_Europe": {
"utc": "2026-10-03 12:19 UTC",
"stations": 45,
"t0_rms_s": 1.5110702608857516,
"v_kms": 7.071278974220315,
"ca": [
[
49.68097225065866,
-4.286398095275085,
-147.41300439834595
],
[
50.17352884342994,
-1.7422410616196602,
-120.22392249107361
],
[
50.28722677319256,
-1.0969439006134212,
-113.29120254516602
],
[
50.34359200582169,
-0.7672780083574665,
-107.26256608963013
],
[
50.39813807454715,
-0.4416178141532689,
-106.28527522087097
],
[
50.474938763776336,
0.028721735570883075,
-100.27764987945557
],
[
50.458242263668495,
-0.08031116446249402,
-102.48805689811707
],
[
50.48642262597799,
0.10030038460694612,
-99.0170385837555
],
[
50.45246196129986,
-0.11597005348848836,
-102.86739993095398
],
[
50.70001449932109,
1.4983330525942538,
-85.73596286773682
],
[
50.88022658484752,
2.7955569795944033,
-71.13996124267578
],
[
50.890315319090526,
2.8720176595924722,
-69.3419349193573
],
[
50.83936569470999,
2.4903428480145116,
-75.32912468910217
],
[
51.0289501837351,
3.9719734944273135,
-58.40026807785034
],
[
51.0880129475564,
4.472283630155815,
-55.21893787384033
],
[
51.11690276007427,
4.724988195271039,
-50.607028007507324
],
[
51.154417075514885,
5.061695917564571,
-47.63208723068237
],
[
51.16815681994341,
5.18758780082744,
-46.83433556556702
],
[
51.20593103429147,
5.5413235647036005,
-43.19211935997009
],
[
51.26313560465976,
6.094044822458296,
-30.066311597824097
],
[
51.326402995289214,
6.756550043083508,
-30.72496247291565
],
[
51.36619618748058,
7.193198756269772,
-26.261382579803467
],
[
51.50222554524715,
8.872909168549834,
-9.16100025177002
],
[
51.558044743989555,
9.680108509926336,
-2.478559732437134
],
[
51.566589432203116,
9.812150373988406,
0.3579845428466797
],
[
51.60349260520093,
10.406897638198846,
3.8177101612091064
],
[
51.62705814797918,
10.824044199618344,
11.029848098754883
],
[
51.736418086840835,
13.405629750411013,
34.06613898277283
],
[
51.39860814725719,
7.564712838371189,
-22.969857692718506
],
[
51.77979957060567,
15.475773174887708,
58.828940629959106
],
[
51.77453868481207,
15.078145390694818,
53.34382939338684
],
[
51.78680194014927,
16.804750679477568,
71.64358496665955
],
[
51.786760333614666,
16.578869860803913,
69.38079118728638
],
[
51.78383526684172,
17.51943197961017,
77.30333089828491
],
[
51.77667138982217,
18.19714086438838,
84.59421920776367
],
[
51.77333207609721,
18.423658974218366,
86.36457991600037
],
[
51.74103777964802,
19.862998750112546,
102.30152630805969
],
[
51.75291718453868,
19.427711593038516,
96.87794494628906
],
[
51.74196619765101,
19.830924017483685,
100.97955536842346
],
[
51.69437205398979,
21.187367129137698,
114.60929918289185
],
[
51.7739058089836,
18.386923130428553,
85.99635648727417
],
[
51.55894671029261,
23.729682647005685,
140.25733137130737
],
[
51.34329033761241,
26.48423575805742,
167.76775479316711
],
[
51.3393359762161,
26.527367191357794,
168.70865154266357
],
[
51.786805507363106,
16.623095415982302,
67.82382726669312
]
],
"offset_sd_median_hz": 55.68906970882991
},
"2026-10-03_SouthAmerica": {
"utc": "2026-10-03 08:38 UTC",
"stations": 6,
"t0_rms_s": 2.1213203435596424,
"v_kms": 7.100233927518952,
"ca": [
[
-24.42636520633059,
-39.032266541179744,
267.26572489738464
],
[
-38.03381783704823,
-54.789606627691114,
-40.502705097198486
],
[
-39.330652166899945,
-56.809004937438154,
-75.51542568206787
],
[
-38.02770265998915,
-54.78040726613133,
-42.34938168525696
],
[
-38.02012345473304,
-54.76900975332197,
-43.159380197525024
],
[
-38.14095199215084,
-54.9512402310319,
-41.692227602005005
]
],
"offset_sd_median_hz": 55.52755202642167
},
"2026-10-03_Australia": {
"utc": "2026-10-03 11:20 UTC",
"stations": 6,
"t0_rms_s": 0.6454972243679028,
"v_kms": 7.098692302512828,
"ca": [
[
-30.558236025432794,
149.49188402916255,
0.8984365463256836
],
[
-30.570206668846524,
149.5051140521379,
1.664686918258667
],
[
-33.20733229830597,
152.55894381076263,
59.26902198791504
],
[
-30.60050410953769,
149.53862214953645,
1.3387207984924316
],
[
-32.812113682161325,
152.08238759524934,
51.706862688064575
],
[
-32.92338072550679,
152.2158315683196,
53.75318193435669
]
],
"offset_sd_median_hz": 60.15296495284982
}
},
"v_kms": 7.073275218950461,
"burst": {
"utc": "2026-10-03 12:18:55 UTC",
"receivers": 44,
"up": 22,
"down": 22,
"min_hz": -9057.47484203577,
"max_hz": 9606.405798036318,
"min_name": "M0EYT",
"max_name": "EU1AEM",
"lat_range": [
46.09548,
56.857
],
"lon_range": [
-6.235,
27.17
]
}
}
File diff suppressed because one or more lines are too long
+68
View File
@@ -0,0 +1,68 @@
"""SatNOGS facts quoted on the page, from the "Listening to the ISS" tab of
The Shape of Distance (src/handoff/The_Shape_of_Distance_PRESENTATION.html,
page 3; recordings of 3 October 2026 at 437.55 / 437.80 MHz).
python3 satnogs.py -> data/satnogs_20261003.json
"""
import base64, datetime as dt, json, os, re
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
PRES = os.path.join(HERE, "..", "src", "handoff", "The_Shape_of_Distance_PRESENTATION.html")
BURST_T = -10.0 # s from the Europe pass epoch: the instant compared across receivers
def page3():
s = open(PRES, encoding="utf-8").read()
blobs = re.findall(r"[A-Za-z0-9+/]{20000,}={0,2}", s)
html = base64.b64decode(blobs[3]).decode("utf-8")
assert "<title>Listening to the ISS</title>" in html
js = re.findall(r"<script[^>]*>(.*?)</script>", html, flags=re.S)[0]
return json.loads(js[js.index("{"): js.rindex("}") + 1])
def main():
D = page3()
out = {"source": "The Shape of Distance, Listening to the ISS (https://distance.aethercosmology.com/#doppler)", "passes": {}}
for key, p in D["passes"].items():
st = p["stations"]
dt0 = np.array([s["t0"] - s["pub"]["t_ca"] for s in st])
V = np.array([s["V"] for s in st]); E = np.array([s["V_err"] for s in st]); w = 1 / E**2
down = [s["t0"] for s in st]
out["passes"][key] = {
"utc": dt.datetime.fromtimestamp(p["t_epoch"], dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"stations": len(st),
"t0_rms_s": float(np.sqrt((dt0**2).mean())),
"v_kms": float((V * w).sum() / w.sum() / 1000),
# point beneath the station at each receiver's closest approach (published orbit)
"ca": [[s["pub"]["lat"], s["pub"]["lon"], s["t0"]] for s in st],
"offset_sd_median_hz": float(np.median([s["offset_sd"] for s in st])),
}
allV = np.array([s["V"] for p in D["passes"].values() for s in p["stations"]])
allE = np.array([s["V_err"] for p in D["passes"].values() for s in p["stations"]])
out["v_kms"] = float((allV / allE**2).sum() / (1 / allE**2).sum() / 1000)
# one instant of the Europe pass: carrier-corrected shift at every receiver with data within 3 s
p = D["passes"]["2026-10-03_Europe"]
shifts = []
for s in p["stations"]:
pts = np.array(s["pts"]); t = pts[:, 0]; i = np.searchsorted(t, BURST_T)
if 0 < i < len(t) and t[i] - t[i - 1] < 6:
shifts.append((s["name"], float(np.interp(BURST_T, t, pts[:, 1]) - (s["f"] - s["f_nom"])), s["lat"], s["lon"]))
v = np.array([x[1] for x in shifts])
lo, hi = min(shifts, key=lambda x: x[1]), max(shifts, key=lambda x: x[1])
out["burst"] = {
"utc": dt.datetime.fromtimestamp(p["t_epoch"] + BURST_T, dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
"receivers": len(shifts), "up": int((v > 0).sum()), "down": int((v < 0).sum()),
"min_hz": float(v.min()), "max_hz": float(v.max()), "min_name": lo[0], "max_name": hi[0],
"lat_range": [min(x[2] for x in shifts), max(x[2] for x in shifts)],
"lon_range": [min(x[3] for x in shifts), max(x[3] for x in shifts)],
}
with open(os.path.join(HERE, "data", "satnogs_20261003.json"), "w") as fh:
json.dump(out, fh, indent=1)
print(json.dumps({k: v for k, v in out.items() if k != "passes"}, indent=1))
for k, q in out["passes"].items():
print(k, q["utc"], q["stations"], "t0 rms %.2f s" % q["t0_rms_s"], "v %.3f" % q["v_kms"], "offset sd %.0f Hz" % q["offset_sd_median_hz"])
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+639
View File
@@ -0,0 +1,639 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>The Sync Test</title>
<meta name="description" content="The ISS at its published-orbit position on the globe, North-pole AE, dual-pole AE and Mercator maps, with a spoofed radio signal moving at the measured speed of {{V_SIG}} km/s.">
<meta property="og:type" content="website">
<meta property="og:title" content="The Sync Test">
<meta property="og:description" content="ISS position and a spoofed radio signal on four maps. On the globe they stay level; one orbit after the signal is lined up, it is {{AE_LAG1}} behind on the North-pole AE map.">
<meta property="og:url" content="https://iss.aethercosmology.com/">
<meta property="og:image" content="https://iss.aethercosmology.com/og.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='9' fill='%23081120' stroke='%233ee6ff' stroke-width='2'/%3E%3Ccircle cx='25' cy='9' r='3.5' fill='%23fff'/%3E%3Ccircle cx='22' cy='25' r='3.5' fill='none' stroke='%23ff4fd8' stroke-width='2'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
<style>
/* Layout: one row of four synchronised map panels on wide screens (2 / 1 columns below), readouts under each map; text in a ~70ch column above and below. */
:root{
--bg:#02050c; --panel:#07101e; --panel2:#0b182b; --line:rgba(120,180,255,.14); --line2:rgba(120,180,255,.28);
--ink:#e8f0fb; --dim:#9badc6; --faint:#65789a;
--station:#ffffff; --signal:#ff4fd8; --track:#3ee6ff; --ok:#3dffa0; --warn:#ffc94a; --bad:#ff4d5e;
--disp:"Space Grotesk","Inter",system-ui,sans-serif;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
color-scheme:dark;
}
*{box-sizing:border-box}
html{background:var(--bg)}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans);-webkit-font-smoothing:antialiased;
padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:20;background:var(--bg)}
a{color:var(--track);text-decoration-color:rgba(62,230,255,.35);text-underline-offset:2px}
a:hover{text-decoration-color:var(--track)}
:focus-visible{outline:2px solid var(--track);outline-offset:2px}
.wrap{max-width:1720px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))}
.top{padding-block:28px 6px}
h1{font:700 clamp(28px,3.2vw,40px)/1.1 var(--disp);margin:0;letter-spacing:-.01em;text-wrap:balance}
.sub{margin:6px 0 0;color:var(--dim);font-size:15px}
.intro{display:grid;gap:10px 40px;padding-block:14px 18px}
@media (min-width:1100px){.intro{grid-template-columns:repeat(2,minmax(0,64ch))}}
.intro p{margin:0;max-width:74ch;color:var(--ink)}
.intro b{font-family:var(--disp);font-weight:600;color:var(--dim);letter-spacing:.01em}
.k-st{color:var(--station);font-weight:600}.k-sig{color:var(--signal);font-weight:600}.k-bad{color:var(--bad);font-weight:600}
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:rgba(2,5,12,.9);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);border-block:1px solid var(--line)}
.barin{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding-block:9px}
.sigrow{padding-block:0 9px;gap:8px 22px}
.rowlab{font:500 10.5px var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
@media (max-width:620px){.bar{position:static}}
button{font:inherit;color:inherit}
#play{width:38px;height:34px;border-radius:8px;border:1px solid var(--line2);background:var(--panel2);cursor:pointer;display:grid;place-items:center}
#play svg{width:14px;height:14px;fill:var(--ink)}
.rates{display:flex;border:1px solid var(--line2);border-radius:8px;overflow:hidden}
.rates button{border:0;background:transparent;padding:6px 10px;font:500 12.5px var(--mono);color:var(--dim);cursor:pointer}
.rates button+button{border-left:1px solid var(--line)}
.rates button[aria-pressed="true"]{background:var(--panel2);color:var(--ink)}
#time{flex:1 1 220px;min-width:140px;accent-color:var(--track)}
.sync{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;font-size:12.5px;color:var(--dim)}
.sync button{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 10px;font:500 12.5px var(--sans);cursor:pointer;color:var(--ink)}
#sync{border-color:rgba(255,79,216,.6)}
#syncinfo{font-variant-numeric:tabular-nums}
.corr{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;font-size:12.5px;color:var(--dim)}
#corr{border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:6px 10px;font:500 12.5px var(--sans);cursor:pointer;color:var(--dim)}
#corr[aria-pressed="true"]{color:var(--ink);border-color:rgba(255,79,216,.6)}
#latwrap{display:flex;align-items:center;gap:6px}
#latwrap[hidden]{display:none}
#lat{width:110px;accent-color:var(--signal)}
#latv{font:500 12.5px var(--mono);min-width:3.6em;color:var(--ink)}
#sync:disabled{opacity:.45;cursor:default}
.clock{display:flex;gap:12px;font:500 13px var(--mono);color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
.clock b{color:var(--ink);font-weight:500}
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;padding-block:12px 0;margin-block:0;list-style:none;color:var(--dim);font-size:13px}
.legend li{display:flex;align-items:center;gap:7px}
.legend svg{width:22px;height:14px;flex:none}
.grid{display:grid;gap:12px;grid-template-columns:repeat(4,minmax(0,1fr));padding-block:12px 8px}
@media (max-width:1199px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:620px){.grid{grid-template-columns:minmax(0,1fr)}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 10px 8px;display:flex;flex-direction:column;gap:8px;min-width:0}
.ph{display:flex;align-items:baseline;justify-content:space-between;gap:10px;min-width:0}
.ph h2{font:600 16px/1.2 var(--disp);margin:0}
.ph p{margin:0;color:var(--faint);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.map{position:relative;aspect-ratio:1;width:100%;max-width:100%}
.map canvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:pan-y}
.ro{display:grid;grid-template-columns:1fr 1fr;gap:6px 10px;margin:0}
.ro div{min-width:0}
.ro .w{grid-column:1/-1}
.ro dt{font:500 10.5px/1.3 var(--sans);letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.ro dd{margin:1px 0 0;font:500 13px/1.35 var(--mono);font-variant-numeric:tabular-nums;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pill{display:inline-block;margin-left:6px;padding:0 6px;border-radius:999px;font-size:11.5px;line-height:18px;border:1px solid currentColor}
.pill.ok{color:var(--ok)}.pill.warn{color:var(--warn)}.pill.bad{color:var(--bad)}
.sp{display:flex;flex-direction:column;gap:2px}
.sp span{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}
.sp canvas{width:100%;height:54px;display:block;cursor:pointer}
.after{padding-block:22px 10px;display:grid;gap:10px}
.after h2{font:600 19px/1.25 var(--disp);margin:14px 0 2px}
.after h3{font:600 15.5px/1.3 var(--disp);margin:6px 0 0;color:var(--ink)}
.after p b{color:var(--ink);font-weight:600}
.after p{margin:0;max-width:74ch;color:var(--dim)}
.tw{overflow-x:auto;border:1px solid var(--line);border-radius:10px;max-width:980px}
table{border-collapse:collapse;width:100%;min-width:640px;font-size:13.5px}
th,td{padding:8px 12px;text-align:right;border-bottom:1px solid var(--line);white-space:nowrap}
tr:last-child td,tr:last-child th{border-bottom:0}
thead th{font:600 12.5px var(--disp);color:var(--dim);background:var(--panel)}
tbody th{text-align:left;font-weight:500;color:var(--dim)}
td{font-family:var(--mono);font-variant-numeric:tabular-nums}
.method{display:grid;grid-template-columns:max-content minmax(0,74ch);gap:8px 20px;margin:0;color:var(--dim)}
.method dt{font:600 13.5px var(--disp);color:var(--ink);padding-top:1px}
.method dd{margin:0}
@media (max-width:620px){.method{grid-template-columns:minmax(0,1fr);gap:2px}.method dd{margin-bottom:10px}}
#dl{justify-self:start;border:1px solid var(--line2);background:var(--panel2);border-radius:8px;padding:7px 14px;cursor:pointer;font-size:13.5px}
footer{padding-block:18px 30px;color:var(--faint);font-size:13px}
#tip{position:fixed;z-index:30;pointer-events:none;background:rgba(6,12,24,.96);border:1px solid var(--line2);border-radius:8px;padding:6px 9px;font-size:12.5px;line-height:1.4;max-width:240px}
#tip b{font-weight:600}
#tip span{display:block;color:var(--dim)}
</style>
</head>
<body>
<header class="wrap top">
<h1>The Sync Test</h1>
<p class="sub">ISS position and a spoofed radio signal on four maps</p>
</header>
<section class="wrap intro">
<p><b>Hypothesis.</b> The radio signals attributed to the International Space Station come from ground transmitters along its track, imitating a source that moves at the measured speed, {{V_SIG}} km/s.</p>
<p><b>Test.</b> The <span class="k-st">station</span> is placed where the published orbit puts it, which is where it is seen to pass at the predicted times (<a href="https://spotthestation.nasa.gov/">Spot the Station</a>); its speed across each map follows from that map's distances. The <span class="k-sig">spoofed signal</span> is the source those transmitters imitate, moving along the same track at {{V_SIG}} km/s, lined up with the station at each northbound equator crossing, or at any moment with <i>Line up signal now</i>. A city turns <span class="k-bad">red</span> when the station is above its horizon and the signal is not, or the reverse.</p>
</section>
<div class="bar">
<div class="wrap barin">
<button id="play" type="button" aria-label="Pause"><svg viewBox="0 0 14 14" aria-hidden="true"><path id="playicon" d="M2 1h3.5v12H2zM8.5 1H12v12H8.5z"/></svg></button>
<div class="rates" role="group" aria-label="Playback speed">
<button type="button" data-rate="1" aria-pressed="false">×1</button>
<button type="button" data-rate="10" aria-pressed="false">×10</button>
<button type="button" data-rate="60" aria-pressed="true">×60</button>
<button type="button" data-rate="300" aria-pressed="false">×300</button>
<button type="button" data-rate="1000" aria-pressed="false">×1000</button>
</div>
<input id="time" type="range" min="0" max="1" step="10" value="0" aria-label="Time">
<div class="clock"><b id="clock">—</b><span id="orbit">—</span></div>
</div>
<div class="wrap barin sigrow">
<span class="rowlab">Signal</span>
<div class="sync">
<button id="sync" type="button">Line up signal now</button>
<button id="syncauto" type="button" hidden>Each orbit</button>
<span id="syncinfo">Lined up: each northbound equator crossing</span>
</div>
<div class="corr">
<button id="corr" type="button" aria-pressed="false">Real-time correction</button>
<label id="latwrap" hidden>Latency <input id="lat" type="range" min="0" max="9" step="1" value="4" aria-label="Correction latency"> <span id="latv">10 s</span></label>
</div>
</div>
</div>
<ul class="wrap legend">
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="4" fill="#fff"/></svg>Station, published-orbit position</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="5" fill="none" stroke="#ff4fd8" stroke-width="2"/></svg>Spoofed signal at the measured speed</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><path d="M1 7h20" stroke="#ff4fd8" stroke-opacity=".6" stroke-width="3"/></svg>Gap along the track</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="6" fill="none" stroke="#fff" stroke-opacity=".6"/></svg>Above the horizon</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="3" fill="#3dffa0"/></svg>City: station and signal in view</li>
<li><svg viewBox="0 0 22 14" aria-hidden="true"><circle cx="11" cy="7" r="3" fill="#ff4d5e"/></svg>City: one without the other</li>
</ul>
<main class="wrap grid">
<article class="panel" data-key="globe">
<header class="ph"><h2>Globe</h2><p>Sphere, radius 6,371 km</p></header>
<div class="map"><canvas aria-label="Globe: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="ae">
<header class="ph"><h2>North-pole AE</h2><p>Centred on the North Pole</p></header>
<div class="map"><canvas aria-label="North-pole AE map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="dualpole">
<header class="ph"><h2>Dual-pole AE</h2><p>Centred on 0°N 0°E</p></header>
<div class="map"><canvas aria-label="Dual-pole AE map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
<article class="panel" data-key="mercator">
<header class="ph"><h2>Mercator</h2><p>True on the equator, edges joined</p></header>
<div class="map"><canvas aria-label="Mercator map: station and signal"></canvas></div>
<dl class="ro">
<div><dt>Map speed</dt><dd data-f="v">—</dd></div>
<div><dt>Speed change</dt><dd data-f="a">—</dd></div>
<div class="w"><dt>Signal</dt><dd data-f="s">—</dd></div>
<div><dt>Signal speed</dt><dd data-f="vs">—</dd></div>
<div><dt>Cities out of sync now</dt><dd data-f="c">—</dd></div>
</dl>
<div class="sp"><span>Map speed this orbit, km/s · dashed: measured</span><canvas aria-label="Map speed this orbit"></canvas></div>
</article>
</main>
<section class="wrap after">
<h2>Result</h2>
<p>On the globe the signal stays level with the station by construction: both come from the published orbit (numerically within {{GLOBE_DRIFT}} km over {{N_ORBITS}} orbits). On each flat map the station's track per orbit is longer than the {{SIG_TRACK}} km the signal covers in the same time: {{AE_TRACK}} km on the North-pole map, {{ME_TRACK}} km on Mercator and {{DP_TRACK}} km on the dual-pole map. One orbit after it is lined up, at whatever moment, the signal is {{AE_LAG1}} behind on the North-pole map, {{ME_LAG1}} on Mercator and {{DP_LAG1}} on the dual-pole map. Restarted level every minute instead, it is still up to {{AE_LAG60}} adrift on the North-pole map. The recordings show no such drift: in {{SN_N}} SatNOGS recordings of three passes on 3 October 2026, the S-curves are centred within {{SN_RMS}} s (rms per pass) of the closest approaches the published orbit predicts.</p>
<h3>Other ways to send the signal</h3>
<p><b>Corrected in real time</b> (<i>Real-time correction</i>, above): transmitters that track the station and steer the signal to where it was a latency earlier keep it that latency behind. To stay with the station the signal must then cross the map at the map speed, up to {{AE_XSIG}} times the {{V_SIG}} km/s its Doppler shift shows on the North-pole map. The recordings allow neither: their S-curves are centred within {{SN_RMS}} s of the predicted closest approaches, and their shifts give {{REC_V_SOUTH}} km/s over South America and Australia, where the North-pole map needs {{REC_AE_SOUTH}} km/s.</p>
<p><b>Ground transmitters timed to the station</b> can keep in step with it, but a transmitter sends one frequency to every receiver in range. At {{BURST_UTC}}, {{BURST_N}} receivers from Ireland to Belarus were recording the station's UHF signal at the same instant: {{BURST_UP}} heard it shifted up and {{BURST_DOWN}} down, from {{BURST_MIN}} to {{BURST_MAX}} kHz, each within about {{SN_OFFSET}} Hz of the shift the published orbit predicts for its own position. Imitating that takes a separate transmitter for each receiver, heard by no other. The signal also arrives from where the transmitter is, which a directional antenna or a pair of antennas measures, not from the station's place in the sky.</p>
<p><b>The station adjusting its own signal</b> over a flat map: the signal then arrives from the right direction, and the station can give one receiver the shift the globe predicts, but every other receiver hears the correction meant for that one. Over the North-pole map, adjusted for VK4JBE in Brisbane two minutes before closest approach on its highest pass in this window, it is {{ONE_E100}} Hz off for a receiver 100 km east and {{ONE_N1000}} kHz off 1,000 km north (437.55 MHz). Unadjusted, its shift would show the map speed: {{REC_AE_SOUTH}} km/s where the recordings from South America and Australia show {{REC_V_SOUTH}} km/s, and {{REC_AE_EU}} km/s where those from Europe show {{REC_V_EU}} km/s.</p>
<h2>Over {{N_ORBITS}} orbits</h2>
<div class="tw">
<table>
<thead><tr><th scope="col"></th><th scope="col">Globe</th><th scope="col">North-pole AE</th><th scope="col">Dual-pole AE</th><th scope="col">Mercator</th></tr></thead>
<tbody>
<tr><th scope="row">Map speed, km/s</th><td>{{G_VR}}</td><td>{{AE_VR}}</td><td>{{DP_VR}}</td><td>{{ME_VR}}</td></tr>
<tr><th scope="row">Fastest ÷ slowest, each orbit</th><td>{{G_RATIO}}</td><td>{{AE_RATIO}}</td><td>{{DP_RATIO}}</td><td>{{ME_RATIO}}</td></tr>
<tr><th scope="row">Speed gained and lost per orbit, km/s</th><td>{{G_SWING}}</td><td>{{AE_SWING}}</td><td>{{DP_SWING}}</td><td>{{ME_SWING}}</td></tr>
<tr><th scope="row">Largest speed change along the track, g</th><td>{{G_AMAX}}</td><td>{{AE_AMAX}}</td><td>{{DP_AMAX}}</td><td>{{ME_AMAX}}</td></tr>
<tr><th scope="row">Station's track per orbit, map km</th><td>{{G_TRACK}}</td><td>{{AE_TRACK}}</td><td>{{DP_TRACK}}</td><td>{{ME_TRACK}}</td></tr>
<tr><th scope="row">Signal's distance per orbit, map km</th><td>{{G_TRACK}}</td><td>{{SIG_TRACK}}</td><td>{{SIG_TRACK}}</td><td>{{SIG_TRACK}}</td></tr>
<tr><th scope="row">Lag one orbit after lining up, any start</th><td>level</td><td>{{AE_LAG1}}</td><td>{{DP_LAG1}}</td><td>{{ME_LAG1}}</td></tr>
<tr><th scope="row">Gap one orbit after lining up, map km</th><td>{{GLOBE_DRIFT}}</td><td>{{AE_GAP1}}</td><td>{{DP_GAP1}}</td><td>{{ME_GAP1}}</td></tr>
<tr><th scope="row">Largest lag, restarted every minute</th><td>level</td><td>{{AE_LAG60}}</td><td>{{DP_LAG60}}</td><td>{{ME_LAG60}}</td></tr>
</tbody>
</table>
</div>
<h2>Method</h2>
<dl class="method">
<dt>Orbit</dt><dd>ISS (NORAD 25544) orbital elements from CelesTrak, epoch {{EPOCH_UTC}}; positions every second by SGP4 for {{N_ORBITS}} orbits from {{START_UTC}}. Period {{PERIOD}} min, mean altitude {{ALT}} km.</dd>
<dt>Measured speed</dt><dd>{{V_SIG}} km/s: the straight-line reading of the SatNOGS recordings, from the height of each S-curve with no map assumed (weighted mean of {{SN_N}} receivers). Over a plane this is the speed of a source in level flight and of the point beneath it; climbing or descending would make the source faster than the point beneath it, so level flight is the flat maps' best case. On the globe the same recordings fit the published orbit: {{V_ORBIT_RANGE}} km/s relative to the ground at {{ALT}} km, and {{GLOBE_V_RANGE}} km/s for the point beneath.</dd>
<dt>Map speed</dt><dd>Speed of the point beneath the station across the map, from positions 1 s apart, subdivided where a step exceeds 20 km on the map. Speed change: the rate of change of that speed over 10 s, along the track; turning adds to it. The ratio of fastest to slowest does not depend on the map's scale.</dd>
<dt>Signal</dt><dd>A source moving along the station's track on the map at {{V_SIG}} km/s from a moment when it is level with the station: each northbound equator crossing, or the moment chosen with <i>Line up signal now</i>, after which it runs without restarts. With <i>Real-time correction</i> the signal is instead placed where the station was the chosen latency earlier, so it moves at the map speed while its Doppler shift still shows {{V_SIG}} km/s. Signal speed: its speed across the map against the speed its Doppler shift shows. Lag: time since the station was at the signal's position. Gap: distance between them along the track, on the map. The one-orbit figures cover every start time at 1-min intervals over the {{N_ORBITS}} orbits.</dd>
<dt>In view</dt><dd>Within {{HORIZON_DEG}}° of arc ({{HORIZON_KM}} km) of the point beneath the station or the signal: the area over which the station is seen to rise and set, which is the horizon of a sphere at {{ALT}} km. Over a plane nothing would hide it: at {{ALT}} km it would stand {{EL_HORIZON}}° above the horizon at {{HORIZON_KM}} km and {{EL_10000}}° at 10,000 km. Cities: GeoNames, population one million or more, with places within 30 km of a larger one merged ({{N_CITIES}}).</dd>
<dt>Maps</dt><dd>As in the <a href="https://pingstats.aethercosmology.com">Cable Latency Survey</a>, except that Mercator's east and west edges are joined so that the track continues across 180°. On the dual-pole map the speed has no upper limit near 0°N 180°E, where the map's rim divides the Pacific; this window's closest pass, {{DP_RIM}}° away, needs {{DP_VMAX}} km/s.</dd>
</dl>
<h2>Sources</h2>
<p><a href="https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&amp;FORMAT=TLE">CelesTrak GP elements</a> · <a href="https://pypi.org/project/sgp4/">sgp4</a> (Python) · <a href="https://www.naturalearthdata.com/">Natural Earth</a> 1:110m land · <a href="https://download.geonames.org/export/dump/">GeoNames</a> cities15000 · SatNOGS recordings of 3 October 2026 as analysed in <a href="https://distance.aethercosmology.com/#doppler">The Shape of Distance, Listening to the ISS</a> · <a href="https://spotthestation.nasa.gov/">NASA Spot the Station</a>.</p>
<button id="dl" type="button">Download the data (JSON)</button>
</section>
<footer class="wrap"><a href="https://github.com/aetherbird/cable-latency-survey">Code and data</a> (MIT, CC BY 4.0) · <a href="https://github.com/aetherbird/cable-latency-survey/blob/main/CONTRIBUTING.md">Contribute</a> · Related: <a href="https://pingstats.aethercosmology.com">Cable Latency Survey</a> · <a href="https://distance.aethercosmology.com">The Shape of Distance</a></footer>
<div id="tip" hidden></div>
<script>/*D3*/</script>
<script>
(() => {
"use strict";
const D = /*DATA*/null;
const RAD = Math.PI / 180, STEP = D.step, N = D.lat.length;
const NODES = D.nodes, NORB = NODES.length - 1, T_END = NODES[NORB];
const RE = 6371;
const VM = D.v_sig; // measured, km/s: level flight over a plane
const VG = D.vg_ref; // globe: the point beneath, from the same orbit
const COSH = Math.cos(D.horizon_deg * RAD);
const nf = new Intl.NumberFormat("en-US");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const C = { station: "#ffffff", signal: "#ff4fd8", track: "#3ee6ff", ok: "#3dffa0", bad: "#ff4d5e",
ocean: "#06101e", land: "#13243b", coast: "rgba(150,200,255,.22)", grat: "rgba(120,180,255,.08)", rim: "rgba(120,180,255,.3)",
city: "rgba(155,173,198,.32)", faint: "#65789a" };
// sample unit vectors
const UX = new Float64Array(N), UY = new Float64Array(N), UZ = new Float64Array(N);
for (let i = 0; i < N; i++) {
const p = D.lat[i] * RAD, l = D.lon[i] * RAD, c = Math.cos(p);
UX[i] = c * Math.cos(l); UY[i] = c * Math.sin(l); UZ[i] = Math.sin(p);
}
const clampT = t => Math.max(0, Math.min((N - 1) * STEP - 1e-6, t));
function uAt(t) {
const x = clampT(t) / STEP, i = Math.floor(x), f = x - i;
const a = UX[i] + (UX[i + 1] - UX[i]) * f, b = UY[i] + (UY[i + 1] - UY[i]) * f, c = UZ[i] + (UZ[i + 1] - UZ[i]) * f;
const n = Math.hypot(a, b, c);
return [a / n, b / n, c / n];
}
const lonlat = u => [Math.atan2(u[1], u[0]) / RAD, Math.asin(Math.max(-1, Math.min(1, u[2]))) / RAD];
function orbitOf(t) { let k = 0; while (k < NORB - 1 && t >= NODES[k + 1]) k++; return k; }
function at(arr, t) {
const x = clampT(t) / STEP, i = Math.floor(x), f = x - i;
return arr[i] + (arr[i + 1] - arr[i]) * f;
}
// signal: level with the station at the sync time, then VM km/s along the map track.
// syncT null: each northbound equator crossing; otherwise the moment chosen with "Line up signal now".
const SM = {};
for (const key of ["ae", "dualpole", "mercator"]) SM[key] = Float64Array.from(D.maps[key].S);
let syncT = null, corr = false, latency = 10;
const LATENCY = [0, 1, 2, 5, 10, 20, 30, 60, 120, 300];
function timeAtDistance(arr, km) {
let lo = 0, hi = arr.length - 1;
if (km <= arr[lo]) return 0;
if (km >= arr[hi]) return hi * STEP;
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (arr[mid] <= km) lo = mid; else hi = mid; }
return (lo + (km - arr[lo]) / (arr[hi] - arr[lo])) * STEP;
}
function signalAt(key, t, k) {
const arr = SM[key];
if (corr) { // steered to where the station was `latency` s earlier: moves at the map speed
const L = Math.min(latency, t);
return { lag: L, gap: at(arr, t) - at(arr, t - L), v: at(D.maps[key].v, t - L) / 1000 };
}
const ts = syncT === null ? NODES[k] : syncT;
if (t <= ts) return { lag: 0, gap: 0, v: VM };
const target = at(arr, ts) + VM * (t - ts);
return { lag: t - timeAtDistance(arr, target), gap: at(arr, t) - target, v: VM };
}
// cities
const CITY = D.cities, NC = CITY.length;
const CX = new Float64Array(NC), CY = new Float64Array(NC), CZ = new Float64Array(NC);
CITY.forEach((c, j) => { const p = c[1] * RAD, l = c[2] * RAD; CX[j] = Math.cos(p) * Math.cos(l); CY[j] = Math.cos(p) * Math.sin(l); CZ[j] = Math.sin(p); });
const inView = (u, j) => u[0] * CX[j] + u[1] * CY[j] + u[2] * CZ[j] >= COSH;
// orbit tracks as [lon, lat] lists
const trackCache = {};
function track(k) {
if (trackCache[k]) return trackCache[k];
const pts = [lonlat(uAt(NODES[k]))];
for (let i = Math.ceil(NODES[k] / STEP); i * STEP < NODES[k + 1]; i++) pts.push([D.lon[i], D.lat[i]]);
pts.push(lonlat(uAt(NODES[k + 1])));
return (trackCache[k] = pts);
}
function segment(t0, t1) {
if (t1 < t0) [t0, t1] = [t1, t0];
const pts = [lonlat(uAt(t0))];
for (let i = Math.ceil(t0 / STEP); i * STEP < t1; i++) pts.push([D.lon[i], D.lat[i]]);
pts.push(lonlat(uAt(t1)));
return { type: "LineString", coordinates: pts };
}
// panels
const LAND = { type: "MultiPolygon", coordinates: D.land };
const GRAT = d3.geoGraticule().step([30, 30])();
const EQ = { type: "LineString", coordinates: d3.range(-180, 181, 2).map(l => [l, 0]) };
const SPH = { type: "Sphere" };
const panels = [...document.querySelectorAll(".panel")].map(el => {
const key = el.dataset.key;
const proj = key === "globe" ? d3.geoOrthographic().clipAngle(90)
: key === "ae" ? d3.geoAzimuthalEquidistant().rotate([0, -90]).clipAngle(180 - 1e-3)
: key === "dualpole" ? d3.geoAzimuthalEquidistant().rotate([0, 0]).clipAngle(180 - 1e-3)
: d3.geoMercator();
proj.precision(0.4);
const [cv, sp] = el.querySelectorAll("canvas");
const f = {}; el.querySelectorAll("[data-f]").forEach(d => (f[d.dataset.f] = d));
return { el, key, proj, cv, ctx: cv.getContext("2d"), sp, sctx: sp.getContext("2d"), f, W: 0, bg: null, cxy: null, state: new Uint8Array(NC), spK: -1, spImg: null };
});
let dpr = 1;
function layout() {
dpr = Math.min(2, window.devicePixelRatio || 1);
for (const p of panels) {
const W = p.cv.clientWidth;
if (!W) continue;
p.W = W; p.cv.width = Math.round(W * dpr); p.cv.height = Math.round(W * dpr);
const s = W / 2 - 3;
if (p.key === "globe") p.proj.scale(s).translate([W / 2, W / 2]);
else if (p.key === "mercator") p.proj.scale(W / (2 * Math.PI)).translate([W / 2, W / 2]);
else p.proj.scale(s / Math.PI).translate([W / 2, W / 2]);
if (p.key !== "globe") {
p.bg = document.createElement("canvas"); p.bg.width = p.cv.width; p.bg.height = p.cv.height;
const g = p.bg.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0);
base(g, p.proj);
p.cxy = CITY.map(c => p.proj([c[2], c[1]]));
g.fillStyle = C.city; g.beginPath();
for (const q of p.cxy) if (q) { g.moveTo(q[0] + 1.2, q[1]); g.arc(q[0], q[1], 1.2, 0, 7); }
g.fill();
}
const sw = p.sp.clientWidth; p.sp.width = Math.round(sw * dpr); p.sp.height = Math.round(54 * dpr); p.spK = -1;
}
dirty = true;
}
function base(g, proj) {
const path = d3.geoPath(proj, g);
g.beginPath(); path(SPH); g.fillStyle = C.ocean; g.fill();
g.beginPath(); path(GRAT); g.strokeStyle = C.grat; g.lineWidth = 0.7; g.stroke();
g.beginPath(); path(EQ); g.strokeStyle = "rgba(120,180,255,.16)"; g.stroke();
g.beginPath(); path(LAND); g.fillStyle = C.land; g.fill(); g.strokeStyle = C.coast; g.lineWidth = 0.6; g.stroke();
g.beginPath(); path(SPH); g.strokeStyle = C.rim; g.lineWidth = 1; g.stroke();
}
// state at time t
function snapshot(t) {
const k = orbitOf(t), us = uAt(t), st = lonlat(us);
const m = {};
for (const key of ["ae", "dualpole", "mercator"]) {
const M = D.maps[key], sg = signalAt(key, t, k);
m[key] = { lag: sg.lag, gap: sg.gap, v: at(M.v, t) / 1000, a: at(M.a, t) / 1000, ug: uAt(t - sg.lag), vs: sg.v };
}
m.globe = { lag: 0, gap: 0, v: at(D.vglobe, t) / 1000, a: at(D.aglobe, t) / 1000, ug: us, vs: at(D.vglobe, t) / 1000 };
return { t, k, us, st, m };
}
function drawPanel(p, S, pulse) {
if (!p.W) return;
const g = p.ctx, W = p.W, proj = p.proj, path = d3.geoPath(proj, g), M = S.m[p.key];
g.setTransform(dpr, 0, 0, dpr, 0, 0);
if (p.key === "globe") {
proj.rotate([-S.st[0], -S.st[1] * 0.6]);
g.clearRect(0, 0, W, W);
base(g, proj);
} else {
g.clearRect(0, 0, W, W);
g.drawImage(p.bg, 0, 0, W, W);
}
const tr = track(S.k);
g.lineJoin = "round"; g.lineCap = "round";
g.beginPath(); path({ type: "LineString", coordinates: tr }); g.strokeStyle = "rgba(62,230,255,.22)"; g.lineWidth = 1; g.stroke();
g.beginPath(); path(segment(NODES[S.k], S.t)); g.strokeStyle = "rgba(62,230,255,.75)"; g.lineWidth = 1.5; g.stroke();
const sg = lonlat(M.ug);
if (Math.abs(M.lag) > 0.5) {
g.beginPath(); path(segment(S.t - M.lag, S.t)); g.strokeStyle = "rgba(255,79,216,.55)"; g.lineWidth = 3.5; g.stroke();
}
// above-the-horizon circles
const circ = d3.geoCircle().radius(D.horizon_deg).precision(4);
g.beginPath(); path(circ.center(S.st)()); g.fillStyle = "rgba(255,255,255,.035)"; g.fill(); g.strokeStyle = "rgba(255,255,255,.5)"; g.lineWidth = 1; g.stroke();
if (Math.abs(M.lag) > 0.5) {
g.beginPath(); path(circ.center(sg)()); g.fillStyle = "rgba(255,79,216,.05)"; g.fill(); g.strokeStyle = "rgba(255,79,216,.65)"; g.stroke();
}
// cities
let cxy = p.cxy;
if (p.key === "globe") {
const ctr = [S.st[0], S.st[1] * 0.6];
cxy = p.cxy = CITY.map(c => (d3.geoDistance([c[2], c[1]], ctr) < Math.PI / 2 - 0.01 ? proj([c[2], c[1]]) : null));
g.fillStyle = C.city; g.beginPath();
for (const q of cxy) if (q) { g.moveTo(q[0] + 1.2, q[1]); g.arc(q[0], q[1], 1.2, 0, 7); }
g.fill();
}
const okP = new Path2D(), badP = new Path2D();
for (let j = 0; j < NC; j++) {
const s = inView(S.us, j), h = inView(M.ug, j), st = s && h ? 1 : s || h ? 2 : 0;
p.state[j] = st;
const q = cxy[j];
if (!st || !q) continue;
const P = st === 1 ? okP : badP, r = st === 1 ? 2.2 : 2.6;
P.moveTo(q[0] + r, q[1]); P.arc(q[0], q[1], r, 0, 7);
}
g.fillStyle = C.ok; g.fill(okP); g.fillStyle = C.bad; g.fill(badP);
// signal, then station
const qs = proj(S.st), qg = proj(sg);
const vis = ll => p.key !== "globe" || d3.geoDistance(ll, [S.st[0], S.st[1] * 0.6]) < Math.PI / 2;
if (qg && vis(sg)) {
if (!reduce) for (const ph of [pulse, (pulse + 0.5) % 1]) {
g.beginPath(); g.arc(qg[0], qg[1], 7 + 15 * ph, 0, 7); g.strokeStyle = `rgba(255,79,216,${(0.55 * (1 - ph)).toFixed(3)})`; g.lineWidth = 1.2; g.stroke();
}
g.beginPath(); g.arc(qg[0], qg[1], 6.5, 0, 7); g.strokeStyle = C.signal; g.lineWidth = 2.2; g.stroke();
}
if (qs) {
g.save(); g.shadowColor = "rgba(255,255,255,.9)"; g.shadowBlur = 10;
g.beginPath(); g.arc(qs[0], qs[1], 4.2, 0, 7); g.fillStyle = C.station; g.fill(); g.restore();
}
}
function drawSpark(p, S) {
const c = p.sctx, w = p.sp.width / dpr, h = 54, L = 30;
if (!w) return;
const k = S.k, t0 = NODES[k], t1 = NODES[k + 1];
const series = p.key === "globe" ? D.vglobe : D.maps[p.key].v;
if (p.spK !== k) {
// linear scale per panel, 0 to this orbit's top speed
let top = 0;
for (let i = Math.ceil(t0 / STEP); i * STEP <= t1; i++) top = Math.max(top, series[i] / 1000);
const nice = [10, 15, 20, 30, 50, 100, 150, 200, 300, 500, 700, 1000, 1500];
p.spTop = nice.find(n => n >= top * 1.05) || Math.ceil(top / 500) * 500;
const y = v => h - 3 - v / p.spTop * (h - 9);
const img = document.createElement("canvas"); img.width = p.sp.width; img.height = p.sp.height;
const g = img.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.font = "9.5px JetBrains Mono, monospace"; g.fillStyle = C.faint; g.textBaseline = "middle";
g.fillRect(L, Math.round(y(0)) + 0.5, w - L, 0.6); g.fillText("0", 0, y(0));
g.fillRect(L, Math.round(y(p.spTop)) + 0.5, w - L, 0.6); g.fillText(nf.format(p.spTop), 0, y(p.spTop));
const ref = p.key === "globe" ? VG : VM;
g.setLineDash([3, 3]); g.strokeStyle = "rgba(255,255,255,.55)"; g.lineWidth = 1;
g.beginPath(); g.moveTo(L, y(ref)); g.lineTo(w, y(ref)); g.stroke(); g.setLineDash([]);
const X = t => L + (t - t0) / (t1 - t0) * (w - L);
g.beginPath();
let first = true;
for (let i = Math.ceil(t0 / STEP); i * STEP <= t1; i++) { const xx = X(i * STEP), yy = y(series[i] / 1000); first ? g.moveTo(xx, yy) : g.lineTo(xx, yy); first = false; }
g.strokeStyle = C.track; g.lineWidth = 1.4; g.stroke();
p.spImg = img; p.spK = k; p.spY = y;
}
c.setTransform(1, 0, 0, 1, 0, 0); c.clearRect(0, 0, p.sp.width, p.sp.height); c.drawImage(p.spImg, 0, 0);
c.setTransform(dpr, 0, 0, dpr, 0, 0);
const x = L + (S.t - t0) / (t1 - t0) * (w - L);
c.fillStyle = "rgba(255,255,255,.35)"; c.fillRect(x - 0.5, 0, 1, h);
c.beginPath(); c.arc(x, p.spY(S.m[p.key].v), 3, 0, 7); c.fillStyle = C.station; c.fill();
}
// readouts
const mmss = s => { s = Math.round(s); return s >= 60 ? `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, "0")} s` : `${s} s`; };
const fv = v => (v >= 100 ? nf.format(Math.round(v)) : v.toFixed(2));
function speedHTML(v, ref) { // value with its ratio to the measured speed, tinted by tier
const r = v / ref, dev = Math.abs(r - 1), cls = dev <= 0.05 ? "ok" : dev <= 0.25 ? "warn" : "bad";
return `${fv(v)} km/s<span class="pill ${cls}">×${r >= 10 ? Math.round(r) : r.toFixed(2)}</span>`;
}
function readouts(S) {
for (const p of panels) {
const M = S.m[p.key], ref = p.key === "globe" ? VG : VM;
p.f.v.innerHTML = speedHTML(M.v, ref);
p.f.vs.innerHTML = speedHTML(M.vs, ref);
const a = M.a;
p.f.a.textContent = Math.abs(a) < 0.005 ? "0.00 g" : `${a > 0 ? "+" : "−"}${Math.abs(a) >= 100 ? nf.format(Math.round(Math.abs(a))) : Math.abs(a).toFixed(2)} g`;
if (p.key === "globe" || Math.abs(M.lag) < 0.5) p.f.s.textContent = "level with the station";
else p.f.s.textContent = `${mmss(Math.abs(M.lag))} ${M.lag > 0 ? "behind" : "ahead"} · ${nf.format(Math.round(Math.abs(M.gap)))} km`;
let now = 0;
for (let j = 0; j < NC; j++) if (p.state[j] === 2) now++;
p.f.c.textContent = String(now);
}
const d = new Date((D.epoch_unix + S.t) * 1000);
clock.textContent = d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
orbitEl.textContent = `Orbit ${S.k + 1} of ${NORB}`;
if (!dragging) range.value = String(Math.round(S.t));
}
// controls
const play = document.getElementById("play"), icon = document.getElementById("playicon");
const range = document.getElementById("time"), clock = document.getElementById("clock"), orbitEl = document.getElementById("orbit");
range.max = String(Math.floor(T_END));
let t = Math.min(D.t_init, T_END - 1), rate = 60, playing = !reduce, dirty = true, dragging = false, lastRO = 0;
function setPlaying(on) {
playing = on;
icon.setAttribute("d", on ? "M2 1h3.5v12H2zM8.5 1H12v12H8.5z" : "M2.5 1l10 6-10 6z");
play.setAttribute("aria-label", on ? "Pause" : "Play");
dirty = true;
}
setPlaying(playing);
play.addEventListener("click", () => setPlaying(!playing));
document.querySelectorAll(".rates button").forEach(b => b.addEventListener("click", () => {
rate = +b.dataset.rate;
document.querySelectorAll(".rates button").forEach(o => o.setAttribute("aria-pressed", String(o === b)));
}));
range.addEventListener("input", () => { t = +range.value; dirty = true; });
const syncBtn = document.getElementById("sync"), syncAuto = document.getElementById("syncauto"), syncInfo = document.getElementById("syncinfo");
syncBtn.addEventListener("click", () => {
syncT = t; syncAuto.hidden = false;
syncInfo.textContent = "Lined up: " + new Date((D.epoch_unix + t) * 1000).toISOString().slice(11, 19) + " UTC";
dirty = true;
});
const corrBtn = document.getElementById("corr"), latIn = document.getElementById("lat"), latV = document.getElementById("latv"), latWrap = document.getElementById("latwrap");
let syncText = "";
corrBtn.addEventListener("click", () => {
corr = !corr;
corrBtn.setAttribute("aria-pressed", String(corr));
latWrap.hidden = !corr; syncBtn.disabled = corr; syncAuto.disabled = corr;
if (corr) { syncText = syncInfo.textContent; syncInfo.textContent = "Corrected continuously"; }
else syncInfo.textContent = syncText;
dirty = true;
});
latIn.addEventListener("input", () => { latency = LATENCY[+latIn.value]; latV.textContent = latency >= 60 ? `${latency / 60} min` : `${latency} s`; dirty = true; });
syncAuto.addEventListener("click", () => {
syncT = null; syncAuto.hidden = true;
syncInfo.textContent = "Lined up: each northbound equator crossing";
dirty = true;
});
range.addEventListener("pointerdown", () => (dragging = true));
window.addEventListener("pointerup", () => (dragging = false));
document.addEventListener("keydown", e => {
if (e.code === "Space" && !/^(INPUT|BUTTON|SELECT|TEXTAREA|A)$/.test(e.target.tagName)) { e.preventDefault(); setPlaying(!playing); }
});
for (const p of panels) {
const seek = e => {
const r = p.sp.getBoundingClientRect(), k = orbitOf(t);
const f = Math.max(0, Math.min(1, (e.clientX - r.left - 30) / (r.width - 30)));
t = NODES[k] + f * (NODES[k + 1] - NODES[k]) - 1e-3; dirty = true;
};
p.sp.addEventListener("pointerdown", e => { seek(e); p.sp.setPointerCapture(e.pointerId); });
p.sp.addEventListener("pointermove", e => { if (e.buttons) seek(e); });
}
// city tooltip
const tip = document.getElementById("tip");
const STATE = (p, j) => {
if (p.key === "globe") return p.state[j] ? "Station and signal in view" : "Neither in view";
const S = lastS, s = inView(S.us, j), h = inView(S.m[p.key].ug, j);
return s && h ? "Station and signal in view" : s ? "Station in view, no signal" : h ? "Signal, no station in view" : "Neither in view";
};
let lastS = null;
for (const p of panels) {
p.cv.addEventListener("pointermove", e => {
if (!p.cxy || !lastS) return;
const r = p.cv.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
let best = -1, bd = 81;
p.cxy.forEach((q, j) => { if (q) { const d = (q[0] - x) ** 2 + (q[1] - y) ** 2; if (d < bd) { bd = d; best = j; } } });
if (best < 0) { tip.hidden = true; return; }
tip.innerHTML = `<b>${CITY[best][0]}</b>, ${CITY[best][3]}<span>${STATE(p, best)}</span>`;
tip.hidden = false;
const tw = tip.offsetWidth, th = tip.offsetHeight;
tip.style.left = Math.min(window.innerWidth - tw - 8, e.clientX + 14) + "px";
tip.style.top = Math.max(8, e.clientY - th - 10) + "px";
});
p.cv.addEventListener("pointerleave", () => (tip.hidden = true));
}
// data download
document.getElementById("dl").addEventListener("click", () => {
const { land, ...rest } = D;
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([JSON.stringify(rest)], { type: "application/json" }));
a.download = "sync_test_data.json"; document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
});
// loop
let last = performance.now();
function frame(now) {
const dt = Math.min(0.25, (now - last) / 1000); last = now;
if (playing) { t += dt * rate; if (t >= T_END) t -= T_END; dirty = true; }
if (dirty) {
const S = snapshot(t), pulse = (now / 1600) % 1;
lastS = S;
for (const p of panels) { drawPanel(p, S, pulse); drawSpark(p, S); }
if (now - lastRO > 120 || !playing) { readouts(S); lastRO = now; }
dirty = playing;
}
requestAnimationFrame(frame);
}
new ResizeObserver(() => layout()).observe(document.querySelector(".grid"));
layout();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>