natus wheel: Semisquare & Sesquiquadrate; Ott orb pair detection; intensity sort

- 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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-27 20:16:47 -04:00
parent 4b2e89c088
commit a724479e60
3 changed files with 116 additions and 47 deletions

View File

@@ -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 = {

View File

@@ -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):

View File

@@ -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 `<img src="${src}" class="tt-el-vec" width="1em" height="1em" alt="${info.classical}" aria-hidden="true">`;
}
/** 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 = '<small class="tt-aspects">';
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 = '<small class="tt-aspects">';
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 });