tawasuyu

tawasuyu/brahman

Rust 307TOML 110JSON 14Markdown 113965 Sutichaykuna

⎇ 6191ce7dee1e6336b0dfd8becaf47ca8e32a96a6 crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs Rust · 518 siq'ikuna · 18.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): fase 25 — production polish (tree search + hotkey SVG + aspect tooltips) 6191ce7de · 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    /// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
79    /// Vacío sino se activó el módulo Uranian.
80    #[serde(default)]
81    pub uranian_groups: Vec<UranianGroup>,
82}
83
84/// Etiqueta legible de un overlay para el footer del canvas. La engine
85/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct OverlayMeta {
88    pub module_id: String,
89    /// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
90    /// "Sinastría · Ana", "Saturn return 29a".
91    pub label: String,
92}
93
94/// Grupo de cuerpos natales que caen en la misma posición del
95/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
96/// muy cercana). En la astrología uraniana esto es una "fórmula" o
97/// "axis" — los cuerpos están en correspondencia simbólica directa
98/// porque comparten un cuadrante simétrico.
99///
100/// Solo se emiten grupos con 2+ miembros (los singletons no son
101/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct UranianGroup {
104    /// Identificadores agnósticos de los cuerpos en el grupo
105    /// (ej. `["sun", "jupiter", "saturn"]`).
106    pub bodies: Vec<String>,
107    /// Posición en el dial de 90° (la longitud módulo 90).
108    pub mod90_deg: f64,
109}
110
111/// Resumen textual de un aspecto para listas legibles. La engine lo
112/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
113/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct AspectSummary {
116    /// Module al que pertenece — "natal", "transit", "synastry",
117    /// "progression", "solar_arc", "planetary_return".
118    pub module_id: String,
119    /// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
120    pub from_body: String,
121    pub to_body: String,
122    /// Identificador del aspecto — "conjunction", "trine", etc.
123    pub kind: String,
124    pub orb_deg: f64,
125    /// `Some(true)` = applying, `Some(false)` = separating. `None` para
126    /// cross-aspects (sinastría/return) donde no se computa.
127    #[serde(default)]
128    pub applying: Option<bool>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Layer {
133    pub module_id: String,
134    pub kind: LayerKind,
135    /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
136    /// a píxeles. Permite stack de anillos.
137    pub ring: f32,
138    #[serde(default)]
139    pub z: i32,
140    pub geometry: Geometry,
141    #[serde(default)]
142    pub glyphs: Vec<Glyph>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum LayerKind {
148    SignDial,
149    Houses,
150    Bodies,
151    Aspects,
152    Lots,
153    FixedStars,
154    Midpoints,
155    Outer,
156    Custom,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub enum Geometry {
161    GlyphsOnly,
162    /// Anillo dividido en sectores. `cusps_deg` son los grados
163    /// zodiacales donde van las divisiones radiales.
164    Ring { cusps_deg: Vec<f32> },
165    Lines(Vec<LineSeg>),
166    Points(Vec<PointMark>),
167}
168
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct LineSeg {
171    /// Grados zodiacales del extremo "a".
172    pub from_deg: f32,
173    /// Grados zodiacales del extremo "b".
174    pub to_deg: f32,
175    /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
176    /// resuelve a color.
177    pub kind: String,
178    pub opacity: f32,
179    /// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
180    /// (natal × natal, cross con overlays). Vacío en `Default::default`
181    /// para serde back-compat.
182    #[serde(default)]
183    pub from_body: String,
184    /// Cuerpo en el extremo "b".
185    #[serde(default)]
186    pub to_body: String,
187    /// Orb absoluto en grados (para tooltips).
188    #[serde(default)]
189    pub orb_deg: f32,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct PointMark {
194    pub deg: f32,
195    pub label: String,
196    pub tag: String,
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200pub struct Glyph {
201    /// Grado eclíptico [0, 360).
202    pub deg: f32,
203    /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
204    /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
205    pub symbol: String,
206    #[serde(default)]
207    pub annotation: Option<String>,
208    #[serde(default)]
209    pub retrograde: bool,
210    #[serde(default)]
211    pub house: Option<u8>,
212    /// Marker de dignidad esencial, set solo cuando
213    /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
214    /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
215    #[serde(default)]
216    pub dignity_marker: Option<String>,
217}
218
219// =====================================================================
220// Errores
221// =====================================================================
222
223#[derive(Debug, Error)]
224pub enum EngineError {
225    #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
226    BridgeDisabled,
227    #[error("model: {0}")]
228    Model(#[from] tahuantinsuyu_model::ModelError),
229    #[error("eternal: {0}")]
230    Eternal(String),
231    #[error("kind {0:?} todavía no implementado")]
232    UnsupportedKind(ChartKind),
233}
234
235// =====================================================================
236// API pública
237// =====================================================================
238
239/// Pedidos que el host (Shell) eleva a la engine para componer un
240/// `RenderModel`. La capa natal **siempre** se computa; estos requests
241/// son **overlays adicionales**.
242///
243/// Cada variante mapea 1-a-1 con un Module declarado en
244/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
245/// dueña única del cómputo (no depende del trait Module — los módulos
246/// son sólo metadata + UI controls).
247/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
248/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
249/// prender uno, el shell debe apagar los otros. Single source of truth
250/// — el shell y el canvas leen de acá en vez de hardcodear listas.
251pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
252
253#[derive(Debug, Clone)]
254pub enum PipelineRequest {
255    /// `module_id = "transit"` — anillo externo con planetas al
256    /// instante actual (reloj de pared) + cross aspects natal × transit.
257    Transit,
258    /// `module_id = "progression"` — anillo interno con los planetas
259    /// progresados (método secundario "día por año") a la edad pedida
260    /// + cross aspects natal × progresada.
261    SecondaryProgression {
262        /// Edad simbólica en años a la que avanzar la carta. Para "la
263        /// edad de hoy", el shell la calcula a partir de `birth_data` +
264        /// `SystemTime::now`.
265        target_age_years: f64,
266    },
267    /// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
268    /// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
269    /// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
270    /// + cross aspects natal × dirigida.
271    SolarArc {
272        target_age_years: f64,
273    },
274    /// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
275    /// carta del partner en el anillo externo (compartido con Transit
276    /// — mutuamente excluyentes), cross aspects natal × partner.
277    /// El partner viene como `Chart` completo del shell.
278    Synastry {
279        partner_chart: Box<Chart>,
280    },
281    /// `module_id = "planetary_return"` — carta natal fresca al
282    /// instante del próximo retorno del cuerpo elegido a su posición
283    /// natal, para la edad pedida. Sun = retorno solar anual, Moon =
284    /// mensual, Júpiter/Saturno = generacionales. Anillo externo
285    /// compartido con Transit/Synastry — mutuamente excluyentes a
286    /// nivel de Shell.
287    PlanetaryReturn {
288        /// Identificador agnóstico del cuerpo ("sun", "moon",
289        /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
290        body: String,
291        target_age_years: f64,
292        /// Días extra que se suman al anchor de búsqueda (birth +
293        /// age*año). Para Solar return suele ser 0 (el return cae cerca
294        /// del cumpleaños); para Lunar return permite saltar de un
295        /// retorno mensual al siguiente (~28 días por click).
296        shift_days: i64,
297    },
298    /// `module_id = "midpoints"` — anillo de puntos medios entre pares
299    /// de cuerpos natales. Por simplicidad filtramos a los que
300    /// involucran al Sol o a la Luna (~10 puntos).
301    Midpoints,
302    /// `module_id = "composite"` — carta compuesta (midpoint composite,
303    /// método Davison) entre dos sujetos. Renderea los planetas
304    /// compuestos en un anillo interno propio (radio 0.36, entre solar
305    /// arc 0.40 y aspects). Útil para análisis de relaciones.
306    Composite {
307        partner_chart: Box<Chart>,
308    },
309    /// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
310    /// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
311    /// dentro de una tolerancia (~2°). El resultado se publica en
312    /// `RenderModel.uranian_groups` para que la UI lo liste como
313    /// fórmulas analíticas. La visualización geométrica completa del
314    /// dial de 90° queda pendiente para una fase posterior.
315    Uranian,
316}
317
318/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
319/// multiplicador de orbe usar). Es independiente de los overlays.
320#[derive(Debug, Clone)]
321pub struct NatalOptions {
322    /// Incluir aspectos mayores (conj/opp/trine/square/sextile).
323    pub show_majors: bool,
324    /// Incluir aspectos menores (quincunx/semi-sextile/etc).
325    pub show_minors: bool,
326    /// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
327    /// modern_western; `0.5` = tight; `2.0` = wide.
328    pub orb_multiplier: f64,
329    /// Si `true`, anota cada cuerpo natal con su dignidad esencial
330    /// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
331    /// renderea como sufijo del glifo.
332    pub show_dignities: bool,
333}
334
335impl Default for NatalOptions {
336    fn default() -> Self {
337        Self {
338            show_majors: true,
339            show_minors: false,
340            orb_multiplier: 1.0,
341            show_dignities: false,
342        }
343    }
344}
345
346/// Composición canónica: carta natal + todos los overlays pedidos.
347/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
348pub fn compose(
349    chart: &Chart,
350    offset_minutes: i64,
351    requests: &[PipelineRequest],
352) -> Result<RenderModel, EngineError> {
353    compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
354}
355
356/// Variante que permite controlar qué aspectos natales se computan y
357/// con qué multiplicador de orbe.
358pub fn compose_with_options(
359    chart: &Chart,
360    offset_minutes: i64,
361    requests: &[PipelineRequest],
362    natal_options: &NatalOptions,
363) -> Result<RenderModel, EngineError> {
364    #[cfg(feature = "eternal-bridge")]
365    {
366        bridge::compose(chart, offset_minutes, requests, natal_options)
367    }
368    #[cfg(not(feature = "eternal-bridge"))]
369    {
370        let _ = (offset_minutes, requests, natal_options);
371        Ok(compute_mock(chart))
372    }
373}
374
375/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
376pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
377    compose(chart, 0, &[])
378}
379
380/// Atajo: natal con time-scrubbing pero sin overlays.
381pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
382    compose(chart, offset_minutes, &[])
383}
384
385/// Atajo: natal + overlay de tránsitos al instante actual.
386pub fn compute_with_transits_at_now(
387    chart: &Chart,
388    offset_minutes: i64,
389) -> Result<RenderModel, EngineError> {
390    compose(chart, offset_minutes, &[PipelineRequest::Transit])
391}
392
393/// Helper retrocompatible: construye un `PlanetaryReturn` con
394/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
395/// fino (todos los Solar return y muchos casos básicos).
396pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
397    PipelineRequest::PlanetaryReturn {
398        body,
399        target_age_years,
400        shift_days: 0,
401    }
402}
403
404/// Stub determinista — útil para tests + para la UI sin eternal.
405pub fn compute_mock(chart: &Chart) -> RenderModel {
406    use std::time::Instant;
407    let t0 = Instant::now();
408
409    let sign_dial = Layer {
410        module_id: "natal".into(),
411        kind: LayerKind::SignDial,
412        ring: 1.0,
413        z: 0,
414        geometry: Geometry::Ring {
415            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
416        },
417        glyphs: (0..12)
418            .map(|i| Glyph {
419                deg: (i as f32) * 30.0 + 15.0,
420                symbol: ZODIAC_GLYPHS[i].into(),
421                annotation: None,
422                retrograde: false,
423                house: None,
424            dignity_marker: None,
425            })
426            .collect(),
427    };
428
429    RenderModel {
430        chart_id: chart.id,
431        chart_kind: chart.kind,
432        title: chart.label.clone(),
433        subtitle: chart.birth_data.birthplace_label.clone(),
434        compute_ms: t0.elapsed().as_millis() as u64,
435        ascendant_deg: 0.0,
436        midheaven_deg: 270.0,
437        descendant_deg: 180.0,
438        imum_coeli_deg: 90.0,
439        layers: vec![sign_dial],
440        overlays: Vec::new(),
441        aspect_summary: Vec::new(),
442        uranian_groups: Vec::new(),
443    }
444}
445
446const ZODIAC_GLYPHS: [&str; 12] = [
447    "aries",
448    "taurus",
449    "gemini",
450    "cancer",
451    "leo",
452    "virgo",
453    "libra",
454    "scorpio",
455    "sagittarius",
456    "capricorn",
457    "aquarius",
458    "pisces",
459];
460
461// =====================================================================
462// Tests
463// =====================================================================
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use tahuantinsuyu_model::{
469        Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
470    };
471
472    fn sample_chart() -> Chart {
473        Chart {
474            id: ChartId::new(),
475            contact_id: ContactId::new(),
476            kind: ChartKind::Natal,
477            label: "test".into(),
478            birth_data: StoredBirthData {
479                year: 1987,
480                month: 3,
481                day: 14,
482                hour: 5,
483                minute: 22,
484                second: 0.0,
485                tz_offset_minutes: -240,
486                latitude_deg: 10.4806,
487                longitude_deg: -66.9036,
488                altitude_m: 900.0,
489                time_certainty: Default::default(),
490                subject_name: None,
491                birthplace_label: None,
492            },
493            config: StoredChartConfig::default(),
494            related_chart_id: None,
495            created_at_ms: 0,
496        }
497    }
498
499    #[test]
500    fn mock_emits_sign_dial() {
501        let model = compute_mock(&sample_chart());
502        assert_eq!(model.layers.len(), 1);
503        assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
504        assert_eq!(model.layers[0].glyphs.len(), 12);
505    }
506
507    #[cfg(feature = "eternal-bridge")]
508    #[test]
509    fn real_compute_natal_demo() {
510        let model = compute(&sample_chart()).expect("compute con eternal");
511        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
512        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
513        assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
514        // El Asc debe ser un grado válido.
515        assert!(model.ascendant_deg.is_finite());
516        assert!((0.0..360.0).contains(&model.ascendant_deg));
517    }
518}