Godstrument/workers/world_almanac.py
type-two 510ddaaf8c 🔧 review fixes — the five-angle audit's whole findings list
Correctness (four were my own regressions from the REVELATION wave):
· ☸ WHEELS polymeter permanently detuned after any skipped frame —
  seqAbsStep advanced by ++ per transition, but one frame can cross two
  16ths (bpm ≳155, or the clamped dt on every tab refocus). Proven to
  drift 12 steps out at 174bpm; now advances by the true distance, so
  seqAbsStep % 16 === seqCurStep always holds.
· Sample & hold with exactly one lit step froze on its first sample
  forever (guard keyed on the wrapped step, which never changed) — now
  keyed on the absolute step.
· PSALM double-activation left the MICROPHONE HOT after "stop": the
  re-entrancy guard sat after the getUserMedia await, orphaning the
  first stream/interval/context. Guard is now synchronous, a stop
  during the prompt is honoured, and a second tap while starting
  cancels. Mic off means off — the promise the feature makes.
· Live-recording a drum tap into a short wheel wrote steps past wlen
  that never played; the tap now folds onto the lane's own wheel.
· Switching MIDI out port left notes droning on the old port (pending
  off-timers fired at the NEW one) — midiFlushOut() releases every held
  note + all-notes-off before the swap.
· world_quakes' seen-set grew unbounded on long-running rigs.

Security: stored XSS via the commune room name — the one untrusted
string reaching innerHTML unescaped (hub only lower-cased it). Escaped
client-side at both sinks, and the hub now strips markup chars too.

Robustness: world_ephemeris/world_almanac imported swisseph unguarded,
so a machine without pyswisseph crash-looped them every 10s forever
against the supervisor's exit-0 contract — both now retire quietly
(verified by simulating the missing dep), and pyswisseph is documented
in requirements.

Mobile: the top bar's five separately-pinned chips collided below
~500px — the first thing a phone visitor saw was a broken header. Now
a clean row with the account chip on its own line and the source
counter moved left.

Housekeeping: dead measureSpaced(), dead #account .code selector, and
an 888K stale worktree removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:43:20 +10:00

94 lines
3.9 KiB
Python

"""
world_almanac.py — the planting calendar. Sowing by the moon.
For millennia people planted by an almanac: the moon's phase and the zodiac sign
it's passing through told you what to sow. It's not fetched data — it's computed
from the same sky world_ephemeris uses. The zodiac's four elements cycle in a
fixed order (fire, earth, air, water), and biodynamic tradition maps them to a
crop type — a slow four-step sequencer the moon walks through, changing every
~2.25 days:
earth signs -> ROOT days water signs -> LEAF days
air signs -> FLOWER days fire signs -> FRUIT/SEED days
Musically it's a modal calendar: the instrument's character can follow the moon
through the garden.
Emits:
/gs/almanac/element 0..1 stepped — the day's element (root/leaf/flower/fruit)
/gs/almanac/waxing 1 waxing (sow above-ground), 0 waning (sow roots)
/gs/almanac/dec 0..1 — Moon's declination (ascending/descending, ~27.3d)
/gs/almanac/fertile 0..1 — a simple biodynamic favourability score
"""
from __future__ import annotations
import argparse
import datetime
import math
import time
try: # house rule: import-safe (see world_sats.py) — a
import swisseph as swe # missing pyswisseph retires this worker quietly
except Exception: # rather than crash-looping under the supervisor.
import sys
print("world_almanac: pyswisseph not installed — skipping (optional)")
sys.exit(0)
from pythonosc.udp_client import SimpleUDPClient
FLG = swe.FLG_MOSEPH | swe.FLG_SPEED
# element index by sign (Aries..Pisces): fire,earth,air,water repeating.
# biodynamic crop: 0 fire=fruit, 1 earth=root, 2 air=flower, 3 water=leaf
ELEMENTS = ["fruit", "root", "flower", "leaf"]
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("--interval", type=float, default=30.0)
args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port)
print(f"[almanac] emitting /gs/almanac/* (root/leaf/flower/fruit by the moon) "
f"every {args.interval:g}s")
last_label = None
while True:
try:
now = datetime.datetime.now(datetime.timezone.utc)
jd = swe.julday(now.year, now.month, now.day,
now.hour + now.minute / 60 + now.second / 3600)
moon, _ = swe.calc_ut(jd, swe.MOON, FLG)
sun, _ = swe.calc_ut(jd, swe.SUN, FLG)
eq, _ = swe.calc_ut(jd, swe.MOON,
swe.FLG_MOSEPH | swe.FLG_EQUATORIAL | swe.FLG_SPEED)
sign = int(moon[0] // 30) % 12
elem = sign % 4 # 0 fire,1 earth,2 air,3 water
phase_angle = (moon[0] - sun[0]) % 360
waxing = 1.0 if phase_angle < 180 else 0.0
illum = (1 - math.cos(math.radians(phase_angle))) / 2
dec = eq[1] # Moon declination (deg)
dec01 = max(0.0, min(1.0, (dec + 28.5) / 57.0))
ascending = 1.0 if eq[4] > 0 else 0.0
fertile = 0.45 * illum + 0.35 * ascending + 0.20 * (1.0 if elem in (1, 3) else 0.0)
client.send_message("/gs/almanac/element", elem / 3.0)
client.send_message("/gs/almanac/waxing", waxing)
client.send_message("/gs/almanac/dec", dec01)
client.send_message("/gs/almanac/fertile", fertile)
label = ELEMENTS[elem]
if label != last_label:
print(f" 🌱 {label} day (moon in sign {sign + 1}, "
f"{'waxing' if waxing else 'waning'}, "
f"{'ascending' if ascending else 'descending'})")
last_label = label
except Exception as e: # noqa
print(f"[almanac] warn: {e}")
time.sleep(args.interval)
if __name__ == "__main__":
main()