1use serde::Serialize;
5
6use crate::almanac::rise_set;
7use crate::chart::{chart_without_hours, Chart, ChartRequest, Placement};
8use crate::dignities::{element, solar_phase, triplicity_ruler};
9pub(crate) use crate::dignities::{traditional_ruler, SIGNS};
10use crate::ephemeris::KernelSet;
11use crate::error::EngineError;
12use crate::instant::UtcInstant;
13use crate::judgment::{cusp_signs, judge, Judgment};
14use crate::pyfloat;
15
16const CHALDEAN_ORDER: [&str; 7] = [
17 "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon",
18];
19const DAY_RULERS: [&str; 7] = [
21 "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun",
22];
23pub(crate) const PTOLEMAIC: [(f64, &str); 5] = [
24 (0.0, "Conjunction"),
25 (60.0, "Sextile"),
26 (90.0, "Square"),
27 (120.0, "Trine"),
28 (180.0, "Opposition"),
29];
30
31fn modern_ruler(sign: &str) -> Option<&'static str> {
32 Some(match sign {
33 "Scorpio" => "Pluto",
34 "Aquarius" => "Uranus",
35 "Pisces" => "Neptune",
36 _ => return None,
37 })
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize)]
42pub struct PlanetaryHours {
43 pub is_day: bool,
45 pub day_ruler: &'static str,
47 pub hour_ruler: &'static str,
49 pub hour_number: i64,
51 pub hour_type: &'static str,
53 pub sunrise: String,
55 pub sunset: String,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq)]
62pub(crate) struct SolarDay {
63 pub sunrise: UtcInstant,
64 pub sunset: UtcInstant,
65 pub next_sunrise: UtcInstant,
66 pub computed: bool,
68}
69
70const SUNRISE_SLACK_DAYS: f64 = 0.05;
72
73pub(crate) fn jd_utc(instant: UtcInstant) -> f64 {
75 2_451_545.0 + instant.seconds_since(&UtcInstant::J2000) / 86_400.0
76}
77
78impl SolarDay {
79 pub fn from_julian_days((rise, set, next): (f64, f64, f64)) -> Self {
81 let at = |jd: f64| UtcInstant::J2000.plus_days(jd - 2_451_545.0);
82 SolarDay {
83 sunrise: at(rise),
84 sunset: at(set),
85 next_sunrise: at(next),
86 computed: true,
87 }
88 }
89
90 pub fn covers(&self, instant: UtcInstant) -> bool {
93 let probe = instant.plus_days(SUNRISE_SLACK_DAYS);
94 self.computed && self.sunrise <= probe && probe < self.next_sunrise
95 }
96}
97
98pub(crate) fn solar_day(
101 kernels: &KernelSet,
102 instant: UtcInstant,
103 latitude: f64,
104 longitude: f64,
105) -> SolarDay {
106 match rise_set(kernels, jd_utc(instant), latitude, longitude) {
107 Ok(times) => SolarDay::from_julian_days(times),
108 Err(_) => {
109 let rise = instant.with_time(6, 0, 0, 0);
110 SolarDay {
111 sunrise: rise,
112 sunset: instant.with_time(18, 0, 0, 0),
113 next_sunrise: rise.add_micros(86_400_000_000),
114 computed: false,
115 }
116 }
117 }
118}
119
120pub fn planetary_hours(
123 kernels: &KernelSet,
124 instant: UtcInstant,
125 latitude: f64,
126 longitude: f64,
127) -> PlanetaryHours {
128 hours_in(&solar_day(kernels, instant, latitude, longitude), instant)
129}
130
131pub(crate) fn hours_in(day: &SolarDay, instant: UtcInstant) -> PlanetaryHours {
133 let SolarDay {
134 sunrise,
135 sunset,
136 next_sunrise,
137 ..
138 } = *day;
139
140 let is_day = sunrise <= instant && instant <= sunset;
141 let day_ruler = DAY_RULERS[sunrise.weekday() as usize];
142 let start = CHALDEAN_ORDER.iter().position(|p| *p == day_ruler).unwrap() as i64;
143
144 let (hour_number, offset, hour_type) = if is_day {
145 let hour_length = sunset.seconds_since(&sunrise) / 12.0;
146 let elapsed = instant.seconds_since(&sunrise);
147 let n = (elapsed / hour_length.max(1.0)) as i64 + 1;
148 (n.clamp(1, 12), 0, "Day")
149 } else {
150 let hour_length = next_sunrise.seconds_since(&sunset) / 12.0;
151 let elapsed = instant.seconds_since(&sunset);
152 let n = if elapsed < 0.0 {
153 12
154 } else {
155 (elapsed / hour_length.max(1.0)) as i64 + 1
156 };
157 (n.clamp(1, 12), 12, "Night")
158 };
159 let hour_ruler = CHALDEAN_ORDER[(start + offset + hour_number - 1).rem_euclid(7) as usize];
160
161 PlanetaryHours {
162 is_day,
163 day_ruler,
164 hour_ruler,
165 hour_number,
166 hour_type,
167 sunrise: sunrise.isoformat(),
168 sunset: sunset.isoformat(),
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Serialize)]
174pub struct ApplyingAspect {
175 pub planet: &'static str,
177 pub aspect: &'static str,
179 pub degrees_to_exact: f64,
181 pub target_sign: &'static str,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize)]
187pub struct SeparatingAspect {
188 pub planet: &'static str,
190 pub aspect: &'static str,
192 pub degrees_ago: f64,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize)]
198pub struct MoonStatus {
199 pub void_of_course: bool,
201 pub degrees_to_next_sign: f64,
203 pub hours_to_next_sign: f64,
205 pub next_sign: &'static str,
207 pub speed_status: &'static str,
209 pub applying_aspects: Vec<ApplyingAspect>,
211 pub next_applying_aspect: Option<ApplyingAspect>,
213 pub last_aspect: Option<SeparatingAspect>,
215 pub separating_aspects: Vec<SeparatingAspect>,
217}
218
219pub fn moon_status(moon: &Placement, planets: &[Placement]) -> MoonStatus {
222 let moon_lon = moon.ecliptic_longitude;
223 let moon_speed = moon.speed;
224 let sign_start = (moon_lon / 30.0).floor() * 30.0;
225 let to_sign_end = ((moon_lon / 30.0).floor() + 1.0) * 30.0 - moon_lon;
226 let from_sign_start = moon_lon - sign_start;
227 let speed_abs = if moon_speed.abs() > 1.0 {
228 moon_speed.abs()
229 } else {
230 13.18
231 };
232 let current = ((moon_lon / 30.0).floor() as i64).rem_euclid(12) as usize;
233
234 let mut applying: Vec<ApplyingAspect> = Vec::new();
235 let mut separating: Vec<SeparatingAspect> = Vec::new();
236 for p in planets {
237 if !matches!(
238 p.name,
239 "Sun" | "Mercury" | "Venus" | "Mars" | "Jupiter" | "Saturn"
240 ) {
241 continue;
242 }
243 for (angle, aspect) in PTOLEMAIC {
244 let mut targets = vec![pyfloat::rem(p.ecliptic_longitude + angle, 360.0)];
245 if angle != 0.0 && angle != 180.0 {
246 targets.push(pyfloat::rem(p.ecliptic_longitude - angle, 360.0));
247 }
248 for target in targets {
249 let ahead = pyfloat::rem(target - moon_lon, 360.0);
250 if 0.0 < ahead
251 && ahead <= to_sign_end
252 && moon_speed - p.speed > 0.0
253 && !applying
254 .iter()
255 .any(|a| a.planet == p.name && a.aspect == aspect)
256 {
257 applying.push(ApplyingAspect {
258 planet: p.name,
259 aspect,
260 degrees_to_exact: pyfloat::round(ahead, 2),
261 target_sign: moon.sign,
262 });
263 }
264 let behind = pyfloat::rem(moon_lon - target, 360.0);
265 if 0.0 < behind
266 && behind <= from_sign_start
267 && !separating
268 .iter()
269 .any(|s| s.planet == p.name && s.aspect == aspect)
270 {
271 separating.push(SeparatingAspect {
272 planet: p.name,
273 aspect,
274 degrees_ago: pyfloat::round(behind, 2),
275 });
276 }
277 }
278 }
279 }
280 applying.sort_by(|a, b| a.degrees_to_exact.total_cmp(&b.degrees_to_exact));
281 separating.sort_by(|a, b| a.degrees_ago.total_cmp(&b.degrees_ago));
282
283 MoonStatus {
284 void_of_course: applying.is_empty(),
285 degrees_to_next_sign: pyfloat::round(to_sign_end, 2),
286 hours_to_next_sign: pyfloat::round(to_sign_end / (speed_abs / 24.0), 1),
287 next_sign: SIGNS[(current + 1) % 12],
288 speed_status: if moon_speed > 13.5 {
289 "swift"
290 } else if moon_speed < 12.5 {
291 "slow"
292 } else {
293 "average"
294 },
295 next_applying_aspect: applying.first().cloned(),
296 last_aspect: separating.first().cloned(),
297 applying_aspects: applying,
298 separating_aspects: separating,
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize)]
304pub struct Stricture {
305 pub code: &'static str,
307 pub severity: &'static str,
309 pub message: String,
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize)]
315pub struct HoraryData {
316 pub planetary_hours: PlanetaryHours,
318 pub ascendant_sign: &'static str,
320 pub ascendant_degree: String,
322 pub traditional_asc_ruler: &'static str,
324 pub modern_asc_ruler: &'static str,
326 pub is_radical: bool,
328 pub strictures: Vec<Stricture>,
330 pub moon_status: MoonStatus,
332 pub judgment: Judgment,
334}
335
336#[derive(Debug, Clone, PartialEq, Serialize)]
338pub struct HoraryChart {
339 #[serde(flatten)]
341 pub chart: Chart,
342 pub horary_data: HoraryData,
344}
345
346pub fn calculate_horary_chart(
350 kernels: &KernelSet,
351 req: &ChartRequest,
352 quesited_house: Option<u8>,
353) -> Result<HoraryChart, EngineError> {
354 if let Some(house) = quesited_house.filter(|h| !(1..=12).contains(h)) {
355 return Err(EngineError::InvalidInput(format!(
356 "quesited_house must be 1-12, got {house}"
357 )));
358 }
359 let mut chart = chart_without_hours(kernels, req)?;
360 let hours = planetary_hours(kernels, req.instant, req.latitude, req.longitude);
361 chart.planetary_hours = Some(hours.clone());
362
363 let by_name = |name: &str| chart.planets.iter().find(|p| p.name == name);
364 let (asc_sign, asc_deg, asc_min) = match (by_name("Ascendant"), chart.houses.first()) {
365 (Some(asc), _) => (asc.sign, asc.degree, asc.minute),
366 (None, Some(h)) => (h.sign, h.degree, h.minute),
367 _ => ("Aries", 0, 0),
368 };
369 let traditional = traditional_ruler(asc_sign).unwrap_or("Mars");
370 let modern = modern_ruler(asc_sign).unwrap_or(traditional);
371
372 let mut strictures = Vec::new();
373 if asc_deg < 3 {
374 strictures.push(Stricture {
375 code: "EARLY_ASC",
376 severity: "warning",
377 message: format!(
378 "Ascendant is very early ({asc_deg}° {asc_sign}): The question may be premature, or circumstances are still developing."
379 ),
380 });
381 } else if asc_deg >= 27 {
382 strictures.push(Stricture {
383 code: "LATE_ASC",
384 severity: "warning",
385 message: format!(
386 "Ascendant is very late ({asc_deg}° {asc_sign}): The situation has already been decided or is out of the querent's control."
387 ),
388 });
389 }
390 match by_name("Saturn").map(|s| s.house) {
391 Some(1) => strictures.push(Stricture {
392 code: "SATURN_IN_1ST",
393 severity: "warning",
394 message: "Saturn in the 1st House: Querent may be obstructed, anxious, or facing delays."
395 .into(),
396 }),
397 Some(7) => strictures.push(Stricture {
398 code: "SATURN_IN_7TH",
399 severity: "info",
400 message: "Saturn in the 7th House: Astrologer's judgment may be challenged or the matter may be difficult to judge clearly."
401 .into(),
402 }),
403 _ => {}
404 }
405
406 let moon_place =
407 by_name("Moon").ok_or_else(|| EngineError::InvalidInput("chart has no Moon".into()))?;
408 let moon = moon_status(moon_place, &chart.planets);
409 if moon.void_of_course {
410 strictures.push(Stricture {
411 code: "MOON_VOC",
412 severity: "info",
413 message: "Moon is Void of Course: Nothing will come of the matter in question, or no immediate action will yield changes."
414 .into(),
415 });
416 }
417
418 let moon_lon = pyfloat::rem(moon_place.ecliptic_longitude, 360.0);
420 if (195.0..225.0).contains(&moon_lon) {
421 strictures.push(Stricture {
422 code: "MOON_VIA_COMBUSTA",
423 severity: "warning",
424 message: "Moon in the Via Combusta (15° Libra to 15° Scorpio): the matter is troubled and its outcome uncertain."
425 .into(),
426 });
427 }
428 if moon_place.degree >= 27 {
429 strictures.push(Stricture {
430 code: "MOON_LATE_DEGREES",
431 severity: "info",
432 message: format!(
433 "Moon in the last degrees of {} ({}°): she has little left to do in her sign.",
434 moon_place.sign, moon_place.degree
435 ),
436 });
437 }
438 if let (Some(ruler), Some(sun)) = (by_name(traditional), by_name("Sun")) {
439 if solar_phase(ruler.name, ruler.ecliptic_longitude, sun.ecliptic_longitude)
440 == Some("combust")
441 {
442 strictures.push(Stricture {
443 code: "ASC_RULER_COMBUST",
444 severity: "warning",
445 message: format!(
446 "The lord of the Ascendant ({traditional}) is combust: the querent is fearful, overpowered or without hope."
447 ),
448 });
449 }
450 }
451
452 let cusps: Vec<f64> = chart.houses.iter().map(|h| h.ecliptic_longitude).collect();
453 let judgment = judge(
454 kernels,
455 req.instant.julian_day(),
456 &chart.planets,
457 &cusp_signs(&cusps),
458 asc_sign,
459 quesited_house,
460 &chart.receptions,
461 );
462
463 let triplicity = triplicity_ruler(element(asc_sign).unwrap_or("Fire"), hours.is_day);
464 let is_radical = hours.hour_ruler == traditional || Some(hours.hour_ruler) == triplicity;
465
466 Ok(HoraryChart {
467 horary_data: HoraryData {
468 planetary_hours: hours,
469 ascendant_sign: asc_sign,
470 ascendant_degree: format!("{asc_deg}° {asc_min}' {asc_sign}"),
471 traditional_asc_ruler: traditional,
472 modern_asc_ruler: modern,
473 is_radical,
474 strictures,
475 moon_status: moon,
476 judgment,
477 },
478 chart,
479 })
480}