From 9c7d58f0b35883d4a2e0a29baee32a136bcdbf6a Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 21 Apr 2026 00:37:33 -0400 Subject: [PATCH] updates to pyswiss aspect & aspect application data served over API, incl. seminal invocation of Space parades & Time stellia --- pyswiss/apps/charts/calc.py | 68 +++++- .../management/commands/populate_ephemeris.py | 12 +- .../charts/tests/integrated/test_views.py | 34 ++- pyswiss/apps/charts/tests/unit/test_calc.py | 219 ++++++++++++++++-- 4 files changed, 297 insertions(+), 36 deletions(-) diff --git a/pyswiss/apps/charts/calc.py b/pyswiss/apps/charts/calc.py index 8399e29..f123694 100644 --- a/pyswiss/apps/charts/calc.py +++ b/pyswiss/apps/charts/calc.py @@ -21,10 +21,14 @@ SIGN_ELEMENT = { ASPECTS = [ ('Conjunction', 0, 8.0), + ('Semisextile', 30, 4.0), ('Sextile', 60, 6.0), ('Square', 90, 8.0), ('Trine', 120, 8.0), + ('Quincunx', 150, 5.0), ('Opposition', 180, 10.0), + # ('Semisquare', 45, 4.0), + # ('Sesquiquadrate', 135, 4.0), ] PLANET_CODES = { @@ -67,6 +71,7 @@ def get_planet_positions(jd): planets[name] = { 'sign': get_sign(degree), 'degree': degree, + 'speed': pos[3], 'retrograde': pos[3] < 0, } return planets @@ -74,30 +79,69 @@ def get_planet_positions(jd): def get_element_counts(planets): sign_counts = {s: 0 for s in SIGNS} - counts = {'Fire': 0, 'Water': 0, 'Earth': 0, 'Air': 0} + sign_planets = {s: [] for s in SIGNS} + classic = {'Fire': [], 'Water': [], 'Earth': [], 'Air': []} - for data in planets.values(): + for name, data in planets.items(): sign = data['sign'] - counts[SIGN_ELEMENT[sign]] += 1 + el = SIGN_ELEMENT[sign] + classic[el].append({'planet': name, 'sign': sign}) sign_counts[sign] += 1 + sign_planets[sign].append({'planet': name, 'sign': sign}) - # Time: highest planet concentration in a single sign, minus 1 - counts['Time'] = max(sign_counts.values()) - 1 + result = { + el: {'count': len(contribs), 'contributors': contribs} + for el, contribs in classic.items() + } - # Space: longest consecutive run of occupied signs (circular), minus 1 - indices = [i for i, s in enumerate(SIGNS) if sign_counts[s] > 0] + # Time: stellium — highest concentration in one sign, bonus = size - 1. + # Collect all signs tied at the maximum. + max_in_sign = max(sign_counts.values()) + stellia = [ + {'sign': s, 'planets': sign_planets[s]} + for s in SIGNS + if sign_counts[s] == max_in_sign and max_in_sign > 1 + ] + result['Time'] = { + 'count': max_in_sign - 1, + 'stellia': stellia, + } + + # Space: parade — longest consecutive run of occupied signs (circular), + # bonus = run length - 1. Collect all runs tied at the maximum. + index_set = {i for i, s in enumerate(SIGNS) if sign_counts[s] > 0} + indices = sorted(index_set) 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: + if (indices[start] + offset) % len(SIGNS) in index_set: seq_len += 1 else: break max_seq = max(max_seq, seq_len) - counts['Space'] = max_seq - 1 - return counts + parades = [] + for start in range(len(indices)): + run = [] + for offset in range(max_seq): + idx = (indices[start] + offset) % len(SIGNS) + if idx not in index_set: + break + run.append(idx) + else: + sign_run = [SIGNS[i] for i in run] + parade_planets = [ + p for s in sign_run for p in sign_planets[s] + ] + parades.append({'signs': sign_run, 'planets': parade_planets}) + + result['Space'] = { + 'count': max_seq - 1, + 'parades': parades, + } + + return result def calculate_aspects(planets): @@ -119,12 +163,16 @@ def calculate_aspects(planets): for aspect_name, target, max_orb in ASPECTS: orb = abs(angle - target) if orb <= max_orb: + s1 = abs(planets[name1].get('speed', 0)) + s2 = abs(planets[name2].get('speed', 0)) + applying = name1 if s1 >= s2 else name2 aspects.append({ 'planet1': name1, 'planet2': name2, 'type': aspect_name, 'angle': round(angle, 2), 'orb': round(orb, 2), + 'applying_planet': applying, }) break return aspects diff --git a/pyswiss/apps/charts/management/commands/populate_ephemeris.py b/pyswiss/apps/charts/management/commands/populate_ephemeris.py index bb9f675..106b7dd 100644 --- a/pyswiss/apps/charts/management/commands/populate_ephemeris.py +++ b/pyswiss/apps/charts/management/commands/populate_ephemeris.py @@ -31,12 +31,12 @@ class Command(BaseCommand): EphemerisSnapshot.objects.update_or_create( dt=dt, defaults={ - 'fire': elements['Fire'], - 'water': elements['Water'], - 'earth': elements['Earth'], - 'air': elements['Air'], - 'time_el': elements['Time'], - 'space_el': elements['Space'], + 'fire': elements['Fire']['count'], + 'water': elements['Water']['count'], + 'earth': elements['Earth']['count'], + 'air': elements['Air']['count'], + 'time_el': elements['Time']['count'], + 'space_el': elements['Space']['count'], 'chart_data': {'planets': planets}, }, ) diff --git a/pyswiss/apps/charts/tests/integrated/test_views.py b/pyswiss/apps/charts/tests/integrated/test_views.py index f52fb87..48b55c3 100644 --- a/pyswiss/apps/charts/tests/integrated/test_views.py +++ b/pyswiss/apps/charts/tests/integrated/test_views.py @@ -114,10 +114,42 @@ class ChartApiTest(TestCase): """All 10 planets are assigned to exactly one classical element.""" data = self._get({'dt': J2000, **LONDON}).json() classical = sum( - data['elements'][e] for e in ('Fire', 'Water', 'Earth', 'Air') + data['elements'][e]['count'] for e in ('Fire', 'Water', 'Earth', 'Air') ) self.assertEqual(classical, 10) + def test_each_element_has_count_key(self): + data = self._get({'dt': J2000, **LONDON}).json() + for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'): + with self.subTest(element=key): + self.assertIn('count', data['elements'][key]) + + def test_classic_elements_have_contributors(self): + data = self._get({'dt': J2000, **LONDON}).json() + for key in ('Fire', 'Water', 'Earth', 'Air'): + with self.subTest(element=key): + self.assertIn('contributors', data['elements'][key]) + + def test_time_has_stellia(self): + data = self._get({'dt': J2000, **LONDON}).json() + self.assertIn('stellia', data['elements']['Time']) + + def test_space_has_parades(self): + data = self._get({'dt': J2000, **LONDON}).json() + self.assertIn('parades', data['elements']['Space']) + + def test_each_planet_has_speed(self): + data = self._get({'dt': J2000, **LONDON}).json() + for name, planet in data['planets'].items(): + with self.subTest(planet=name): + self.assertIn('speed', planet) + + def test_each_aspect_has_applying_planet(self): + data = self._get({'dt': J2000, **LONDON}).json() + for aspect in data['aspects']: + with self.subTest(aspect=aspect): + self.assertIn('applying_planet', aspect) + # ── house system ────────────────────────────────────────────────────── def test_default_house_system_is_porphyry(self): diff --git a/pyswiss/apps/charts/tests/unit/test_calc.py b/pyswiss/apps/charts/tests/unit/test_calc.py index 119bd69..356ddd3 100644 --- a/pyswiss/apps/charts/tests/unit/test_calc.py +++ b/pyswiss/apps/charts/tests/unit/test_calc.py @@ -9,25 +9,55 @@ Run: """ from django.test import SimpleTestCase -from apps.charts.calc import calculate_aspects +from apps.charts.calc import calculate_aspects, get_element_counts # --------------------------------------------------------------------------- -# Synthetic planet data — degrees chosen for predictable aspects -# Matches FAKE_PLANETS in test_populate_ephemeris.py +# FAKE_PLANETS_ASPECTS — degrees only; used by calculate_aspects tests. +# Each planet also carries a speed (deg/day) for applying_planet tests. # --------------------------------------------------------------------------- FAKE_PLANETS = { - 'Sun': {'degree': 10.0}, # Aries - 'Moon': {'degree': 130.0}, # Leo — 120° from Sun → Trine - 'Mercury': {'degree': 250.0}, # Sagittarius — 120° from Sun → Trine - 'Venus': {'degree': 40.0}, # Taurus — 90° from Moon → Square - 'Mars': {'degree': 160.0}, # Virgo — 60° from Neptune → Sextile - 'Jupiter': {'degree': 280.0}, # Capricorn — 120° from Mars → Trine - 'Saturn': {'degree': 70.0}, # Gemini — 120° from Uranus → Trine - 'Uranus': {'degree': 310.0}, # Aquarius — 60° from Sun (wrap) → Sextile - 'Neptune': {'degree': 100.0}, # Cancer - 'Pluto': {'degree': 340.0}, # Pisces + '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 } @@ -36,6 +66,131 @@ def _aspect_pairs(aspects): return {(a['planet1'], a['planet2'], a['type']) for a in aspects} +# =========================================================================== +# 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 +# =========================================================================== + class CalculateAspectsTest(SimpleTestCase): def setUp(self): @@ -55,8 +210,32 @@ class CalculateAspectsTest(SimpleTestCase): self.assertIn('angle', aspect) self.assertIn('orb', aspect) + 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') + def test_each_aspect_type_is_a_known_name(self): - known = {'Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition'} + known = { + 'Conjunction', 'Semisextile', 'Sextile', 'Square', + 'Trine', 'Quincunx', 'Opposition', + } for aspect in self.aspects: with self.subTest(aspect=aspect): self.assertIn(aspect['type'], known) @@ -126,11 +305,13 @@ class CalculateAspectsTest(SimpleTestCase): def test_orb_is_within_allowed_maximum(self): max_orbs = { - 'Conjunction': 8.0, - 'Sextile': 6.0, - 'Square': 8.0, - 'Trine': 8.0, - 'Opposition': 10.0, + 'Conjunction': 8.0, + 'Semisextile': 4.0, + 'Sextile': 6.0, + 'Square': 8.0, + 'Trine': 8.0, + 'Quincunx': 5.0, + 'Opposition': 10.0, } for aspect in self.aspects: with self.subTest(aspect=aspect):