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];
20const 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];
34const 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
42fn 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
64fn 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
74fn 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
107pub fn planetary_hours(
110 kernels: &KernelSet,
111 instant: UtcInstant,
112 latitude: f64,
113 longitude: f64,
114) -> PlanetaryHours {
115 let jd_utc = 2_451_545.0 + instant.seconds_since(&UtcInstant::J2000) / 86_400.0;
116 let (sunrise, sunset, next_sunrise) = match rise_set(kernels, jd_utc, latitude, longitude) {
117 Ok((rise, set, next)) => {
118 let at = |jd: f64| UtcInstant::J2000.plus_days(jd - 2_451_545.0);
119 (at(rise), at(set), at(next))
120 }
121 Err(_) => {
122 let rise = instant.with_time(6, 0, 0, 0);
123 (
124 rise,
125 instant.with_time(18, 0, 0, 0),
126 rise.add_micros(86_400_000_000),
127 )
128 }
129 };
130
131 let is_day = sunrise <= instant && instant <= sunset;
132 let day_ruler = DAY_RULERS[sunrise.weekday() as usize];
133 let start = CHALDEAN_ORDER.iter().position(|p| *p == day_ruler).unwrap() as i64;
134
135 let (hour_number, offset, hour_type) = if is_day {
136 let hour_length = sunset.seconds_since(&sunrise) / 12.0;
137 let elapsed = instant.seconds_since(&sunrise);
138 let n = (elapsed / hour_length.max(1.0)) as i64 + 1;
139 (n.clamp(1, 12), 0, "Day")
140 } else {
141 let hour_length = next_sunrise.seconds_since(&sunset) / 12.0;
142 let elapsed = instant.seconds_since(&sunset);
143 let n = if elapsed < 0.0 {
144 12
145 } else {
146 (elapsed / hour_length.max(1.0)) as i64 + 1
147 };
148 (n.clamp(1, 12), 12, "Night")
149 };
150 let hour_ruler = CHALDEAN_ORDER[(start + offset + hour_number - 1).rem_euclid(7) as usize];
151
152 PlanetaryHours {
153 is_day,
154 day_ruler,
155 hour_ruler,
156 hour_number,
157 hour_type,
158 sunrise: sunrise.isoformat(),
159 sunset: sunset.isoformat(),
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Serialize)]
165pub struct ApplyingAspect {
166 pub planet: &'static str,
168 pub aspect: &'static str,
170 pub degrees_to_exact: f64,
172 pub target_sign: &'static str,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize)]
178pub struct SeparatingAspect {
179 pub planet: &'static str,
181 pub aspect: &'static str,
183 pub degrees_ago: f64,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize)]
189pub struct MoonStatus {
190 pub void_of_course: bool,
192 pub degrees_to_next_sign: f64,
194 pub hours_to_next_sign: f64,
196 pub next_sign: &'static str,
198 pub speed_status: &'static str,
200 pub applying_aspects: Vec<ApplyingAspect>,
202 pub next_applying_aspect: Option<ApplyingAspect>,
204 pub last_aspect: Option<SeparatingAspect>,
206 pub separating_aspects: Vec<SeparatingAspect>,
208}
209
210pub fn moon_status(moon: &Placement, planets: &[Placement]) -> MoonStatus {
213 let moon_lon = moon.ecliptic_longitude;
214 let moon_speed = moon.speed;
215 let sign_start = (moon_lon / 30.0).floor() * 30.0;
216 let to_sign_end = ((moon_lon / 30.0).floor() + 1.0) * 30.0 - moon_lon;
217 let from_sign_start = moon_lon - sign_start;
218 let speed_abs = if moon_speed.abs() > 1.0 {
219 moon_speed.abs()
220 } else {
221 13.18
222 };
223 let current = ((moon_lon / 30.0).floor() as i64).rem_euclid(12) as usize;
224
225 let mut applying: Vec<ApplyingAspect> = Vec::new();
226 let mut separating: Vec<SeparatingAspect> = Vec::new();
227 for p in planets {
228 if !matches!(
229 p.name,
230 "Sun" | "Mercury" | "Venus" | "Mars" | "Jupiter" | "Saturn"
231 ) {
232 continue;
233 }
234 for (angle, aspect) in PTOLEMAIC {
235 let mut targets = vec![pyfloat::rem(p.ecliptic_longitude + angle, 360.0)];
236 if angle != 0.0 && angle != 180.0 {
237 targets.push(pyfloat::rem(p.ecliptic_longitude - angle, 360.0));
238 }
239 for target in targets {
240 let ahead = pyfloat::rem(target - moon_lon, 360.0);
241 if 0.0 < ahead
242 && ahead <= to_sign_end
243 && moon_speed - p.speed > 0.0
244 && !applying
245 .iter()
246 .any(|a| a.planet == p.name && a.aspect == aspect)
247 {
248 applying.push(ApplyingAspect {
249 planet: p.name,
250 aspect,
251 degrees_to_exact: pyfloat::round(ahead, 2),
252 target_sign: moon.sign,
253 });
254 }
255 let behind = pyfloat::rem(moon_lon - target, 360.0);
256 if 0.0 < behind
257 && behind <= from_sign_start
258 && !separating
259 .iter()
260 .any(|s| s.planet == p.name && s.aspect == aspect)
261 {
262 separating.push(SeparatingAspect {
263 planet: p.name,
264 aspect,
265 degrees_ago: pyfloat::round(behind, 2),
266 });
267 }
268 }
269 }
270 }
271 applying.sort_by(|a, b| a.degrees_to_exact.total_cmp(&b.degrees_to_exact));
272 separating.sort_by(|a, b| a.degrees_ago.total_cmp(&b.degrees_ago));
273
274 MoonStatus {
275 void_of_course: applying.is_empty(),
276 degrees_to_next_sign: pyfloat::round(to_sign_end, 2),
277 hours_to_next_sign: pyfloat::round(to_sign_end / (speed_abs / 24.0), 1),
278 next_sign: SIGNS[(current + 1) % 12],
279 speed_status: if moon_speed > 13.5 {
280 "swift"
281 } else if moon_speed < 12.5 {
282 "slow"
283 } else {
284 "average"
285 },
286 next_applying_aspect: applying.first().cloned(),
287 last_aspect: separating.first().cloned(),
288 applying_aspects: applying,
289 separating_aspects: separating,
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize)]
295pub struct Stricture {
296 pub code: &'static str,
298 pub severity: &'static str,
300 pub message: String,
302}
303
304#[derive(Debug, Clone, PartialEq, Serialize)]
306pub struct HoraryData {
307 pub planetary_hours: PlanetaryHours,
309 pub ascendant_sign: &'static str,
311 pub ascendant_degree: String,
313 pub traditional_asc_ruler: &'static str,
315 pub modern_asc_ruler: &'static str,
317 pub is_radical: bool,
319 pub strictures: Vec<Stricture>,
321 pub moon_status: MoonStatus,
323}
324
325#[derive(Debug, Clone, PartialEq, Serialize)]
327pub struct HoraryChart {
328 #[serde(flatten)]
330 pub chart: Chart,
331 pub horary_data: HoraryData,
333}
334
335pub fn calculate_horary_chart(
338 kernels: &KernelSet,
339 req: &ChartRequest,
340) -> Result<HoraryChart, EngineError> {
341 let chart = calculate_chart(kernels, req)?;
342 let hours = planetary_hours(kernels, req.instant, req.latitude, req.longitude);
343
344 let by_name = |name: &str| chart.planets.iter().find(|p| p.name == name);
345 let (asc_sign, asc_deg, asc_min) = match (by_name("Ascendant"), chart.houses.first()) {
346 (Some(asc), _) => (asc.sign, asc.degree, asc.minute),
347 (None, Some(h)) => (h.sign, h.degree, h.minute),
348 _ => ("Aries", 0, 0),
349 };
350 let traditional = traditional_ruler(asc_sign).unwrap_or("Mars");
351 let modern = modern_ruler(asc_sign).unwrap_or(traditional);
352
353 let mut strictures = Vec::new();
354 if asc_deg < 3 {
355 strictures.push(Stricture {
356 code: "EARLY_ASC",
357 severity: "warning",
358 message: format!(
359 "Ascendant is very early ({asc_deg}° {asc_sign}): The question may be premature, or circumstances are still developing."
360 ),
361 });
362 } else if asc_deg >= 27 {
363 strictures.push(Stricture {
364 code: "LATE_ASC",
365 severity: "warning",
366 message: format!(
367 "Ascendant is very late ({asc_deg}° {asc_sign}): The situation has already been decided or is out of the querent's control."
368 ),
369 });
370 }
371 match by_name("Saturn").map(|s| s.house) {
372 Some(1) => strictures.push(Stricture {
373 code: "SATURN_IN_1ST",
374 severity: "warning",
375 message: "Saturn in the 1st House: Querent may be obstructed, anxious, or facing delays."
376 .into(),
377 }),
378 Some(7) => strictures.push(Stricture {
379 code: "SATURN_IN_7TH",
380 severity: "info",
381 message: "Saturn in the 7th House: Astrologer's judgment may be challenged or the matter may be difficult to judge clearly."
382 .into(),
383 }),
384 _ => {}
385 }
386
387 let moon =
388 by_name("Moon").ok_or_else(|| EngineError::InvalidInput("chart has no Moon".into()))?;
389 let moon = moon_status(moon, &chart.planets);
390 if moon.void_of_course {
391 strictures.push(Stricture {
392 code: "MOON_VOC",
393 severity: "info",
394 message: "Moon is Void of Course: Nothing will come of the matter in question, or no immediate action will yield changes."
395 .into(),
396 });
397 }
398
399 let triplicity = triplicity_ruler(element(asc_sign).unwrap_or("Fire"), hours.is_day);
400 let is_radical = hours.hour_ruler == traditional || Some(hours.hour_ruler) == triplicity;
401
402 Ok(HoraryChart {
403 horary_data: HoraryData {
404 planetary_hours: hours,
405 ascendant_sign: asc_sign,
406 ascendant_degree: format!("{asc_deg}° {asc_min}' {asc_sign}"),
407 traditional_asc_ruler: traditional,
408 modern_asc_ruler: modern,
409 is_radical,
410 strictures,
411 moon_status: moon,
412 },
413 chart,
414 })
415}