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:
@@ -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}
|
||||
|
||||
81
pyswiss/apps/charts/tests/integrated/test_windows_api.py
Normal file
81
pyswiss/apps/charts/tests/integrated/test_windows_api.py
Normal 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)
|
||||
113
pyswiss/apps/charts/tests/unit/test_windows.py
Normal file
113
pyswiss/apps/charts/tests/unit/test_windows.py
Normal 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')
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -47,3 +47,9 @@ SWISSEPH_PATH = os.environ.get(
|
||||
'SWISSEPH_PATH',
|
||||
r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe',
|
||||
)
|
||||
|
||||
# The game window — the date bounds the Set-the-Game-Clock ritual can resolve
|
||||
# within (/api/windows/ reverse lookup). Defaults span the precomputed
|
||||
# snapshot range: 1781-03-13 (Uranus's discovery) → 2100-12-31.
|
||||
GAME_WINDOW_START = os.environ.get('GAME_WINDOW_START', '1781-03-13')
|
||||
GAME_WINDOW_END = os.environ.get('GAME_WINDOW_END', '2100-12-31')
|
||||
|
||||
Reference in New Issue
Block a user