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
|
|
|
"""
|
|
|
|
|
Unit tests for calc.py helper functions.
|
|
|
|
|
|
|
|
|
|
These tests verify pure calculation logic without hitting the database
|
|
|
|
|
or the Swiss Ephemeris — all inputs are fixed synthetic data.
|
|
|
|
|
|
|
|
|
|
Run:
|
|
|
|
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
|
|
|
|
"""
|
|
|
|
|
from django.test import SimpleTestCase
|
|
|
|
|
|
2026-04-21 00:37:33 -04:00
|
|
|
from apps.charts.calc import calculate_aspects, get_element_counts
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-04-21 00:37:33 -04:00
|
|
|
# FAKE_PLANETS_ASPECTS — degrees only; used by calculate_aspects tests.
|
|
|
|
|
# Each planet also carries a speed (deg/day) for applying_planet tests.
|
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
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
FAKE_PLANETS = {
|
2026-04-21 00:37:33 -04:00
|
|
|
'Sun': {'degree': 10.0, 'speed': 1.00}, # Aries
|
|
|
|
|
'Moon': {'degree': 130.0, 'speed': 13.00}, # Leo — 120° from Sun → Trine
|
|
|
|
|
'Mercury': {'degree': 250.0, 'speed': 1.50}, # Sagittarius — 120° from Sun → Trine
|
|
|
|
|
'Venus': {'degree': 40.0, 'speed': 1.10}, # Taurus — 90° from Moon → Square
|
|
|
|
|
'Mars': {'degree': 160.0, 'speed': 0.50}, # Virgo — 60° from Neptune → Sextile
|
|
|
|
|
'Jupiter': {'degree': 280.0, 'speed': 0.08}, # Capricorn — 120° from Mars → Trine
|
|
|
|
|
'Saturn': {'degree': 70.0, 'speed': 0.03}, # Gemini — 120° from Uranus → Trine
|
|
|
|
|
'Uranus': {'degree': 310.0, 'speed': 0.01}, # Aquarius — 60° from Sun (wrap) → Sextile
|
|
|
|
|
'Neptune': {'degree': 100.0, 'speed': 0.006}, # Cancer
|
|
|
|
|
'Pluto': {'degree': 340.0, 'speed': 0.003}, # Pisces
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# FAKE_PLANETS_ELEMENTS — sign + degree + speed; used by get_element_counts.
|
|
|
|
|
# Designed to produce a known stellium and parade.
|
|
|
|
|
#
|
|
|
|
|
# Occupied signs: Aries(0), Taurus(1), Gemini(2), Leo(4), Virgo(5),
|
|
|
|
|
# Scorpio(7), Capricorn(9), Aquarius(10)
|
|
|
|
|
# Gaps at Cancer(3), Libra(6), Sagittarius(8), Pisces(11) prevent wrap-around.
|
|
|
|
|
#
|
|
|
|
|
# Consecutive runs: Aries→Taurus→Gemini = 3 ← parade (Space = 2)
|
|
|
|
|
# Leo→Virgo = 2
|
|
|
|
|
# Capricorn→Aquarius = 2
|
|
|
|
|
#
|
|
|
|
|
# Time = 2 (Aries has Sun+Mercury+Venus → stellium of 3, bonus = 2)
|
|
|
|
|
# Space = 2 (Aries→Taurus→Gemini = 3-sign parade, bonus = 2)
|
|
|
|
|
# Classic: Fire=4, Earth=3, Air=2, Water=1
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
FAKE_PLANETS_ELEMENTS = {
|
|
|
|
|
'Sun': {'sign': 'Aries', 'degree': 10.0, 'speed': 1.00}, # Fire, stellium
|
|
|
|
|
'Moon': {'sign': 'Taurus', 'degree': 40.0, 'speed': 13.00}, # Earth, parade
|
|
|
|
|
'Mercury': {'sign': 'Aries', 'degree': 20.0, 'speed': 1.50}, # Fire, stellium
|
|
|
|
|
'Venus': {'sign': 'Aries', 'degree': 25.0, 'speed': 1.10}, # Fire, stellium
|
|
|
|
|
'Mars': {'sign': 'Leo', 'degree': 130.0, 'speed': 0.50}, # Fire
|
|
|
|
|
'Jupiter': {'sign': 'Scorpio', 'degree': 220.0, 'speed': 0.08}, # Water
|
|
|
|
|
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'speed': 0.03}, # Air, parade
|
|
|
|
|
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'speed': 0.01}, # Air
|
|
|
|
|
'Neptune': {'sign': 'Capricorn', 'degree': 270.0, 'speed': 0.006}, # Earth
|
|
|
|
|
'Pluto': {'sign': 'Virgo', 'degree': 160.0, 'speed': 0.003}, # Earth
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _aspect_pairs(aspects):
|
|
|
|
|
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
|
|
|
|
|
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 00:37:33 -04:00
|
|
|
# ===========================================================================
|
|
|
|
|
# get_element_counts — enriched shape
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class GetElementCountsTest(SimpleTestCase):
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.counts = get_element_counts(FAKE_PLANETS_ELEMENTS)
|
|
|
|
|
|
|
|
|
|
# ── top-level keys ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_returns_all_six_elements(self):
|
|
|
|
|
for key in ('Fire', 'Earth', 'Air', 'Water', 'Time', 'Space'):
|
|
|
|
|
with self.subTest(key=key):
|
|
|
|
|
self.assertIn(key, self.counts)
|
|
|
|
|
|
|
|
|
|
# ── classic four — count + contributors ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_classic_element_has_count_key(self):
|
|
|
|
|
self.assertIn('count', self.counts['Fire'])
|
|
|
|
|
|
|
|
|
|
def test_classic_element_has_contributors_key(self):
|
|
|
|
|
self.assertIn('contributors', self.counts['Fire'])
|
|
|
|
|
|
|
|
|
|
def test_fire_count_is_correct(self):
|
|
|
|
|
# Sun + Mercury + Venus (Aries) + Mars (Leo) = 4
|
|
|
|
|
self.assertEqual(self.counts['Fire']['count'], 4)
|
|
|
|
|
|
|
|
|
|
def test_earth_count_is_correct(self):
|
|
|
|
|
# Moon (Taurus) + Neptune (Capricorn) + Pluto (Virgo) = 3
|
|
|
|
|
self.assertEqual(self.counts['Earth']['count'], 3)
|
|
|
|
|
|
|
|
|
|
def test_air_count_is_correct(self):
|
|
|
|
|
# Saturn (Gemini) + Uranus (Aquarius) = 2
|
|
|
|
|
self.assertEqual(self.counts['Air']['count'], 2)
|
|
|
|
|
|
|
|
|
|
def test_water_count_is_correct(self):
|
|
|
|
|
# Jupiter (Scorpio) = 1
|
|
|
|
|
self.assertEqual(self.counts['Water']['count'], 1)
|
|
|
|
|
|
|
|
|
|
def test_fire_contributors_contains_expected_planets(self):
|
|
|
|
|
planets = {c['planet'] for c in self.counts['Fire']['contributors']}
|
|
|
|
|
self.assertEqual(planets, {'Sun', 'Mercury', 'Venus', 'Mars'})
|
|
|
|
|
|
|
|
|
|
def test_contributor_has_planet_and_sign_keys(self):
|
|
|
|
|
contrib = self.counts['Fire']['contributors'][0]
|
|
|
|
|
self.assertIn('planet', contrib)
|
|
|
|
|
self.assertIn('sign', contrib)
|
|
|
|
|
|
|
|
|
|
def test_fire_contributor_signs_are_correct(self):
|
|
|
|
|
sign_map = {c['planet']: c['sign'] for c in self.counts['Fire']['contributors']}
|
|
|
|
|
self.assertEqual(sign_map['Sun'], 'Aries')
|
|
|
|
|
self.assertEqual(sign_map['Mercury'], 'Aries')
|
|
|
|
|
self.assertEqual(sign_map['Venus'], 'Aries')
|
|
|
|
|
self.assertEqual(sign_map['Mars'], 'Leo')
|
|
|
|
|
|
|
|
|
|
# ── Time — count + stellia ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_time_has_count_key(self):
|
|
|
|
|
self.assertIn('count', self.counts['Time'])
|
|
|
|
|
|
|
|
|
|
def test_time_has_stellia_key(self):
|
|
|
|
|
self.assertIn('stellia', self.counts['Time'])
|
|
|
|
|
|
|
|
|
|
def test_time_count_is_correct(self):
|
|
|
|
|
# Aries has 3 planets → bonus = 2
|
|
|
|
|
self.assertEqual(self.counts['Time']['count'], 2)
|
|
|
|
|
|
|
|
|
|
def test_time_stellia_is_a_list(self):
|
|
|
|
|
self.assertIsInstance(self.counts['Time']['stellia'], list)
|
|
|
|
|
|
|
|
|
|
def test_time_stellia_contains_one_entry(self):
|
|
|
|
|
self.assertEqual(len(self.counts['Time']['stellia']), 1)
|
|
|
|
|
|
|
|
|
|
def test_time_stellium_sign_is_aries(self):
|
|
|
|
|
self.assertEqual(self.counts['Time']['stellia'][0]['sign'], 'Aries')
|
|
|
|
|
|
|
|
|
|
def test_time_stellium_planets_are_correct(self):
|
|
|
|
|
planet_names = {p['planet'] for p in self.counts['Time']['stellia'][0]['planets']}
|
|
|
|
|
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus'})
|
|
|
|
|
|
|
|
|
|
def test_time_stellium_planet_entries_have_sign(self):
|
|
|
|
|
for entry in self.counts['Time']['stellia'][0]['planets']:
|
|
|
|
|
with self.subTest(planet=entry['planet']):
|
|
|
|
|
self.assertEqual(entry['sign'], 'Aries')
|
|
|
|
|
|
|
|
|
|
# ── Space — count + parades ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_space_has_count_key(self):
|
|
|
|
|
self.assertIn('count', self.counts['Space'])
|
|
|
|
|
|
|
|
|
|
def test_space_has_parades_key(self):
|
|
|
|
|
self.assertIn('parades', self.counts['Space'])
|
|
|
|
|
|
|
|
|
|
def test_space_count_is_correct(self):
|
|
|
|
|
# Aries→Taurus→Gemini = 3 consecutive → bonus = 2
|
|
|
|
|
self.assertEqual(self.counts['Space']['count'], 2)
|
|
|
|
|
|
|
|
|
|
def test_space_parades_is_a_list(self):
|
|
|
|
|
self.assertIsInstance(self.counts['Space']['parades'], list)
|
|
|
|
|
|
|
|
|
|
def test_space_parades_contains_one_entry(self):
|
|
|
|
|
self.assertEqual(len(self.counts['Space']['parades']), 1)
|
|
|
|
|
|
|
|
|
|
def test_space_parade_signs_are_correct(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self.counts['Space']['parades'][0]['signs'],
|
|
|
|
|
['Aries', 'Taurus', 'Gemini'],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_space_parade_planets_are_correct(self):
|
|
|
|
|
planet_names = {p['planet'] for p in self.counts['Space']['parades'][0]['planets']}
|
|
|
|
|
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus', 'Moon', 'Saturn'})
|
|
|
|
|
|
|
|
|
|
def test_space_parade_planet_entries_have_planet_and_sign(self):
|
|
|
|
|
for entry in self.counts['Space']['parades'][0]['planets']:
|
|
|
|
|
with self.subTest(planet=entry['planet']):
|
|
|
|
|
self.assertIn('planet', entry)
|
|
|
|
|
self.assertIn('sign', entry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# calculate_aspects
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
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
|
|
|
class CalculateAspectsTest(SimpleTestCase):
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.aspects = calculate_aspects(FAKE_PLANETS)
|
|
|
|
|
|
|
|
|
|
# ── return shape ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_returns_a_list(self):
|
|
|
|
|
self.assertIsInstance(self.aspects, list)
|
|
|
|
|
|
|
|
|
|
def test_each_aspect_has_required_keys(self):
|
|
|
|
|
for aspect in self.aspects:
|
|
|
|
|
with self.subTest(aspect=aspect):
|
|
|
|
|
self.assertIn('planet1', aspect)
|
|
|
|
|
self.assertIn('planet2', aspect)
|
|
|
|
|
self.assertIn('type', aspect)
|
|
|
|
|
self.assertIn('angle', aspect)
|
|
|
|
|
self.assertIn('orb', aspect)
|
|
|
|
|
|
2026-04-21 00:37:33 -04:00
|
|
|
def test_each_aspect_has_applying_planet_key(self):
|
|
|
|
|
for aspect in self.aspects:
|
|
|
|
|
with self.subTest(aspect=aspect):
|
|
|
|
|
self.assertIn('applying_planet', aspect)
|
|
|
|
|
|
|
|
|
|
def test_applying_planet_is_one_of_the_pair(self):
|
|
|
|
|
for aspect in self.aspects:
|
|
|
|
|
with self.subTest(aspect=aspect):
|
|
|
|
|
self.assertIn(
|
|
|
|
|
aspect['applying_planet'],
|
|
|
|
|
(aspect['planet1'], aspect['planet2']),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_applying_planet_is_the_faster_body(self):
|
|
|
|
|
"""Moon (13.0°/day) applies to Sun (1.0°/day) in their Trine."""
|
|
|
|
|
sun_moon = next(
|
|
|
|
|
a for a in self.aspects
|
|
|
|
|
if {a['planet1'], a['planet2']} == {'Sun', 'Moon'}
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(sun_moon['applying_planet'], 'Moon')
|
|
|
|
|
|
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
|
|
|
def test_each_aspect_type_is_a_known_name(self):
|
2026-04-21 00:37:33 -04:00
|
|
|
known = {
|
|
|
|
|
'Conjunction', 'Semisextile', 'Sextile', 'Square',
|
|
|
|
|
'Trine', 'Quincunx', 'Opposition',
|
|
|
|
|
}
|
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
|
|
|
for aspect in self.aspects:
|
|
|
|
|
with self.subTest(aspect=aspect):
|
|
|
|
|
self.assertIn(aspect['type'], known)
|
|
|
|
|
|
|
|
|
|
def test_angle_and_orb_are_floats(self):
|
|
|
|
|
for aspect in self.aspects:
|
|
|
|
|
with self.subTest(aspect=aspect):
|
|
|
|
|
self.assertIsInstance(aspect['angle'], float)
|
|
|
|
|
self.assertIsInstance(aspect['orb'], float)
|
|
|
|
|
|
|
|
|
|
def test_no_self_aspects(self):
|
|
|
|
|
for aspect in self.aspects:
|
|
|
|
|
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
|
|
|
|
|
|
|
|
|
|
def test_no_duplicate_pairs(self):
|
|
|
|
|
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
|
|
|
|
|
self.assertEqual(len(pairs), len(set(pairs)))
|
|
|
|
|
|
|
|
|
|
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_sun_moon_trine(self):
|
|
|
|
|
"""Moon at 130° is exactly 120° from Sun at 10°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_sun_mercury_trine(self):
|
|
|
|
|
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_moon_mercury_trine(self):
|
|
|
|
|
"""Moon 130° → Mercury 250° = 120°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_moon_venus_square(self):
|
|
|
|
|
"""Moon 130° → Venus 40° = 90°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_venus_neptune_sextile(self):
|
|
|
|
|
"""Venus 40° → Neptune 100° = 60°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_mars_neptune_sextile(self):
|
|
|
|
|
"""Mars 160° → Neptune 100° = 60°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_sun_uranus_sextile(self):
|
|
|
|
|
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_mars_jupiter_trine(self):
|
|
|
|
|
"""Mars 160° → Jupiter 280° = 120°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
|
|
|
|
|
|
|
|
|
|
def test_saturn_uranus_trine(self):
|
|
|
|
|
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
|
|
|
|
|
pairs = _aspect_pairs(self.aspects)
|
|
|
|
|
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
|
|
|
|
|
|
|
|
|
|
# ── orb bounds ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_orb_is_within_allowed_maximum(self):
|
|
|
|
|
max_orbs = {
|
2026-04-21 00:37:33 -04:00
|
|
|
'Conjunction': 8.0,
|
|
|
|
|
'Semisextile': 4.0,
|
|
|
|
|
'Sextile': 6.0,
|
|
|
|
|
'Square': 8.0,
|
|
|
|
|
'Trine': 8.0,
|
|
|
|
|
'Quincunx': 5.0,
|
|
|
|
|
'Opposition': 10.0,
|
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
|
|
|
}
|
|
|
|
|
for aspect in self.aspects:
|
|
|
|
|
with self.subTest(aspect=aspect):
|
|
|
|
|
self.assertLessEqual(
|
|
|
|
|
aspect['orb'], max_orbs[aspect['type']],
|
|
|
|
|
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_exact_trine_has_zero_orb(self):
|
|
|
|
|
"""Sun-Moon at exactly 120° should report orb of 0.0."""
|
|
|
|
|
sun_moon = next(
|
|
|
|
|
a for a in self.aspects
|
|
|
|
|
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
|
|
|
|
|
)
|
|
|
|
|
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)
|