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

@@ -77,6 +77,109 @@ def get_planet_positions(jd):
return planets
# ── Reverse lookup — sign windows (Set the Game Clock narrowing) ─────────────
#
# Forward calculation answers "where is the planet at time T"; the Game Clock
# ritual needs the REVERSE — "when does planet P reside in sign S" — so each
# placement can narrow the real date window the ritual still resolves to.
#
# Scan strides (days) sit well under each planet's shortest typical sign
# residence. Retrograde re-dips briefer than the stride can be missed — that
# is CONSERVATIVE: a missed sliver only narrows the reachable window, it never
# admits a false moment (every returned window is forward-verified by its
# construction: sampled in-sign, edges bisected to the true crossing).
SIGN_SCAN_STRIDES = {
'Moon': 0.5, 'Sun': 5.0, 'Mercury': 2.0, 'Venus': 2.0, 'Mars': 5.0,
'Jupiter': 15.0, 'Saturn': 30.0, 'Uranus': 60.0, 'Neptune': 60.0,
'Pluto': 60.0,
}
EDGE_PRECISION_DAYS = 1.0 / 24.0 # bisect sign crossings to the hour
def get_planet_sign(jd, planet):
pos, _ = swe.calc_ut(jd, PLANET_CODES[planet], swe.FLG_SWIEPH)
return get_sign(pos[0])
def jd_to_iso(jd):
y, m, d, h = swe.revjul(jd)
hh = int(h)
mm = int(round((h - hh) * 60))
if mm == 60:
hh, mm = hh + 1, 0
return f'{y:04d}-{m:02d}-{d:02d}T{hh:02d}:{mm:02d}:00Z'
def _bisect_edge(planet, sign, jd_match, jd_other):
"""Refine the sign crossing between a jd where `planet` IS in `sign` and
one where it is not, to EDGE_PRECISION_DAYS. Returns the match-side jd."""
while abs(jd_other - jd_match) > EDGE_PRECISION_DAYS:
mid = (jd_match + jd_other) / 2
if get_planet_sign(mid, planet) == sign:
jd_match = mid
else:
jd_other = mid
return jd_match
def sign_windows(planet, sign, windows):
"""The sub-windows of `windows` — a list of (jd_start, jd_end) — where
`planet` resides in `sign`. Stride scan + bisection edge refine."""
stride = SIGN_SCAN_STRIDES[planet]
out = []
for lo, hi in windows:
if hi <= lo:
continue
samples = []
jd = lo
while jd < hi:
samples.append(jd)
jd += stride
samples.append(hi)
run_start = None
prev = None
for s in samples:
in_sign = get_planet_sign(s, planet) == sign
if in_sign and run_start is None:
run_start = lo if prev is None else _bisect_edge(planet, sign, s, prev)
elif not in_sign and run_start is not None:
out.append((run_start, _bisect_edge(planet, sign, prev, s)))
run_start = None
prev = s
if run_start is not None:
out.append((run_start, hi))
return out
def narrowed_windows(placements, windows):
"""Fold each placement's sign residences into the intersection. Slowest
planets first (fewest fragments); each scan is restricted to the windows
the prior placements already narrowed to, so the fast bodies only ever
scan slivers."""
for planet, sign in sorted(
placements.items(), key=lambda kv: -SIGN_SCAN_STRIDES[kv[0]]):
if not windows:
break
windows = sign_windows(planet, sign, windows)
return windows
def present_signs(planet, windows):
"""The set of signs `planet` occupies at any point within `windows` —
sampled at the scan stride plus both edges (presence needs no bisection)."""
stride = SIGN_SCAN_STRIDES[planet]
signs = set()
for lo, hi in windows:
jd = lo
while jd < hi:
signs.add(get_planet_sign(jd, planet))
jd += stride
signs.add(get_planet_sign(hi, planet))
return signs
def get_element_counts(planets):
sign_counts = {s: 0 for s in SIGNS}
sign_planets = {s: [] for s in SIGNS}

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')

View File

@@ -4,5 +4,6 @@ from . import views
urlpatterns = [
path('chart/', views.chart, name='chart'),
path('charts/', views.charts_list, name='charts_list'),
path('windows/', views.windows, name='windows'),
path('tz/', views.timezone_lookup, name='timezone_lookup'),
]

View File

@@ -5,12 +5,19 @@ from timezonefinder import TimezoneFinder
import swisseph as swe
from django.conf import settings as django_settings
from .calc import (
DEFAULT_HOUSE_SYSTEM,
PLANET_CODES,
SIGNS,
calculate_aspects,
get_element_counts,
get_julian_day,
get_planet_positions,
jd_to_iso,
narrowed_windows,
present_signs,
set_ephe_path,
)
from .models import EphemerisSnapshot
@@ -68,6 +75,53 @@ def chart(request):
})
def windows(request):
"""GET /api/windows/ — REVERSE ephemeris lookup (Set the Game Clock).
Query params:
placements — comma list of Planet:Sign pairs (may be absent/empty)
next — planet name; report which signs it can reach in the windows
Folds each placement's sign residences into the intersected date windows
where ALL placements hold simultaneously, bounded by the game window
(settings GAME_WINDOW_START/END — defaults span the precomputed snapshot
range: 1781-03-13, Uranus's discovery year, → 2100-12-31).
Returns {windows: [{start, end}…], days, next?: {planet, signs: {sign:
bool ×12}}} 200 · 400 on an unknown planet/sign or malformed pair.
"""
placements = {}
for pair in [p for p in request.GET.get('placements', '').split(',') if p]:
planet, sep, sign = pair.partition(':')
if not sep or planet not in PLANET_CODES or sign not in SIGNS:
return HttpResponse(status=400)
placements[planet] = sign
next_planet = request.GET.get('next') or None
if next_planet is not None and next_planet not in PLANET_CODES:
return HttpResponse(status=400)
set_ephe_path()
start = getattr(django_settings, 'GAME_WINDOW_START', '1781-03-13')
end = getattr(django_settings, 'GAME_WINDOW_END', '2100-12-31')
base = [(
get_julian_day(datetime.fromisoformat(start).replace(tzinfo=timezone.utc)),
get_julian_day(datetime.fromisoformat(end).replace(tzinfo=timezone.utc)),
)]
wins = narrowed_windows(placements, base)
payload = {
'windows': [{'start': jd_to_iso(a), 'end': jd_to_iso(b)} for a, b in wins],
'days': round(sum(b - a for a, b in wins), 2),
}
if next_planet:
reachable = present_signs(next_planet, wins)
payload['next'] = {
'planet': next_planet,
'signs': {s: s in reachable for s in SIGNS},
}
return JsonResponse(payload)
_tf = TimezoneFinder()