Godstrument/workers/world_sky.py
type-two 629cbad6fd 🔍 crossover post-merge review — 18 confirmed findings fixed (fable)
A fresh-eyes adversarial pass over the 15 pulled commits (godrum +
keyroll + sats + planes cache + god's eye): 21 raised, 18 confirmed,
all fixed.

The instrument: .mid export no longer smuggles the seeded demo beat
(lanes export only if playing or edited off the seed — same test
zeroHasBuild uses); master ▶/■ remembers per-lane mutes; the beat
panel's bpm field is always editable like the dial (🔒 blocks the
world's hands, not the user's); ⏺ rec with the beat stopped anchors
step 0 at arm instead of the invisible free-running clock; note-length
drags speak pointer events (touch works, window-level so mid-drag
re-renders survive); the resize handle no longer eats 40% of a
sixteenth's click width; the god's eye clears a stale ISS trail after
an rAF nap or resize; the "Agent A" dev-process string is gone; the
grimoire now tells the truth about kit knobs and which voices hold
note length (manual regenerated).

The workers: world_sats — partial Celestrak fetches backfill from the
old cache instead of clobbering it, pass-detection state survives the
6-hourly refresh, the no-TLE exit naps 15 min so the supervisor can't
hammer Celestrak, and the venue default now matches world_sky's
Brisbane instead of Null Island; world_planes bounds its stale-cache
fallback to 1h so a days-old sky can't resurrect as live; world_sky's
docstring admits the lat/lon it emits. run.py retires workers that
exit 0 instead of reviving them every 10s (the sgp4-missing churn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:24:25 +10:00

93 lines
3.9 KiB
Python

"""
world_sky.py — the local sky over the venue, as a slow LFO.
Computes the sun's elevation above the horizon at a given latitude/longitude and
the current instant, using the NOAA solar-position algorithm. No network, no API
key, never rate-limited — just astronomy. This is the most place-specific signal
in the whole instrument: playing Brisbane at 2am sounds nothing like London at
sunset, because the sun is literally somewhere else in the sky.
Emits:
/gs/sky/elev solar elevation in degrees (-90 night .. +90 overhead)
/gs/sky/day 0..1 smooth day/night (0 deep night, 1 broad daylight)
/gs/sky/az solar azimuth in degrees (0=N, 90=E, 180=S, 270=W)
/gs/sky/lat the venue's own latitude (so the console can place
/gs/sky/lon the venue's own longitude "here" on the god's eye map)
"""
from __future__ import annotations
import argparse
import datetime
import math
import time
from pythonosc.udp_client import SimpleUDPClient
def solar_position(lat: float, lon: float, when: datetime.datetime):
"""Return (elevation_deg, azimuth_deg) for lat/lon at UTC time `when`."""
n = when.timetuple().tm_yday
hour = when.hour + when.minute / 60 + when.second / 3600
g = 2 * math.pi / 365 * (n - 1 + (hour - 12) / 24) # fractional year
eqtime = 229.18 * (0.000075 + 0.001868 * math.cos(g)
- 0.032077 * math.sin(g) - 0.014615 * math.cos(2 * g)
- 0.040849 * math.sin(2 * g)) # minutes
decl = (0.006918 - 0.399912 * math.cos(g) + 0.070257 * math.sin(g)
- 0.006758 * math.cos(2 * g) + 0.000907 * math.sin(2 * g)
- 0.002697 * math.cos(3 * g) + 0.00148 * math.sin(3 * g)) # radians
time_offset = eqtime + 4 * lon # minutes (E +)
tst = hour * 60 + time_offset # true solar time
ha = math.radians(tst / 4 - 180) # hour angle
lat_r = math.radians(lat)
cos_zen = (math.sin(lat_r) * math.sin(decl)
+ math.cos(lat_r) * math.cos(decl) * math.cos(ha))
cos_zen = max(-1.0, min(1.0, cos_zen))
zen = math.acos(cos_zen)
elev = 90 - math.degrees(zen)
# azimuth
sin_az = -math.sin(ha) * math.cos(decl)
cos_az = (math.sin(decl) - math.sin(lat_r) * math.cos(zen)) / \
(math.cos(lat_r) * math.sin(zen) + 1e-9)
az = (math.degrees(math.atan2(sin_az, cos_az)) + 360) % 360
return elev, az
def day_fraction(elev: float) -> float:
"""Smooth 0..1 across the twilight band (civil dusk .. full day)."""
return max(0.0, min(1.0, (elev + 6.0) / 18.0))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--lat", type=float, default=-27.47, help="latitude (default Brisbane)")
ap.add_argument("--lon", type=float, default=153.02, help="longitude (default Brisbane)")
ap.add_argument("--interval", type=float, default=10.0)
args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port)
print(f"[sky] emitting /gs/sky/elev|day|az|lat|lon for ({args.lat},{args.lon}) "
f"every {args.interval:g}s")
while True:
try:
elev, az = solar_position(args.lat, args.lon,
datetime.datetime.now(datetime.timezone.utc))
client.send_message("/gs/sky/elev", float(elev))
client.send_message("/gs/sky/day", day_fraction(elev))
client.send_message("/gs/sky/az", float(az))
client.send_message("/gs/sky/lat", float(args.lat)) # the venue's own coordinates,
client.send_message("/gs/sky/lon", float(args.lon)) # so the console can place "here" on a map
except Exception as e: # noqa
print(f"[sky] warn: {e}")
time.sleep(args.interval)
if __name__ == "__main__":
main()