tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114596 Sutichaykuna

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs Rust · 478 siq'ikuna · 16.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): fase 20 — accordion + lunar shift + CompositeModule + 90 ciudades d3649bfd1 · sergio ·

1//! `tahuantinsuyu-engine` — bridge entre el modelo agnóstico y
2//! `eternal-astrology`.
3//!
4//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un
5//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar
6//! el canvas a tipos de la librería astronómica. El canvas habla
7//! grados decimales, radios normalizados y kinds simbólicos.
8//!
9//! ## Por qué un RenderModel intermedio
10//!
11//! 1. El canvas no debería caer si cambia el shape de `NatalChart`
12//!    upstream.
13//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin
14//!    arrancar eternal.
15//! 3. Cada `ChartKind` produce el mismo shape genérico → el render
16//!    coordina N módulos sin saber qué calcularon.
17//!
18//! ## Feature `eternal-bridge`
19//!
20//! - **on** (default): [`compute`] abre una `EphemerisSession` VSOP2013
21//!   compartida y corre la pipeline real.
22//! - **off**: [`compute`] cae a [`compute_mock`] — útil para tests +
23//!   builds sin eternal checked out.
24
25#![forbid(unsafe_code)]
26#![warn(rust_2018_idioms)]
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
32
33// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
34// transporta — el caller (shell) lee del Store y pasa el Chart entero
35// para que el bridge construya su NatalChart en eternal.
36
37#[cfg(feature = "eternal-bridge")]
38mod bridge;
39#[cfg(feature = "eternal-bridge")]
40mod dignity;
41#[cfg(feature = "eternal-bridge")]
42pub mod svg_export;
43
44// =====================================================================
45// RenderModel — lo que el canvas necesita pintar
46// =====================================================================
47
48/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct RenderModel {
51    pub chart_id: ChartId,
52    pub chart_kind: ChartKind,
53    pub title: String,
54    #[serde(default)]
55    pub subtitle: Option<String>,
56    pub compute_ms: u64,
57
58    // ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
59    /// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
60    /// de modo que el Asc cae a las 9 (lado izquierdo).
61    pub ascendant_deg: f32,
62    pub midheaven_deg: f32,
63    pub descendant_deg: f32,
64    pub imum_coeli_deg: f32,
65
66    /// Capas a pintar. Orden = z-order ascendente.
67    pub layers: Vec<Layer>,
68    /// Metadata humana por overlay activo (transit, progresión,
69    /// sinastría, retorno...). Vacío para una carta natal pura. La UI
70    /// la pinta como badges en el footer.
71    #[serde(default)]
72    pub overlays: Vec<OverlayMeta>,
73    /// Lista paralela a las LineSeg de aspectos — uno por aspecto
74    /// natal o cross. Ordenado por `orb_deg` ascendente (los más
75    /// cerrados primero). La UI lo usa para la lista textual.
76    #[serde(default)]
77    pub aspect_summary: Vec<AspectSummary>,
78}
79
80/// Etiqueta legible de un overlay para el footer del canvas. La engine
81/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct OverlayMeta {
84    pub module_id: String,
85    /// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
86    /// "Sinastría · Ana", "Saturn return 29a".
87    pub label: String,
88}
89
90/// Resumen textual de un aspecto para listas legibles. La engine lo
91/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
92/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct AspectSummary {
95    /// Module al que pertenece — "natal", "transit", "synastry",
96    /// "progression", "solar_arc", "planetary_return".
97    pub module_id: String,
98    /// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
99    pub from_body: String,
100    pub to_body: String,
101    /// Identificador del aspecto — "conjunction", "trine", etc.
102    pub kind: String,
103    pub orb_deg: f64,
104    /// `Some(true)` = applying, `Some(false)` = separating. `None` para
105    /// cross-aspects (sinastría/return) donde no se computa.
106    #[serde(default)]
107    pub applying: Option<bool>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Layer {
112    pub module_id: String,
113    pub kind: LayerKind,
114    /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
115    /// a píxeles. Permite stack de anillos.
116    pub ring: f32,
117    #[serde(default)]
118    pub z: i32,
119    pub geometry: Geometry,
120    #[serde(default)]
121    pub glyphs: Vec<Glyph>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum LayerKind {
127    SignDial,
128    Houses,
129    Bodies,
130    Aspects,
131    Lots,
132    FixedStars,
133    Midpoints,
134    Outer,
135    Custom,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub enum Geometry {
140    GlyphsOnly,
141    /// Anillo dividido en sectores. `cusps_deg` son los grados
142    /// zodiacales donde van las divisiones radiales.
143    Ring { cusps_deg: Vec<f32> },
144    Lines(Vec<LineSeg>),
145    Points(Vec<PointMark>),
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct LineSeg {
150    /// Grados zodiacales del extremo "a".
151    pub from_deg: f32,
152    /// Grados zodiacales del extremo "b".
153    pub to_deg: f32,
154    /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
155    /// resuelve a color.
156    pub kind: String,
157    pub opacity: f32,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct PointMark {
162    pub deg: f32,
163    pub label: String,
164    pub tag: String,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct Glyph {
169    /// Grado eclíptico [0, 360).
170    pub deg: f32,
171    /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
172    /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
173    pub symbol: String,
174    #[serde(default)]
175    pub annotation: Option<String>,
176    #[serde(default)]
177    pub retrograde: bool,
178    #[serde(default)]
179    pub house: Option<u8>,
180    /// Marker de dignidad esencial, set solo cuando
181    /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
182    /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
183    #[serde(default)]
184    pub dignity_marker: Option<String>,
185}
186
187// =====================================================================
188// Errores
189// =====================================================================
190
191#[derive(Debug, Error)]
192pub enum EngineError {
193    #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
194    BridgeDisabled,
195    #[error("model: {0}")]
196    Model(#[from] tahuantinsuyu_model::ModelError),
197    #[error("eternal: {0}")]
198    Eternal(String),
199    #[error("kind {0:?} todavía no implementado")]
200    UnsupportedKind(ChartKind),
201}
202
203// =====================================================================
204// API pública
205// =====================================================================
206
207/// Pedidos que el host (Shell) eleva a la engine para componer un
208/// `RenderModel`. La capa natal **siempre** se computa; estos requests
209/// son **overlays adicionales**.
210///
211/// Cada variante mapea 1-a-1 con un Module declarado en
212/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
213/// dueña única del cómputo (no depende del trait Module — los módulos
214/// son sólo metadata + UI controls).
215/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
216/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
217/// prender uno, el shell debe apagar los otros. Single source of truth
218/// — el shell y el canvas leen de acá en vez de hardcodear listas.
219pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
220
221#[derive(Debug, Clone)]
222pub enum PipelineRequest {
223    /// `module_id = "transit"` — anillo externo con planetas al
224    /// instante actual (reloj de pared) + cross aspects natal × transit.
225    Transit,
226    /// `module_id = "progression"` — anillo interno con los planetas
227    /// progresados (método secundario "día por año") a la edad pedida
228    /// + cross aspects natal × progresada.
229    SecondaryProgression {
230        /// Edad simbólica en años a la que avanzar la carta. Para "la
231        /// edad de hoy", el shell la calcula a partir de `birth_data` +
232        /// `SystemTime::now`.
233        target_age_years: f64,
234    },
235    /// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
236    /// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
237    /// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
238    /// + cross aspects natal × dirigida.
239    SolarArc {
240        target_age_years: f64,
241    },
242    /// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
243    /// carta del partner en el anillo externo (compartido con Transit
244    /// — mutuamente excluyentes), cross aspects natal × partner.
245    /// El partner viene como `Chart` completo del shell.
246    Synastry {
247        partner_chart: Box<Chart>,
248    },
249    /// `module_id = "planetary_return"` — carta natal fresca al
250    /// instante del próximo retorno del cuerpo elegido a su posición
251    /// natal, para la edad pedida. Sun = retorno solar anual, Moon =
252    /// mensual, Júpiter/Saturno = generacionales. Anillo externo
253    /// compartido con Transit/Synastry — mutuamente excluyentes a
254    /// nivel de Shell.
255    PlanetaryReturn {
256        /// Identificador agnóstico del cuerpo ("sun", "moon",
257        /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
258        body: String,
259        target_age_years: f64,
260        /// Días extra que se suman al anchor de búsqueda (birth +
261        /// age*año). Para Solar return suele ser 0 (el return cae cerca
262        /// del cumpleaños); para Lunar return permite saltar de un
263        /// retorno mensual al siguiente (~28 días por click).
264        shift_days: i64,
265    },
266    /// `module_id = "midpoints"` — anillo de puntos medios entre pares
267    /// de cuerpos natales. Por simplicidad filtramos a los que
268    /// involucran al Sol o a la Luna (~10 puntos).
269    Midpoints,
270    /// `module_id = "composite"` — carta compuesta (midpoint composite,
271    /// método Davison) entre dos sujetos. Renderea los planetas
272    /// compuestos en un anillo interno propio (radio 0.36, entre solar
273    /// arc 0.40 y aspects). Útil para análisis de relaciones.
274    Composite {
275        partner_chart: Box<Chart>,
276    },
277}
278
279/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
280/// multiplicador de orbe usar). Es independiente de los overlays.
281#[derive(Debug, Clone)]
282pub struct NatalOptions {
283    /// Incluir aspectos mayores (conj/opp/trine/square/sextile).
284    pub show_majors: bool,
285    /// Incluir aspectos menores (quincunx/semi-sextile/etc).
286    pub show_minors: bool,
287    /// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
288    /// modern_western; `0.5` = tight; `2.0` = wide.
289    pub orb_multiplier: f64,
290    /// Si `true`, anota cada cuerpo natal con su dignidad esencial
291    /// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
292    /// renderea como sufijo del glifo.
293    pub show_dignities: bool,
294}
295
296impl Default for NatalOptions {
297    fn default() -> Self {
298        Self {
299            show_majors: true,
300            show_minors: false,
301            orb_multiplier: 1.0,
302            show_dignities: false,
303        }
304    }
305}
306
307/// Composición canónica: carta natal + todos los overlays pedidos.
308/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
309pub fn compose(
310    chart: &Chart,
311    offset_minutes: i64,
312    requests: &[PipelineRequest],
313) -> Result<RenderModel, EngineError> {
314    compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
315}
316
317/// Variante que permite controlar qué aspectos natales se computan y
318/// con qué multiplicador de orbe.
319pub fn compose_with_options(
320    chart: &Chart,
321    offset_minutes: i64,
322    requests: &[PipelineRequest],
323    natal_options: &NatalOptions,
324) -> Result<RenderModel, EngineError> {
325    #[cfg(feature = "eternal-bridge")]
326    {
327        bridge::compose(chart, offset_minutes, requests, natal_options)
328    }
329    #[cfg(not(feature = "eternal-bridge"))]
330    {
331        let _ = (offset_minutes, requests, natal_options);
332        Ok(compute_mock(chart))
333    }
334}
335
336/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
337pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
338    compose(chart, 0, &[])
339}
340
341/// Atajo: natal con time-scrubbing pero sin overlays.
342pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
343    compose(chart, offset_minutes, &[])
344}
345
346/// Atajo: natal + overlay de tránsitos al instante actual.
347pub fn compute_with_transits_at_now(
348    chart: &Chart,
349    offset_minutes: i64,
350) -> Result<RenderModel, EngineError> {
351    compose(chart, offset_minutes, &[PipelineRequest::Transit])
352}
353
354/// Helper retrocompatible: construye un `PlanetaryReturn` con
355/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
356/// fino (todos los Solar return y muchos casos básicos).
357pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
358    PipelineRequest::PlanetaryReturn {
359        body,
360        target_age_years,
361        shift_days: 0,
362    }
363}
364
365/// Stub determinista — útil para tests + para la UI sin eternal.
366pub fn compute_mock(chart: &Chart) -> RenderModel {
367    use std::time::Instant;
368    let t0 = Instant::now();
369
370    let sign_dial = Layer {
371        module_id: "natal".into(),
372        kind: LayerKind::SignDial,
373        ring: 1.0,
374        z: 0,
375        geometry: Geometry::Ring {
376            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
377        },
378        glyphs: (0..12)
379            .map(|i| Glyph {
380                deg: (i as f32) * 30.0 + 15.0,
381                symbol: ZODIAC_GLYPHS[i].into(),
382                annotation: None,
383                retrograde: false,
384                house: None,
385            dignity_marker: None,
386            })
387            .collect(),
388    };
389
390    RenderModel {
391        chart_id: chart.id,
392        chart_kind: chart.kind,
393        title: chart.label.clone(),
394        subtitle: chart.birth_data.birthplace_label.clone(),
395        compute_ms: t0.elapsed().as_millis() as u64,
396        ascendant_deg: 0.0,
397        midheaven_deg: 270.0,
398        descendant_deg: 180.0,
399        imum_coeli_deg: 90.0,
400        layers: vec![sign_dial],
401        overlays: Vec::new(),
402        aspect_summary: Vec::new(),
403    }
404}
405
406const ZODIAC_GLYPHS: [&str; 12] = [
407    "aries",
408    "taurus",
409    "gemini",
410    "cancer",
411    "leo",
412    "virgo",
413    "libra",
414    "scorpio",
415    "sagittarius",
416    "capricorn",
417    "aquarius",
418    "pisces",
419];
420
421// =====================================================================
422// Tests
423// =====================================================================
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use tahuantinsuyu_model::{
429        Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
430    };
431
432    fn sample_chart() -> Chart {
433        Chart {
434            id: ChartId::new(),
435            contact_id: ContactId::new(),
436            kind: ChartKind::Natal,
437            label: "test".into(),
438            birth_data: StoredBirthData {
439                year: 1987,
440                month: 3,
441                day: 14,
442                hour: 5,
443                minute: 22,
444                second: 0.0,
445                tz_offset_minutes: -240,
446                latitude_deg: 10.4806,
447                longitude_deg: -66.9036,
448                altitude_m: 900.0,
449                time_certainty: Default::default(),
450                subject_name: None,
451                birthplace_label: None,
452            },
453            config: StoredChartConfig::default(),
454            related_chart_id: None,
455            created_at_ms: 0,
456        }
457    }
458
459    #[test]
460    fn mock_emits_sign_dial() {
461        let model = compute_mock(&sample_chart());
462        assert_eq!(model.layers.len(), 1);
463        assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
464        assert_eq!(model.layers[0].glyphs.len(), 12);
465    }
466
467    #[cfg(feature = "eternal-bridge")]
468    #[test]
469    fn real_compute_natal_demo() {
470        let model = compute(&sample_chart()).expect("compute con eternal");
471        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
472        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
473        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
474        // El Asc debe ser un grado válido.
475        assert!(model.ascendant_deg.is_finite());
476        assert!((0.0..360.0).contains(&model.ascendant_deg));
477    }
478}