- 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>
198 lines
6.0 KiB
Python
198 lines
6.0 KiB
Python
from datetime import datetime, timezone
|
||
|
||
from django.http import HttpResponse, JsonResponse
|
||
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
|
||
|
||
|
||
def chart(request):
|
||
dt_str = request.GET.get('dt')
|
||
lat_str = request.GET.get('lat')
|
||
lon_str = request.GET.get('lon')
|
||
|
||
if not dt_str or lat_str is None or lon_str is None:
|
||
return HttpResponse(status=400)
|
||
|
||
try:
|
||
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||
except ValueError:
|
||
return HttpResponse(status=400)
|
||
|
||
try:
|
||
lat = float(lat_str)
|
||
lon = float(lon_str)
|
||
except ValueError:
|
||
return HttpResponse(status=400)
|
||
|
||
if not (-90 <= lat <= 90):
|
||
return HttpResponse(status=400)
|
||
|
||
house_system_param = request.GET.get('house_system')
|
||
if house_system_param is not None:
|
||
if not (hasattr(request, 'user') and request.user.is_authenticated
|
||
and request.user.is_superuser):
|
||
return HttpResponse(status=403)
|
||
house_system = house_system_param
|
||
else:
|
||
house_system = DEFAULT_HOUSE_SYSTEM
|
||
|
||
set_ephe_path()
|
||
|
||
jd = get_julian_day(dt)
|
||
planets = get_planet_positions(jd)
|
||
|
||
cusps, ascmc = swe.houses(jd, lat, lon, house_system.encode())
|
||
houses = {
|
||
'cusps': list(cusps),
|
||
'asc': ascmc[0],
|
||
'mc': ascmc[1],
|
||
}
|
||
|
||
return JsonResponse({
|
||
'planets': planets,
|
||
'houses': houses,
|
||
'elements': get_element_counts(planets),
|
||
'aspects': calculate_aspects(planets),
|
||
'house_system': house_system,
|
||
})
|
||
|
||
|
||
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()
|
||
|
||
|
||
def timezone_lookup(request):
|
||
"""GET /api/tz/ — resolve IANA timezone string from lat/lon.
|
||
|
||
Query params: lat (float), lon (float)
|
||
Returns: { "timezone": "America/New_York" }
|
||
Returns 404 JSON { "timezone": null } if coordinates fall in international
|
||
waters (no timezone found) — not an error, just no result.
|
||
"""
|
||
lat_str = request.GET.get('lat')
|
||
lon_str = request.GET.get('lon')
|
||
|
||
if lat_str is None or lon_str is None:
|
||
return HttpResponse(status=400)
|
||
|
||
try:
|
||
lat = float(lat_str)
|
||
lon = float(lon_str)
|
||
except ValueError:
|
||
return HttpResponse(status=400)
|
||
|
||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||
return HttpResponse(status=400)
|
||
|
||
tz = _tf.timezone_at(lat=lat, lng=lon)
|
||
return JsonResponse({'timezone': tz})
|
||
|
||
|
||
def charts_list(request):
|
||
date_from_str = request.GET.get('date_from')
|
||
date_to_str = request.GET.get('date_to')
|
||
|
||
if not date_from_str or not date_to_str:
|
||
return HttpResponse(status=400)
|
||
|
||
try:
|
||
date_from = datetime.strptime(date_from_str, '%Y-%m-%d').replace(
|
||
tzinfo=timezone.utc)
|
||
date_to = datetime.strptime(date_to_str, '%Y-%m-%d').replace(
|
||
hour=23, minute=59, second=59, tzinfo=timezone.utc)
|
||
except ValueError:
|
||
return HttpResponse(status=400)
|
||
|
||
if date_to < date_from:
|
||
return HttpResponse(status=400)
|
||
|
||
qs = EphemerisSnapshot.objects.filter(dt__gte=date_from, dt__lte=date_to)
|
||
|
||
element_fields = {
|
||
'fire_min': 'fire', 'water_min': 'water',
|
||
'earth_min': 'earth', 'air_min': 'air',
|
||
'time_min': 'time_el', 'space_min': 'space_el',
|
||
}
|
||
for param, field in element_fields.items():
|
||
value = request.GET.get(param)
|
||
if value is not None:
|
||
try:
|
||
qs = qs.filter(**{f'{field}__gte': int(value)})
|
||
except ValueError:
|
||
return HttpResponse(status=400)
|
||
|
||
results = [
|
||
{
|
||
'dt': snap.dt.isoformat(),
|
||
'elements': snap.elements_dict(),
|
||
'planets': snap.chart_data.get('planets', {}),
|
||
}
|
||
for snap in qs
|
||
]
|
||
|
||
return JsonResponse({'results': results, 'count': len(results)})
|