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