From 301005bdbca8bca324ec8255caecd93201475338 Mon Sep 17 00:00:00 2001 From: jing Date: Mon, 13 Jul 2026 16:42:03 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=B0=20world=5Fsats:=20real=20satellite?= =?UTF-8?q?s=20as=20a=20source=20(SGP4=20from=20cached=20Celestrak=20TLEs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sky's own schedule joins the orchestra. Unlike world_iss (which begs a web API per position), world_sats fetches TLEs once from Celestrak — the stations plus the named recon birds GAOFEN and COSMOS — caches them to ~/.cache/godverse/tles.txt (24 h freshness, atomic writes, stale-on-failure), and propagates every craft locally with SGP4 at any rate, no per-fix HTTP. For the venue city (--lat/--lon, retargeted by run.py) it computes each craft's elevation above the horizon and emits: /gs/sats/best_elev highest elevation among all tracked (-30..90 deg) /gs/sats/overhead_count how many above 10 deg /gs/sats/iss_lat/iss_lon ISS sweep (available even without world_iss) /gs/sats/recon_elev best elevation among only the GAOFEN/COSMOS watchers /gs/sats/pass.event impulse when any craft crosses rising through 30 deg Wiring: config.json declares the six signals with norm ranges + labels, three starter routes (best_elev -> pad.brightness, pass -> delay.feedback, recon_elev -> lfo.rate) and adds sats.best_elev to the cosmos group; run.py adds the worker to the WORLD profile and the lat/lon retarget set; requirements notes the pure-python sgp4 dep. The worker is import-safe (sits out if sgp4 is absent) and exits politely with no cache and no network. Grimoire + manual entry added. Math ported from GODSIGH's verified satellites layer: TLE -> Satrec.twoline2rv (handles no_kozai), SGP4 -> TEME km, GMST rotation -> WGS-84 geodetic, then an ECEF up-vector for elevation. Verified end-to-end against live Celestrak data: 98 sats tracked, ISS position/altitude plausible, and all signals arrive over the hub WebSocket correctly normalized with their routes active. Co-Authored-By: Claude Opus 4.8 --- config.json | 11 +- requirements.txt | 3 + run.py | 3 +- viz/index.html | 2 + viz/manual.html | 2 + workers/world_sats.py | 312 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 workers/world_sats.py diff --git a/config.json b/config.json index 53f4424..67c5abc 100644 --- a/config.json +++ b/config.json @@ -30,7 +30,7 @@ "groups": { "planet": {"label": "the local world", "orbit": "earth", "members": ["weather.temp", "weather.wind", "weather.pressure", "air.pm25", "sky.day", "sky.elev"]}, - "cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel"]}, + "cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel", "sats.best_elev"]}, "human": {"label": "humanity", "orbit": "mars", "members": ["wiki.rate", "clock.popvel", "clock.births"]}, "market": {"label": "your market", "orbit": "mercury", "members": ["crypto.vel", "market.velocity", "market.turnover"]}, "heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]}, @@ -102,6 +102,12 @@ "planes.avgalt": {"norm": {"lo": 0, "hi": 13000}, "label": "avg altitude"}, "planes.avgspeed": {"norm": {"lo": 0, "hi": 300}, "label": "avg airspeed"}, "iss.vel": {"norm": {"mode": "minmax", "window": 60}, "label": "ISS ground speed"}, + "sats.best_elev": {"norm": {"lo": -30, "hi": 90}, "filter": {"min_cutoff": 0.3, "beta": 0.005}, "label": "highest satellite elevation"}, + "sats.overhead_count": {"norm": {"lo": 0, "hi": 8}, "label": "satellites overhead (>10°)"}, + "sats.iss_lat": {"norm": {"lo": -90, "hi": 90}, "label": "ISS latitude"}, + "sats.iss_lon": {"norm": {"lo": -180, "hi": 180}, "label": "ISS longitude"}, + "sats.recon_elev": {"norm": {"lo": -30, "hi": 90}, "filter": {"min_cutoff": 0.3, "beta": 0.005}, "label": "recon-satellite elevation"}, + "sats.pass.event": {"type": "event", "norm": {"lo": 0, "hi": 90}, "decay": 3.0, "label": "satellite pass (rising 30°)"}, "tof.cx": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand X (ToF)"}, "tof.cy": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand Y (ToF)"}, "tof.near": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 1.5, "beta": 0.1}, "label": "hand proximity"}, @@ -169,6 +175,9 @@ {"source": "weather.wind", "dest": "lfo.rate", "amount": 0.55, "curve": "lin", "label": "wind speed = modulation rate"}, {"source": "weather.pressure","dest": "drone.voices", "amount": 0.4, "curve": "lin", "label": "barometric pressure = drone weight"}, {"source": "iss.vel", "dest": "wavetable.morph", "amount": 0.4, "curve": "lin", "label": "the ISS drifts the timbre"}, + {"source": "sats.best_elev", "dest": "pad.brightness", "amount": 0.4, "curve": "scurve", "label": "something overhead brightens the pads"}, + {"source": "sats.pass.event", "dest": "delay.feedback", "amount": 0.7, "label": "a satellite pass throws the delay"}, + {"source": "sats.recon_elev", "dest": "lfo.rate", "amount": 0.35, "curve": "lin", "label": "the sky watching back sets the LFO"}, {"source": "tof.cx", "dest": "wavetable.morph", "amount": 0.9, "curve": "lin", "label": "your hand X morphs the wavetable"}, {"source": "tof.cy", "dest": "delay.feedback", "amount": 0.7, "curve": "scurve", "label": "your hand Y feeds the delay"}, {"source": "light.lux", "dest": "reverb.size", "amount": 0.5, "curve": "lin", "label": "room light sizes the reverb"}, diff --git a/requirements.txt b/requirements.txt index ba08ffc..6100947 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,9 @@ python-osc>=1.9 websockets>=12.0 +# Satellites worker (workers/world_sats.py) — SGP4 propagation from Celestrak TLEs +# pip install sgp4 # pure-python, no build; worker is import-safe without it + # Optional: MIDI output from the hub to Ableton/TouchDesigner via a virtual port # pip install python-rtmidi # Optional: microphone FFT worker (workers/audio_worker.py) diff --git a/run.py b/run.py index a2f4f0d..687fb10 100644 --- a/run.py +++ b/run.py @@ -33,6 +33,7 @@ WORLD = [ "workers/world_air.py", "workers/world_sun.py", "workers/world_iss.py", + "workers/world_sats.py", "workers/world_crypto.py", "workers/world_fx.py", "workers/world_planes.py", @@ -49,7 +50,7 @@ WORLD = [ ] # workers that retarget to the venue city -LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py"} +LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py", "world_sats.py"} BBOX_WORKERS = {"world_planes.py"} diff --git a/viz/index.html b/viz/index.html index cd948b3..792ba8b 100644 --- a/viz/index.html +++ b/viz/index.html @@ -743,6 +743,8 @@

iss.vel — ISS ground speed. The ground-track velocity of the International Space Station, derived from its live position (open-notify / orbital elements). The station laps the Earth every ~92 minutes at roughly 7.66 km/s, so raw speed is nearly constant — which is why it's normalized minmax over a 60-sample window, letting the hub auto-scale the tiny real variations into usable motion. Character: a smooth, patient drift. It morphs the wavetable (amt 0.4) — the timbre slowly bends as the one permanently-crewed outpost above us circles. A human hand is up there right now; here it turns a knob on the timbre.

+

sats.* — the sky's schedule joins the orchestra. The world_sats worker brings real satellites in as a source, but by a different trick than the ISS feed: instead of begging a web API for each position, it fetches the two-line element sets (TLEs) from Celestrak once — the space stations, plus the named recon birds GAOFEN and COSMOS — caches them, and then propagates every one locally with SGP4, the same orbital model NORAD uses. From that it computes each satellite's live latitude, longitude and altitude, and — for the venue city — its elevation above the horizon. sats.best_elev is the highest elevation among all tracked craft (the continuous "something is passing overhead" signal, -30→90°); sats.overhead_count is how many are above 10° right now; sats.iss_lat / sats.iss_lon keep the station's sweep available even when world_iss isn't running; sats.recon_elev is the best elevation among only the watchers — the surveillance satellites' own channel; and sats.pass.event fires a single impulse the moment any craft crosses rising through 30°, a pass beginning. In the starter patch, something overhead brightens the pads (best_elev → pad.brightness, amt 0.4), a satellite pass throws the delay (pass → delay.feedback, amt 0.7), and the sky watching back sets the LFO (recon_elev → lfo.rate) — so a recon bird cresting the sky above the room is a rhythm you can hear arrive. (Needs pip install sgp4; the worker is import-safe and simply sits out if it's absent.)

