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