Set the Game Clock — ephemeris narrowing (hard CSP): placements shrink the REAL date window; narrowed range prints below the prompt; unreachable signs dim + reject — TDD

- PySwiss gains its FIRST reverse lookup: GET /api/windows/?placements=
  Uranus:Aquarius,…&next=Saturn — sign_windows (per-planet stride scan +
  bisection edge refine to the hour) folds thru narrowed_windows
  (slowest planet first, each scan restricted to the prior intersection
  so the fast bodies only ever scan slivers) over the game window
  (settings GAME_WINDOW_START/END, default 1781-03-13 — Uranus's
  discovery — → 2100-12-31, the snapshot span); present_signs reports
  the next planet's reachable signs. Self-validating UTs (every window
  forward-checked at midpoint + edges) + 8 API ITs
- epic clock_windows endpoint (lazy, table_sky-shaped — room views stay
  HTTP-free) proxies the lookup, cached per room+placements (six felts
  polling one ritual state = ONE upstream call; failures cached 60s);
  fails OPEN {available:false} when PySwiss is unreachable
- place_clock_planet enforces the HARD constraint: a sign outside the
  narrowed windows' reach → 409 sign_unreachable; fail-open w.o PySwiss
  (the ritual never bricks on microservice downtime); PlaceClockPlanet
  ITs sever PySwiss in setUp so the turn walk stays deterministic
  against a live local service
- felt: #id_clock_windows readout below the prompt for ALL viewers —
  "1995-04-01 → 1998-04-17 · 2 windows" — fetched at parse + after own
  placement + on every clock_placement broadcast; drawRim opts gain
  allowedSigns → unreachable wedges .nw-sign--blocked (dimmed, inert,
  no handlers); SkyWheelSpec R10/R11
- SeedMapClockNarrowingTest FT stubs PySwiss in-process (real proxy,
  real gating): readout renders, blocked Aries won't place, allowed
  Pisces lands Saturn, readout re-narrows

[[project-voronoi-spec]]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-10 12:30:16 -04:00
parent b2ddd98956
commit 32db203543
15 changed files with 888 additions and 7 deletions

View File

@@ -0,0 +1,81 @@
"""
Integration tests for GET /api/windows/ — the reverse ephemeris lookup
(Set the Game Clock narrowing, EarthmanRPG roadmap step 21).
The endpoint folds `placements` (Planet:Sign pairs) into the intersected date
windows where every placement holds simultaneously, bounded by the game window
(settings GAME_WINDOW_START/END — defaults span the precomputed snapshot
range, 1781-03-13 → 2100-12-31), and reports which signs the `next` planet can
reach within them. Tests override the game window to a tight decade so the
real ephemeris scan stays fast + deterministic.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test apps.charts
"""
from django.test import SimpleTestCase, override_settings
@override_settings(GAME_WINDOW_START='1990-01-01', GAME_WINDOW_END='2000-01-01')
class WindowsApiTest(SimpleTestCase):
def test_empty_placements_returns_the_full_game_window(self):
response = self.client.get('/api/windows/')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data['windows']), 1)
self.assertTrue(data['windows'][0]['start'].startswith('1990-01-01'))
self.assertTrue(data['windows'][0]['end'].startswith('2000-01-01'))
self.assertAlmostEqual(data['days'], 3652, delta=2)
def test_placements_narrow_the_windows(self):
response = self.client.get(
'/api/windows/',
{'placements': 'Uranus:Aquarius,Saturn:Pisces'},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertGreaterEqual(len(data['windows']), 1)
self.assertLess(data['days'], 500)
for w in data['windows']:
self.assertLess(w['start'], w['end'])
def test_next_reports_reachable_signs(self):
response = self.client.get(
'/api/windows/',
{'placements': 'Saturn:Pisces', 'next': 'Jupiter'},
)
self.assertEqual(response.status_code, 200)
nxt = response.json()['next']
self.assertEqual(nxt['planet'], 'Jupiter')
self.assertEqual(len(nxt['signs']), 12)
reachable = [s for s, ok in nxt['signs'].items() if ok]
# Jupiter (1-year residence) reaches SOME but not ALL signs inside
# Saturn's ~3-year Pisces windows.
self.assertGreater(len(reachable), 0)
self.assertLess(len(reachable), 12)
def test_unreachable_placement_yields_empty_windows(self):
response = self.client.get(
'/api/windows/', {'placements': 'Saturn:Leo', 'next': 'Jupiter'})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['windows'], [])
self.assertEqual(data['days'], 0)
self.assertEqual(
[s for s, ok in data['next']['signs'].items() if ok], [])
def test_invalid_planet_400(self):
self.assertEqual(self.client.get(
'/api/windows/', {'placements': 'Vulcan:Aries'}).status_code, 400)
def test_invalid_sign_400(self):
self.assertEqual(self.client.get(
'/api/windows/', {'placements': 'Saturn:Notasign'}).status_code, 400)
def test_invalid_next_400(self):
self.assertEqual(self.client.get(
'/api/windows/', {'next': 'Vulcan'}).status_code, 400)
def test_malformed_placements_400(self):
self.assertEqual(self.client.get(
'/api/windows/', {'placements': 'SaturnPisces'}).status_code, 400)

