1use serde::Serialize;
5
6use crate::almanac::rise_set;
7use crate::chart::{calculate_chart, Chart, ChartRequest, Placement};
8use crate::ephemeris::KernelSet;
9use crate::error::EngineError;
10use crate::instant::UtcInstant;
11use crate::pyfloat;
12
13const CHALDEAN_ORDER: [&str; 7] = [
14 "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon",
15];
16const DAY_RULERS: [&str; 7] = [
18 "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun",
19];
20pub(crate) const SIGNS: [&str; 12] = [
21 "Aries",
22 "Taurus",
23 "Gemini",
24 "Cancer",
25 "Leo",
26 "Virgo",
27 "Libra",
28 "Scorpio",
29 "Sagittarius",
30 "Capricorn",
31 "Aquarius",
32 "Pisces",
33];
34pub(crate) const PTOLEMAIC: [(f64, &str); 5] = [
35 (0.0, "Conjunction"),
36 (60.0, "Sextile"),
37 (90.0, "Square"),
38 (120.0, "Trine"),
39 (180.0, "Opposition"),
40];
41
42pub(crate) fn traditional_ruler(sign: &str) -> Option<&'static str> {
43 Some(match sign {
44 "Aries" | "Scorpio" => "Mars",
45 "Taurus" | "Libra" => "Venus",
46 "Gemini" | "Virgo" => "Mercury",
47 "Cancer" => "Moon",
48 "Leo" => "Sun",
49 "Sagittarius" | "Pisces" => "Jupiter",
50 "Capricorn" | "Aquarius" => "Saturn",
51 _ => return None,
52 })
53}
54
55fn modern_ruler(sign: &str) -> Option<&'static str> {
56 Some(match sign {
57 "Scorpio" => "Pluto",
58 "Aquarius" => "Uranus",
59 "Pisces" => "Neptune",
60 _ => return None,
61 })
62}
63
64pub(crate) fn element(sign: &str) -> Option<&'static str> {
65 Some(match sign {
66 "Aries" | "Leo" | "Sagittarius" => "Fire",
67 "Taurus" | "Virgo" | "Capricorn" => "Earth",
68 "Gemini" | "Libra" | "Aquarius" => "Air",
69 "Cancer" | "Scorpio" | "Pisces" => "Water",
70 _ => return None,
71 })
72}
73
74pub(crate) fn triplicity_ruler(element: &str, is_day: bool) -> Option<&'static str> {
75 Some(match (element, is_day) {
76 ("Fire", true) => "Sun",
77 ("Earth", true) => "Venus",
78 ("Air", true) => "Saturn",
79 ("Water", true) => "Venus",
80 ("Fire", false) => "Jupiter",
81 ("Earth", false) => "Moon",
82 ("Air", false) => "Mercury",
83 ("Water", false) => "Mars",
84 _ => return None,
85 })
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize)]
90pub struct PlanetaryHours {
91 pub is_day: bool,
93 pub day_ruler: &'static str,
95 pub hour_ruler: &'static str,
97 pub hour_number: i64,
99 pub hour_type: &'static str,
101 pub sunrise: String,
103 pub sunset: String,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
110pub(crate) struct SolarDay {
111 pub sunrise: UtcInstant,
112 pub sunset: UtcInstant,
113 pub next_sunrise: UtcInstant,
114 pub computed: bool,
116}
117
118const SUNRISE_SLACK_DAYS: f64 = 0.05;
120
121pub(crate) fn jd_utc(instant: UtcInstant) -> f64 {
123 2_451_545.0 + instant.seconds_since(&UtcInstant::J2000) / 86_400.0
124}
125
126impl SolarDay {
127 pub fn from_julian_days((rise, set, next): (f64, f64, f64)) -> Self {
129 let at = |jd: f64| UtcInstant::J2000.plus_days(jd - 2_451_545.0);
130 SolarDay {
131 sunrise: at(rise),
132 sunset: at(set),
133 next_sunrise: at(next),
134 computed: true,
135 }
136 }
137
138 pub fn covers(&self, instant: UtcInstant) -> bool {
141 let probe = instant.plus_days(SUNRISE_SLACK_DAYS);
142 self.computed && self.sunrise <= probe && probe < self.next_sunrise
143 }
144}
145
146pub(crate) fn solar_day(
149 kernels: &KernelSet,
150 instant: UtcInstant,
151 latitude: f64,
152 longitude: f64,
153) -> SolarDay {
154 match rise_set(kernels, jd_utc(instant), latitude, longitude) {
155 Ok(times) => SolarDay::from_julian_days(times),
156 Err(_) => {
157 let rise = instant.with_time(6, 0, 0, 0);
158 SolarDay {
159 sunrise: rise,
160 sunset: instant.with_time(18, 0, 0, 0),
161 next_sunrise: rise.add_micros(86_400_000_000),
162 computed: false,
163 }
164 }
165 }
166}
167
168pub fn planetary_hours(
171 kernels: &KernelSet,
172 instant: UtcInstant,
173 latitude: f64,
174 longitude: f64,
175) -> PlanetaryHours {
176 hours_in(&solar_day(kernels, instant, latitude, longitude), instant)
177}
178
179pub(crate) fn hours_in(day: &SolarDay, instant: UtcInstant) -> PlanetaryHours {
181 let SolarDay {
182 sunrise,
183 sunset,
184 next_sunrise,
185 ..
186 } = *day;
187
188 let is_day = sunrise <= instant && instant <= sunset;
189 let day_ruler = DAY_RULERS[sunrise.weekday() as usize];
190 let start = CHALDEAN_ORDER.iter().position(|p| *p == day_ruler).unwrap() as i64;
191
192 let (hour_number, offset, hour_type) = if is_day {
193 let hour_length = sunset.seconds_since(&sunrise) / 12.0;
194 let elapsed = instant.seconds_since(&sunrise);
195 let n = (elapsed / hour_length.max(1.0)) as i64 + 1;
196 (n.clamp(1, 12), 0, "Day")
197 } else {
198 let hour_length = next_sunrise.seconds_since(&sunset) / 12.0;
199 let elapsed = instant.seconds_since(&sunset);
200 let n = if elapsed < 0.0 {
201 12
202 } else {
203 (elapsed / hour_length.max(1.0)) as i64 + 1
204 };
205 (n.clamp(1, 12), 12, "Night")
206 };
207 let hour_ruler = CHALDEAN_ORDER[(start + offset + hour_number - 1).rem_euclid(7) as usize];
208
209 PlanetaryHours {
210 is_day,
211 day_ruler,
212 hour_ruler,
213 hour_number,
214 hour_type,
215 sunrise: sunrise.isoformat(),
216 sunset: sunset.isoformat(),
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize)]
222pub struct ApplyingAspect {
223 pub planet: &'static str,
225 pub aspect: &'static str,
227 pub degrees_to_exact: f64,
229 pub target_sign: &'static str,
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize)]
235pub struct SeparatingAspect {
236 pub planet: &'static str,
238 pub aspect: &'static str,
240 pub degrees_ago: f64,
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize)]
246pub struct MoonStatus {
247 pub void_of_course: bool,
249 pub degrees_to_next_sign: f64,
251 pub hours_to_next_sign: f64,
253 pub next_sign: &'static str,
255 pub speed_status: &'static str,
257 pub applying_aspects: Vec<ApplyingAspect>,
259 pub next_applying_aspect: Option<ApplyingAspect>,
261 pub last_aspect: Option<SeparatingAspect>,
263 pub separating_aspects: Vec<SeparatingAspect>,
265}
266
267pub fn moon_status(moon: &Placement, planets: &[Placement]) -> MoonStatus {
270 let moon_lon = moon.ecliptic_longitude;
271 let moon_speed = moon.speed;
272 let sign_start = (moon_lon / 30.0).floor() * 30.0;
273 let to_sign_end = ((moon_lon / 30.0).floor() + 1.0) * 30.0 - moon_lon;
274 let from_sign_start = moon_lon - sign_start;
275 let speed_abs = if moon_speed.abs() > 1.0 {
276 moon_speed.abs()
277 } else {
278 13.18
279 };
280 let current = ((moon_lon / 30.0).floor() as i64).rem_euclid(12) as usize;
281
282 let mut applying: Vec<ApplyingAspect> = Vec::new();
283 let mut separating: Vec<SeparatingAspect> = Vec::new();
284 for p in planets {
285 if !matches!(
286 p.name,
287 "Sun" | "Mercury" | "Venus" | "Mars" | "Jupiter" | "Saturn"
288 ) {
289 continue;
290 }
291 for (angle, aspect) in PTOLEMAIC {
292 let mut targets = vec![pyfloat::rem(p.ecliptic_longitude + angle, 360.0)];
293 if angle != 0.0 && angle != 180.0 {
294 targets.push(pyfloat::rem(p.ecliptic_longitude - angle, 360.0));
295 }
296 for target in targets {
297 let ahead = pyfloat::rem(target - moon_lon, 360.0);
298 if 0.0 < ahead
299 && ahead <= to_sign_end
300 && moon_speed - p.speed > 0.0
301 && !applying
302 .iter()
303 .any(|a| a.planet == p.name && a.aspect == aspect)
304 {
305 applying.push(ApplyingAspect {
306 planet: p.name,
307 aspect,
308 degrees_to_exact: pyfloat::round(ahead, 2),
309 target_sign: moon.sign,
310 });
311 }
312 let behind = pyfloat::rem(moon_lon - target, 360.0);
313 if 0.0 < behind
314 && behind <= from_sign_start
315 && !separating
316 .iter()
317 .any(|s| s.planet == p.name && s.aspect == aspect)
318 {
319 separating.push(SeparatingAspect {
320 planet: p.name,
321 aspect,
322 degrees_ago: pyfloat::round(behind, 2),
323 });
324 }
325 }
326 }
327 }
328 applying.sort_by(|a, b| a.degrees_to_exact.total_cmp(&b.degrees_to_exact));
329 separating.sort_by(|a, b| a.degrees_ago.total_cmp(&b.degrees_ago));
330
331 MoonStatus {
332 void_of_course: applying.is_empty(),
333 degrees_to_next_sign: pyfloat::round(to_sign_end, 2),
334 hours_to_next_sign: pyfloat::round(to_sign_end / (speed_abs / 24.0), 1),
335 next_sign: SIGNS[(current + 1) % 12],
336 speed_status: if moon_speed > 13.5 {
337 "swift"
338 } else if moon_speed < 12.5 {
339 "slow"
340 } else {
341 "average"
342 },
343 next_applying_aspect: applying.first().cloned(),
344 last_aspect: separating.first().cloned(),
345 applying_aspects: applying,
346 separating_aspects: separating,
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Serialize)]
352pub struct Stricture {
353 pub code: &'static str,
355 pub severity: &'static str,
357 pub message: String,
359}
360
361#[derive(Debug, Clone, PartialEq, Serialize)]
363pub struct HoraryData {
364 pub planetary_hours: PlanetaryHours,
366 pub ascendant_sign: &'static str,
368 pub ascendant_degree: String,
370 pub traditional_asc_ruler: &'static str,
372 pub modern_asc_ruler: &'static str,
374 pub is_radical: bool,
376 pub strictures: Vec<Stricture>,
378 pub moon_status: MoonStatus,
380}
381
382#[derive(Debug, Clone, PartialEq, Serialize)]
384pub struct HoraryChart {
385 #[serde(flatten)]
387 pub chart: Chart,
388 pub horary_data: HoraryData,
390}
391
392pub fn calculate_horary_chart(
395 kernels: &KernelSet,
396 req: &ChartRequest,
397) -> Result<HoraryChart, EngineError> {
398 let chart = calculate_chart(kernels, req)?;
399 let hours = planetary_hours(kernels, req.instant, req.latitude, req.longitude);
400
401 let by_name = |name: &str| chart.planets.iter().find(|p| p.name == name);
402 let (asc_sign, asc_deg, asc_min) = match (by_name("Ascendant"), chart.houses.first()) {
403 (Some(asc), _) => (asc.sign, asc.degree, asc.minute),
404 (None, Some(h)) => (h.sign, h.degree, h.minute),
405 _ => ("Aries", 0, 0),
406 };
407 let traditional = traditional_ruler(asc_sign).unwrap_or("Mars");
408 let modern = modern_ruler(asc_sign).unwrap_or(traditional);
409
410 let mut strictures = Vec::new();
411 if asc_deg < 3 {
412 strictures.push(Stricture {
413 code: "EARLY_ASC",
414 severity: "warning",
415 message: format!(
416 "Ascendant is very early ({asc_deg}° {asc_sign}): The question may be premature, or circumstances are still developing."
417 ),
418 });
419 } else if asc_deg >= 27 {
420 strictures.push(Stricture {
421 code: "LATE_ASC",
422 severity: "warning",
423 message: format!(
424 "Ascendant is very late ({asc_deg}° {asc_sign}): The situation has already been decided or is out of the querent's control."
425 ),
426 });
427 }
428 match by_name("Saturn").map(|s| s.house) {
429 Some(1) => strictures.push(Stricture {
430 code: "SATURN_IN_1ST",
431 severity: "warning",
432 message: "Saturn in the 1st House: Querent may be obstructed, anxious, or facing delays."
433 .into(),
434 }),
435 Some(7) => strictures.push(Stricture {
436 code: "SATURN_IN_7TH",
437 severity: "info",
438 message: "Saturn in the 7th House: Astrologer's judgment may be challenged or the matter may be difficult to judge clearly."
439 .into(),
440 }),
441 _ => {}
442 }
443
444 let moon =
445 by_name("Moon").ok_or_else(|| EngineError::InvalidInput("chart has no Moon".into()))?;
446 let moon = moon_status(moon, &chart.planets);
447 if moon.void_of_course {
448 strictures.push(Stricture {
449 code: "MOON_VOC",
450 severity: "info",
451 message: "Moon is Void of Course: Nothing will come of the matter in question, or no immediate action will yield changes."
452 .into(),
453 });
454 }
455
456 let triplicity = triplicity_ruler(element(asc_sign).unwrap_or("Fire"), hours.is_day);
457 let is_radical = hours.hour_ruler == traditional || Some(hours.hour_ruler) == triplicity;
458
459 Ok(HoraryChart {
460 horary_data: HoraryData {
461 planetary_hours: hours,
462 ascendant_sign: asc_sign,
463 ascendant_degree: format!("{asc_deg}° {asc_min}' {asc_sign}"),
464 traditional_asc_ruler: traditional,
465 modern_asc_ruler: modern,
466 is_radical,
467 strictures,
468 moon_status: moon,
469 },
470 chart,
471 })
472}