From a724479e60d3bf211a2065d815373ebaa76752e4 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 27 Apr 2026 20:16:47 -0400 Subject: [PATCH] natus wheel: Semisquare & Sesquiquadrate; Ott orb pair detection; intensity sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pyswiss ASPECTS: uncomment Semisquare (45°, 4°) & Sesquiquadrate (135°, 4°) in angle order; update test known-types + max-orbs - ALL_ASPECTS replaces MAJOR_ASPECTS (client-side angle detection now covers all 9 types) - Ott's Orb system: PLANET_ORB per body (Sun/Moon=10, Mer/Ven/Mar=8, Jup/Sat=6, Ura/Nep/Plu=4); allowed orb = avg of two bodies - _angleAspectWith uses per-planet Ott orb as detection threshold - ASPECT_AV values + _sortAspects: intensity = A.V. × (1 − orb/ottOrb); aspects exceeding Ott orb filtered out; ties: separating first → partner importance → aspect importance Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- pyswiss/apps/charts/calc.py | 18 +-- pyswiss/apps/charts/tests/unit/test_calc.py | 20 +-- .../static/apps/gameboard/natus-wheel.js | 125 ++++++++++++++---- 3 files changed, 116 insertions(+), 47 deletions(-) diff --git a/pyswiss/apps/charts/calc.py b/pyswiss/apps/charts/calc.py index f123694..e898f4e 100644 --- a/pyswiss/apps/charts/calc.py +++ b/pyswiss/apps/charts/calc.py @@ -20,15 +20,15 @@ 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), + ('Conjunction', 0, 8.0), + ('Semisextile', 30, 4.0), + ('Semisquare', 45, 4.0), + ('Sextile', 60, 6.0), + ('Square', 90, 8.0), + ('Trine', 120, 8.0), + ('Sesquiquadrate', 135, 4.0), + ('Quincunx', 150, 5.0), + ('Opposition', 180, 10.0), ] PLANET_CODES = { diff --git a/pyswiss/apps/charts/tests/unit/test_calc.py b/pyswiss/apps/charts/tests/unit/test_calc.py index 356ddd3..ff020d6 100644 --- a/pyswiss/apps/charts/tests/unit/test_calc.py +++ b/pyswiss/apps/charts/tests/unit/test_calc.py @@ -233,8 +233,8 @@ class CalculateAspectsTest(SimpleTestCase): def test_each_aspect_type_is_a_known_name(self): known = { - 'Conjunction', 'Semisextile', 'Sextile', 'Square', - 'Trine', 'Quincunx', 'Opposition', + 'Conjunction', 'Semisextile', 'Semisquare', 'Sextile', 'Square', + 'Trine', 'Sesquiquadrate', 'Quincunx', 'Opposition', } for aspect in self.aspects: with self.subTest(aspect=aspect): @@ -305,13 +305,15 @@ class CalculateAspectsTest(SimpleTestCase): def test_orb_is_within_allowed_maximum(self): max_orbs = { - 'Conjunction': 8.0, - 'Semisextile': 4.0, - 'Sextile': 6.0, - 'Square': 8.0, - 'Trine': 8.0, - 'Quincunx': 5.0, - 'Opposition': 10.0, + 'Conjunction': 8.0, + 'Semisextile': 4.0, + 'Semisquare': 4.0, + 'Sextile': 6.0, + 'Square': 8.0, + 'Trine': 8.0, + 'Sesquiquadrate': 4.0, + 'Quincunx': 5.0, + 'Opposition': 10.0, } for aspect in self.aspects: with self.subTest(aspect=aspect): diff --git a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js index 9ec641e..2391b7c 100644 --- a/src/apps/gameboard/static/apps/gameboard/natus-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/natus-wheel.js @@ -75,13 +75,15 @@ const NatusWheel = (() => { const CLASSIC_ELEMENTS = new Set(['Fire', 'Stone', 'Air', 'Water']); const ASPECT_SYMBOLS = { - Conjunction: '☌', - Semisextile: '⚺', - Sextile: '⚹', - Square: '□', - Trine: '△', - Quincunx: '⚻', - Opposition: '☍', + Conjunction: '☌', + Semisextile: '⚺', + Semisquare: '∠', + Sextile: '⚹', + Square: '□', + Trine: '△', + Sesquiquadrate: '⊼', + Quincunx: '⚻', + Opposition: '☍', }; const APPLY_SYM = '⇥'; // →| applying (converging toward exact) @@ -89,13 +91,15 @@ const NatusWheel = (() => { // SVG stroke-dasharray and width per aspect type — color comes from applying planet. const ASPECT_STYLES = { - Conjunction: { dash: 'none', width: 1.2 }, - Semisextile: { dash: '1 4', width: 0.6 }, - Sextile: { dash: '6 4', width: 0.8 }, - Square: { dash: '2 4', width: 1.2 }, - Trine: { dash: '12 4', width: 0.8 }, - Quincunx: { dash: '12 4 2 4', width: 0.8 }, - Opposition: { dash: '10 4 2 4 2 4', width: 1.2 }, + Conjunction: { dash: 'none', width: 1.2 }, + Semisextile: { dash: '1 4', width: 0.6 }, + Semisquare: { dash: '3 4', width: 0.7 }, + Sextile: { dash: '6 4', width: 0.8 }, + Square: { dash: '2 4', width: 1.2 }, + Trine: { dash: '12 4', width: 0.8 }, + Sesquiquadrate: { dash: '8 4 3 4', width: 0.7 }, + Quincunx: { dash: '12 4 2 4', width: 0.8 }, + Opposition: { dash: '10 4 2 4 2 4', width: 1.2 }, }; const HOUSE_LABELS = [ @@ -108,16 +112,47 @@ const NatusWheel = (() => { MC: { label: 'Midheaven', sym: 'MC', house: 10 }, }; - // Major aspect angles and their exact expected separations - const MAJOR_ASPECTS = [ - { type: 'Conjunction', angle: 0 }, - { type: 'Sextile', angle: 60 }, - { type: 'Square', angle: 90 }, - { type: 'Trine', angle: 120 }, - { type: 'Opposition', angle: 180 }, + // All aspect angles — used for client-side angle (ASC/MC) aspect detection. + const ALL_ASPECTS = [ + { type: 'Conjunction', angle: 0 }, + { type: 'Semisextile', angle: 30 }, + { type: 'Semisquare', angle: 45 }, + { type: 'Sextile', angle: 60 }, + { type: 'Square', angle: 90 }, + { type: 'Trine', angle: 120 }, + { type: 'Sesquiquadrate', angle: 135 }, + { type: 'Quincunx', angle: 150 }, + { type: 'Opposition', angle: 180 }, ]; - const ANGLE_ORB = 10; + // Ott's Orb: per-body allowed orb; angle points (ASC/MC) use ANGLE_BODY_ORB. + // Allowed orb for a pair = (orbOtt_body1 + orbOtt_body2) / 2. + const PLANET_ORB = { + Sun: 10, Moon: 10, + Mercury: 8, Venus: 8, Mars: 8, + Jupiter: 6, Saturn: 6, + Uranus: 4, Neptune: 4, Pluto: 4, + }; + const ANGLE_BODY_ORB = 10; + + // Aspect Values for intensity scoring: A.V. × (1 − actual_orb / ott_orb) + const ASPECT_AV = { + Conjunction: 12, Opposition: 6, Trine: 4, Square: 3, Sextile: 2, + Sesquiquadrate: 1.5, Semisquare: 1.5, Quincunx: 1, Semisextile: 1, + }; + + // Tie-break 2: partner body importance (lower = more important) + const BODY_IMPORTANCE = { + Sun: 0, Moon: 1, ASC: 2, MC: 3, + Mercury: 4, Venus: 5, Mars: 6, + Jupiter: 7, Saturn: 8, Uranus: 9, Neptune: 10, Pluto: 11, + }; + + // Tie-break 3: aspect importance (lower = more important) + const ASPECT_IMPORTANCE = { + Conjunction: 0, Opposition: 1, Trine: 2, Square: 3, Sextile: 4, + Sesquiquadrate: 5, Semisquare: 6, Quincunx: 7, Semisextile: 8, + }; // Cardinal / Fixed / Mutable — parallel index to SIGNS const SIGN_MODALITIES = [ @@ -287,6 +322,37 @@ const NatusWheel = (() => { return ``; } + /** Average of two bodies' Ott orbs — the allowed orb for their aspects. */ + function _ottOrb(body1, body2) { + const o1 = PLANET_ORB[body1] ?? ANGLE_BODY_ORB; + const o2 = PLANET_ORB[body2] ?? ANGLE_BODY_ORB; + return (o1 + o2) / 2; + } + + /** + * Sort an aspect list for display: descending intensity = A.V. × (1 − orb/ottOrb). + * Ties resolved by: separating before applying → partner importance → aspect importance. + */ + function _sortAspects(aspects, thisBody) { + return aspects.filter(a => a.orb <= _ottOrb(thisBody, a.partner)).sort((a, b) => { + const ottA = _ottOrb(thisBody, a.partner); + const ottB = _ottOrb(thisBody, b.partner); + const intA = (ASPECT_AV[a.type] ?? 1) * (1 - a.orb / ottA); + const intB = (ASPECT_AV[b.type] ?? 1) * (1 - b.orb / ottB); + if (Math.abs(intA - intB) > 1e-9) return intB - intA; + // tie-break 1: separating (applying_planet !== thisBody) before applying + const aApplying = a.applying_planet === thisBody ? 1 : 0; + const bApplying = b.applying_planet === thisBody ? 1 : 0; + if (aApplying !== bApplying) return aApplying - bApplying; + // tie-break 2: partner body importance + const impA = BODY_IMPORTANCE[a.partner] ?? 99; + const impB = BODY_IMPORTANCE[b.partner] ?? 99; + if (impA !== impB) return impA - impB; + // tie-break 3: aspect importance + return (ASPECT_IMPORTANCE[a.type] ?? 99) - (ASPECT_IMPORTANCE[b.type] ?? 99); + }); + } + /** CSS color string for an aspect line, keyed to the applying planet. */ function _aspectColor(applying_planet) { const code = PLANET_ELEMENTS[applying_planet]; @@ -446,7 +512,7 @@ const NatusWheel = (() => { const elKey = (sign.element || '').toLowerCase(); let aspectHtml = ''; - const myAspects = _aspectIndex[angleName] || []; + const myAspects = _sortAspects(_aspectIndex[angleName] || [], angleName); if (myAspects.length) { aspectHtml = ''; myAspects.forEach(({ partner, type, orb, applying_planet }) => { @@ -564,7 +630,7 @@ const NatusWheel = (() => { // Aspect list — always shown in small font; lines on SVG toggled separately. let aspectHtml = ''; - const myAspects = _aspectIndex[item.name] || []; + const myAspects = _sortAspects(_aspectIndex[item.name] || [], item.name); if (myAspects.length) { aspectHtml = ''; myAspects.forEach(({ partner, type, orb, applying_planet }) => { @@ -1230,13 +1296,14 @@ const NatusWheel = (() => { * Build the aspect index (planet → aspects list) and create the persistent * nw-aspects group. Lines are drawn per-planet on click, not here. */ - /** Compute aspect type + orb between an angle (deg) and a planet (deg), or null. */ - function _angleAspectWith(angleDeg, planetDeg) { + /** Compute aspect type + orb between an angle (ASC/MC) and a planet, or null. */ + function _angleAspectWith(angleDeg, planetDeg, planetName) { let diff = Math.abs(planetDeg - angleDeg) % 360; if (diff > 180) diff = 360 - diff; - for (const { type, angle } of MAJOR_ASPECTS) { + const maxOrb = _ottOrb(planetName, 'ASC'); + for (const { type, angle } of ALL_ASPECTS) { const orb = Math.abs(diff - angle); - if (orb <= ANGLE_ORB) return { type, orb: +orb.toFixed(1) }; + if (orb <= maxOrb) return { type, orb: +orb.toFixed(1) }; } return null; } @@ -1257,7 +1324,7 @@ const NatusWheel = (() => { const angleDeg = angleName === 'ASC' ? data.houses.asc : data.houses.mc; _aspectIndex[angleName] = []; Object.entries(data.planets).forEach(([pname, pdata]) => { - const asp = _angleAspectWith(angleDeg, pdata.degree); + const asp = _angleAspectWith(angleDeg, pdata.degree, pname); if (!asp) return; const shared = { type: asp.type, orb: asp.orb, applying_planet: pname }; _aspectIndex[angleName].push({ partner: pname, ...shared });