View File

@@ -0,0 +1,113 @@
"""
Unit tests for the reverse ephemeris lookup — sign_windows / narrowed_windows /
present_signs (Set the Game Clock narrowing, EarthmanRPG roadmap step 21).
Unlike test_calc.py these DO drive the Swiss Ephemeris (Moshier fallback when
data files are absent — 0.1° precision, far inside the ±1-day tolerances
here). The assertions are largely SELF-VALIDATING: rather than hardcoding
astrological history, each returned window is forward-checked — its midpoint
must actually carry the planet in the claimed sign, and its edges must be true
sign boundaries.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test apps.charts
"""
from datetime import datetime, timezone
from django.test import SimpleTestCase
import swisseph as swe
from apps.charts.calc import (
PLANET_CODES,
get_julian_day,
get_sign,
narrowed_windows,
present_signs,
set_ephe_path,
sign_windows,
)
def _jd(iso):
return get_julian_day(datetime.fromisoformat(iso).replace(tzinfo=timezone.utc))
def _sign_at(jd, planet):
pos, _ = swe.calc_ut(jd, PLANET_CODES[planet], swe.FLG_SWIEPH)
return get_sign(pos[0])
class SignWindowsTest(SimpleTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
set_ephe_path()
def test_sun_in_aries_2020(self):
wins = sign_windows('Sun', 'Aries', [(_jd('2020-01-01'), _jd('2020-12-31'))])
self.assertEqual(len(wins), 1)
a, b = wins[0]
# Ingress at the March equinox, egress ~30 days later.
self.assertAlmostEqual(a, _jd('2020-03-20'), delta=1.0)
self.assertAlmostEqual(b, _jd('2020-04-19'), delta=1.0)
def test_window_edges_are_true_sign_boundaries(self):
lo, hi = _jd('1990-01-01'), _jd('2000-01-01')
wins = sign_windows('Saturn', 'Pisces', [(lo, hi)])
# Saturn's mid-90s Pisces residence, split by the retrograde dip back
# into Aquarius — at least one window, every one self-consistent.
self.assertGreaterEqual(len(wins), 1)
for a, b in wins:
self.assertLess(a, b)
self.assertEqual(_sign_at((a + b) / 2, 'Saturn'), 'Pisces')
if a > lo + 1: # true boundary (not clipped by the scan bound)
self.assertNotEqual(_sign_at(a - 0.2, 'Saturn'), 'Pisces')
if b < hi - 1:
self.assertNotEqual(_sign_at(b + 0.2, 'Saturn'), 'Pisces')
def test_edges_refined_to_the_hour(self):
wins = sign_windows('Sun', 'Aries', [(_jd('2020-01-01'), _jd('2020-12-31'))])
a, b = wins[0]
# Within 1/24 day of the crossing on each side.
self.assertEqual(_sign_at(a + 1 / 24, 'Sun'), 'Aries')
self.assertNotEqual(_sign_at(a - 1 / 24, 'Sun'), 'Aries')
self.assertEqual(_sign_at(b - 1 / 24, 'Sun'), 'Aries')
self.assertNotEqual(_sign_at(b + 1 / 24, 'Sun'), 'Aries')
def test_narrowed_windows_intersect_all_placements(self):
base = [(_jd('1990-01-01'), _jd('2000-01-01'))]
wins = narrowed_windows({'Uranus': 'Aquarius', 'Saturn': 'Pisces'}, base)
self.assertGreaterEqual(len(wins), 1)
for a, b in wins:
mid = (a + b) / 2
self.assertEqual(_sign_at(mid, 'Uranus'), 'Aquarius')
self.assertEqual(_sign_at(mid, 'Saturn'), 'Pisces')
total = sum(b - a for a, b in wins)
# Vastly narrower than the 10-year base, but a real window.
self.assertGreater(total, 30)
self.assertLess(total, 500)
def test_narrowed_windows_empty_when_unreachable(self):
# Saturn resided in Leo in the late 70s and mid 00s — never in the 90s.
wins = narrowed_windows(
{'Saturn': 'Leo'}, [(_jd('1990-01-01'), _jd('2000-01-01'))])
self.assertEqual(wins, [])
def test_no_placements_keeps_the_base_window(self):
base = [(_jd('1990-01-01'), _jd('2000-01-01'))]
self.assertEqual(narrowed_windows({}, base), base)
def test_present_signs_lists_reachable_signs_only(self):
signs = present_signs(
'Saturn', [(_jd('1994-06-01'), _jd('1996-06-01'))])
# Pisces until the 1996 Aries ingress — exactly two reachable signs.
self.assertEqual(signs, {'Pisces', 'Aries'})
def test_moon_windows_inside_a_narrow_window(self):
# The end-game shape: by the Moon's turn the windows are days wide.
wins = sign_windows('Moon', 'Cancer', [(_jd('2020-06-01'), _jd('2020-07-01'))])
self.assertEqual(len(wins), 1)
a, b = wins[0]
self.assertAlmostEqual(b - a, 2.4, delta=0.5) # ~2.4-day residence
self.assertEqual(_sign_at((a + b) / 2, 'Moon'), 'Cancer')