1use 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
31pub const MAX_SEARCH_DAYS: f64 = 92.0;
33const FAVOURABLE: f64 = 65.0;
35const MIXED: f64 = 45.0;
37const NATAL_ORB: f64 = 3.0;
39const SOLAR_DAY_CACHE_MAX_LAT: f64 = 60.0;
42
43const BENEFICS: [&str; 2] = ["Venus", "Jupiter"];
44const MALEFICS: [&str; 2] = ["Mars", "Saturn"];
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum Purpose {
51 #[default]
53 General,
54 Contract,
56 Partnership,
58 Travel,
60 Health,
62 Finance,
64 Launch,
66 Career,
68}
69
70impl Purpose {
71 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 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 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 fn fears_mercury_retrograde(self) -> bool {
110 matches!(self, Purpose::Contract | Purpose::Travel | Purpose::Launch)
111 }
112
113 fn fears_venus_retrograde(self) -> bool {
115 matches!(self, Purpose::Partnership)
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct HourRange {
123 pub from: u8,
125 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct UtcOffset {
146 pub from: String,
148 pub minutes: i32,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct NatalPoint {
155 pub name: String,
157 pub ecliptic_longitude: f64,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(default, deny_unknown_fields)]
164pub struct ElectionCriteria {
165 pub purpose: Purpose,
167 pub avoid_void_moon: bool,
169 pub avoid_mercury_retrograde: Option<bool>,
171 pub avoid_venus_retrograde: Option<bool>,
173 pub daytime_only: bool,
175 pub local_hours: Option<HourRange>,
177 pub utc_offsets: Vec<UtcOffset>,
179 pub step_minutes: u32,
181 pub min_score: f64,
183 pub max_results: usize,
185 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 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 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#[derive(Debug, Clone, PartialEq, Serialize)]
272pub struct CriteriaSummary {
273 pub purpose: Purpose,
275 pub avoid_void_moon: bool,
277 pub avoid_mercury_retrograde: bool,
279 pub avoid_venus_retrograde: bool,
281 pub daytime_only: bool,
283 pub local_hours: Option<HourRange>,
285 pub step_minutes: u32,
287 pub min_score: f64,
289 pub max_results: usize,
291 pub natal: bool,
293}
294
295struct Resolved {
296 summary: CriteriaSummary,
297 offsets: Vec<(UtcInstant, i32)>,
298 natal: Vec<NatalPoint>,
299}
300
301impl Resolved {
302 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#[derive(Debug, Clone, PartialEq, Serialize)]
318pub struct ElectionFactor {
319 pub code: &'static str,
321 pub weight: f64,
323 pub planet: Option<&'static str>,
325 pub target: Option<String>,
327 pub aspect: Option<&'static str>,
329 pub house: Option<u8>,
331 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#[derive(Debug, Clone, PartialEq, Serialize)]
371pub struct ElectionData {
372 pub score: f64,
374 pub verdict: &'static str,
376 pub purpose: Purpose,
378 pub factors: Vec<ElectionFactor>,
381 pub excluded_by: Vec<&'static str>,
384 pub planetary_hours: PlanetaryHours,
386 pub moon_status: MoonStatus,
388}
389
390#[derive(Debug, Clone, PartialEq, Serialize)]
392pub struct ElectionChart {
393 #[serde(flatten)]
395 pub chart: Chart,
396 pub election_data: ElectionData,
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize)]
402pub struct ElectionWindow {
403 pub start: String,
405 pub end: String,
407 pub best: String,
409 pub score: f64,
411 pub verdict: &'static str,
413 pub factors: Vec<ElectionFactor>,
415}
416
417#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
419pub struct Exclusions {
420 pub void_moon: u32,
422 pub mercury_retrograde: u32,
424 pub venus_retrograde: u32,
426 pub night: u32,
428 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#[derive(Debug, Clone, PartialEq, Serialize)]
446pub struct ElectionSearch {
447 pub start: String,
449 pub end: String,
451 pub windows: Vec<ElectionWindow>,
453 pub evaluated: u32,
455 pub excluded: Exclusions,
457 pub criteria: CriteriaSummary,
459}
460
461fn 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
474fn 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
491fn 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
506fn 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
515fn 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
526fn 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
541fn 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
552struct 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
588struct 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
629struct 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
643fn 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
668fn 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 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 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 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 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 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 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 matches!(sig.name, "Mercury" | "Venus"),
866 );
867 }
868 }
869
870 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 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
899fn 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
945fn 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 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
1031pub 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
1061struct Sampler<'a> {
1066 kernels: &'a KernelSet,
1067 req: &'a ChartRequest<'a>,
1068 system: HouseSystem,
1069 sidereal: bool,
1070 ayanamsa: &'static str,
1071 origin: UtcInstant,
1073 anchors: Vec<(i64, Anchor)>,
1074}
1075
1076struct Anchor {
1078 bodies: Vec<BodyPosition>,
1079 gast_hours: f64,
1080}
1081
1082fn wrapped(a: f64, b: f64) -> f64 {
1084 pyfloat::rem(b - a + 180.0, 360.0) - 180.0
1085}
1086
1087fn 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 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 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; 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 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 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
1213struct 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
1261pub 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 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 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 windows.sort_by(|a, b| b.1.score.total_cmp(&a.1.score));
1369 windows.truncate(resolved.summary.max_results);
1370
1371 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 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 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 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 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 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, },
1599 NatalPoint {
1600 name: "Sun".into(),
1601 ecliptic_longitude: 135.5, },
1603 NatalPoint {
1604 name: "Moon".into(),
1605 ecliptic_longitude: 250.0,
1606 },
1607 NatalPoint {
1608 name: "Jupiter".into(),
1609 ecliptic_longitude: 280.0, },
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 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 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}