+

planes.count / planes.avgalt / planes.avgspeed — aircraft aloft. A live census of aircraft from OpenSky: how many are in the air in a region, their average altitude (0→13000 m) and airspeed (0→300 m/s). Count is minmax-normalized over 100 samples because "how many planes" swings between night and rush hour. Character: a swarm — dozens to hundreds of tracked objects, breathing on the diurnal cycle of human travel. Aircraft aloft = drone density (amt 0.5): the more of humanity is airborne, the thicker the drone. Altitude tilts the pad (amt 0.3): a sky full of cruising jets brightens the pads, a sky of low departures dims them. The drone is literally the sound of everyone who is flying.

diff --git a/viz/manual.html b/viz/manual.html index 48c0817..8af79ae 100644 --- a/viz/manual.html +++ b/viz/manual.html @@ -723,6 +723,8 @@

iss.vel — ISS ground speed. The ground-track velocity of the International Space Station, derived from its live position (open-notify / orbital elements). The station laps the Earth every ~92 minutes at roughly 7.66 km/s, so raw speed is nearly constant — which is why it's normalized minmax over a 60-sample window, letting the hub auto-scale the tiny real variations into usable motion. Character: a smooth, patient drift. It morphs the wavetable (amt 0.4) — the timbre slowly bends as the one permanently-crewed outpost above us circles. A human hand is up there right now; here it turns a knob on the timbre.

