Files
python-tdd/pyswiss/apps/charts/calc.py
Disco DeDisco 6248d95bf3
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz
PySwiss:
- calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs)
- /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone)
- aspects included in /api/chart/ response
- timezonefinder==8.2.2 added to requirements
- 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields)

Main app:
- Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033)
- Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross,
  confirmed_at/retired_at lifecycle (migration 0034)
- natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution,
  computes planet-in-house distinctions, returns enriched JSON
- natus_save view: find-or-create draft Character, confirmed_at on action='confirm'
- natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects,
  ASC/MC axes); NatusWheel.draw() / redraw() / clear()
- _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button
  with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill,
  NVM / SAVE SKY footer; html.natus-open class toggle pattern
- _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown,
  portrait collapse at 600px, landscape sidebar z-index sink
- room.html: include overlay when table_status == SKY_SELECT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00

131 lines
3.7 KiB
Python

"""
Core ephemeris calculation logic — shared by views and management commands.
"""
from django.conf import settings as django_settings
import swisseph as swe
DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry
SIGNS = [
'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
]
SIGN_ELEMENT = {
'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire',
'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth',
'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air',
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water',
}
ASPECTS = [
('Conjunction', 0, 8.0),
('Sextile', 60, 6.0),
('Square', 90, 8.0),
('Trine', 120, 8.0),
('Opposition', 180, 10.0),
]
PLANET_CODES = {
'Sun': swe.SUN,
'Moon': swe.MOON,
'Mercury': swe.MERCURY,
'Venus': swe.VENUS,
'Mars': swe.MARS,
'Jupiter': swe.JUPITER,
'Saturn': swe.SATURN,
'Uranus': swe.URANUS,
'Neptune': swe.NEPTUNE,
'Pluto': swe.PLUTO,
}
def set_ephe_path():
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
if ephe_path:
swe.set_ephe_path(ephe_path)
def get_sign(lon):
return SIGNS[int(lon // 30) % 12]
def get_julian_day(dt):
return swe.julday(
dt.year, dt.month, dt.day,
dt.hour + dt.minute / 60 + dt.second / 3600,
)
def get_planet_positions(jd):
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
planets = {}
for name, code in PLANET_CODES.items():
pos, _ = swe.calc_ut(jd, code, flag)
degree = pos[0]
planets[name] = {
'sign': get_sign(degree),
'degree': degree,
'retrograde': pos[3] < 0,
}
return planets
def get_element_counts(planets):
sign_counts = {s: 0 for s in SIGNS}
counts = {'Fire': 0, 'Water': 0, 'Earth': 0, 'Air': 0}
for data in planets.values():
sign = data['sign']
counts[SIGN_ELEMENT[sign]] += 1
sign_counts[sign] += 1
# Time: highest planet concentration in a single sign, minus 1
counts['Time'] = max(sign_counts.values()) - 1
# Space: longest consecutive run of occupied signs (circular), minus 1
indices = [i for i, s in enumerate(SIGNS) if sign_counts[s] > 0]
max_seq = 0
for start in range(len(indices)):
seq_len = 1
for offset in range(1, len(indices)):
if (indices[start] + offset) % len(SIGNS) in indices:
seq_len += 1
else:
break
max_seq = max(max_seq, seq_len)
counts['Space'] = max_seq - 1
return counts
def calculate_aspects(planets):
"""Return a list of aspects between all planet pairs.
Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}.
Only the first matching aspect type is reported per pair (aspects are
well-separated enough that at most one can apply with standard orbs).
"""
names = list(planets.keys())
aspects = []
for i, name1 in enumerate(names):
for name2 in names[i + 1:]:
deg1 = planets[name1]['degree']
deg2 = planets[name2]['degree']
angle = abs(deg1 - deg2)
if angle > 180:
angle = 360 - angle
for aspect_name, target, max_orb in ASPECTS:
orb = abs(angle - target)
if orb <= max_orb:
aspects.append({
'planet1': name1,
'planet2': name2,
'type': aspect_name,
'angle': round(angle, 2),
'orb': round(orb, 2),
})
break
return aspects