1use serde::{Serialize, Serializer};
5use serde_json::Value;
6
7use crate::aspects::{natal_aspects, Aspect, OrbSettings, Point};
8use crate::chinese::calendar::{chinese_calendar, ChineseCalendar};
9use crate::dignities::{
10 antiscia, condition, is_diurnal, receptions, AntisciaContact, Body, Condition, DignityScheme,
11 Reception,
12};
13use crate::ephemeris::KernelSet;
14use crate::error::EngineError;
15use crate::fixed_stars::{fixed_stars, FixedStarPosition};
16use crate::horary::{planetary_hours, PlanetaryHours};
17use crate::houses::{houses_at_sidereal_time, HouseSystem, Houses};
18use crate::instant::UtcInstant;
19use crate::lots::{arabic_parts, Lot};
20use crate::lunar::{lunar_status, LunarStatus};
21use crate::minor_bodies::{is_minor_body, minor_body_symbol};
22use crate::planets::planets_and_sidereal_time;
23use crate::symbolic::symbolic_degree_number;
24use crate::temperament::{temperament, Temperament};
25use crate::zodiac::{
26 ayanamsa, ayanamsa_info, determine_house, longitude_to_zodiac, AyanamsaInfo, DEFAULT_AYANAMSA,
27};
28
29const EXPECTED_BODIES: [&str; 26] = [
32 "Sun",
33 "Moon",
34 "Mercury",
35 "Venus",
36 "Mars",
37 "Jupiter",
38 "Saturn",
39 "Uranus",
40 "Neptune",
41 "Pluto",
42 "North Node",
43 "South Node",
44 "Chiron",
45 "Lilith",
46 "Ceres",
47 "Pallas",
48 "Juno",
49 "Vesta",
50 "Astraea",
51 "Eris",
52 "Sedna",
53 "Makemake",
54 "Orcus",
55 "Ixion",
56 "Ascendant",
57 "Midheaven",
58];
59
60#[derive(Debug, Clone)]
62pub struct ChartRequest<'a> {
63 pub instant: UtcInstant,
65 pub latitude: f64,
67 pub longitude: f64,
69 pub house_system: &'a str,
71 pub zodiac_type: &'a str,
73 pub ayanamsa: &'a str,
75 pub orb_settings: Option<&'a Value>,
77 pub dignity_scheme: &'a str,
79 pub chinese_calendar: bool,
82}
83
84impl<'a> ChartRequest<'a> {
85 pub fn new(instant: UtcInstant, latitude: f64, longitude: f64) -> Self {
87 ChartRequest {
88 instant,
89 latitude,
90 longitude,
91 house_system: "P",
92 zodiac_type: "tropical",
93 ayanamsa: DEFAULT_AYANAMSA,
94 orb_settings: None,
95 dignity_scheme: "lilly",
96 chinese_calendar: false,
97 }
98 }
99
100 pub(crate) fn check_place(&self) -> Result<(), EngineError> {
103 let (lat, lon) = (self.latitude, self.longitude);
104 if !(lat.is_finite() && lat.abs() <= 90.0) {
105 return Err(EngineError::InvalidInput(format!(
106 "latitude must be within ±90°, got {lat}"
107 )));
108 }
109 if !(lon.is_finite() && lon.abs() <= 360.0) {
110 return Err(EngineError::InvalidInput(format!(
111 "longitude must be within ±360°, got {lon}"
112 )));
113 }
114 Ok(())
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize)]
120pub struct Placement {
121 pub name: &'static str,
123 pub symbol: &'static str,
125 pub sign: &'static str,
127 pub sign_symbol: &'static str,
129 pub degree: i64,
131 pub minute: i64,
133 pub ecliptic_longitude: f64,
135 pub house: u8,
137 pub speed: f64,
139 pub is_retrograde: bool,
141 pub symbolic_degree: i64,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub condition: Option<Condition>,
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize)]
150pub struct HouseCusp {
151 pub house_number: u8,
153 pub sign: &'static str,
155 pub sign_symbol: &'static str,
157 pub degree: i64,
159 pub minute: i64,
161 pub ecliptic_longitude: f64,
163 pub symbolic_degree: i64,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize)]
169pub struct Chart {
170 pub house_system: String,
172 pub zodiac_type: &'static str,
174 pub ayanamsa: Option<&'static str>,
176 pub ayanamsa_name: Option<&'static str>,
178 pub ayanamsa_value: Option<f64>,
180 pub ayanamsa_formatted: Option<String>,
182 pub precession_rate_arcsec_yr: f64,
184 pub orb_settings: Value,
186 pub planets: Vec<Placement>,
188 pub unavailable_bodies: Vec<&'static str>,
191 pub houses: Vec<HouseCusp>,
193 pub aspects: Vec<Aspect>,
195 pub fixed_stars: Vec<FixedStarPosition>,
197 pub arabic_parts: Vec<Lot>,
199 pub temperament: Temperament,
201 #[serde(serialize_with = "empty_object_if_none")]
203 pub lunar_status: Option<LunarStatus>,
204 pub sect: Option<&'static str>,
206 pub dignity_scheme: &'static str,
208 pub receptions: Vec<Reception>,
210 pub antiscia: Vec<AntisciaContact>,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub planetary_hours: Option<PlanetaryHours>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub chinese_calendar: Option<ChineseCalendar>,
218}
219
220fn empty_object_if_none<S: Serializer>(v: &Option<LunarStatus>, s: S) -> Result<S::Ok, S::Error> {
221 match v {
222 Some(status) => status.serialize(s),
223 None => serde_json::Map::new().serialize(s),
224 }
225}
226
227impl Chart {
228 pub(crate) fn points(&self) -> Vec<Point<'_>> {
230 self.planets
231 .iter()
232 .map(|p| Point {
233 name: p.name,
234 longitude: p.ecliptic_longitude,
235 })
236 .collect()
237 }
238}
239
240fn symbol_of(name: &str) -> &'static str {
241 match name {
242 "Sun" => "☉",
243 "Moon" => "☽",
244 "Mercury" => "☿",
245 "Venus" => "♀",
246 "Mars" => "♂",
247 "Jupiter" => "♃",
248 "Saturn" => "♄",
249 "Uranus" => "♅",
250 "Neptune" => "♆",
251 "Pluto" => "♇",
252 "North Node" => "☊",
253 "South Node" => "☋",
254 "Chiron" => "⚷",
255 "Lilith" => "⚸",
256 "Ascendant" => "ASC",
257 "Midheaven" => "MC",
258 _ => minor_body_symbol(name),
259 }
260}
261
262pub(crate) struct Sky {
265 pub zodiac_type: &'static str,
266 pub is_sidereal: bool,
267 pub ayanamsa_code: &'static str,
268 pub info: AyanamsaInfo,
269 pub jd: f64,
270 pub cusps: Vec<f64>,
272 pub house_cusps: Vec<HouseCusp>,
273 pub planets: Vec<Placement>,
274}
275
276pub(crate) fn sky(kernels: &KernelSet, req: &ChartRequest) -> Result<Sky, EngineError> {
278 req.check_place()?;
279 let zodiac_type = match req.zodiac_type.trim().to_lowercase().as_str() {
280 "sidereal" => "sidereal",
281 _ => "tropical",
282 };
283 let is_sidereal = zodiac_type == "sidereal";
284 let ayanamsa_code = ayanamsa(req.ayanamsa).code;
285
286 let jd = req.instant.julian_day();
287 let info = ayanamsa_info(jd, ayanamsa_code);
288 let shift = if is_sidereal { info.value } else { 0.0 };
289
290 let (engine, gast_hours) = planets_and_sidereal_time(kernels, jd, shift)?;
292 let houses = houses_at_sidereal_time(
293 jd,
294 gast_hours,
295 req.latitude,
296 req.longitude,
297 HouseSystem::from_code(req.house_system),
298 shift,
299 );
300 let cusps = houses.cusps.to_vec();
301
302 let house_cusps: Vec<HouseCusp> = houses
303 .cusps
304 .iter()
305 .enumerate()
306 .map(|(i, &lon)| {
307 let z = longitude_to_zodiac(lon);
308 HouseCusp {
309 house_number: i as u8 + 1,
310 sign: z.sign.name,
311 sign_symbol: z.sign.symbol,
312 degree: z.degree,
313 minute: z.minute,
314 ecliptic_longitude: lon,
315 symbolic_degree: symbolic_degree_number(z.degree, z.minute),
316 }
317 })
318 .collect();
319
320 let raw: Vec<(&'static str, f64, f64, bool)> = engine
321 .bodies
322 .iter()
323 .map(|b| (b.name, b.longitude, b.speed, b.is_retrograde))
324 .collect();
325 let planets = placements(raw, &houses);
326
327 Ok(Sky {
328 zodiac_type,
329 is_sidereal,
330 ayanamsa_code,
331 info,
332 jd,
333 cusps,
334 house_cusps,
335 planets,
336 })
337}
338
339pub(crate) fn placements(
342 mut raw: Vec<(&'static str, f64, f64, bool)>,
343 houses: &Houses,
344) -> Vec<Placement> {
345 raw.push(("Ascendant", houses.ascendant, 0.0, false));
346 raw.push(("Midheaven", houses.midheaven, 0.0, false));
347 raw.iter()
348 .map(|&(name, lon, speed, is_retrograde)| {
349 let z = longitude_to_zodiac(lon);
350 Placement {
351 name,
352 symbol: symbol_of(name),
353 sign: z.sign.name,
354 sign_symbol: z.sign.symbol,
355 degree: z.degree,
356 minute: z.minute,
357 ecliptic_longitude: lon,
358 house: determine_house(lon, &houses.cusps),
359 speed,
360 is_retrograde,
361 symbolic_degree: symbolic_degree_number(z.degree, z.minute),
362 condition: None,
363 }
364 })
365 .collect()
366}
367
368fn add_conditions(planets: &mut [Placement], scheme: DignityScheme) -> Option<&'static str> {
370 let at = |name: &str| {
371 planets
372 .iter()
373 .find(|p| p.name == name)
374 .map(|p| p.ecliptic_longitude)
375 };
376 let (sun, asc) = (at("Sun")?, at("Ascendant")?);
377 for planet in planets.iter_mut() {
378 planet.condition = condition(Body::from(&*planet), sun, asc, scheme);
379 }
380 Some(if is_diurnal(sun, asc) {
381 "diurnal"
382 } else {
383 "nocturnal"
384 })
385}
386
387pub fn calculate_chart(kernels: &KernelSet, req: &ChartRequest) -> Result<Chart, EngineError> {
389 let mut chart = chart_without_hours(kernels, req)?;
390 chart.planetary_hours = Some(planetary_hours(
391 kernels,
392 req.instant,
393 req.latitude,
394 req.longitude,
395 ));
396 Ok(chart)
397}
398
399pub(crate) fn chart_without_hours(
402 kernels: &KernelSet,
403 req: &ChartRequest,
404) -> Result<Chart, EngineError> {
405 let orbs = OrbSettings::merge(req.orb_settings)?;
406 let scheme = DignityScheme::from_code(req.dignity_scheme);
407 let Sky {
408 zodiac_type,
409 is_sidereal,
410 ayanamsa_code,
411 info,
412 jd,
413 cusps,
414 house_cusps,
415 mut planets,
416 } = sky(kernels, req)?;
417 let sect = add_conditions(&mut planets, scheme);
418 let points: Vec<Point> = planets
419 .iter()
420 .map(|p| Point {
421 name: p.name,
422 longitude: p.ecliptic_longitude,
423 })
424 .collect();
425
426 let mut aspects = natal_aspects(&points, &orbs)?;
427 let star_points: Vec<Point> = points
429 .iter()
430 .copied()
431 .filter(|p| !is_minor_body(p.name))
432 .collect();
433 let (stars, star_aspects) = fixed_stars(
434 jd,
435 &cusps,
436 &star_points,
437 orbs.fixed_star_orb()?,
438 is_sidereal.then_some(info.value),
439 );
440 aspects.extend(star_aspects);
441
442 let lots = arabic_parts(&points, &cusps);
443 let temperament = temperament(&planets, &aspects);
444 let lunar = lunar_status(&planets);
445 let bodies: Vec<Body> = planets.iter().map(Body::from).collect();
446 let aspect_between = |a: &str, b: &str| {
447 aspects
448 .iter()
449 .find(|x| (x.body1 == a && x.body2 == b) || (x.body1 == b && x.body2 == a))
450 .map(|x| x.aspect_type)
451 };
452 let chart_receptions = receptions(&bodies, aspect_between, scheme);
453 let chart_antiscia = antiscia(&planets);
454
455 let mut unavailable: Vec<&'static str> = EXPECTED_BODIES
456 .iter()
457 .copied()
458 .filter(|name| !planets.iter().any(|p| p.name == *name))
459 .collect();
460 unavailable.sort_unstable();
461
462 Ok(Chart {
463 house_system: req.house_system.to_string(),
464 zodiac_type,
465 ayanamsa: is_sidereal.then_some(ayanamsa_code),
466 ayanamsa_name: is_sidereal.then_some(info.name),
467 ayanamsa_value: is_sidereal.then_some(info.value),
468 ayanamsa_formatted: is_sidereal.then(|| info.formatted.clone()),
469 precession_rate_arcsec_yr: info.precession_rate_arcsec_yr,
470 orb_settings: orbs.to_value(),
471 planets,
472 unavailable_bodies: unavailable,
473 houses: house_cusps,
474 aspects,
475 fixed_stars: stars,
476 arabic_parts: lots,
477 temperament,
478 lunar_status: lunar,
479 sect,
480 dignity_scheme: scheme.code(),
481 receptions: chart_receptions,
482 antiscia: chart_antiscia,
483 planetary_hours: None,
484 chinese_calendar: if req.chinese_calendar {
485 chinese_calendar(kernels, req.instant)?
486 } else {
487 None
488 },
489 })
490}