+

sats.* — the sky's schedule joins the orchestra. The world_sats worker brings real satellites in as a source, but by a different trick than the ISS feed: instead of begging a web API for each position, it fetches the two-line element sets (TLEs) from Celestrak once — the space stations, plus the named recon birds GAOFEN and COSMOS — caches them, and then propagates every one locally with SGP4, the same orbital model NORAD uses. From that it computes each satellite's live latitude, longitude and altitude, and — for the venue city — its elevation above the horizon. sats.best_elev is the highest elevation among all tracked craft (the continuous "something is passing overhead" signal, -30→90°); sats.overhead_count is how many are above 10° right now; sats.iss_lat / sats.iss_lon keep the station's sweep available even when world_iss isn't running; sats.recon_elev is the best elevation among only the watchers — the surveillance satellites' own channel; and sats.pass.event fires a single impulse the moment any craft crosses rising through 30°, a pass beginning. In the starter patch, something overhead brightens the pads (best_elev → pad.brightness, amt 0.4), a satellite pass throws the delay (pass → delay.feedback, amt 0.7), and the sky watching back sets the LFO (recon_elev → lfo.rate) — so a recon bird cresting the sky above the room is a rhythm you can hear arrive. (Needs pip install sgp4; the worker is import-safe and simply sits out if it's absent.)

+

planes.count / planes.avgalt / planes.avgspeed — aircraft aloft. A live census of aircraft from OpenSky: how many are in the air in a region, their average altitude (0→13000 m) and airspeed (0→300 m/s). Count is minmax-normalized over 100 samples because "how many planes" swings between night and rush hour. Character: a swarm — dozens to hundreds of tracked objects, breathing on the diurnal cycle of human travel. Aircraft aloft = drone density (amt 0.5): the more of humanity is airborne, the thicker the drone. Altitude tilts the pad (amt 0.3): a sky full of cruising jets brightens the pads, a sky of low departures dims them. The drone is literally the sound of everyone who is flying.

