Skip to main content

astroceleste_engine/election/
mod.rs

1//! Electional astrology: how well a moment suits beginning something, and a search over a
2//! span of time for the best moments at a place.
3//!
4//! A moment is assessed with the traditional electional rules (Bonatti, Lilly and the
5//! Arabic authors after Sahl): the Moon's condition and the next aspect she perfects, the
6//! Ascendant and its ruler, the qualities of the Moon's and the Ascendant's degrees (Lilly), benefics and malefics on the angles, the retrogradation of the
7//! planets the matter needs, the ruler of the house of the matter, the planetary hour and,
8//! when a natal chart is given, the election's contacts with it. Each rule that applies is
9//! an [`ElectionFactor`] with a stable code and a signed weight; the score is 50 plus the
10//! weights, between 0 and 100. Explanations are left to the caller, keyed by code.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::almanac::SunEvents;
16use crate::chart::{chart_without_hours, placements, sky, Chart, ChartRequest, Placement};
17use crate::degree_qualities::degree_qualities;
18use crate::dignities::{is_debilitated, is_dignified, sign_index, solar_phase};
19use crate::ephemeris::KernelSet;
20use crate::error::EngineError;
21use crate::horary::{
22    hours_in, jd_utc, moon_status, planetary_hours, solar_day, traditional_ruler, MoonStatus,
23    PlanetaryHours, SolarDay, SIGNS,
24};
25use crate::houses::{houses_at_sidereal_time, HouseSystem};
26use crate::instant::UtcInstant;
27use crate::planets::{planets_and_sidereal_time, require_kernel, BodyPosition, NodeType};
28use crate::pyfloat;
29use crate::zodiac::{ayanamsa, ayanamsa_info};
30
31mod assess;
32mod criteria;
33mod search;
34#[cfg(test)]
35mod tests;
36
37use assess::*;
38pub use criteria::*;
39pub use search::search_elections;
40
41/// Longest span a search covers, in days.
42pub const MAX_SEARCH_DAYS: f64 = 92.0;
43/// Scores from here up are favourable.
44const FAVOURABLE: f64 = 65.0;
45/// Scores from here up (and below [`FAVOURABLE`]) are mixed; below, unfavourable.
46const MIXED: f64 = 45.0;
47/// Orb (degrees) of the election's contacts with natal points.
48const NATAL_ORB: f64 = 3.0;
49/// Below this latitude, where the Sun rises and sets every day, a search that finds
50/// sunrises day by day reuses each day for all its moments.
51const SOLAR_DAY_CACHE_MAX_LAT: f64 = 60.0;
52
53const BENEFICS: [&str; 2] = ["Venus", "Jupiter"];
54const MALEFICS: [&str; 2] = ["Mars", "Saturn"];
55
56/// One electional rule that applies to a moment.
57#[derive(Debug, Clone, PartialEq, Serialize)]
58pub struct ElectionFactor {
59    /// Stable code, e.g. "MOON_VOC", "BENEFIC_ANGULAR".
60    pub code: &'static str,
61    /// Points added to (or, when negative, taken from) the score.
62    pub weight: f64,
63    /// The election planet the rule is about, if any.
64    pub planet: Option<&'static str>,
65    /// The other party: the planet aspected, or the natal point contacted.
66    pub target: Option<String>,
67    /// Aspect name, e.g. "Trine", for the rules about aspects.
68    pub aspect: Option<&'static str>,
69    /// House (1-12) of `planet`, for the rules about houses.
70    pub house: Option<u8>,
71    /// Sign of `planet`, for the rules about dignity.
72    pub sign: Option<&'static str>,
73}
74
75impl ElectionFactor {
76    fn new(code: &'static str, weight: f64) -> Self {
77        ElectionFactor {
78            code,
79            weight,
80            planet: None,
81            target: None,
82            aspect: None,
83            house: None,
84            sign: None,
85        }
86    }
87    fn planet(mut self, planet: &'static str) -> Self {
88        self.planet = Some(planet);
89        self
90    }
91    fn target(mut self, target: impl Into<String>) -> Self {
92        self.target = Some(target.into());
93        self
94    }
95    fn aspect(mut self, aspect: &'static str) -> Self {
96        self.aspect = Some(aspect);
97        self
98    }
99    fn house(mut self, house: u8) -> Self {
100        self.house = Some(house);
101        self
102    }
103    fn sign(mut self, sign: &'static str) -> Self {
104        self.sign = Some(sign);
105        self
106    }
107}
108
109/// The electional assessment of a moment.
110#[derive(Debug, Clone, PartialEq, Serialize)]
111pub struct ElectionData {
112    /// 0-100: 50 plus the factors' weights.
113    pub score: f64,
114    /// "favourable" (65 and up), "mixed" (45 and up) or "unfavourable".
115    pub verdict: &'static str,
116    /// What the election is for.
117    pub purpose: Purpose,
118    /// The rules that apply, Moon first, then the Ascendant, angles, retrogrades, the
119    /// matter, the hour and the natal chart.
120    pub factors: Vec<ElectionFactor>,
121    /// The criteria filters this moment fails ("void_moon", "mercury_retrograde",
122    /// "venus_retrograde", "night", "outside_hours"): a search would skip it.
123    pub excluded_by: Vec<&'static str>,
124    /// Planetary day and hour.
125    pub planetary_hours: PlanetaryHours,
126    /// The Moon's aspects and void-of-course status.
127    pub moon_status: MoonStatus,
128}
129
130/// A chart for a candidate moment with its electional assessment (serialized flattened).
131#[derive(Debug, Clone, PartialEq, Serialize)]
132pub struct ElectionChart {
133    /// The chart, serialized inline.
134    #[serde(flatten)]
135    pub chart: Chart,
136    /// The electional assessment.
137    pub election_data: ElectionData,
138}
139
140/// A run of consecutive assessed moments scoring at least the minimum.
141#[derive(Debug, Clone, PartialEq, Serialize)]
142pub struct ElectionWindow {
143    /// First moment of the run, ISO 8601 UTC.
144    pub start: String,
145    /// Last moment of the run, ISO 8601 UTC.
146    pub end: String,
147    /// The best moment of the run (the earliest, on a tie), ISO 8601 UTC.
148    pub best: String,
149    /// Score of the best moment.
150    pub score: f64,
151    /// Verdict of the best moment.
152    pub verdict: &'static str,
153    /// The factors of the best moment.
154    pub factors: Vec<ElectionFactor>,
155}
156
157/// How many moments each criteria filter left out.
158#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
159pub struct Exclusions {
160    /// Moon void of course.
161    pub void_moon: u32,
162    /// Mercury retrograde.
163    pub mercury_retrograde: u32,
164    /// Venus retrograde.
165    pub venus_retrograde: u32,
166    /// Between sunset and sunrise.
167    pub night: u32,
168    /// Outside the local hours.
169    pub outside_hours: u32,
170}
171
172impl Exclusions {
173    fn count(&mut self, reason: &str) {
174        match reason {
175            "void_moon" => self.void_moon += 1,
176            "mercury_retrograde" => self.mercury_retrograde += 1,
177            "venus_retrograde" => self.venus_retrograde += 1,
178            "night" => self.night += 1,
179            _ => self.outside_hours += 1,
180        }
181    }
182}
183
184/// The result of a search.
185#[derive(Debug, Clone, PartialEq, Serialize)]
186pub struct ElectionSearch {
187    /// Start of the span searched, ISO 8601 UTC.
188    pub start: String,
189    /// End of the span searched, ISO 8601 UTC.
190    pub end: String,
191    /// The best windows, best first.
192    pub windows: Vec<ElectionWindow>,
193    /// Moments assessed.
194    pub evaluated: u32,
195    /// Moments left out by each filter (a moment counts once, for the first it fails).
196    pub excluded: Exclusions,
197    /// The criteria, defaults resolved.
198    pub criteria: CriteriaSummary,
199}
200
201/// A chart for a candidate moment with its electional assessment.
202pub fn calculate_election_chart(
203    kernels: &KernelSet,
204    req: &ChartRequest,
205    criteria: &ElectionCriteria,
206) -> Result<ElectionChart, EngineError> {
207    let resolved = criteria.resolve()?;
208    let mut chart = chart_without_hours(kernels, req)?;
209    let hours = planetary_hours(kernels, req.instant, req.latitude, req.longitude);
210    chart.planetary_hours = Some(hours.clone());
211    let cusps: Vec<f64> = chart.houses.iter().map(|h| h.ecliptic_longitude).collect();
212    let election_data = assess(
213        &Moment {
214            instant: req.instant,
215            planets: &chart.planets,
216            cusps: &cusps,
217            hours: &hours,
218        },
219        &resolved,
220    )?;
221    Ok(ElectionChart {
222        chart,
223        election_data,
224    })
225}