//! Bridge real: `tahuantinsuyu_model::Chart` → eternal_astrology → [`RenderModel`]. //! //! La sesión de efemérides VSOP2013 es **compartida globalmente** vía //! `OnceLock` — abrirla cuesta unos cuantos ms (carga de las series en //! memoria), y como es read-only se puede leer en paralelo desde varios //! cómputos. use std::sync::OnceLock; use std::time::Instant; use eternal_astrology::{ find_aspects, find_synastry_aspects, Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac, }; use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig}; use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac}; use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, RenderModel}; // ===================================================================== // Sesión global cacheada // ===================================================================== static SESSION: OnceLock = OnceLock::new(); fn session() -> Result<&'static EphemerisSession, EngineError> { if let Some(s) = SESSION.get() { return Ok(s); } let opened = EphemerisSession::open(SessionConfig::vsop2013()) .map_err(|e| EngineError::Eternal(format!("EphemerisSession::open: {:?}", e)))?; // Si otro thread ya pobló la celda mientras abríamos, el set_once // falla silenciosamente — usamos el que quedó dentro. let _ = SESSION.set(opened); Ok(SESSION.get().expect("session was just set")) } // ===================================================================== // Traducciones Stored* → eternal // ===================================================================== fn map_house_system(h: HouseSystem) -> EHouseSystem { match h { HouseSystem::Placidus => EHouseSystem::Placidus, HouseSystem::Koch => EHouseSystem::Koch, HouseSystem::Regiomontanus => EHouseSystem::Regiomontanus, HouseSystem::Campanus => EHouseSystem::Campanus, HouseSystem::Porphyry => EHouseSystem::Porphyry, HouseSystem::Equal => EHouseSystem::Equal, HouseSystem::WholeSign => EHouseSystem::WholeSign, } } fn map_zodiac(z: Zodiac, ayanamsha_hint: Option<&str>) -> EZodiac { match z { Zodiac::Tropical => EZodiac::Tropical, Zodiac::Sidereal => { let mode = match ayanamsha_hint.unwrap_or("lahiri").to_ascii_lowercase().as_str() { "fagan_bradley" | "fagan-bradley" | "faganbradley" => Ayanamsha::FaganBradley, "raman" => Ayanamsha::Raman, "krishnamurti" => Ayanamsha::Krishnamurti, "de_luce" | "deluce" => Ayanamsha::DeLuce, "djwhal_khul" | "djwhalkhul" => Ayanamsha::DjwhalKhul, "ushashashi" => Ayanamsha::Ushashashi, "yukteshwar" => Ayanamsha::Yukteshwar, _ => Ayanamsha::Lahiri, }; EZodiac::Sidereal(mode) } // Dracónico aún no soportado en eternal — caemos a tropical por // ahora; cuando eternal lo agregue, lo cableamos acá. Zodiac::Draconic => EZodiac::Tropical, } } fn map_body_set(cfg: &StoredChartConfig) -> BodySet { let mut bodies: Vec = Vec::new(); for name in &cfg.bodies { if let Some(b) = map_body(name) { bodies.push(b); } } if bodies.is_empty() { // Default razonable si el config vino vacío. return BodySet::classical_modern(); } let mut set = BodySet { bodies, include_south_node: cfg.include_south_node, }; if cfg.include_lilith { set = set.with_lilith(); } if cfg.include_main_belt_asteroids { set = set.with_main_belt_asteroids(); } set } fn map_body(name: &str) -> Option { Some(match name.to_ascii_lowercase().as_str() { "sun" => Body::Sun, "moon" => Body::Moon, "mercury" => Body::Mercury, "venus" => Body::Venus, "mars" => Body::Mars, "jupiter" => Body::Jupiter, "saturn" => Body::Saturn, "uranus" => Body::Uranus, "neptune" => Body::Neptune, "pluto" => Body::Pluto, "mean_node" | "meannode" => Body::MeanNode, "true_node" | "truenode" => Body::TrueNode, "mean_lilith" | "lilith" => Body::MeanLilith, "true_lilith" => Body::TrueLilith, "ceres" => Body::Ceres, "pallas" => Body::Pallas, "juno" => Body::Juno, "vesta" => Body::Vesta, _ => return None, }) } fn body_symbol(b: Body) -> &'static str { match b { Body::Sun => "sun", Body::Moon => "moon", Body::Mercury => "mercury", Body::Venus => "venus", Body::Mars => "mars", Body::Jupiter => "jupiter", Body::Saturn => "saturn", Body::Uranus => "uranus", Body::Neptune => "neptune", Body::Pluto => "pluto", Body::MeanNode => "north_node", Body::TrueNode => "north_node", Body::MeanLilith => "lilith", Body::TrueLilith => "lilith", Body::Ceres => "ceres", Body::Pallas => "pallas", Body::Juno => "juno", Body::Vesta => "vesta", Body::Chiron => "chiron", Body::Pholus => "chiron", Body::Eris => "chiron", Body::Sedna => "chiron", // `Body` es `#[non_exhaustive]` — cualquier cuerpo nuevo // upstream cae al símbolo de fallback hasta que lo cableemos. _ => "custom", } } fn aspect_kind_id(k: EAspectKind) -> &'static str { match k { EAspectKind::Conjunction => "conjunction", EAspectKind::Opposition => "opposition", EAspectKind::Trine => "trine", EAspectKind::Square => "square", EAspectKind::Sextile => "sextile", EAspectKind::Quincunx => "quincunx", EAspectKind::SemiSextile => "semi_sextile", EAspectKind::SemiSquare => "semi_square", EAspectKind::Sesquiquadrate => "sesquiquadrate", EAspectKind::Quintile => "quintile", EAspectKind::BiQuintile => "biquintile", EAspectKind::Septile => "septile", } } // ===================================================================== // compute() // ===================================================================== /// Construye los tipos eternales (`BirthData`, `ChartConfig`) desde el /// `Chart` agnóstico, aplicando el offset temporal. Devuelve también el /// `Observer` y la `ChartConfig` para reusar en pipelines extendidas /// (transits, sinastría) sin re-traducir. fn build_eternal_inputs( chart: &Chart, offset_minutes: i64, ) -> Result<(BirthData, ChartConfig, Observer), EngineError> { chart.validate()?; let bd = &chart.birth_data; let base_instant = ESInstant::from_civil_local( bd.year, u8::try_from(bd.month).map_err(|_| { EngineError::Eternal(format!("mes fuera de u8: {}", bd.month)) })?, u8::try_from(bd.day).map_err(|_| { EngineError::Eternal(format!("día fuera de u8: {}", bd.day)) })?, u8::try_from(bd.hour).map_err(|_| { EngineError::Eternal(format!("hora fuera de u8: {}", bd.hour)) })?, u8::try_from(bd.minute).map_err(|_| { EngineError::Eternal(format!("minuto fuera de u8: {}", bd.minute)) })?, bd.second, bd.tz_offset_minutes, ) .map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?; let instant = if offset_minutes == 0 { base_instant } else { let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0); ESInstant::from_utc(shifted_utc) }; let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m); let mut birth_e = BirthData::new(instant, observer); if let Some(name) = &bd.subject_name { birth_e = birth_e.with_name(name.clone()); } let config_e = ChartConfig { house_system: map_house_system(chart.config.house_system), zodiac: map_zodiac(chart.config.zodiac, chart.config.ayanamsha.as_deref()), bodies: map_body_set(&chart.config), include_horizon: false, }; Ok((birth_e, config_e, observer)) } /// Computa solo la `NatalChart` (sin construir RenderModel). Útil para /// pipelines compuestas (transits, sinastría) que necesitan el natal /// crudo para correr `find_synastry_aspects`. fn compute_natal_chart( chart: &Chart, offset_minutes: i64, ) -> Result<(NatalChart, ChartConfig, Observer), EngineError> { let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?; let session = session()?; let natal = NatalChart::compute(&birth_e, &config_e, session) .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?; Ok((natal, config_e, observer)) } pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result { let t0 = Instant::now(); let (natal, _, _) = compute_natal_chart(chart, offset_minutes)?; let aspects = find_aspects(&natal, &OrbTable::modern_western()); Ok(build_render_model(chart, &natal, &aspects, t0)) } /// Pipeline natal + overlay de tránsitos. Computa la carta natal /// (eventualmente con un `offset_minutes` aplicado) **y además** una /// segunda `NatalChart` con el mismo observer pero al instante /// `transit_at` (usualmente `Instant::now()`). Devuelve un `RenderModel` /// con dos capas extra: /// /// - `LayerKind::Outer` con `module_id = "transit"` — glifos /// planetarios del cielo actual, pintados en un anillo externo. /// - `LayerKind::Aspects` con `module_id = "transit"` — aspectos cross /// natal × transit (sólo mayores). Convención: `LineSeg.from_deg` = /// longitud natal, `LineSeg.to_deg` = longitud transit. pub fn compute_with_transits( chart: &Chart, offset_minutes: i64, transit_at: ESInstant, ) -> Result { let t0 = Instant::now(); let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?; let aspects = find_aspects(&natal, &OrbTable::modern_western()); let mut render = build_render_model(chart, &natal, &aspects, t0); // Carta de tránsito: mismo observer, mismo config, instante "ahora". let transit_birth = BirthData::new(transit_at, observer); let session = session()?; let transit = NatalChart::compute(&transit_birth, &config_e, session).map_err(|e| { EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e)) })?; // Outer ring de glifos: planetas del cielo actual. let outer_glyphs: Vec = transit .placements .iter() .map(|p| Glyph { deg: p.longitude.longitude_deg() as f32, symbol: body_symbol(p.body).into(), annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())), retrograde: p.longitude_rate_rad_per_day < 0.0, house: None, }) .collect(); render.layers.push(Layer { module_id: "transit".into(), kind: LayerKind::Outer, ring: 0.82, z: 4, geometry: Geometry::GlyphsOnly, glyphs: outer_glyphs, }); // Cross aspects natal × transit. find_synastry_aspects toma una lista // de `AspectKind`s — usamos solo mayores para no saturar. let cross = find_synastry_aspects( &natal, &transit, &OrbTable::modern_western(), EAspectKind::MAJORS, ); let cross_lines: Vec = cross .iter() .filter_map(|a| { let natal_p = natal.placement(a.person_a_body)?; let transit_p = transit.placement(a.person_b_body)?; let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind); Some(LineSeg { from_deg: natal_p.longitude.longitude_deg() as f32, to_deg: transit_p.longitude.longitude_deg() as f32, kind: aspect_kind_id(a.kind).into(), // Apagamos un poco más los cross para distinguirlos del // tejido natal-natal. opacity: opacity * 0.75, }) }) .collect(); render.layers.push(Layer { module_id: "transit".into(), kind: LayerKind::Aspects, ring: 0.0, z: 5, geometry: Geometry::Lines(cross_lines), glyphs: Vec::new(), }); render.compute_ms = t0.elapsed().as_millis() as u64; Ok(render) } /// Atajo: tránsitos al instante actual del reloj. pub fn compute_with_transits_at_now( chart: &Chart, offset_minutes: i64, ) -> Result { compute_with_transits(chart, offset_minutes, ESInstant::now()) } // ===================================================================== // NatalChart → RenderModel // ===================================================================== fn build_render_model( chart: &Chart, natal: &NatalChart, aspects: &[Aspect], started: Instant, ) -> RenderModel { let ascendant_deg = natal.ascendant().longitude_deg() as f32; let midheaven_deg = natal.midheaven().longitude_deg() as f32; let descendant_deg = natal.descendant().longitude_deg() as f32; let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32; // ─── Capa 0: Sign Dial ──────────────────────────────────────────── let sign_dial = Layer { module_id: "natal".into(), kind: LayerKind::SignDial, ring: 1.0, z: 0, geometry: Geometry::Ring { cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(), }, glyphs: (0..12) .map(|i| Glyph { deg: (i as f32) * 30.0 + 15.0, symbol: ZODIAC_SYMBOLS[i].into(), annotation: None, retrograde: false, house: None, }) .collect(), }; // ─── Capa 1: Houses ─────────────────────────────────────────────── let cusps_deg: Vec = natal .houses .cusps .iter() .map(|c| c.to_degrees() as f32) .collect(); let houses = Layer { module_id: "natal".into(), kind: LayerKind::Houses, ring: 0.86, z: 1, geometry: Geometry::Ring { cusps_deg: cusps_deg.clone(), }, glyphs: cusps_deg .iter() .enumerate() .map(|(i, c)| Glyph { deg: *c + 4.0, symbol: format!("h{}", i + 1), annotation: None, retrograde: false, house: Some((i as u8) + 1), }) .collect(), }; // ─── Capa 2: Bodies ─────────────────────────────────────────────── let body_glyphs: Vec = natal .placements .iter() .map(|p| Glyph { deg: p.longitude.longitude_deg() as f32, symbol: body_symbol(p.body).into(), annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())), // `BodyPlacement` cambió entre versiones de eternal entre // `pub fn is_retrograde(&self) -> bool` y `pub // is_retrograde: bool` — leemos el campo crudo // `longitude_rate_rad_per_day` (estable en ambas) para no // depender del wrapper. retrograde: p.longitude_rate_rad_per_day < 0.0, house: Some(p.house_number), }) .collect(); let bodies = Layer { module_id: "natal".into(), kind: LayerKind::Bodies, ring: 0.72, z: 2, geometry: Geometry::Points( natal .placements .iter() .map(|p| crate::PointMark { deg: p.longitude.longitude_deg() as f32, label: p.body.name().into(), tag: body_symbol(p.body).into(), }) .collect(), ), glyphs: body_glyphs, }; // ─── Capa 3: Aspects ────────────────────────────────────────────── let mut aspect_lines: Vec = Vec::with_capacity(aspects.len()); for a in aspects { // Solo los aspectos mayores se pintan en este pase — los menores // saturan visualmente. Fase 4 pondrá un toggle para mostrarlos. if !EAspectKind::MAJORS.contains(&a.kind) { continue; } let pa = natal.placement(a.a); let pb = natal.placement(a.b); if let (Some(pa), Some(pb)) = (pa, pb) { let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind); aspect_lines.push(LineSeg { from_deg: pa.longitude.longitude_deg() as f32, to_deg: pb.longitude.longitude_deg() as f32, kind: aspect_kind_id(a.kind).into(), opacity, }); } } let aspects_layer = Layer { module_id: "natal".into(), kind: LayerKind::Aspects, ring: 0.58, z: 3, geometry: Geometry::Lines(aspect_lines), glyphs: Vec::new(), }; let subtitle = chart .birth_data .birthplace_label .clone() .or_else(|| { Some(format!( "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°", chart.birth_data.year, chart.birth_data.month, chart.birth_data.day, chart.birth_data.latitude_deg, chart.birth_data.longitude_deg, )) }); RenderModel { chart_id: chart.id, chart_kind: chart.kind, title: chart.label.clone(), subtitle, compute_ms: started.elapsed().as_millis() as u64, ascendant_deg, midheaven_deg, descendant_deg, imum_coeli_deg, layers: vec![sign_dial, houses, bodies, aspects_layer], } } /// Mapea el orb absoluto a una opacidad — los aspectos más exactos se /// pintan más fuerte, los flojos casi se desvanecen. fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 { let max = match kind { EAspectKind::Conjunction | EAspectKind::Opposition => 8.0, EAspectKind::Trine | EAspectKind::Square => 7.0, EAspectKind::Sextile => 5.0, _ => 3.0, }; let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25); t as f32 } const ZODIAC_SYMBOLS: [&str; 12] = [ "aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", ];