1use serde::Serialize;
5use serde_json::{json, Map, Value};
6
7use crate::aspects::{cross_aspects, points_from_json, CrossAspect};
8use crate::catalog::{DERIVED_HOUSE_MEANINGS, ROOT_HOUSE_THEMES};
9use crate::chart::{chart_without_hours, ChartRequest, HouseCusp, Placement};
10use crate::ephemeris::KernelSet;
11use crate::error::EngineError;
12use crate::zodiac::ZODIAC_SIGNS;
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct TransitChart {
17 pub transit_planets: Vec<Placement>,
19 pub transit_houses: Vec<HouseCusp>,
21 pub cross_aspects: Vec<CrossAspect>,
23 pub transit_datetime: String,
25 pub transit_lat: f64,
27 pub transit_lon: f64,
29 pub zodiac_type: &'static str,
31 pub ayanamsa: Option<&'static str>,
33 pub ayanamsa_name: Option<&'static str>,
35 pub ayanamsa_value: Option<f64>,
37 pub ayanamsa_formatted: Option<String>,
39}
40
41pub fn calculate_transit_chart(
45 kernels: &KernelSet,
46 natal_planets: &Value,
47 req: &ChartRequest,
48) -> Result<TransitChart, EngineError> {
49 let natal = points_from_json(natal_planets)?;
50 let req = &ChartRequest {
51 chinese_calendar: false,
52 ..req.clone()
53 };
54 let chart = chart_without_hours(kernels, req)?;
55 let aspects = cross_aspects(&natal, &chart.points(), req.orb_settings)?;
56 Ok(TransitChart {
57 cross_aspects: aspects,
58 transit_datetime: req.instant.isoformat(),
59 transit_lat: req.latitude,
60 transit_lon: req.longitude,
61 zodiac_type: chart.zodiac_type,
62 ayanamsa: chart.ayanamsa,
63 ayanamsa_name: chart.ayanamsa_name,
64 ayanamsa_value: chart.ayanamsa_value,
65 ayanamsa_formatted: chart.ayanamsa_formatted,
66 transit_planets: chart.planets,
67 transit_houses: chart.houses,
68 })
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize)]
73pub struct Synastry {
74 pub cross_aspects: Vec<CrossAspect>,
76}
77
78pub fn calculate_synastry(
80 chart_a_planets: &Value,
81 chart_b_planets: &Value,
82 orb_settings: Option<&Value>,
83) -> Result<Synastry, EngineError> {
84 let a = points_from_json(chart_a_planets)?;
85 let b = points_from_json(chart_b_planets)?;
86 Ok(Synastry {
87 cross_aspects: cross_aspects(&a, &b, orb_settings)?,
88 })
89}
90
91fn get(obj: &Map<String, Value>, key: &str, default: Value) -> Value {
93 obj.get(key).cloned().unwrap_or(default)
94}
95
96fn truthy(v: Option<&Value>) -> bool {
98 match v {
99 None | Some(Value::Null) => false,
100 Some(Value::Bool(b)) => *b,
101 Some(Value::Number(n)) => n.as_f64() != Some(0.0),
102 Some(Value::String(s)) => !s.is_empty(),
103 Some(Value::Array(a)) => !a.is_empty(),
104 Some(Value::Object(o)) => !o.is_empty(),
105 }
106}
107
108fn house_number_is(h: &Value, n: i64) -> bool {
109 h.get("house_number").and_then(Value::as_f64) == Some(n as f64)
110}
111
112fn turned_house(item: &Map<String, Value>, root: i64) -> (Value, i64) {
114 let radix = get(item, "house", json!(1));
115 let n = radix.as_f64().unwrap_or(1.0) as i64;
116 (radix, (n - root).rem_euclid(12) + 1)
117}
118
119fn turn_items(base: &Map<String, Value>, key: &str, root: i64) -> Result<Vec<Value>, EngineError> {
120 let items = match base.get(key) {
121 None | Some(Value::Null) => return Ok(Vec::new()),
122 Some(Value::Array(items)) => items,
123 Some(other) => {
124 return Err(EngineError::InvalidInput(format!(
125 "{key} must be a list, got {other}"
126 )))
127 }
128 };
129 items
130 .iter()
131 .map(|item| {
132 let mut copy = item.as_object().cloned().ok_or_else(|| {
133 EngineError::InvalidInput(format!("{key} entries must be objects"))
134 })?;
135 let (radix, turned) = turned_house(©, root);
136 copy.insert("radix_house".into(), radix);
137 copy.insert("house".into(), json!(turned));
138 Ok(Value::Object(copy))
139 })
140 .collect()
141}
142
143pub fn calculate_derived_chart(
148 base: &Value,
149 root: i64,
150 custom_name: Option<&str>,
151) -> Result<Value, EngineError> {
152 let base = base
153 .as_object()
154 .ok_or_else(|| EngineError::InvalidInput("base chart must be an object".into()))?;
155 let root = if (1..=12).contains(&root) { root } else { 1 };
156 let meanings = &DERIVED_HOUSE_MEANINGS[root as usize - 1];
157
158 let stored_houses = base
159 .get("houses")
160 .and_then(Value::as_array)
161 .cloned()
162 .unwrap_or_default();
163 let base_houses: Vec<Value> = if stored_houses.len() >= 12 {
164 stored_houses
165 } else {
166 (0..12)
167 .map(|i| {
168 json!({
169 "house_number": i + 1,
170 "degree": i * 30,
171 "minute": 0,
172 "sign": ZODIAC_SIGNS[i].name,
173 "sign_symbol": ZODIAC_SIGNS[i].symbol,
174 "ecliptic_longitude": (i * 30) as f64,
175 })
176 })
177 .collect()
178 };
179 let house_obj = |r: i64| -> Map<String, Value> {
180 base_houses
181 .iter()
182 .find(|h| house_number_is(h, r))
183 .unwrap_or(&base_houses[r as usize - 1])
184 .as_object()
185 .cloned()
186 .unwrap_or_default()
187 };
188
189 let mut derived_houses: Vec<Map<String, Value>> = Vec::with_capacity(12);
190 for d in 1..=12_i64 {
191 let r = (root - 1 + d - 1) % 12 + 1;
192 let radix = house_obj(r);
193 let meaning = meanings[d as usize - 1];
194 let mut h = Map::new();
195 h.insert("house_number".into(), json!(d));
196 h.insert("radix_house_number".into(), json!(r));
197 h.insert("sign".into(), get(&radix, "sign", json!("")));
198 h.insert("sign_symbol".into(), get(&radix, "sign_symbol", json!("")));
199 h.insert("degree".into(), get(&radix, "degree", json!(0)));
200 h.insert("minute".into(), get(&radix, "minute", json!(0)));
201 h.insert(
202 "ecliptic_longitude".into(),
203 get(&radix, "ecliptic_longitude", json!(0.0)),
204 );
205 h.insert(
206 "symbolic_degree".into(),
207 get(&radix, "symbolic_degree", Value::Null),
208 );
209 h.insert(
210 "degree_symbol".into(),
211 get(&radix, "degree_symbol", Value::Null),
212 );
213 h.insert(
214 "degree_symbols".into(),
215 get(&radix, "degree_symbols", Value::Null),
216 );
217 h.insert("meaning_en".into(), json!(meaning.en));
218 h.insert("meaning_it".into(), json!(meaning.it));
219 h.insert(
220 "derived_label".into(),
221 json!(format!("House {d} (Radix {r})")),
222 );
223 derived_houses.push(h);
224 }
225
226 let mut planets = Vec::new();
227 if let Some(Value::Array(items)) = base.get("planets") {
228 for p in items {
229 let mut copy = p.as_object().cloned().ok_or_else(|| {
230 EngineError::InvalidInput("planets entries must be objects".into())
231 })?;
232 let (radix, turned) = turned_house(©, root);
233 copy.insert("radix_house".into(), radix);
234 let angle = match copy.get("name").and_then(Value::as_str) {
235 Some("Ascendant") => Some((0, 1)),
236 Some("Midheaven") => Some((9, 10)),
237 _ => None,
238 };
239 match angle {
240 Some((index, house)) => {
241 let cusp = &derived_houses[index];
242 for key in [
243 "sign",
244 "sign_symbol",
245 "degree",
246 "minute",
247 "ecliptic_longitude",
248 ] {
249 copy.insert(key.into(), cusp[key].clone());
250 }
251 copy.insert("house".into(), json!(house));
252 if truthy(cusp.get("symbolic_degree")) {
253 for key in ["symbolic_degree", "degree_symbol", "degree_symbols"] {
254 copy.insert(key.into(), cusp[key].clone());
255 }
256 }
257 }
258 None => {
259 copy.insert("house".into(), json!(turned));
260 }
261 }
262 planets.push(Value::Object(copy));
263 }
264 }
265 let fixed_stars = turn_items(base, "fixed_stars", root)?;
266 let lots = turn_items(base, "arabic_parts", root)?;
267
268 let (theme_en, theme_it) = ROOT_HOUSE_THEMES[root as usize - 1];
269 let root_cusp = base_houses
270 .iter()
271 .find(|h| house_number_is(h, root))
272 .unwrap_or(&base_houses[0])
273 .as_object()
274 .cloned()
275 .unwrap_or_default();
276 let base_name = match base.get("user_name") {
277 Some(v) if truthy(Some(v)) => v.clone(),
278 _ => json!("Radix Chart"),
279 };
280 let name = match custom_name {
281 Some(n) if !n.is_empty() => n.to_string(),
282 _ => format!(
283 "{} [Derived H{root} - {theme_en}]",
284 base_name
285 .as_str()
286 .map_or_else(|| base_name.to_string(), str::to_string)
287 ),
288 };
289 let meanings_map: Map<String, Value> = meanings
290 .iter()
291 .enumerate()
292 .map(|(i, m)| ((i + 1).to_string(), json!({"en": m.en, "it": m.it})))
293 .collect();
294 let at = |key: &str| root_cusp.get(key).cloned().unwrap_or(Value::Null);
295
296 let derived_data = json!({
297 "derived_root_house": root,
298 "derived_from_id": get(base, "id", Value::Null),
299 "derived_from_name": base_name.clone(),
300 "root_house_theme": {"name_en": theme_en, "name_it": theme_it},
301 "root_cusp": {
302 "house_number": root,
303 "sign": at("sign"),
304 "degree": at("degree"),
305 "minute": at("minute"),
306 "ecliptic_longitude": at("ecliptic_longitude"),
307 },
308 "meanings": meanings_map,
309 });
310
311 let mut out = Map::new();
312 let mut put = |k: &str, v: Value| {
313 out.insert(k.to_string(), v);
314 };
315 put("id", Value::Null);
316 put("chart_type", json!("DERIVED"));
317 put("user_name", json!(name));
318 put("birth_datetime", get(base, "birth_datetime", Value::Null));
319 put("local_datetime", get(base, "local_datetime", Value::Null));
320 put("timezone", get(base, "timezone", json!("UTC")));
321 put(
322 "timezone_offset",
323 get(base, "timezone_offset", json!("+00:00")),
324 );
325 put("timezone_abbr", get(base, "timezone_abbr", json!("UTC")));
326 put("is_dst", get(base, "is_dst", json!(false)));
327 put("dst_offset", get(base, "dst_offset", json!("+00:00")));
328 put("raw_offset", get(base, "raw_offset", json!("+00:00")));
329 put("house_system", get(base, "house_system", json!("P")));
330 put("zodiac_type", get(base, "zodiac_type", json!("tropical")));
331 put(
332 "zodiac_type_label",
333 get(base, "zodiac_type_label", json!("Tropical")),
334 );
335 put("ayanamsa", get(base, "ayanamsa", Value::Null));
336 put("ayanamsa_name", get(base, "ayanamsa_name", Value::Null));
337 put("ayanamsa_value", get(base, "ayanamsa_value", Value::Null));
338 put(
339 "ayanamsa_formatted",
340 get(base, "ayanamsa_formatted", json!("")),
341 );
342 put("orb_settings", get(base, "orb_settings", json!({})));
343 put("coordinates", get(base, "coordinates", json!({})));
344 put("planets", Value::Array(planets));
345 put(
346 "houses",
347 Value::Array(derived_houses.into_iter().map(Value::Object).collect()),
348 );
349 put("aspects", get(base, "aspects", json!([])));
350 put("fixed_stars", Value::Array(fixed_stars));
351 put("arabic_parts", Value::Array(lots));
352 put("temperament", get(base, "temperament", json!({})));
353 put("lunar_status", get(base, "lunar_status", json!({})));
354 put("derived_from_id", get(base, "id", Value::Null));
355 put("derived_from_name", base_name);
356 put("derived_house", json!(root));
357 put("derived_data", derived_data);
358 Ok(Value::Object(out))
359}