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 });