Skip to main content

astroceleste_engine/
horary.rs

1//! Horary charts (`charts/horary.py`): planetary day and hour, significators, Moon's
2//! applying and separating aspects, void of course, and considerations before judgment.
3
4use serde::Serialize;
5
6use crate::almanac::rise_set;
7use crate::chart::{calculate_chart, Chart, ChartRequest, Placement};
8use crate::ephemeris::KernelSet;
9use crate::error::EngineError;
10use crate::instant::UtcInstant;
11use crate::pyfloat;
12
13const CHALDEAN_ORDER: [&str; 7] = [
14    "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon",
15];
16/// Ruler of each weekday, Monday first (Python `weekday()`).
17const DAY_RULERS: [&str; 7] = [
18    "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun",
19];
20const SIGNS: [&str; 12] = [
21    "Aries",
22    "Taurus",
23    "Gemini",
24    "Cancer",
25    "Leo",
26    "Virgo",
27    "Libra",
28    "Scorpio",
29    "Sagittarius",
30    "Capricorn",
31    "Aquarius",
32    "Pisces",
33];
34const PTOLEMAIC: [(f64, &str); 5] = [
35    (0.0, "Conjunction"),
36    (60.0, "Sextile"),
37    (90.0, "Square"),
38    (120.0, "Trine"),
39    (180.0, "Opposition"),
40];
41
42fn traditional_ruler(sign: &str) -> Option<&'static str> {
43    Some(match sign {
44        "Aries" | "Scorpio" => "Mars",
45        "Taurus" | "Libra" => "Venus",
46        "Gemini" | "Virgo" => "Mercury",
47        "Cancer" => "Moon",
48        "Leo" => "Sun",
49        "Sagittarius" | "Pisces" => "Jupiter",
50        "Capricorn" | "Aquarius" => "Saturn",
51        _ => return None,
52    })
53}
54
55fn modern_ruler(sign: &str) -> Option<&'static str> {
56    Some(match sign {
57        "Scorpio" => "Pluto",
58        "Aquarius" => "Uranus",
59        "Pisces" => "Neptune",
60        _ => return None,
61    })
62}
63
64fn element(sign: &str) -> Option<&'static str> {
65    Some(match sign {
66        "Aries" | "Leo" | "Sagittarius" => "Fire",
67        "Taurus" | "Virgo" | "Capricorn" => "Earth",
68        "Gemini" | "Libra" | "Aquarius" => "Air",
69        "Cancer" | "Scorpio" | "Pisces" => "Water",
70        _ => return None,
71    })
72}
73
74fn triplicity_ruler(element: &str, is_day: bool) -> Option<&'static str> {
75    Some(match (element, is_day) {
76        ("Fire", true) => "Sun",
77        ("Earth", true) => "Venus",
78        ("Air", true) => "Saturn",
79        ("Water", true) => "Venus",
80        ("Fire", false) => "Jupiter",
81        ("Earth", false) => "Moon",
82        ("Air", false) => "Mercury",
83        ("Water", false) => "Mars",
84        _ => return None,
85    })
86}
87
88/// Planetary day and hour of a horary chart.
89#[derive(Debug, Clone, PartialEq, Serialize)]
90pub struct PlanetaryHours {
91    /// Whether the instant falls between sunrise and sunset.
92    pub is_day: bool,
93    /// Planet ruling the day (from the weekday of sunrise).
94    pub day_ruler: &'static str,
95    /// Planet ruling the hour, in Chaldean order from the day ruler.
96    pub hour_ruler: &'static str,
97    /// Planetary hour (1-12) within the day or the night.
98    pub hour_number: i64,
99    /// "Day" or "Night".
100    pub hour_type: &'static str,
101    /// Sunrise, ISO 8601 UTC (06:00 when it cannot be computed).
102    pub sunrise: String,
103    /// Sunset, ISO 8601 UTC (18:00 when it cannot be computed).
104    pub sunset: String,
105}
106
107/// Planetary day and hour at `instant` for a place, from the actual sunrise and sunset.
108/// Where they cannot be computed, 06:00 and 18:00 UTC stand in.
109pub fn planetary_hours(
110    kernels: &KernelSet,
111    instant: UtcInstant,
112    latitude: f64,
113    longitude: f64,
114) -> PlanetaryHours {
115    let jd_utc = 2_451_545.0 + instant.seconds_since(&UtcInstant::J2000) / 86_400.0;
116    let (sunrise, sunset, next_sunrise) = match rise_set(kernels, jd_utc, latitude, longitude) {
117        Ok((rise, set, next)) => {
118            let at = |jd: f64| UtcInstant::J2000.plus_days(jd - 2_451_545.0);
119            (at(rise), at(set), at(next))
120        }
121        Err(_) => {
122            let rise = instant.with_time(6, 0, 0, 0);
123            (
124                rise,
125                instant.with_time(18, 0, 0, 0),
126                rise.add_micros(86_400_000_000),
127            )
128        }
129    };
130
131    let is_day = sunrise <= instant && instant <= sunset;
132    let day_ruler = DAY_RULERS[sunrise.weekday() as usize];
133    let start = CHALDEAN_ORDER.iter().position(|p| *p == day_ruler).unwrap() as i64;
134
135    let (hour_number, offset, hour_type) = if is_day {
136        let hour_length = sunset.seconds_since(&sunrise) / 12.0;
137        let elapsed = instant.seconds_since(&sunrise);
138        let n = (elapsed / hour_length.max(1.0)) as i64 + 1;
139        (n.clamp(1, 12), 0, "Day")
140    } else {
141        let hour_length = next_sunrise.seconds_since(&sunset) / 12.0;
142        let elapsed = instant.seconds_since(&sunset);
143        let n = if elapsed < 0.0 {
144            12
145        } else {
146            (elapsed / hour_length.max(1.0)) as i64 + 1
147        };
148        (n.clamp(1, 12), 12, "Night")
149    };
150    let hour_ruler = CHALDEAN_ORDER[(start + offset + hour_number - 1).rem_euclid(7) as usize];
151
152    PlanetaryHours {
153        is_day,
154        day_ruler,
155        hour_ruler,
156        hour_number,
157        hour_type,
158        sunrise: sunrise.isoformat(),
159        sunset: sunset.isoformat(),
160    }
161}
162
163/// A Ptolemaic aspect the Moon perfects before leaving its sign.
164#[derive(Debug, Clone, PartialEq, Serialize)]
165pub struct ApplyingAspect {
166    /// Planet aspected (Sun to Saturn).
167    pub planet: &'static str,
168    /// Aspect name, e.g. "Trine".
169    pub aspect: &'static str,
170    /// Degrees the Moon has left to travel to exactness.
171    pub degrees_to_exact: f64,
172    /// The Moon's current sign.
173    pub target_sign: &'static str,
174}
175
176/// A Ptolemaic aspect the Moon has perfected since entering its sign.
177#[derive(Debug, Clone, PartialEq, Serialize)]
178pub struct SeparatingAspect {
179    /// Planet aspected (Sun to Saturn).
180    pub planet: &'static str,
181    /// Aspect name, e.g. "Trine".
182    pub aspect: &'static str,
183    /// Degrees the Moon has travelled since exactness.
184    pub degrees_ago: f64,
185}
186
187/// The Moon's condition in a horary chart.
188#[derive(Debug, Clone, PartialEq, Serialize)]
189pub struct MoonStatus {
190    /// Whether the Moon makes no applying aspect before leaving its sign.
191    pub void_of_course: bool,
192    /// Degrees left in the current sign.
193    pub degrees_to_next_sign: f64,
194    /// Hours until the Moon enters the next sign, at its current speed.
195    pub hours_to_next_sign: f64,
196    /// Sign the Moon enters next.
197    pub next_sign: &'static str,
198    /// "swift" (> 13.5°/day), "slow" (< 12.5°/day) or "average".
199    pub speed_status: &'static str,
200    /// Applying aspects, closest first.
201    pub applying_aspects: Vec<ApplyingAspect>,
202    /// The closest applying aspect.
203    pub next_applying_aspect: Option<ApplyingAspect>,
204    /// The most recent separating aspect.
205    pub last_aspect: Option<SeparatingAspect>,
206    /// Separating aspects, most recent first.
207    pub separating_aspects: Vec<SeparatingAspect>,
208}
209
210/// The Moon's Ptolemaic aspects to the traditional planets before it leaves its sign,
211/// and void-of-course status (`analyze_moon_horary`).
212pub fn moon_status(moon: &Placement, planets: &[Placement]) -> MoonStatus {
213    let moon_lon = moon.ecliptic_longitude;
214    let moon_speed = moon.speed;
215    let sign_start = (moon_lon / 30.0).floor() * 30.0;
216    let to_sign_end = ((moon_lon / 30.0).floor() + 1.0) * 30.0 - moon_lon;
217    let from_sign_start = moon_lon - sign_start;
218    let speed_abs = if moon_speed.abs() > 1.0 {
219        moon_speed.abs()
220    } else {
221        13.18
222    };
223    let current = ((moon_lon / 30.0).floor() as i64).rem_euclid(12) as usize;
224
225    let mut applying: Vec<ApplyingAspect> = Vec::new();
226    let mut separating: Vec<SeparatingAspect> = Vec::new();
227    for p in planets {
228        if !matches!(
229            p.name,
230            "Sun" | "Mercury" | "Venus" | "Mars" | "Jupiter" | "Saturn"
231        ) {
232            continue;
233        }
234        for (angle, aspect) in PTOLEMAIC {
235            let mut targets = vec![pyfloat::rem(p.ecliptic_longitude + angle, 360.0)];
236            if angle != 0.0 && angle != 180.0 {
237                targets.push(pyfloat::rem(p.ecliptic_longitude - angle, 360.0));
238            }
239            for target in targets {
240                let ahead = pyfloat::rem(target - moon_lon, 360.0);
241                if 0.0 < ahead
242                    && ahead <= to_sign_end
243                    && moon_speed - p.speed > 0.0
244                    && !applying
245                        .iter()
246                        .any(|a| a.planet == p.name && a.aspect == aspect)
247                {
248                    applying.push(ApplyingAspect {
249                        planet: p.name,
250                        aspect,
251                        degrees_to_exact: pyfloat::round(ahead, 2),
252                        target_sign: moon.sign,
253                    });
254                }
255                let behind = pyfloat::rem(moon_lon - target, 360.0);
256                if 0.0 < behind
257                    && behind <= from_sign_start
258                    && !separating
259                        .iter()
260                        .any(|s| s.planet == p.name && s.aspect == aspect)
261                {
262                    separating.push(SeparatingAspect {
263                        planet: p.name,
264                        aspect,
265                        degrees_ago: pyfloat::round(behind, 2),
266                    });
267                }
268            }
269        }
270    }
271    applying.sort_by(|a, b| a.degrees_to_exact.total_cmp(&b.degrees_to_exact));
272    separating.sort_by(|a, b| a.degrees_ago.total_cmp(&b.degrees_ago));
273
274    MoonStatus {
275        void_of_course: applying.is_empty(),
276        degrees_to_next_sign: pyfloat::round(to_sign_end, 2),
277        hours_to_next_sign: pyfloat::round(to_sign_end / (speed_abs / 24.0), 1),
278        next_sign: SIGNS[(current + 1) % 12],
279        speed_status: if moon_speed > 13.5 {
280            "swift"
281        } else if moon_speed < 12.5 {
282            "slow"
283        } else {
284            "average"
285        },
286        next_applying_aspect: applying.first().cloned(),
287        last_aspect: separating.first().cloned(),
288        applying_aspects: applying,
289        separating_aspects: separating,
290    }
291}
292
293/// A consideration before judgement (stricture against judging the chart).
294#[derive(Debug, Clone, PartialEq, Serialize)]
295pub struct Stricture {
296    /// Stable code, e.g. "EARLY_ASC", "MOON_VOC".
297    pub code: &'static str,
298    /// "warning" or "info".
299    pub severity: &'static str,
300    /// Explanation for the reader, in English.
301    pub message: String,
302}
303
304/// The horary-specific part of a horary chart.
305#[derive(Debug, Clone, PartialEq, Serialize)]
306pub struct HoraryData {
307    /// Planetary day and hour.
308    pub planetary_hours: PlanetaryHours,
309    /// Sign on the Ascendant.
310    pub ascendant_sign: &'static str,
311    /// Ascendant as text, e.g. "12° 34' Leo".
312    pub ascendant_degree: String,
313    /// Traditional ruler of the Ascendant sign.
314    pub traditional_asc_ruler: &'static str,
315    /// Modern ruler of the Ascendant sign.
316    pub modern_asc_ruler: &'static str,
317    /// Whether the hour ruler matches the Ascendant's ruler or triplicity ruler.
318    pub is_radical: bool,
319    /// Considerations before judgement that apply.
320    pub strictures: Vec<Stricture>,
321    /// The Moon's condition.
322    pub moon_status: MoonStatus,
323}
324
325/// A horary chart: a complete chart plus `horary_data` (serialized flattened).
326#[derive(Debug, Clone, PartialEq, Serialize)]
327pub struct HoraryChart {
328    /// The chart, serialized inline.
329    #[serde(flatten)]
330    pub chart: Chart,
331    /// Horary-specific data.
332    pub horary_data: HoraryData,
333}
334
335/// A chart for the moment of the question, with its horary analysis
336/// (`calculate_horary_chart_data`).
337pub fn calculate_horary_chart(
338    kernels: &KernelSet,
339    req: &ChartRequest,
340) -> Result<HoraryChart, EngineError> {
341    let chart = calculate_chart(kernels, req)?;
342    let hours = planetary_hours(kernels, req.instant, req.latitude, req.longitude);
343
344    let by_name = |name: &str| chart.planets.iter().find(|p| p.name == name);
345    let (asc_sign, asc_deg, asc_min) = match (by_name("Ascendant"), chart.houses.first()) {
346        (Some(asc), _) => (asc.sign, asc.degree, asc.minute),
347        (None, Some(h)) => (h.sign, h.degree, h.minute),
348        _ => ("Aries", 0, 0),
349    };
350    let traditional = traditional_ruler(asc_sign).unwrap_or("Mars");
351    let modern = modern_ruler(asc_sign).unwrap_or(traditional);
352
353    let mut strictures = Vec::new();
354    if asc_deg < 3 {
355        strictures.push(Stricture {
356            code: "EARLY_ASC",
357            severity: "warning",
358            message: format!(
359                "Ascendant is very early ({asc_deg}° {asc_sign}): The question may be premature, or circumstances are still developing."
360            ),
361        });
362    } else if asc_deg >= 27 {
363        strictures.push(Stricture {
364            code: "LATE_ASC",
365            severity: "warning",
366            message: format!(
367                "Ascendant is very late ({asc_deg}° {asc_sign}): The situation has already been decided or is out of the querent's control."
368            ),
369        });
370    }
371    match by_name("Saturn").map(|s| s.house) {
372        Some(1) => strictures.push(Stricture {
373            code: "SATURN_IN_1ST",
374            severity: "warning",
375            message: "Saturn in the 1st House: Querent may be obstructed, anxious, or facing delays."
376                .into(),
377        }),
378        Some(7) => strictures.push(Stricture {
379            code: "SATURN_IN_7TH",
380            severity: "info",
381            message: "Saturn in the 7th House: Astrologer's judgment may be challenged or the matter may be difficult to judge clearly."
382                .into(),
383        }),
384        _ => {}
385    }
386
387    let moon =
388        by_name("Moon").ok_or_else(|| EngineError::InvalidInput("chart has no Moon".into()))?;
389    let moon = moon_status(moon, &chart.planets);
390    if moon.void_of_course {
391        strictures.push(Stricture {
392            code: "MOON_VOC",
393            severity: "info",
394            message: "Moon is Void of Course: Nothing will come of the matter in question, or no immediate action will yield changes."
395                .into(),
396        });
397    }
398
399    let triplicity = triplicity_ruler(element(asc_sign).unwrap_or("Fire"), hours.is_day);
400    let is_radical = hours.hour_ruler == traditional || Some(hours.hour_ruler) == triplicity;
401
402    Ok(HoraryChart {
403        horary_data: HoraryData {
404            planetary_hours: hours,
405            ascendant_sign: asc_sign,
406            ascendant_degree: format!("{asc_deg}° {asc_min}' {asc_sign}"),
407            traditional_asc_ruler: traditional,
408            modern_asc_ruler: modern,
409            is_radical,
410            strictures,
411            moon_status: moon,
412        },
413        chart,
414    })
415}