diff --git a/workers/world_sats.py b/workers/world_sats.py new file mode 100644 index 0000000..4880077 --- /dev/null +++ b/workers/world_sats.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""world_sats.py — the sky's own schedule, as control signals (SGP4, no polling APIs). + +Real satellites, propagated locally. TLEs (two-line element sets) are fetched once +from Celestrak and cached; from them SGP4 gives *any* satellite's position at *any* +rate with no per-fix HTTP — the opposite of world_iss.py, which begs a web API for +every point. We track the space stations plus a few named recon birds (GAOFEN, +COSMOS), turn each into a lat/lon/altitude, and derive its **elevation above the +venue city's horizon** — so "something is passing overhead" becomes a continuous, +playable signal, and a recon satellite cresting the sky rings a bell. + +Emits (raw floats; the hub normalizes): + /gs/sats/best_elev highest elevation among tracked sats, degrees (can be < 0) + /gs/sats/overhead_count how many are above 10 degrees right now + /gs/sats/iss_lat ISS geodetic latitude (the sweep, even without world_iss) + /gs/sats/iss_lon ISS geodetic longitude + /gs/sats/pass.event impulse (magnitude = elevation) when any sat crosses + rising through 30 degrees — a pass begins + /gs/sats/recon_elev best elevation among the GAOFEN/COSMOS set only + +Depends on `sgp4` (pure-python, `pip install sgp4`). Import-safe: if it is missing, +or there is no cached TLE and no network, the worker prints why and exits cleanly. +""" +from __future__ import annotations +import argparse +import datetime +import math +import os +import sys +import time +import urllib.error +import urllib.request + +from pythonosc.udp_client import SimpleUDPClient + +try: + from sgp4.api import Satrec, jday + HAVE_SGP4 = True +except Exception: # pure-python but still optional (house rule: import-safe) + HAVE_SGP4 = False + +NAME = "world_sats" +UA = "Godstrument/1.0 (live instrument; contact monsterrobotparty@gmail.com)" + +CACHE_DIR = os.path.expanduser("~/.cache/godverse") +CACHE_FILE = os.path.join(CACHE_DIR, "tles.txt") +CACHE_MAX_AGE = 24 * 3600.0 # Celestrak politeness — TLEs age slowly +REFRESH_EVERY = 6 * 3600.0 # re-check the cache age this often while running + +# (url, is_recon) — the stations give us the ISS; the named queries are the watchers. +# A NAME query with no match returns the plain text "No GP data found"; parse_tles +# rejects any group whose first data line doesn't start with "1". +TLE_SOURCES = [ + ("https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle", False), + ("https://celestrak.org/NORAD/elements/gp.php?NAME=GAOFEN&FORMAT=tle", True), + ("https://celestrak.org/NORAD/elements/gp.php?NAME=COSMOS%202486&FORMAT=tle", True), + ("https://celestrak.org/NORAD/elements/gp.php?NAME=COSMOS%202506&FORMAT=tle", True), +] + +PASS_ELEV = 30.0 # a sat rising through this elevation rings the bell +OVERHEAD_ELEV = 10.0 # counted as "overhead" above this +ISS_CATNR = "25544" # NORAD catalog number of the ISS (ZARYA) +WGS_A = 6378.137 # WGS-84 semi-major axis, km +WGS_B = 6356.7523142 # semi-minor axis, km + + +def fetch_url(url, timeout=20): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", "replace") + + +def parse_tles(text): + """Parse TLE text into [(name, line1, line2)]. Tolerant of 2-line groups and of + Celestrak's plain-text 'No GP data found' (any group whose line1 isn't a '1').""" + lines = [ln.rstrip() for ln in text.splitlines() + if ln.strip() and not ln.startswith("#")] + out = [] + i = 0 + while i < len(lines): + ln = lines[i] + if ln.startswith("1 ") and i + 1 < len(lines) and lines[i + 1].startswith("2 "): + out.append(("", ln, lines[i + 1])) # 2-line group, no name + i += 2 + elif (not ln.startswith(("1 ", "2 "))) and i + 2 < len(lines) \ + and lines[i + 1].startswith("1 ") and lines[i + 2].startswith("2 "): + out.append((ln.strip(), lines[i + 1], lines[i + 2])) # named 3-line group + i += 3 + else: + i += 1 # junk / 'No GP data found' — skip + return out + + +def _write_cache(groups, recon_catnrs): + """Cache the merged 3LE with a recon-catnr header so the tags survive a reload.""" + try: + os.makedirs(CACHE_DIR, exist_ok=True) + tmp = CACHE_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("# recon " + ",".join(sorted(recon_catnrs)) + "\n") + for name, l1, l2, _is_recon in groups: + f.write((name + "\n" if name else "") + l1 + "\n" + l2 + "\n") + os.replace(tmp, CACHE_FILE) # atomic + print(f"[{NAME}] refreshed TLE cache -> {CACHE_FILE}") + except OSError as e: + print(f"[{NAME}] warning: could not write cache: {e}") + + +def _read_cache(): + """Return (groups, recon_catnrs) from the tagged cache, or ([], set()).""" + if not os.path.isfile(CACHE_FILE): + return [], set() + try: + with open(CACHE_FILE, "r", encoding="utf-8") as f: + text = f.read() + except OSError: + return [], set() + recon = set() + for ln in text.splitlines(): + if ln.startswith("# recon "): + recon = {x for x in ln[len("# recon "):].strip().split(",") if x} + break + groups = [(name, l1, l2, l1[2:7].strip() in recon) for name, l1, l2 in parse_tles(text)] + return groups, recon + + +def load_grouped_tles(force_fetch=False): + """Return [(name, l1, l2, is_recon)] — a fresh cache if possible, else a fetch. + + Fetches each source separately so recon birds keep their tag, writes the tagged + cache, and falls back to a stale cache on network failure. Returns [] only when + there is no cache and no network.""" + fresh = False + if not force_fetch: + try: + fresh = os.path.isfile(CACHE_FILE) and (time.time() - os.path.getmtime(CACHE_FILE)) < CACHE_MAX_AGE + except OSError: + fresh = False + if fresh: + groups, _ = _read_cache() + if groups: + print(f"[{NAME}] using cached TLEs (< 24 h old)") + return groups + merged, recon_catnrs = [], set() + for url, is_recon in TLE_SOURCES: + try: + txt = fetch_url(url) + except (urllib.error.URLError, OSError, ValueError) as e: + print(f"[{NAME}] TLE fetch failed for {url.split('?')[-1]}: {e}") + continue + for name, l1, l2 in parse_tles(txt): + catnr = l1[2:7].strip() + if is_recon: + recon_catnrs.add(catnr) + merged.append((name, l1, l2, is_recon)) + if merged: + _write_cache(merged, recon_catnrs) + return merged + groups, _ = _read_cache() # network down — serve stale + if groups: + print(f"[{NAME}] network down — using stale TLE cache") + return groups + + +def gstime(jdut1): + """Greenwich Mean Sidereal Time (radians) — the IAU-82 series (matches satellite.js).""" + t = (jdut1 - 2451545.0) / 36525.0 + g = 67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t + g = math.radians(g / 240.0) % (2 * math.pi) # seconds -> degrees (/240) -> radians + return g + 2 * math.pi if g < 0 else g + + +def eci_to_geodetic(r, gmst): + """TEME position (km) + GMST -> (lat_deg, lon_deg, alt_km). WGS-84, iterative.""" + x, y, z = r + a, b = WGS_A, WGS_B + f = (a - b) / a + e2 = 2 * f - f * f + R = math.hypot(x, y) + lon = math.atan2(y, x) - gmst + lon = (lon + math.pi) % (2 * math.pi) - math.pi # wrap to [-pi, pi] + lat = math.atan2(z, R) + C = 1.0 + for _ in range(20): # converges in a handful of steps + C = 1.0 / math.sqrt(1 - e2 * math.sin(lat) ** 2) + lat = math.atan2(z + a * C * e2 * math.sin(lat), R) + alt = R / math.cos(lat) - a * C + return math.degrees(lat), math.degrees(lon), alt + + +def _ecef(lat_deg, lon_deg, alt_km): + a, b = WGS_A, WGS_B + la, lo = math.radians(lat_deg), math.radians(lon_deg) + f = (a - b) / a + e2 = 2 * f - f * f + N = a / math.sqrt(1 - e2 * math.sin(la) ** 2) + return ((N + alt_km) * math.cos(la) * math.cos(lo), + (N + alt_km) * math.cos(la) * math.sin(lo), + (N * (1 - e2) + alt_km) * math.sin(la)) + + +def elevation(sat_lat, sat_lon, sat_alt, obs_lat, obs_lon): + """Elevation angle (degrees) of a sat above an observer's local horizon.""" + sx, sy, sz = _ecef(sat_lat, sat_lon, sat_alt) + ox, oy, oz = _ecef(obs_lat, obs_lon, 0.0) + rx, ry, rz = sx - ox, sy - oy, sz - oz + rng = math.sqrt(rx * rx + ry * ry + rz * rz) + if rng <= 0: + return 0.0 + la, lo = math.radians(obs_lat), math.radians(obs_lon) + ux, uy, uz = math.cos(la) * math.cos(lo), math.cos(la) * math.sin(lo), math.sin(la) + dot = (rx * ux + ry * uy + rz * uz) / rng + return math.degrees(math.asin(max(-1.0, min(1.0, dot)))) + + +class Sat: + __slots__ = ("name", "catnr", "rec", "is_recon", "prev_elev") + + def __init__(self, name, line1, line2, is_recon): + self.catnr = line1[2:7].strip() + self.name = name or f"SAT {self.catnr}" + self.rec = Satrec.twoline2rv(line1, line2) # handles no_kozai internally + self.is_recon = is_recon + self.prev_elev = None + + def geodetic_now(self, dt): + jd, fr = jday(dt.year, dt.month, dt.day, dt.hour, dt.minute, + dt.second + dt.microsecond * 1e-6) + e, r, _v = self.rec.sgp4(jd, fr) + if e != 0: # decayed / propagation error + return None + return eci_to_geodetic(r, gstime(jd + fr)) + + +def build_sats(groups): + sats = [] + for name, l1, l2, is_recon in groups: + try: + sats.append(Sat(name, l1, l2, is_recon)) + except Exception as e: # a malformed TLE shouldn't kill the set + print(f"[{NAME}] skip TLE '{name or l1[2:7]}': {e}") + return sats + + +def main(): + ap = argparse.ArgumentParser(description="SGP4 satellites -> OSC (the sky's schedule)") + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=9000) + ap.add_argument("--interval", type=float, default=2.0) + ap.add_argument("--lat", type=float, default=0.0, help="venue latitude (run.py retargets this)") + ap.add_argument("--lon", type=float, default=0.0, help="venue longitude") + args = ap.parse_args() + + if not HAVE_SGP4: + print(f"[{NAME}] sgp4 not installed — `pip install sgp4` to bring the sky in. Exiting.") + sys.exit(0) + + sats = build_sats(load_grouped_tles()) + if not sats: + print(f"[{NAME}] no TLEs (no cache and no network) — exiting politely.") + sys.exit(0) + + client = SimpleUDPClient(args.host, args.port) + n_recon = sum(1 for s in sats if s.is_recon) + have_recon = n_recon > 0 + print(f"[{NAME}] tracking {len(sats)} satellites ({n_recon} recon) from " + f"({args.lat:.2f}, {args.lon:.2f}); emitting /gs/sats/* every {args.interval:g}s") + + last_refresh = time.time() + while True: + if time.time() - last_refresh > REFRESH_EVERY: # age-check + refresh TLEs in place + last_refresh = time.time() + fresh = build_sats(load_grouped_tles()) # refetches only if the cache is stale + if fresh: + sats = fresh + have_recon = any(s.is_recon for s in sats) + + now = datetime.datetime.now(datetime.timezone.utc) + best_elev, recon_best, overhead = -90.0, -90.0, 0 + for s in sats: + geo = s.geodetic_now(now) + if geo is None: + continue + lat, lon, alt = geo + el = elevation(lat, lon, alt, args.lat, args.lon) + if el > best_elev: + best_elev = el + if s.is_recon and el > recon_best: + recon_best = el + if el > OVERHEAD_ELEV: + overhead += 1 + if s.prev_elev is not None and s.prev_elev < PASS_ELEV <= el: # rising through 30deg + client.send_message("/gs/sats/pass.event", float(el)) # -> sats.pass.event (impulse) + print(f"[{NAME}] pass: {s.name} rising through {PASS_ELEV:.0f}deg (now {el:.1f})") + s.prev_elev = el + if s.catnr == ISS_CATNR: + client.send_message("/gs/sats/iss_lat", float(lat)) + client.send_message("/gs/sats/iss_lon", float(lon)) + + client.send_message("/gs/sats/best_elev", float(best_elev)) + client.send_message("/gs/sats/overhead_count", float(overhead)) + if have_recon: + client.send_message("/gs/sats/recon_elev", float(recon_best)) + + time.sleep(args.interval) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0)