astroceleste_engine/election/
criteria.rs1use super::*;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum Purpose {
11 #[default]
13 General,
14 Contract,
16 Partnership,
18 Travel,
20 Health,
22 Finance,
24 Launch,
26 Career,
28}
29
30impl Purpose {
31 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 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 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 pub(super) fn fears_mercury_retrograde(self) -> bool {
70 matches!(self, Purpose::Contract | Purpose::Travel | Purpose::Launch)
71 }
72
73 pub(super) fn fears_venus_retrograde(self) -> bool {
75 matches!(self, Purpose::Partnership)
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct HourRange {
83 pub from: u8,
85 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct UtcOffset {
106 pub from: String,
108 pub minutes: i32,
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct NatalPoint {
115 pub name: String,
117 pub ecliptic_longitude: f64,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(default, deny_unknown_fields)]
124pub struct ElectionCriteria {
125 pub purpose: Purpose,
127 pub avoid_void_moon: bool,
129 pub avoid_mercury_retrograde: Option<bool>,
131 pub avoid_venus_retrograde: Option<bool>,
133 pub daytime_only: bool,
135 pub local_hours: Option<HourRange>,
137 pub utc_offsets: Vec<UtcOffset>,
139 pub step_minutes: u32,
141 pub min_score: f64,
143 pub max_results: usize,
145 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 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 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#[derive(Debug, Clone, PartialEq, Serialize)]
232pub struct CriteriaSummary {
233 pub purpose: Purpose,
235 pub avoid_void_moon: bool,
237 pub avoid_mercury_retrograde: bool,
239 pub avoid_venus_retrograde: bool,
241 pub daytime_only: bool,
243 pub local_hours: Option<HourRange>,
245 pub step_minutes: u32,
247 pub min_score: f64,
249 pub max_results: usize,
251 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 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}