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