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