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>
131 lines
3.7 KiB
Python
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
|