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