ad8b889437
- 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>
460 lines
22 KiB
Python
460 lines
22 KiB
Python
"""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()
|