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