Skip to main content

astroceleste_engine/
election.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::{calculate_chart, placements, sky, Chart, ChartRequest, Placement};
17use crate::degree_qualities::degree_qualities;
18use crate::ephemeris::KernelSet;
19use crate::error::EngineError;
20use crate::horary::{
21    hours_in, jd_utc, moon_status, planetary_hours, solar_day, traditional_ruler, MoonStatus,
22    PlanetaryHours, SolarDay, SIGNS,
23};
24use crate::houses::{houses_at_sidereal_time, HouseSystem};
25use crate::instant::UtcInstant;
26use crate::planets::{planets_and_sidereal_time, require_kernel, BodyPosition};
27use crate::pyfloat;
28use crate::zodiac::{ayanamsa, ayanamsa_info};
29
30/// Longest span a search covers, in days.
31pub const MAX_SEARCH_DAYS: f64 = 92.0;
32/// Scores from here up are favourable.
33const FAVOURABLE: f64 = 65.0;
34/// Scores from here up (and below [`FAVOURABLE`]) are mixed; below, unfavourable.
35const MIXED: f64 = 45.0;
36/// A planet this close to the Sun (degrees) is combust, unless cazimi.
37const COMBUST_ORB: f64 = 8.5;
38/// Within 17 arc minutes of the Sun a planet is cazimi, in the heart of the Sun.
39const CAZIMI_ORB: f64 = 17.0 / 60.0;
40/// Orb (degrees) of the election's contacts with natal points.
41const NATAL_ORB: f64 = 3.0;
42/// Below this latitude, where the Sun rises and sets every day, a search that finds
43/// sunrises day by day reuses each day for all its moments.
44const SOLAR_DAY_CACHE_MAX_LAT: f64 = 60.0;
45
46const BENEFICS: [&str; 2] = ["Venus", "Jupiter"];
47const MALEFICS: [&str; 2] = ["Mars", "Saturn"];
48
49/// What the election is for. Each purpose has a house of the matter, a natural
50/// significator and the planetary hours that favour it.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum Purpose {
54    /// No particular matter: the general rules only.
55    #[default]
56    General,
57    /// Signing a contract or an agreement (7th house, Mercury).
58    Contract,
59    /// Marriage, engagement or partnership (7th house, Venus).
60    Partnership,
61    /// Setting out on a journey (9th house, Mercury).
62    Travel,
63    /// Beginning a treatment or a surgery (1st house, the Sun).
64    Health,
65    /// Buying, investing or borrowing (2nd house, Jupiter).
66    Finance,
67    /// Opening a business or launching a venture (10th house, Jupiter).
68    Launch,
69    /// Starting a job or asking for a promotion (10th house, the Sun).
70    Career,
71}
72
73impl Purpose {
74    /// House of the matter.
75    fn house(self) -> Option<usize> {
76        match self {
77            Purpose::General => None,
78            Purpose::Contract | Purpose::Partnership => Some(7),
79            Purpose::Travel => Some(9),
80            Purpose::Health => Some(1),
81            Purpose::Finance => Some(2),
82            Purpose::Launch | Purpose::Career => Some(10),
83        }
84    }
85
86    /// Natural significator of the matter.
87    fn significator(self) -> Option<&'static str> {
88        match self {
89            Purpose::General => None,
90            Purpose::Contract | Purpose::Travel => Some("Mercury"),
91            Purpose::Partnership => Some("Venus"),
92            Purpose::Health | Purpose::Career => Some("Sun"),
93            Purpose::Finance | Purpose::Launch => Some("Jupiter"),
94        }
95    }
96
97    /// Hour rulers that favour the matter.
98    fn hour_rulers(self) -> &'static [&'static str] {
99        match self {
100            Purpose::General => &["Jupiter", "Venus"],
101            Purpose::Contract => &["Mercury", "Jupiter"],
102            Purpose::Partnership => &["Venus", "Moon"],
103            Purpose::Travel => &["Mercury", "Moon"],
104            Purpose::Health => &["Sun", "Jupiter"],
105            Purpose::Finance => &["Jupiter", "Venus"],
106            Purpose::Launch => &["Jupiter", "Sun"],
107            Purpose::Career => &["Sun", "Jupiter"],
108        }
109    }
110
111    /// Whether Mercury retrograde spoils the matter.
112    fn fears_mercury_retrograde(self) -> bool {
113        matches!(self, Purpose::Contract | Purpose::Travel | Purpose::Launch)
114    }
115
116    /// Whether Venus retrograde spoils the matter.
117    fn fears_venus_retrograde(self) -> bool {
118        matches!(self, Purpose::Partnership)
119    }
120}
121
122/// Local clock hours, `from` inclusive to `to` exclusive; `from > to` spans midnight.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct HourRange {
126    /// First hour allowed (0-23).
127    pub from: u8,
128    /// Hour at which the range ends (1-24).
129    pub to: u8,
130}
131
132impl HourRange {
133    fn contains(&self, minute_of_day: i64) -> bool {
134        let from = i64::from(self.from) * 60;
135        let to = i64::from(self.to) * 60;
136        if from <= to {
137            (from..to).contains(&minute_of_day)
138        } else {
139            minute_of_day >= from || minute_of_day < to
140        }
141    }
142}
143
144/// The UTC offset of local time from an instant on (until the next one), so that
145/// [`ElectionCriteria::local_hours`] follows daylight saving time.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct UtcOffset {
149    /// ISO 8601 instant from which the offset applies.
150    pub from: String,
151    /// Minutes to add to UTC for local time (east positive, e.g. 120 for CEST).
152    pub minutes: i32,
153}
154
155/// A natal point, as in a stored chart's `planets` list (other fields are ignored).
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub struct NatalPoint {
158    /// Body or angle name, e.g. "Sun", "Ascendant".
159    pub name: String,
160    /// Ecliptic longitude in degrees.
161    pub ecliptic_longitude: f64,
162}
163
164/// What to look for. Every field has a default, so `{}` is a valid criteria object.
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166#[serde(default, deny_unknown_fields)]
167pub struct ElectionCriteria {
168    /// What the election is for.
169    pub purpose: Purpose,
170    /// Leave out moments when the Moon is void of course (default true).
171    pub avoid_void_moon: bool,
172    /// Leave out moments when Mercury is retrograde (default: when the purpose fears it).
173    pub avoid_mercury_retrograde: Option<bool>,
174    /// Leave out moments when Venus is retrograde (default: when the purpose fears it).
175    pub avoid_venus_retrograde: Option<bool>,
176    /// Leave out moments between sunset and sunrise.
177    pub daytime_only: bool,
178    /// Only moments within these local clock hours.
179    pub local_hours: Option<HourRange>,
180    /// UTC offsets of local time for `local_hours` (none: local time is UTC).
181    pub utc_offsets: Vec<UtcOffset>,
182    /// Minutes between the moments a search assesses (5-60, default 10).
183    pub step_minutes: u32,
184    /// Lowest score a search keeps (default 60).
185    pub min_score: f64,
186    /// Most windows a search returns (1-50, default 20).
187    pub max_results: usize,
188    /// The natal chart to elect for, if any.
189    pub natal: Option<Vec<NatalPoint>>,
190}
191
192impl Default for ElectionCriteria {
193    fn default() -> Self {
194        ElectionCriteria {
195            purpose: Purpose::General,
196            avoid_void_moon: true,
197            avoid_mercury_retrograde: None,
198            avoid_venus_retrograde: None,
199            daytime_only: false,
200            local_hours: None,
201            utc_offsets: Vec::new(),
202            step_minutes: 10,
203            min_score: 60.0,
204            max_results: 20,
205            natal: None,
206        }
207    }
208}
209
210impl ElectionCriteria {
211    /// Criteria from a JSON object (`null` gives the defaults).
212    pub fn from_value(value: &Value) -> Result<Self, EngineError> {
213        if value.is_null() {
214            return Ok(ElectionCriteria::default());
215        }
216        serde_json::from_value(value.clone())
217            .map_err(|e| EngineError::InvalidInput(format!("election criteria: {e}")))
218    }
219
220    /// The criteria with every default resolved and out-of-range values clamped.
221    fn resolve(&self) -> Result<Resolved, EngineError> {
222        if let Some(range) = self.local_hours {
223            if range.from > 23 || range.to == 0 || range.to > 24 || range.from == range.to {
224                return Err(EngineError::InvalidInput(
225                    "local_hours needs 0 <= from <= 23, 1 <= to <= 24 and from != to".into(),
226                ));
227            }
228        }
229        let mut offsets = self
230            .utc_offsets
231            .iter()
232            .map(|o| {
233                if o.minutes.abs() > 18 * 60 {
234                    return Err(EngineError::InvalidInput(format!(
235                        "UTC offset {} is out of range",
236                        o.minutes
237                    )));
238                }
239                UtcInstant::parse(&o.from)
240                    .map(|at| (at, o.minutes))
241                    .map_err(|e| EngineError::InvalidInput(format!("utc_offsets: {e}")))
242            })
243            .collect::<Result<Vec<_>, _>>()?;
244        offsets.sort_by_key(|(at, _)| *at);
245        if !self.min_score.is_finite() {
246            return Err(EngineError::InvalidInput(
247                "min_score must be a number".into(),
248            ));
249        }
250        Ok(Resolved {
251            summary: CriteriaSummary {
252                purpose: self.purpose,
253                avoid_void_moon: self.avoid_void_moon,
254                avoid_mercury_retrograde: self
255                    .avoid_mercury_retrograde
256                    .unwrap_or(self.purpose.fears_mercury_retrograde()),
257                avoid_venus_retrograde: self
258                    .avoid_venus_retrograde
259                    .unwrap_or(self.purpose.fears_venus_retrograde()),
260                daytime_only: self.daytime_only,
261                local_hours: self.local_hours,
262                step_minutes: self.step_minutes.clamp(5, 60),
263                min_score: self.min_score.clamp(0.0, 100.0),
264                max_results: self.max_results.clamp(1, 50),
265                natal: self.natal.is_some(),
266            },
267            offsets,
268            natal: self.natal.clone().unwrap_or_default(),
269        })
270    }
271}
272
273/// The criteria a search ran with, defaults resolved (the natal chart only as a flag).
274#[derive(Debug, Clone, PartialEq, Serialize)]
275pub struct CriteriaSummary {
276    /// What the election is for.
277    pub purpose: Purpose,
278    /// Whether void-of-course Moon moments were left out.
279    pub avoid_void_moon: bool,
280    /// Whether moments with Mercury retrograde were left out.
281    pub avoid_mercury_retrograde: bool,
282    /// Whether moments with Venus retrograde were left out.
283    pub avoid_venus_retrograde: bool,
284    /// Whether night-time moments were left out.
285    pub daytime_only: bool,
286    /// Local clock hours searched, if restricted.
287    pub local_hours: Option<HourRange>,
288    /// Minutes between assessed moments.
289    pub step_minutes: u32,
290    /// Lowest score kept.
291    pub min_score: f64,
292    /// Most windows returned.
293    pub max_results: usize,
294    /// Whether a natal chart was given.
295    pub natal: bool,
296}
297
298struct Resolved {
299    summary: CriteriaSummary,
300    offsets: Vec<(UtcInstant, i32)>,
301    natal: Vec<NatalPoint>,
302}
303
304impl Resolved {
305    /// Minutes since local midnight at `instant`.
306    fn local_minute_of_day(&self, instant: UtcInstant) -> i64 {
307        let offset = self
308            .offsets
309            .iter()
310            .rev()
311            .find(|(at, _)| *at <= instant)
312            .or(self.offsets.first())
313            .map_or(0, |(_, minutes)| *minutes);
314        let (_, _, _, hh, mm, ..) = instant.add_micros(i64::from(offset) * 60_000_000).civil();
315        i64::from(hh) * 60 + i64::from(mm)
316    }
317}
318
319/// One electional rule that applies to a moment.
320#[derive(Debug, Clone, PartialEq, Serialize)]
321pub struct ElectionFactor {
322    /// Stable code, e.g. "MOON_VOC", "BENEFIC_ANGULAR".
323    pub code: &'static str,
324    /// Points added to (or, when negative, taken from) the score.
325    pub weight: f64,
326    /// The election planet the rule is about, if any.
327    pub planet: Option<&'static str>,
328    /// The other party: the planet aspected, or the natal point contacted.
329    pub target: Option<String>,
330    /// Aspect name, e.g. "Trine", for the rules about aspects.
331    pub aspect: Option<&'static str>,
332    /// House (1-12) of `planet`, for the rules about houses.
333    pub house: Option<u8>,
334    /// Sign of `planet`, for the rules about dignity.
335    pub sign: Option<&'static str>,
336}
337
338impl ElectionFactor {
339    fn new(code: &'static str, weight: f64) -> Self {
340        ElectionFactor {
341            code,
342            weight,
343            planet: None,
344            target: None,
345            aspect: None,
346            house: None,
347            sign: None,
348        }
349    }
350    fn planet(mut self, planet: &'static str) -> Self {
351        self.planet = Some(planet);
352        self
353    }
354    fn target(mut self, target: impl Into<String>) -> Self {
355        self.target = Some(target.into());
356        self
357    }
358    fn aspect(mut self, aspect: &'static str) -> Self {
359        self.aspect = Some(aspect);
360        self
361    }
362    fn house(mut self, house: u8) -> Self {
363        self.house = Some(house);
364        self
365    }
366    fn sign(mut self, sign: &'static str) -> Self {
367        self.sign = Some(sign);
368        self
369    }
370}
371
372/// The electional assessment of a moment.
373#[derive(Debug, Clone, PartialEq, Serialize)]
374pub struct ElectionData {
375    /// 0-100: 50 plus the factors' weights.
376    pub score: f64,
377    /// "favourable" (65 and up), "mixed" (45 and up) or "unfavourable".
378    pub verdict: &'static str,
379    /// What the election is for.
380    pub purpose: Purpose,
381    /// The rules that apply, Moon first, then the Ascendant, angles, retrogrades, the
382    /// matter, the hour and the natal chart.
383    pub factors: Vec<ElectionFactor>,
384    /// The criteria filters this moment fails ("void_moon", "mercury_retrograde",
385    /// "venus_retrograde", "night", "outside_hours"): a search would skip it.
386    pub excluded_by: Vec<&'static str>,
387    /// Planetary day and hour.
388    pub planetary_hours: PlanetaryHours,
389    /// The Moon's aspects and void-of-course status.
390    pub moon_status: MoonStatus,
391}
392
393/// A chart for a candidate moment with its electional assessment (serialized flattened).
394#[derive(Debug, Clone, PartialEq, Serialize)]
395pub struct ElectionChart {
396    /// The chart, serialized inline.
397    #[serde(flatten)]
398    pub chart: Chart,
399    /// The electional assessment.
400    pub election_data: ElectionData,
401}
402
403/// A run of consecutive assessed moments scoring at least the minimum.
404#[derive(Debug, Clone, PartialEq, Serialize)]
405pub struct ElectionWindow {
406    /// First moment of the run, ISO 8601 UTC.
407    pub start: String,
408    /// Last moment of the run, ISO 8601 UTC.
409    pub end: String,
410    /// The best moment of the run (the earliest, on a tie), ISO 8601 UTC.
411    pub best: String,
412    /// Score of the best moment.
413    pub score: f64,
414    /// Verdict of the best moment.
415    pub verdict: &'static str,
416    /// The factors of the best moment.
417    pub factors: Vec<ElectionFactor>,
418}
419
420/// How many moments each criteria filter left out.
421#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
422pub struct Exclusions {
423    /// Moon void of course.
424    pub void_moon: u32,
425    /// Mercury retrograde.
426    pub mercury_retrograde: u32,
427    /// Venus retrograde.
428    pub venus_retrograde: u32,
429    /// Between sunset and sunrise.
430    pub night: u32,
431    /// Outside the local hours.
432    pub outside_hours: u32,
433}
434
435impl Exclusions {
436    fn count(&mut self, reason: &str) {
437        match reason {
438            "void_moon" => self.void_moon += 1,
439            "mercury_retrograde" => self.mercury_retrograde += 1,
440            "venus_retrograde" => self.venus_retrograde += 1,
441            "night" => self.night += 1,
442            _ => self.outside_hours += 1,
443        }
444    }
445}
446
447/// The result of a search.
448#[derive(Debug, Clone, PartialEq, Serialize)]
449pub struct ElectionSearch {
450    /// Start of the span searched, ISO 8601 UTC.
451    pub start: String,
452    /// End of the span searched, ISO 8601 UTC.
453    pub end: String,
454    /// The best windows, best first.
455    pub windows: Vec<ElectionWindow>,
456    /// Moments assessed.
457    pub evaluated: u32,
458    /// Moments left out by each filter (a moment counts once, for the first it fails).
459    pub excluded: Exclusions,
460    /// The criteria, defaults resolved.
461    pub criteria: CriteriaSummary,
462}
463
464// ---------------------------------------------------------------------------------------
465// Assessment
466// ---------------------------------------------------------------------------------------
467
468/// The sign a longitude falls in (by its degree, not the rounded display).
469fn sign_of(longitude: f64) -> &'static str {
470    SIGNS[(pyfloat::floordiv(pyfloat::rem(longitude, 360.0), 30.0) as usize) % 12]
471}
472
473fn sign_index(sign: &str) -> usize {
474    SIGNS.iter().position(|s| *s == sign).unwrap_or(0)
475}
476
477/// Angular distance between two longitudes, 0-180 degrees.
478fn separation(a: f64, b: f64) -> f64 {
479    let d = pyfloat::rem((a - b).abs(), 360.0);
480    if d > 180.0 {
481        360.0 - d
482    } else {
483        d
484    }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum Dignity {
489    Dignified,
490    Debilitated,
491    Peregrine,
492}
493
494/// Essential dignity by domicile and exaltation, debility by detriment and fall.
495fn dignity(planet: &str, sign: &str) -> Dignity {
496    let (dignified, debilitated): (&[&str], &[&str]) = match planet {
497        "Sun" => (&["Leo", "Aries"], &["Aquarius", "Libra"]),
498        "Moon" => (&["Cancer", "Taurus"], &["Capricorn", "Scorpio"]),
499        "Mercury" => (&["Gemini", "Virgo"], &["Sagittarius", "Pisces"]),
500        "Venus" => (
501            &["Taurus", "Libra", "Pisces"],
502            &["Scorpio", "Aries", "Virgo"],
503        ),
504        "Mars" => (
505            &["Aries", "Scorpio", "Capricorn"],
506            &["Libra", "Taurus", "Cancer"],
507        ),
508        "Jupiter" => (
509            &["Sagittarius", "Pisces", "Cancer"],
510            &["Gemini", "Virgo", "Capricorn"],
511        ),
512        "Saturn" => (
513            &["Capricorn", "Aquarius", "Libra"],
514            &["Cancer", "Leo", "Aries"],
515        ),
516        _ => (&[], &[]),
517    };
518    if dignified.contains(&sign) {
519        Dignity::Dignified
520    } else if debilitated.contains(&sign) {
521        Dignity::Debilitated
522    } else {
523        Dignity::Peregrine
524    }
525}
526
527fn is_angular(house: u8) -> bool {
528    matches!(house, 1 | 4 | 7 | 10)
529}
530
531/// The 6th, 8th and 12th: houses of illness, death and undoing.
532fn is_dark_house(house: u8) -> bool {
533    matches!(house, 6 | 8 | 12)
534}
535
536fn is_soft(aspect: &str) -> bool {
537    matches!(aspect, "Conjunction" | "Sextile" | "Trine")
538}
539
540/// How a contact with a benefic or a malefic counts: a benefic helps by conjunction,
541/// sextile or trine; a malefic hurts by conjunction, square or opposition. `None` for the
542/// contacts that count for nothing (a benefic's square, a malefic's trine).
543fn contact(benefic: bool, aspect: &str) -> Option<bool> {
544    match (benefic, aspect) {
545        (true, "Conjunction" | "Sextile" | "Trine") => Some(true),
546        (false, "Conjunction" | "Square" | "Opposition") => Some(false),
547        _ => None,
548    }
549}
550
551/// A Ptolemaic aspect between two longitudes within `orb`, if any.
552fn ptolemaic_aspect(a: f64, b: f64, orb: f64) -> Option<&'static str> {
553    let d = separation(a, b);
554    [
555        (0.0, "Conjunction"),
556        (60.0, "Sextile"),
557        (90.0, "Square"),
558        (120.0, "Trine"),
559        (180.0, "Opposition"),
560    ]
561    .into_iter()
562    .find(|(angle, _)| (d - angle).abs() <= orb)
563    .map(|(_, name)| name)
564}
565
566/// Whether `planet` is combust (within 8.5° of the Sun, but not cazimi).
567fn is_combust(planet: &Placement, sun: Option<&Placement>) -> bool {
568    planet.name != "Sun"
569        && sun.is_some_and(|sun| {
570            let d = separation(planet.ecliptic_longitude, sun.ecliptic_longitude);
571            d > CAZIMI_ORB && d < COMBUST_ORB
572        })
573}
574
575/// The condition of a significator (the Ascendant's ruler, the ruler of the house of the
576/// matter, the natural significator), as factors named `{prefix}_{condition}`.
577struct Condition {
578    dignified: &'static str,
579    debilitated: &'static str,
580    angular: &'static str,
581    dark_house: &'static str,
582    retrograde: &'static str,
583    combust: &'static str,
584}
585
586const ASC_RULER: Condition = Condition {
587    dignified: "ASC_RULER_DIGNIFIED",
588    debilitated: "ASC_RULER_DEBILITATED",
589    angular: "ASC_RULER_ANGULAR",
590    dark_house: "ASC_RULER_IN_DARK_HOUSE",
591    retrograde: "ASC_RULER_RETROGRADE",
592    combust: "ASC_RULER_COMBUST",
593};
594const HOUSE_RULER: Condition = Condition {
595    dignified: "HOUSE_RULER_DIGNIFIED",
596    debilitated: "HOUSE_RULER_DEBILITATED",
597    angular: "HOUSE_RULER_ANGULAR",
598    dark_house: "HOUSE_RULER_IN_DARK_HOUSE",
599    retrograde: "HOUSE_RULER_RETROGRADE",
600    combust: "HOUSE_RULER_COMBUST",
601};
602const SIGNIFICATOR: Condition = Condition {
603    dignified: "SIGNIFICATOR_DIGNIFIED",
604    debilitated: "SIGNIFICATOR_DEBILITATED",
605    angular: "SIGNIFICATOR_ANGULAR",
606    dark_house: "SIGNIFICATOR_IN_DARK_HOUSE",
607    retrograde: "SIGNIFICATOR_RETROGRADE",
608    combust: "SIGNIFICATOR_COMBUST",
609};
610
611/// Weights of a significator's conditions.
612struct ConditionWeights {
613    dignity: f64,
614    angular: f64,
615    dark_house: f64,
616    affliction: f64,
617}
618
619fn condition(
620    factors: &mut Vec<ElectionFactor>,
621    names: &Condition,
622    weights: &ConditionWeights,
623    planet: &Placement,
624    sun: Option<&Placement>,
625    skip_retrograde: bool,
626) {
627    let sign = sign_of(planet.ecliptic_longitude);
628    let tag = |code, weight| {
629        ElectionFactor::new(code, weight)
630            .planet(planet.name)
631            .house(planet.house)
632            .sign(sign)
633    };
634    match dignity(planet.name, sign) {
635        Dignity::Dignified => factors.push(tag(names.dignified, weights.dignity)),
636        Dignity::Debilitated => factors.push(tag(names.debilitated, -weights.dignity)),
637        Dignity::Peregrine => {}
638    }
639    if is_angular(planet.house) {
640        factors.push(tag(names.angular, weights.angular));
641    } else if is_dark_house(planet.house) {
642        factors.push(tag(names.dark_house, -weights.dark_house));
643    }
644    if planet.is_retrograde && !skip_retrograde {
645        factors.push(tag(names.retrograde, -weights.affliction));
646    }
647    if is_combust(planet, sun) {
648        factors.push(tag(names.combust, -weights.affliction));
649    }
650}
651
652/// A moment's positions, ready to be assessed.
653struct Moment<'a> {
654    instant: UtcInstant,
655    planets: &'a [Placement],
656    cusps: &'a [f64],
657    hours: &'a PlanetaryHours,
658}
659
660impl Moment<'_> {
661    fn get(&self, name: &str) -> Option<&Placement> {
662        self.planets.iter().find(|p| p.name == name)
663    }
664}
665
666/// The filters a moment fails, in the order a search checks them.
667fn exclusions(moment: &Moment, moon: &MoonStatus, criteria: &Resolved) -> Vec<&'static str> {
668    let c = &criteria.summary;
669    let mut out = Vec::new();
670    if let Some(range) = c.local_hours {
671        if !range.contains(criteria.local_minute_of_day(moment.instant)) {
672            out.push("outside_hours");
673        }
674    }
675    if c.daytime_only && !moment.hours.is_day {
676        out.push("night");
677    }
678    if c.avoid_void_moon && moon.void_of_course {
679        out.push("void_moon");
680    }
681    let retrograde = |name| moment.get(name).is_some_and(|p| p.is_retrograde);
682    if c.avoid_mercury_retrograde && retrograde("Mercury") {
683        out.push("mercury_retrograde");
684    }
685    if c.avoid_venus_retrograde && retrograde("Venus") {
686        out.push("venus_retrograde");
687    }
688    out
689}
690
691/// Score, factors and failed filters of a moment.
692fn assess(moment: &Moment, criteria: &Resolved) -> Result<ElectionData, EngineError> {
693    let purpose = criteria.summary.purpose;
694    let moon = moment
695        .get("Moon")
696        .ok_or_else(|| EngineError::InvalidInput("chart has no Moon".into()))?;
697    let sun = moment.get("Sun");
698    let status = moon_status(moon, moment.planets);
699    let mut factors = Vec::new();
700
701    // The Moon: her condition, and the next aspect she perfects.
702    let moon_sign = sign_of(moon.ecliptic_longitude);
703    if status.void_of_course {
704        factors.push(ElectionFactor::new("MOON_VOC", -20.0).planet("Moon"));
705    }
706    if is_combust(moon, sun) {
707        factors.push(ElectionFactor::new("MOON_COMBUST", -12.0).planet("Moon"));
708    } else if let Some(sun) = sun {
709        if pyfloat::rem(moon.ecliptic_longitude - sun.ecliptic_longitude, 360.0) < 180.0 {
710            factors.push(ElectionFactor::new("MOON_WAXING", 5.0).planet("Moon"));
711        }
712    }
713    let lon = pyfloat::rem(moon.ecliptic_longitude, 360.0);
714    if (195.0..225.0).contains(&lon) {
715        factors.push(ElectionFactor::new("MOON_VIA_COMBUSTA", -8.0).planet("Moon"));
716    }
717    degree_factors(&mut factors, moon.ecliptic_longitude, true);
718    match dignity("Moon", moon_sign) {
719        Dignity::Dignified => factors.push(
720            ElectionFactor::new("MOON_DIGNIFIED", 6.0)
721                .planet("Moon")
722                .sign(moon_sign),
723        ),
724        Dignity::Debilitated => factors.push(
725            ElectionFactor::new("MOON_DEBILITATED", -6.0)
726                .planet("Moon")
727                .sign(moon_sign),
728        ),
729        Dignity::Peregrine => {}
730    }
731    if moon.speed > 13.5 {
732        factors.push(ElectionFactor::new("MOON_SWIFT", 2.0).planet("Moon"));
733    } else if moon.speed < 12.5 {
734        factors.push(ElectionFactor::new("MOON_SLOW", -2.0).planet("Moon"));
735    }
736    if is_angular(moon.house) {
737        factors.push(
738            ElectionFactor::new("MOON_ANGULAR", 3.0)
739                .planet("Moon")
740                .house(moon.house),
741        );
742    } else if is_dark_house(moon.house) {
743        factors.push(
744            ElectionFactor::new("MOON_IN_DARK_HOUSE", -5.0)
745                .planet("Moon")
746                .house(moon.house),
747        );
748    }
749    if let Some(next) = &status.next_applying_aspect {
750        let soft = is_soft(next.aspect);
751        let applying = |code, weight| {
752            ElectionFactor::new(code, weight)
753                .planet("Moon")
754                .target(next.planet)
755                .aspect(next.aspect)
756        };
757        if BENEFICS.contains(&next.planet) {
758            factors.push(applying(
759                "MOON_APPLYING_BENEFIC",
760                if soft { 10.0 } else { 4.0 },
761            ));
762        } else if MALEFICS.contains(&next.planet) {
763            // A conjunction with a malefic is no help.
764            let hard = !soft || next.aspect == "Conjunction";
765            factors.push(applying(
766                "MOON_APPLYING_MALEFIC",
767                if hard { -10.0 } else { -3.0 },
768            ));
769        }
770        if purpose.significator() == Some(next.planet) {
771            factors.push(applying(
772                "MOON_APPLYING_SIGNIFICATOR",
773                if soft { 5.0 } else { -3.0 },
774            ));
775        }
776    }
777
778    // The Ascendant and its ruler.
779    let asc_lon = moment
780        .get("Ascendant")
781        .map_or(moment.cusps[0], |a| a.ecliptic_longitude);
782    let asc_sign = sign_of(asc_lon);
783    let asc_degree = pyfloat::rem(asc_lon, 30.0);
784    if asc_degree < 3.0 {
785        factors.push(ElectionFactor::new("ASC_EARLY", -5.0).sign(asc_sign));
786    } else if asc_degree >= 27.0 {
787        factors.push(ElectionFactor::new("ASC_LATE", -5.0).sign(asc_sign));
788    }
789    degree_factors(&mut factors, asc_lon, false);
790    let asc_ruler_name = traditional_ruler(asc_sign);
791    if let Some(ruler) = asc_ruler_name.and_then(|r| moment.get(r)) {
792        condition(
793            &mut factors,
794            &ASC_RULER,
795            &ConditionWeights {
796                dignity: 6.0,
797                angular: 4.0,
798                dark_house: 5.0,
799                affliction: 6.0,
800            },
801            ruler,
802            sun,
803            false,
804        );
805    }
806
807    // Benefics and malefics on the angles.
808    for p in moment.planets {
809        let benefic = BENEFICS.contains(&p.name);
810        let malefic = MALEFICS.contains(&p.name);
811        if !(benefic || malefic) {
812            continue;
813        }
814        let (code, weight) = match (benefic, p.house) {
815            (true, 1) => ("BENEFIC_IN_1ST", 8.0),
816            (true, h) if is_angular(h) => ("BENEFIC_ANGULAR", 4.0),
817            (false, 1) => ("MALEFIC_IN_1ST", -10.0),
818            (false, h) if is_angular(h) => ("MALEFIC_ANGULAR", -5.0),
819            _ => continue,
820        };
821        factors.push(
822            ElectionFactor::new(code, weight)
823                .planet(p.name)
824                .house(p.house),
825        );
826    }
827
828    // Retrograde Mercury and Venus: heavy when the matter needs them.
829    let feared = [
830        (
831            "Mercury",
832            "MERCURY_RETROGRADE",
833            criteria.summary.avoid_mercury_retrograde,
834        ),
835        (
836            "Venus",
837            "VENUS_RETROGRADE",
838            criteria.summary.avoid_venus_retrograde,
839        ),
840    ];
841    for (name, code, avoided) in feared {
842        if moment.get(name).is_some_and(|p| p.is_retrograde) {
843            let weight = if avoided || purpose.significator() == Some(name) {
844                -10.0
845            } else {
846                -2.0
847            };
848            factors.push(ElectionFactor::new(code, weight).planet(name));
849        }
850    }
851
852    // The matter: the ruler of its house, and its natural significator.
853    let house_ruler_name = purpose
854        .house()
855        .filter(|h| *h != 1)
856        .and_then(|h| traditional_ruler(sign_of(moment.cusps[h - 1])));
857    if let Some(ruler) = house_ruler_name.and_then(|r| moment.get(r)) {
858        if Some(ruler.name) != asc_ruler_name {
859            condition(
860                &mut factors,
861                &HOUSE_RULER,
862                &ConditionWeights {
863                    dignity: 5.0,
864                    angular: 3.0,
865                    dark_house: 4.0,
866                    affliction: 5.0,
867                },
868                ruler,
869                sun,
870                false,
871            );
872        }
873    }
874    if let Some(sig) = purpose.significator().and_then(|s| moment.get(s)) {
875        if Some(sig.name) != asc_ruler_name && Some(sig.name) != house_ruler_name {
876            condition(
877                &mut factors,
878                &SIGNIFICATOR,
879                &ConditionWeights {
880                    dignity: 4.0,
881                    angular: 2.0,
882                    dark_house: 3.0,
883                    affliction: 4.0,
884                },
885                sig,
886                sun,
887                // Mercury's and Venus's retrogradation is weighed above.
888                matches!(sig.name, "Mercury" | "Venus"),
889            );
890        }
891    }
892
893    // The planetary hour.
894    let hour_ruler = moment.hours.hour_ruler;
895    if purpose.hour_rulers().contains(&hour_ruler) {
896        factors.push(ElectionFactor::new("HOUR_RULER_FAVOURS_PURPOSE", 5.0).planet(hour_ruler));
897    } else if MALEFICS.contains(&hour_ruler) {
898        factors.push(ElectionFactor::new("HOUR_RULER_MALEFIC", -3.0).planet(hour_ruler));
899    }
900
901    // The natal chart.
902    let natal = &criteria.natal;
903    if !natal.is_empty() {
904        natal_factors(&mut factors, moment, asc_sign, natal);
905    }
906
907    let score = pyfloat::round(
908        (50.0 + factors.iter().map(|f| f.weight).sum::<f64>()).clamp(0.0, 100.0),
909        1,
910    );
911    Ok(ElectionData {
912        score,
913        verdict: verdict(score),
914        purpose,
915        factors,
916        excluded_by: exclusions(moment, &status, criteria),
917        planetary_hours: moment.hours.clone(),
918        moon_status: status,
919    })
920}
921
922/// Lilly's qualities of the Moon's or the Ascendant's degree: pitted and lame degrees
923/// hinder, degrees increasing fortune help and, for the Ascendant, light degrees help while
924/// dark and void ones hinder (smoky degrees are between the two).
925fn degree_factors(factors: &mut Vec<ElectionFactor>, longitude: f64, moon: bool) {
926    let q = degree_qualities(longitude);
927    let tag = |code: &'static str, weight: f64| {
928        let factor = ElectionFactor::new(code, weight).sign(q.sign);
929        if moon {
930            factor.planet("Moon")
931        } else {
932            factor
933        }
934    };
935    let pick = |moon_code, asc_code| if moon { moon_code } else { asc_code };
936    if q.pitted {
937        factors.push(tag(pick("MOON_PITTED_DEGREE", "ASC_PITTED_DEGREE"), -4.0));
938    }
939    if q.azimene {
940        factors.push(tag(pick("MOON_AZIMENE_DEGREE", "ASC_AZIMENE_DEGREE"), -3.0));
941    }
942    if q.fortune {
943        let weight = if moon { 3.0 } else { 4.0 };
944        factors.push(tag(
945            pick("MOON_FORTUNE_DEGREE", "ASC_FORTUNE_DEGREE"),
946            weight,
947        ));
948    }
949    if !moon {
950        match q.light {
951            "light" => factors.push(tag("ASC_LIGHT_DEGREE", 2.0)),
952            "dark" | "void" => factors.push(tag("ASC_DARK_DEGREE", -2.0)),
953            _ => {}
954        }
955    }
956}
957
958fn verdict(score: f64) -> &'static str {
959    if score >= FAVOURABLE {
960        "favourable"
961    } else if score >= MIXED {
962        "mixed"
963    } else {
964        "unfavourable"
965    }
966}
967
968/// The election's contacts with a natal chart: the election's Ascendant counted from the
969/// natal one, benefics and malefics on the natal lights, angles and Ascendant ruler, and
970/// the election Moon's aspects to natal benefics and malefics.
971fn natal_factors(
972    factors: &mut Vec<ElectionFactor>,
973    moment: &Moment,
974    asc_sign: &'static str,
975    natal: &[NatalPoint],
976) {
977    let natal_point = |name: &str| natal.iter().find(|p| p.name == name);
978
979    if let Some(natal_asc) = natal_point("Ascendant") {
980        let natal_asc_sign = sign_of(natal_asc.ecliptic_longitude);
981        let place = (sign_index(asc_sign) + 12 - sign_index(natal_asc_sign)) % 12 + 1;
982        let place = place as u8;
983        if matches!(place, 1 | 5 | 9 | 10 | 11) {
984            factors.push(ElectionFactor::new("NATAL_ASC_WELL_PLACED", 5.0).house(place));
985        } else if is_dark_house(place) {
986            factors.push(ElectionFactor::new("NATAL_ASC_BADLY_PLACED", -6.0).house(place));
987        }
988    }
989
990    // Sensitive natal points: the lights, the angles and the Ascendant's ruler.
991    let mut sensitive: Vec<&NatalPoint> = ["Sun", "Moon", "Ascendant", "Midheaven"]
992        .iter()
993        .filter_map(|n| natal_point(n))
994        .collect();
995    if let Some(ruler) = natal_point("Ascendant")
996        .and_then(|a| traditional_ruler(sign_of(a.ecliptic_longitude)))
997        .and_then(natal_point)
998    {
999        if !sensitive.iter().any(|p| p.name == ruler.name) {
1000            sensitive.push(ruler);
1001        }
1002    }
1003    for p in moment.planets {
1004        let benefic = BENEFICS.contains(&p.name);
1005        if !(benefic || MALEFICS.contains(&p.name)) {
1006            continue;
1007        }
1008        for point in &sensitive {
1009            let Some(aspect) =
1010                ptolemaic_aspect(p.ecliptic_longitude, point.ecliptic_longitude, NATAL_ORB)
1011            else {
1012                continue;
1013            };
1014            let (code, weight) = match contact(benefic, aspect) {
1015                Some(true) => ("NATAL_BENEFIC_CONTACT", 4.0),
1016                Some(false) => ("NATAL_MALEFIC_CONTACT", -6.0),
1017                None => continue,
1018            };
1019            factors.push(
1020                ElectionFactor::new(code, weight)
1021                    .planet(p.name)
1022                    .target(point.name.clone())
1023                    .aspect(aspect),
1024            );
1025        }
1026    }
1027
1028    if let Some(moon) = moment.get("Moon") {
1029        for point in natal {
1030            let benefic = BENEFICS.contains(&point.name.as_str());
1031            if !(benefic || MALEFICS.contains(&point.name.as_str())) {
1032                continue;
1033            }
1034            let Some(aspect) =
1035                ptolemaic_aspect(moon.ecliptic_longitude, point.ecliptic_longitude, NATAL_ORB)
1036            else {
1037                continue;
1038            };
1039            let (code, weight) = match contact(benefic, aspect) {
1040                Some(true) => ("NATAL_MOON_TO_BENEFIC", 3.0),
1041                Some(false) => ("NATAL_MOON_TO_MALEFIC", -4.0),
1042                None => continue,
1043            };
1044            factors.push(
1045                ElectionFactor::new(code, weight)
1046                    .planet("Moon")
1047                    .target(point.name.clone())
1048                    .aspect(aspect),
1049            );
1050        }
1051    }
1052}
1053
1054// ---------------------------------------------------------------------------------------
1055// Entry points
1056// ---------------------------------------------------------------------------------------
1057
1058/// A chart for a candidate moment with its electional assessment.
1059pub fn calculate_election_chart(
1060    kernels: &KernelSet,
1061    req: &ChartRequest,
1062    criteria: &ElectionCriteria,
1063) -> Result<ElectionChart, EngineError> {
1064    let resolved = criteria.resolve()?;
1065    let chart = calculate_chart(kernels, req)?;
1066    let hours = planetary_hours(kernels, req.instant, req.latitude, req.longitude);
1067    let cusps: Vec<f64> = chart.houses.iter().map(|h| h.ecliptic_longitude).collect();
1068    let election_data = assess(
1069        &Moment {
1070            instant: req.instant,
1071            planets: &chart.planets,
1072            cusps: &cusps,
1073            hours: &hours,
1074        },
1075        &resolved,
1076    )?;
1077    Ok(ElectionChart {
1078        chart,
1079        election_data,
1080    })
1081}
1082
1083/// A search's source of positions: exact on an hourly grid, linearly interpolated in
1084/// between. Each planets computation gives the positions at its instant and an hour later
1085/// (from which the speeds come), so one computation serves two hours of moments. Between
1086/// grid points the Moon's longitude is off by well under an arc second.
1087struct Sampler<'a> {
1088    kernels: &'a KernelSet,
1089    req: &'a ChartRequest<'a>,
1090    system: HouseSystem,
1091    sidereal: bool,
1092    ayanamsa: &'static str,
1093    /// Anchors are every two hours from here.
1094    origin: UtcInstant,
1095    anchors: Vec<(i64, Anchor)>,
1096}
1097
1098/// Exact positions (and the Greenwich sidereal time) at a grid instant.
1099struct Anchor {
1100    bodies: Vec<BodyPosition>,
1101    gast_hours: f64,
1102}
1103
1104/// Longitude difference `b - a`, within ±180°.
1105fn wrapped(a: f64, b: f64) -> f64 {
1106    pyfloat::rem(b - a + 180.0, 360.0) - 180.0
1107}
1108
1109/// Bodies whose apparent motion can turn retrograde, as the planets computation flags them.
1110fn can_retrograde(name: &str) -> bool {
1111    !matches!(name, "Sun" | "Moon" | "Lilith")
1112}
1113
1114impl<'a> Sampler<'a> {
1115    fn new(kernels: &'a KernelSet, req: &'a ChartRequest<'a>) -> Self {
1116        Sampler {
1117            kernels,
1118            req,
1119            system: HouseSystem::from_code(req.house_system),
1120            sidereal: req.zodiac_type.trim().eq_ignore_ascii_case("sidereal"),
1121            ayanamsa: ayanamsa(req.ayanamsa).code,
1122            origin: req.instant,
1123            anchors: Vec::new(),
1124        }
1125    }
1126
1127    /// The anchor `index` × 2 hours after the origin (the last three are kept).
1128    fn anchor(&mut self, index: i64) -> Result<&Anchor, EngineError> {
1129        if let Some(pos) = self.anchors.iter().position(|(i, _)| *i == index) {
1130            return Ok(&self.anchors[pos].1);
1131        }
1132        let jd = self
1133            .origin
1134            .add_micros(index * 2 * 3_600_000_000)
1135            .julian_day();
1136        let shift = if self.sidereal {
1137            ayanamsa_info(jd, self.ayanamsa).value
1138        } else {
1139            0.0
1140        };
1141        let (planets, gast_hours) = planets_and_sidereal_time(self.kernels, jd, shift)?;
1142        if self.anchors.len() == 3 {
1143            self.anchors.remove(0);
1144        }
1145        self.anchors.push((
1146            index,
1147            Anchor {
1148                bodies: planets.bodies,
1149                gast_hours,
1150            },
1151        ));
1152        Ok(&self.anchors.last().unwrap().1)
1153    }
1154
1155    /// Placements and house cusps at `instant` (not before the origin).
1156    fn at(&mut self, instant: UtcInstant) -> Result<(Vec<Placement>, Vec<f64>), EngineError> {
1157        let hours = instant.seconds_since(&self.origin) / 3600.0;
1158        let index = (hours / 2.0).floor() as i64;
1159        let into = hours - index as f64 * 2.0; // 0 ≤ into < 2
1160        let (first, gast0) = {
1161            let anchor = self.anchor(index)?;
1162            let bodies: Vec<(&'static str, f64, f64, bool)> = anchor
1163                .bodies
1164                .iter()
1165                .map(|b| (b.name, b.longitude, b.speed, b.is_retrograde))
1166                .collect();
1167            (bodies, anchor.gast_hours)
1168        };
1169        let (next_bodies, gast1) = {
1170            let next = self.anchor(index + 1)?;
1171            (
1172                next.bodies
1173                    .iter()
1174                    .map(|b| (b.name, b.longitude, b.speed))
1175                    .collect::<Vec<_>>(),
1176                next.gast_hours,
1177            )
1178        };
1179
1180        // Each body's track over the three hours from the anchor: exact every hour (an
1181        // anchor's position an hour later comes from its speed, degrees per day over that
1182        // hour), linear in between.
1183        let hour_later = |lon: f64, speed: f64| pyfloat::rem(lon + speed / 24.0, 360.0);
1184        let mut raw = Vec::with_capacity(first.len());
1185        for &(name, lon, speed, is_retrograde) in &first {
1186            if into == 0.0 {
1187                raw.push((name, lon, speed, is_retrograde));
1188                continue;
1189            }
1190            let Some(&(_, next, next_speed)) = next_bodies.iter().find(|(n, ..)| *n == name) else {
1191                continue;
1192            };
1193            let points = [
1194                lon,
1195                hour_later(lon, speed),
1196                next,
1197                hour_later(next, next_speed),
1198            ];
1199            let track = |hours: f64| {
1200                let i = (hours.floor() as usize).min(2);
1201                let (a, b) = (points[i], points[i + 1]);
1202                pyfloat::rem(a + wrapped(a, b) * (hours - i as f64), 360.0)
1203            };
1204            // The speed as the planets computation measures it: over the next hour.
1205            let (longitude, later) = (track(into), track(into + 1.0));
1206            let speed = wrapped(longitude, later) * 24.0;
1207            raw.push((name, longitude, speed, can_retrograde(name) && speed < 0.0));
1208        }
1209
1210        let gast1 = if gast1 < gast0 { gast1 + 24.0 } else { gast1 };
1211        let gast = if into == 0.0 {
1212            gast0
1213        } else {
1214            pyfloat::rem(gast0 + (gast1 - gast0) * into / 2.0, 24.0)
1215        };
1216        let jd = instant.julian_day();
1217        let shift = if self.sidereal {
1218            ayanamsa_info(jd, self.ayanamsa).value
1219        } else {
1220            0.0
1221        };
1222        let houses = houses_at_sidereal_time(
1223            jd,
1224            gast,
1225            self.req.latitude,
1226            self.req.longitude,
1227            self.system,
1228            shift,
1229        );
1230        let cusps = houses.cusps.to_vec();
1231        Ok((placements(raw, &houses), cusps))
1232    }
1233}
1234
1235/// The solar days of a search's moments: from the sunrises and sunsets of the whole span
1236/// when a single kernel covers it, otherwise found day by day.
1237struct SolarDays<'a> {
1238    kernels: &'a KernelSet,
1239    latitude: f64,
1240    longitude: f64,
1241    events: Option<SunEvents>,
1242    last: Option<SolarDay>,
1243}
1244
1245impl<'a> SolarDays<'a> {
1246    fn new(kernels: &'a KernelSet, req: &ChartRequest, end: UtcInstant) -> Self {
1247        let events = SunEvents::find(
1248            kernels,
1249            jd_utc(req.instant) - 1.6,
1250            jd_utc(end) + 1.6,
1251            req.latitude,
1252            req.longitude,
1253        )
1254        .ok()
1255        .flatten();
1256        SolarDays {
1257            kernels,
1258            latitude: req.latitude,
1259            longitude: req.longitude,
1260            events,
1261            last: None,
1262        }
1263    }
1264
1265    fn hours(&mut self, instant: UtcInstant) -> PlanetaryHours {
1266        if let Some(times) = self
1267            .events
1268            .as_ref()
1269            .and_then(|e| e.rise_set(jd_utc(instant)))
1270        {
1271            return hours_in(&SolarDay::from_julian_days(times), instant);
1272        }
1273        let reuse = self.latitude.abs() < SOLAR_DAY_CACHE_MAX_LAT;
1274        let day = match self.last {
1275            Some(d) if reuse && d.covers(instant) => d,
1276            _ => solar_day(self.kernels, instant, self.latitude, self.longitude),
1277        };
1278        self.last = Some(day);
1279        hours_in(&day, instant)
1280    }
1281}
1282
1283/// The best windows from `req.instant` to `end` (at most [`MAX_SEARCH_DAYS`]) at the
1284/// request's place, with its house system and zodiac.
1285///
1286/// Moments are assessed every `step_minutes`; those failing a criteria filter are left
1287/// out, and runs of consecutive moments scoring at least `min_score` form the windows,
1288/// ranked by their best score. Positions between hourly grid points are interpolated
1289/// for speed, then each window's best moment is assessed again on exact positions, so
1290/// its score and factors are what [`calculate_election_chart`] gives for it.
1291pub fn search_elections(
1292    kernels: &KernelSet,
1293    req: &ChartRequest,
1294    end: UtcInstant,
1295    criteria: &ElectionCriteria,
1296) -> Result<ElectionSearch, EngineError> {
1297    let resolved = criteria.resolve()?;
1298    let start = req.instant;
1299    let span_days = end.seconds_since(&start) / 86_400.0;
1300    if span_days <= 0.0 {
1301        return Err(EngineError::InvalidInput(
1302            "the search must end after it starts".into(),
1303        ));
1304    }
1305    if span_days > MAX_SEARCH_DAYS {
1306        return Err(EngineError::InvalidInput(format!(
1307            "a search spans at most {MAX_SEARCH_DAYS} days"
1308        )));
1309    }
1310    // Fail fast rather than after assessing most of the span. The grid runs up to two
1311    // hours past the end, and planets are computed an hour after each grid point.
1312    require_kernel(kernels, start.julian_day())?;
1313    require_kernel(kernels, end.julian_day() + 3.0 / 24.0)?;
1314
1315    let step = i64::from(resolved.summary.step_minutes) * 60_000_000;
1316    let mut sampler = Sampler::new(kernels, req);
1317    let mut days = SolarDays::new(kernels, req, end);
1318    let mut evaluated = 0;
1319    let mut excluded = Exclusions::default();
1320    let mut windows: Vec<(UtcInstant, ElectionWindow)> = Vec::new();
1321    let mut open: Option<(UtcInstant, ElectionWindow)> = None;
1322
1323    let mut instant = start;
1324    while instant <= end {
1325        let kept = 'moment: {
1326            // Clock hours first: they need no ephemeris.
1327            if let Some(range) = resolved.summary.local_hours {
1328                if !range.contains(resolved.local_minute_of_day(instant)) {
1329                    excluded.count("outside_hours");
1330                    break 'moment None;
1331                }
1332            }
1333            let hours = days.hours(instant);
1334            if resolved.summary.daytime_only && !hours.is_day {
1335                excluded.count("night");
1336                break 'moment None;
1337            }
1338            let (planets, cusps) = sampler.at(instant)?;
1339            let data = assess(
1340                &Moment {
1341                    instant,
1342                    planets: &planets,
1343                    cusps: &cusps,
1344                    hours: &hours,
1345                },
1346                &resolved,
1347            )?;
1348            evaluated += 1;
1349            if let Some(reason) = data.excluded_by.first() {
1350                excluded.count(reason);
1351                break 'moment None;
1352            }
1353            (data.score >= resolved.summary.min_score).then_some(data)
1354        };
1355
1356        match kept {
1357            Some(data) => {
1358                let at = instant.isoformat();
1359                match open.as_mut() {
1360                    Some((best, w)) => {
1361                        w.end = at.clone();
1362                        if data.score > w.score {
1363                            *best = instant;
1364                            w.best = at;
1365                            w.score = data.score;
1366                        }
1367                    }
1368                    None => {
1369                        open = Some((
1370                            instant,
1371                            ElectionWindow {
1372                                start: at.clone(),
1373                                end: at.clone(),
1374                                best: at,
1375                                score: data.score,
1376                                verdict: data.verdict,
1377                                factors: Vec::new(),
1378                            },
1379                        ))
1380                    }
1381                }
1382            }
1383            None => windows.extend(open.take()),
1384        }
1385        instant = instant.add_micros(step);
1386    }
1387    windows.extend(open.take());
1388
1389    // Best first; earlier first on a tie (windows are already in time order).
1390    windows.sort_by(|a, b| b.1.score.total_cmp(&a.1.score));
1391    windows.truncate(resolved.summary.max_results);
1392
1393    // The best moments again, on exact positions.
1394    for (best, window) in &mut windows {
1395        let exact = sky(
1396            kernels,
1397            &ChartRequest {
1398                instant: *best,
1399                ..req.clone()
1400            },
1401        )?;
1402        let data = assess(
1403            &Moment {
1404                instant: *best,
1405                planets: &exact.planets,
1406                cusps: &exact.cusps,
1407                hours: &days.hours(*best),
1408            },
1409            &resolved,
1410        )?;
1411        window.score = data.score;
1412        window.verdict = data.verdict;
1413        window.factors = data.factors;
1414    }
1415    windows.sort_by(|a, b| b.1.score.total_cmp(&a.1.score));
1416
1417    Ok(ElectionSearch {
1418        start: start.isoformat(),
1419        end: end.isoformat(),
1420        windows: windows.into_iter().map(|(_, w)| w).collect(),
1421        evaluated,
1422        excluded,
1423        criteria: resolved.summary,
1424    })
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429    use super::*;
1430
1431    fn placement(name: &'static str, longitude: f64, house: u8, speed: f64) -> Placement {
1432        Placement {
1433            name,
1434            symbol: "",
1435            sign: sign_of(longitude),
1436            sign_symbol: "",
1437            degree: pyfloat::rem(longitude, 30.0) as i64,
1438            minute: 0,
1439            ecliptic_longitude: longitude,
1440            house,
1441            speed,
1442            is_retrograde: speed < 0.0 && name != "Moon" && name != "Sun",
1443            symbolic_degree: 1,
1444        }
1445    }
1446
1447    fn hours(ruler: &'static str, is_day: bool) -> PlanetaryHours {
1448        PlanetaryHours {
1449            is_day,
1450            day_ruler: "Sun",
1451            hour_ruler: ruler,
1452            hour_number: 1,
1453            hour_type: if is_day { "Day" } else { "Night" },
1454            sunrise: String::new(),
1455            sunset: String::new(),
1456        }
1457    }
1458
1459    /// Cusps every 30° from 0° Aries: house n starts at (n-1)·30°.
1460    fn cusps() -> Vec<f64> {
1461        (0..12).map(|i| f64::from(i) * 30.0).collect()
1462    }
1463
1464    fn assess_with(
1465        planets: &[Placement],
1466        hour: &PlanetaryHours,
1467        c: &ElectionCriteria,
1468    ) -> ElectionData {
1469        let cusps = cusps();
1470        assess(
1471            &Moment {
1472                instant: UtcInstant::parse("2026-10-01T12:00:00Z").unwrap(),
1473                planets,
1474                cusps: &cusps,
1475                hours: hour,
1476            },
1477            &c.resolve().unwrap(),
1478        )
1479        .unwrap()
1480    }
1481
1482    fn codes(data: &ElectionData) -> Vec<&'static str> {
1483        data.factors.iter().map(|f| f.code).collect()
1484    }
1485
1486    /// A pleasant sky: Moon in Taurus applying by trine to Jupiter, Venus in the 1st.
1487    fn good_sky() -> Vec<Placement> {
1488        vec![
1489            placement("Sun", 100.0, 4, 1.0),
1490            placement("Moon", 40.0, 2, 14.0),
1491            placement("Mercury", 110.0, 4, 1.2),
1492            placement("Venus", 15.0, 1, 1.1),
1493            placement("Mars", 160.0, 6, 0.6),
1494            placement("Jupiter", 165.0, 6, 0.1),
1495            placement("Saturn", 320.0, 11, 0.05),
1496            placement("Ascendant", 10.0, 1, 0.0),
1497            placement("Midheaven", 280.0, 10, 0.0),
1498        ]
1499    }
1500
1501    #[test]
1502    fn a_good_moment_scores_favourable() {
1503        let data = assess_with(
1504            &good_sky(),
1505            &hours("Jupiter", true),
1506            &ElectionCriteria::default(),
1507        );
1508        let codes = codes(&data);
1509        assert!(codes.contains(&"MOON_APPLYING_BENEFIC"), "{codes:?}");
1510        assert!(codes.contains(&"MOON_DIGNIFIED"));
1511        assert!(codes.contains(&"BENEFIC_IN_1ST"));
1512        assert!(codes.contains(&"HOUR_RULER_FAVOURS_PURPOSE"));
1513        assert!(!codes.contains(&"MOON_VOC"));
1514        assert_eq!(data.verdict, "favourable");
1515        assert!(data.excluded_by.is_empty());
1516    }
1517
1518    #[test]
1519    fn a_void_moon_is_penalized_and_excluded() {
1520        // Moon at 29° Taurus: no aspect left before Gemini.
1521        let mut sky = good_sky();
1522        sky[1] = placement("Moon", 59.5, 2, 14.0);
1523        let data = assess_with(&sky, &hours("Jupiter", true), &ElectionCriteria::default());
1524        assert!(codes(&data).contains(&"MOON_VOC"));
1525        assert_eq!(data.excluded_by, vec!["void_moon"]);
1526        let keep = ElectionCriteria {
1527            avoid_void_moon: false,
1528            ..Default::default()
1529        };
1530        assert!(assess_with(&sky, &hours("Jupiter", true), &keep)
1531            .excluded_by
1532            .is_empty());
1533    }
1534
1535    #[test]
1536    fn mercury_retrograde_weighs_on_contracts() {
1537        let mut sky = good_sky();
1538        sky[2] = placement("Mercury", 110.0, 4, -0.5);
1539        let general = assess_with(&sky, &hours("Sun", true), &ElectionCriteria::default());
1540        let contract = assess_with(
1541            &sky,
1542            &hours("Sun", true),
1543            &ElectionCriteria {
1544                purpose: Purpose::Contract,
1545                ..Default::default()
1546            },
1547        );
1548        let weight = |d: &ElectionData| {
1549            d.factors
1550                .iter()
1551                .find(|f| f.code == "MERCURY_RETROGRADE")
1552                .unwrap()
1553                .weight
1554        };
1555        assert_eq!(weight(&general), -2.0);
1556        assert_eq!(weight(&contract), -10.0);
1557        assert!(general.excluded_by.is_empty());
1558        assert_eq!(contract.excluded_by, vec!["mercury_retrograde"]);
1559    }
1560
1561    #[test]
1562    fn degree_qualities_of_the_moon_and_the_ascendant() {
1563        // Ascendant in the 11th degree of Aries (pitted, dark); Moon in the 8th of Taurus
1564        // (lame).
1565        let mut sky = good_sky();
1566        sky[7] = placement("Ascendant", 10.5, 1, 0.0);
1567        sky[1] = placement("Moon", 37.5, 2, 14.0);
1568        let data = assess_with(&sky, &hours("Jupiter", true), &ElectionCriteria::default());
1569        let found = codes(&data);
1570        for code in [
1571            "ASC_PITTED_DEGREE",
1572            "ASC_DARK_DEGREE",
1573            "MOON_AZIMENE_DEGREE",
1574        ] {
1575            assert!(found.contains(&code), "{code} missing from {found:?}");
1576        }
1577        let lame = data
1578            .factors
1579            .iter()
1580            .find(|f| f.code == "MOON_AZIMENE_DEGREE")
1581            .unwrap();
1582        assert_eq!(
1583            (lame.planet, lame.sign, lame.weight),
1584            (Some("Moon"), Some("Taurus"), -3.0)
1585        );
1586
1587        // Ascendant in the 19th of Aries (light, increasing fortune); Moon in the 3rd of
1588        // Taurus (increasing fortune).
1589        sky[7] = placement("Ascendant", 18.5, 1, 0.0);
1590        sky[1] = placement("Moon", 32.5, 2, 14.0);
1591        let data = assess_with(&sky, &hours("Jupiter", true), &ElectionCriteria::default());
1592        let found = codes(&data);
1593        for code in [
1594            "ASC_FORTUNE_DEGREE",
1595            "ASC_LIGHT_DEGREE",
1596            "MOON_FORTUNE_DEGREE",
1597        ] {
1598            assert!(found.contains(&code), "{code} missing from {found:?}");
1599        }
1600        assert!(!found.iter().any(|c| c.ends_with("PITTED_DEGREE")));
1601    }
1602
1603    #[test]
1604    fn malefics_on_the_ascendant_and_bad_hours_hurt() {
1605        let mut sky = good_sky();
1606        sky[6] = placement("Saturn", 20.0, 1, 0.05);
1607        let data = assess_with(&sky, &hours("Saturn", false), &ElectionCriteria::default());
1608        let codes = codes(&data);
1609        assert!(codes.contains(&"MALEFIC_IN_1ST"));
1610        assert!(codes.contains(&"HOUR_RULER_MALEFIC"));
1611    }
1612
1613    #[test]
1614    fn natal_factors_only_with_a_natal_chart() {
1615        let natal = vec![
1616            NatalPoint {
1617                name: "Ascendant".into(),
1618                ecliptic_longitude: 250.0, // Sagittarius: the election's Aries is its 5th
1619            },
1620            NatalPoint {
1621                name: "Sun".into(),
1622                ecliptic_longitude: 135.5, // trine the election's Venus at 15°
1623            },
1624            NatalPoint {
1625                name: "Moon".into(),
1626                ecliptic_longitude: 250.0,
1627            },
1628            NatalPoint {
1629                name: "Jupiter".into(),
1630                ecliptic_longitude: 280.0, // trine the election's Moon at 40°
1631            },
1632        ];
1633        let sky = good_sky();
1634        let without = assess_with(&sky, &hours("Sun", true), &ElectionCriteria::default());
1635        assert!(!codes(&without).iter().any(|c| c.starts_with("NATAL_")));
1636        let with = assess_with(
1637            &sky,
1638            &hours("Sun", true),
1639            &ElectionCriteria {
1640                natal: Some(natal),
1641                ..Default::default()
1642            },
1643        );
1644        let codes = codes(&with);
1645        assert!(codes.contains(&"NATAL_ASC_WELL_PLACED"), "{codes:?}");
1646        assert!(codes.contains(&"NATAL_BENEFIC_CONTACT"));
1647        assert!(codes.contains(&"NATAL_MOON_TO_BENEFIC"));
1648    }
1649
1650    #[test]
1651    fn local_hours_follow_the_utc_offset_and_wrap_midnight() {
1652        let criteria = ElectionCriteria {
1653            local_hours: Some(HourRange { from: 22, to: 2 }),
1654            utc_offsets: vec![UtcOffset {
1655                from: "2026-01-01T00:00:00Z".into(),
1656                minutes: 120,
1657            }],
1658            ..Default::default()
1659        }
1660        .resolve()
1661        .unwrap();
1662        let at = |s| criteria.local_minute_of_day(UtcInstant::parse(s).unwrap());
1663        assert_eq!(at("2026-10-01T21:30:00Z"), 23 * 60 + 30);
1664        let range = criteria.summary.local_hours.unwrap();
1665        assert!(range.contains(at("2026-10-01T21:30:00Z")));
1666        assert!(range.contains(at("2026-10-01T23:59:00Z")));
1667        assert!(!range.contains(at("2026-10-02T00:00:00Z")));
1668    }
1669
1670    #[test]
1671    fn criteria_parse_with_defaults_and_reject_typos() {
1672        let c = ElectionCriteria::from_value(&serde_json::json!({"purpose": "travel"})).unwrap();
1673        assert_eq!(c.purpose, Purpose::Travel);
1674        assert!(c.avoid_void_moon);
1675        let r = c.resolve().unwrap();
1676        assert!(r.summary.avoid_mercury_retrograde);
1677        assert!(!r.summary.avoid_venus_retrograde);
1678        assert!(ElectionCriteria::from_value(&serde_json::json!({"purpose": "war"})).is_err());
1679        assert!(ElectionCriteria::from_value(&serde_json::json!({"avoid_voc": true})).is_err());
1680        let hours = serde_json::json!({"local_hours": {"from": 9, "to": 9}});
1681        assert!(ElectionCriteria::from_value(&hours)
1682            .unwrap()
1683            .resolve()
1684            .is_err());
1685    }
1686
1687    #[test]
1688    fn dignities() {
1689        assert_eq!(dignity("Mercury", "Virgo"), Dignity::Dignified);
1690        assert_eq!(dignity("Mercury", "Pisces"), Dignity::Debilitated);
1691        assert_eq!(dignity("Saturn", "Libra"), Dignity::Dignified);
1692        assert_eq!(dignity("Mars", "Gemini"), Dignity::Peregrine);
1693        assert_eq!(sign_of(359.99), "Pisces");
1694        assert_eq!(sign_of(-0.5), "Pisces");
1695    }
1696
1697    /// The full de440s kernel, when it has been fetched (scripts/fetch-kernels.sh).
1698    fn kernels() -> Option<KernelSet> {
1699        use crate::ephemeris::{Kernel, Spk};
1700        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../kernels/de440s.bsp");
1701        let spk = Spk::open(path).ok()?;
1702        let mut set = KernelSet::new();
1703        set.push(Kernel::new("de440s.bsp", spk).ok()?);
1704        Some(set)
1705    }
1706
1707    #[test]
1708    fn interpolated_moments_match_exact_ones() {
1709        let Some(kernels) = kernels() else {
1710            eprintln!("skipping: kernels/de440s.bsp not found");
1711            return;
1712        };
1713        for (zodiac, system) in [("tropical", "P"), ("sidereal", "W")] {
1714            let mut origin = ChartRequest::new(
1715                UtcInstant::parse("2026-10-01T00:00:00Z").unwrap(),
1716                41.9,
1717                12.5,
1718            );
1719            origin.zodiac_type = zodiac;
1720            origin.house_system = system;
1721            origin.ayanamsa = "lahiri";
1722            let mut sampler = Sampler::new(&kernels, &origin);
1723            let criteria = ElectionCriteria::default().resolve().unwrap();
1724            let hours = hours("Sun", true);
1725            let (mut same, mut total) = (0, 0);
1726            // Every 7 minutes for four days: off the grid most of the time.
1727            for k in 0..(4 * 24 * 60 / 7) {
1728                let instant = origin.instant.add_micros(k * 7 * 60_000_000);
1729                let (planets, cusps) = sampler.at(instant).unwrap();
1730                let exact = sky(
1731                    &kernels,
1732                    &ChartRequest {
1733                        instant,
1734                        ..origin.clone()
1735                    },
1736                )
1737                .unwrap();
1738                for (p, e) in planets.iter().zip(&exact.planets) {
1739                    assert_eq!(p.name, e.name);
1740                    let off = separation(p.ecliptic_longitude, e.ecliptic_longitude) * 3600.0;
1741                    assert!(off < 1.0, "{} off by {off}\" at {instant:?}", p.name);
1742                    if k % (120 / 7 + 1) == 0 && instant.micros() % 7_200_000_000 == 0 {
1743                        assert_eq!(p, e, "grid points are exact");
1744                    }
1745                }
1746                for (c, e) in cusps.iter().zip(&exact.cusps) {
1747                    assert!(separation(*c, *e) * 3600.0 < 0.1, "cusp off at {instant:?}");
1748                }
1749                let moment = |planets, cusps| Moment {
1750                    instant,
1751                    planets,
1752                    cusps,
1753                    hours: &hours,
1754                };
1755                let a = assess(&moment(&planets, &cusps), &criteria).unwrap();
1756                let b = assess(&moment(&exact.planets, &exact.cusps), &criteria).unwrap();
1757                if codes(&a) != codes(&b) {
1758                    eprintln!("{instant:?}: {:?} vs {:?}", codes(&a), codes(&b));
1759                }
1760                same += usize::from(codes(&a) == codes(&b));
1761                total += 1;
1762            }
1763            assert!(
1764                same * 1000 >= total * 998,
1765                "{zodiac}: {same}/{total} moments agree"
1766            );
1767        }
1768    }
1769}