1use 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
29pub const MAX_SEARCH_DAYS: f64 = 92.0;
31const FAVOURABLE: f64 = 65.0;
33const MIXED: f64 = 45.0;
35const COMBUST_ORB: f64 = 8.5;
37const CAZIMI_ORB: f64 = 17.0 / 60.0;
39const NATAL_ORB: f64 = 3.0;
41const SOLAR_DAY_CACHE_MAX_LAT: f64 = 60.0;
44
45const BENEFICS: [&str; 2] = ["Venus", "Jupiter"];
46const MALEFICS: [&str; 2] = ["Mars", "Saturn"];
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Purpose {
53 #[default]
55 General,
56 Contract,
58 Partnership,
60 Travel,
62 Health,
64 Finance,
66 Launch,
68 Career,
70}
71
72impl Purpose {
73 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 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 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 fn fears_mercury_retrograde(self) -> bool {
112 matches!(self, Purpose::Contract | Purpose::Travel | Purpose::Launch)
113 }
114
115 fn fears_venus_retrograde(self) -> bool {
117 matches!(self, Purpose::Partnership)
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct HourRange {
125 pub from: u8,
127 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct UtcOffset {
148 pub from: String,
150 pub minutes: i32,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct NatalPoint {
157 pub name: String,
159 pub ecliptic_longitude: f64,
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(default, deny_unknown_fields)]
166pub struct ElectionCriteria {
167 pub purpose: Purpose,
169 pub avoid_void_moon: bool,
171 pub avoid_mercury_retrograde: Option<bool>,
173 pub avoid_venus_retrograde: Option<bool>,
175 pub daytime_only: bool,
177 pub local_hours: Option<HourRange>,
179 pub utc_offsets: Vec<UtcOffset>,
181 pub step_minutes: u32,
183 pub min_score: f64,
185 pub max_results: usize,
187 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 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 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#[derive(Debug, Clone, PartialEq, Serialize)]
274pub struct CriteriaSummary {
275 pub purpose: Purpose,
277 pub avoid_void_moon: bool,
279 pub avoid_mercury_retrograde: bool,
281 pub avoid_venus_retrograde: bool,
283 pub daytime_only: bool,
285 pub local_hours: Option<HourRange>,
287 pub step_minutes: u32,
289 pub min_score: f64,
291 pub max_results: usize,
293 pub natal: bool,
295}
296
297struct Resolved {
298 summary: CriteriaSummary,
299 offsets: Vec<(UtcInstant, i32)>,
300 natal: Vec<NatalPoint>,
301}
302
303impl Resolved {
304 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#[derive(Debug, Clone, PartialEq, Serialize)]
320pub struct ElectionFactor {
321 pub code: &'static str,
323 pub weight: f64,
325 pub planet: Option<&'static str>,
327 pub target: Option<String>,
329 pub aspect: Option<&'static str>,
331 pub house: Option<u8>,
333 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#[derive(Debug, Clone, PartialEq, Serialize)]
373pub struct ElectionData {
374 pub score: f64,
376 pub verdict: &'static str,
378 pub purpose: Purpose,
380 pub factors: Vec<ElectionFactor>,
383 pub excluded_by: Vec<&'static str>,
386 pub planetary_hours: PlanetaryHours,
388 pub moon_status: MoonStatus,
390}
391
392#[derive(Debug, Clone, PartialEq, Serialize)]
394pub struct ElectionChart {
395 #[serde(flatten)]
397 pub chart: Chart,
398 pub election_data: ElectionData,
400}
401
402#[derive(Debug, Clone, PartialEq, Serialize)]
404pub struct ElectionWindow {
405 pub start: String,
407 pub end: String,
409 pub best: String,
411 pub score: f64,
413 pub verdict: &'static str,
415 pub factors: Vec<ElectionFactor>,
417}
418
419#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
421pub struct Exclusions {
422 pub void_moon: u32,
424 pub mercury_retrograde: u32,
426 pub venus_retrograde: u32,
428 pub night: u32,
430 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#[derive(Debug, Clone, PartialEq, Serialize)]
448pub struct ElectionSearch {
449 pub start: String,
451 pub end: String,
453 pub windows: Vec<ElectionWindow>,
455 pub evaluated: u32,
457 pub excluded: Exclusions,
459 pub criteria: CriteriaSummary,
461}
462
463fn 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
476fn 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
493fn 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
530fn 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
539fn 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
550fn 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
565fn 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
574struct 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
610struct 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
651struct 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
665fn 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
690fn 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 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 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 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 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 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 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 matches!(sig.name, "Mercury" | "Venus"),
886 );
887 }
888 }
889
890 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 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
929fn 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 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
1015pub 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
1044struct Sampler<'a> {
1049 kernels: &'a KernelSet,
1050 req: &'a ChartRequest<'a>,
1051 system: HouseSystem,
1052 sidereal: bool,
1053 ayanamsa: &'static str,
1054 origin: UtcInstant,
1056 anchors: Vec<(i64, Anchor)>,
1057}
1058
1059struct Anchor {
1061 bodies: Vec<BodyPosition>,
1062 gast_hours: f64,
1063}
1064
1065fn wrapped(a: f64, b: f64) -> f64 {
1067 pyfloat::rem(b - a + 180.0, 360.0) - 180.0
1068}
1069
1070fn 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 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 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; 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 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 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
1196struct 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
1244pub 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 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 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 windows.sort_by(|a, b| b.1.score.total_cmp(&a.1.score));
1352 windows.truncate(resolved.summary.max_results);
1353
1354 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 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 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 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, },
1539 NatalPoint {
1540 name: "Sun".into(),
1541 ecliptic_longitude: 135.5, },
1543 NatalPoint {
1544 name: "Moon".into(),
1545 ecliptic_longitude: 250.0,
1546 },
1547 NatalPoint {
1548 name: "Jupiter".into(),
1549 ecliptic_longitude: 280.0, },
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 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 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}