Skip to main content

astroceleste_engine/election/
criteria.rs

1//! What an election is for and how it is filtered, and the criteria resolved with
2//! their defaults.
3
4use super::*;
5
6/// What the election is for. Each purpose has a house of the matter, a natural
7/// significator and the planetary hours that favour it.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum Purpose {
11    /// No particular matter: the general rules only.
12    #[default]
13    General,
14    /// Signing a contract or an agreement (7th house, Mercury).
15    Contract,
16    /// Marriage, engagement or partnership (7th house, Venus).
17    Partnership,
18    /// Setting out on a journey (9th house, Mercury).
19    Travel,
20    /// Beginning a treatment or a surgery (1st house, the Sun).
21    Health,
22    /// Buying, investing or borrowing (2nd house, Jupiter).
23    Finance,
24    /// Opening a business or launching a venture (10th house, Jupiter).
25    Launch,
26    /// Starting a job or asking for a promotion (10th house, the Sun).
27    Career,
28}
29
30impl Purpose {
31    /// House of the matter.
32    pub(super) fn house(self) -> Option<usize> {
33        match self {
34            Purpose::General => None,
35            Purpose::Contract | Purpose::Partnership => Some(7),
36            Purpose::Travel => Some(9),
37            Purpose::Health => Some(1),
38            Purpose::Finance => Some(2),
39            Purpose::Launch | Purpose::Career => Some(10),
40        }
41    }
42
43    /// Natural significator of the matter.
44    pub(super) fn significator(self) -> Option<&'static str> {
45        match self {
46            Purpose::General => None,
47            Purpose::Contract | Purpose::Travel => Some("Mercury"),
48            Purpose::Partnership => Some("Venus"),
49            Purpose::Health | Purpose::Career => Some("Sun"),
50            Purpose::Finance | Purpose::Launch => Some("Jupiter"),
51        }
52    }
53
54    /// Hour rulers that favour the matter.
55    pub(super) fn hour_rulers(self) -> &'static [&'static str] {
56        match self {
57            Purpose::General => &["Jupiter", "Venus"],
58            Purpose::Contract => &["Mercury", "Jupiter"],
59            Purpose::Partnership => &["Venus", "Moon"],
60            Purpose::Travel => &["Mercury", "Moon"],
61            Purpose::Health => &["Sun", "Jupiter"],
62            Purpose::Finance => &["Jupiter", "Venus"],
63            Purpose::Launch => &["Jupiter", "Sun"],
64            Purpose::Career => &["Sun", "Jupiter"],
65        }
66    }
67
68    /// Whether Mercury retrograde spoils the matter.
69    pub(super) fn fears_mercury_retrograde(self) -> bool {
70        matches!(self, Purpose::Contract | Purpose::Travel | Purpose::Launch)
71    }
72
73    /// Whether Venus retrograde spoils the matter.
74    pub(super) fn fears_venus_retrograde(self) -> bool {
75        matches!(self, Purpose::Partnership)
76    }
77}
78
79/// Local clock hours, `from` inclusive to `to` exclusive; `from > to` spans midnight.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct HourRange {
83    /// First hour allowed (0-23).
84    pub from: u8,
85    /// Hour at which the range ends (1-24).
86    pub to: u8,
87}
88
89impl HourRange {
90    pub(super) fn contains(&self, minute_of_day: i64) -> bool {
91        let from = i64::from(self.from) * 60;
92        let to = i64::from(self.to) * 60;
93        if from <= to {
94            (from..to).contains(&minute_of_day)
95        } else {
96            minute_of_day >= from || minute_of_day < to
97        }
98    }
99}
100
101/// The UTC offset of local time from an instant on (until the next one), so that
102/// [`ElectionCriteria::local_hours`] follows daylight saving time.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct UtcOffset {
106    /// ISO 8601 instant from which the offset applies.
107    pub from: String,
108    /// Minutes to add to UTC for local time (east positive, e.g. 120 for CEST).
109    pub minutes: i32,
110}
111
112/// A natal point, as in a stored chart's `planets` list (other fields are ignored).
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct NatalPoint {
115    /// Body or angle name, e.g. "Sun", "Ascendant".
116    pub name: String,
117    /// Ecliptic longitude in degrees.
118    pub ecliptic_longitude: f64,
119}
120
121/// What to look for. Every field has a default, so `{}` is a valid criteria object.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(default, deny_unknown_fields)]
124pub struct ElectionCriteria {
125    /// What the election is for.
126    pub purpose: Purpose,
127    /// Leave out moments when the Moon is void of course (default true).
128    pub avoid_void_moon: bool,
129    /// Leave out moments when Mercury is retrograde (default: when the purpose fears it).
130    pub avoid_mercury_retrograde: Option<bool>,
131    /// Leave out moments when Venus is retrograde (default: when the purpose fears it).
132    pub avoid_venus_retrograde: Option<bool>,
133    /// Leave out moments between sunset and sunrise.
134    pub daytime_only: bool,
135    /// Only moments within these local clock hours.
136    pub local_hours: Option<HourRange>,
137    /// UTC offsets of local time for `local_hours` (none: local time is UTC).
138    pub utc_offsets: Vec<UtcOffset>,
139    /// Minutes between the moments a search assesses (5-60, default 10).
140    pub step_minutes: u32,
141    /// Lowest score a search keeps (default 60).
142    pub min_score: f64,
143    /// Most windows a search returns (1-50, default 20).
144    pub max_results: usize,
145    /// The natal chart to elect for, if any.
146    pub natal: Option<Vec<NatalPoint>>,
147}
148
149impl Default for ElectionCriteria {
150    fn default() -> Self {
151        ElectionCriteria {
152            purpose: Purpose::General,
153            avoid_void_moon: true,
154            avoid_mercury_retrograde: None,
155            avoid_venus_retrograde: None,
156            daytime_only: false,
157            local_hours: None,
158            utc_offsets: Vec::new(),
159            step_minutes: 10,
160            min_score: 60.0,
161            max_results: 20,
162            natal: None,
163        }
164    }
165}
166
167impl ElectionCriteria {
168    /// Criteria from a JSON object (`null` gives the defaults).
169    pub fn from_value(value: &Value) -> Result<Self, EngineError> {
170        if value.is_null() {
171            return Ok(ElectionCriteria::default());
172        }
173        serde_json::from_value(value.clone())
174            .map_err(|e| EngineError::InvalidInput(format!("election criteria: {e}")))
175    }
176
177    /// The criteria with every default resolved and out-of-range values clamped.
178    pub(super) fn resolve(&self) -> Result<Resolved, EngineError> {
179        if let Some(range) = self.local_hours {
180            if range.from > 23 || range.to == 0 || range.to > 24 || range.from == range.to {
181                return Err(EngineError::InvalidInput(
182                    "local_hours needs 0 <= from <= 23, 1 <= to <= 24 and from != to".into(),
183                ));
184            }
185        }
186        let mut offsets = self
187            .utc_offsets
188            .iter()
189            .map(|o| {
190                if o.minutes.abs() > 18 * 60 {
191                    return Err(EngineError::InvalidInput(format!(
192                        "UTC offset {} is out of range",
193                        o.minutes
194                    )));
195                }
196                UtcInstant::parse(&o.from)
197                    .map(|at| (at, o.minutes))
198                    .map_err(|e| EngineError::InvalidInput(format!("utc_offsets: {e}")))
199            })
200            .collect::<Result<Vec<_>, _>>()?;
201        offsets.sort_by_key(|(at, _)| *at);
202        if !self.min_score.is_finite() {
203            return Err(EngineError::InvalidInput(
204                "min_score must be a number".into(),
205            ));
206        }
207        Ok(Resolved {
208            summary: CriteriaSummary {
209                purpose: self.purpose,
210                avoid_void_moon: self.avoid_void_moon,
211                avoid_mercury_retrograde: self
212                    .avoid_mercury_retrograde
213                    .unwrap_or(self.purpose.fears_mercury_retrograde()),
214                avoid_venus_retrograde: self
215                    .avoid_venus_retrograde
216                    .unwrap_or(self.purpose.fears_venus_retrograde()),
217                daytime_only: self.daytime_only,
218                local_hours: self.local_hours,
219                step_minutes: self.step_minutes.clamp(5, 60),
220                min_score: self.min_score.clamp(0.0, 100.0),
221                max_results: self.max_results.clamp(1, 50),
222                natal: self.natal.is_some(),
223            },
224            offsets,
225            natal: self.natal.clone().unwrap_or_default(),
226        })
227    }
228}
229
230/// The criteria a search ran with, defaults resolved (the natal chart only as a flag).
231#[derive(Debug, Clone, PartialEq, Serialize)]
232pub struct CriteriaSummary {
233    /// What the election is for.
234    pub purpose: Purpose,
235    /// Whether void-of-course Moon moments were left out.
236    pub avoid_void_moon: bool,
237    /// Whether moments with Mercury retrograde were left out.
238    pub avoid_mercury_retrograde: bool,
239    /// Whether moments with Venus retrograde were left out.
240    pub avoid_venus_retrograde: bool,
241    /// Whether night-time moments were left out.
242    pub daytime_only: bool,
243    /// Local clock hours searched, if restricted.
244    pub local_hours: Option<HourRange>,
245    /// Minutes between assessed moments.
246    pub step_minutes: u32,
247    /// Lowest score kept.
248    pub min_score: f64,
249    /// Most windows returned.
250    pub max_results: usize,
251    /// Whether a natal chart was given.
252    pub natal: bool,
253}
254
255pub(super) struct Resolved {
256    pub(super) summary: CriteriaSummary,
257    pub(super) offsets: Vec<(UtcInstant, i32)>,
258    pub(super) natal: Vec<NatalPoint>,
259}
260
261impl Resolved {
262    /// Minutes since local midnight at `instant`.
263    pub(super) fn local_minute_of_day(&self, instant: UtcInstant) -> i64 {
264        let offset = self
265            .offsets
266            .iter()
267            .rev()
268            .find(|(at, _)| *at <= instant)
269            .or(self.offsets.first())
270            .map_or(0, |(_, minutes)| *minutes);
271        let (_, _, _, hh, mm, ..) = instant.add_micros(i64::from(offset) * 60_000_000).civil();
272        i64::from(hh) * 60 + i64::from(mm)
273    }
274}