⎇ d890bd4b3a7aa8af918e854c1ae6754ca08673cd
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
History
Who touched what
raw
feat(tahuantinsuyu): fase 21 — background compute + UranianModule
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, 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}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct PointMark {
183 pub deg: f32,
184 pub label: String,
185 pub tag: String,
186}
187
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct Glyph {
190 /// Grado eclíptico [0, 360).
191 pub deg: f32,
192 /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
193 /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
194 pub symbol: String,
195 #[serde(default)]
196 pub annotation: Option<String>,
197 #[serde(default)]
198 pub retrograde: bool,
199 #[serde(default)]
200 pub house: Option<u8>,
201 /// Marker de dignidad esencial, set solo cuando
202 /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
203 /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
204 #[serde(default)]
205 pub dignity_marker: Option<String>,
206}
207
208// =====================================================================
209// Errores
210// =====================================================================
211
212#[derive(Debug, Error)]
213pub enum EngineError {
214 #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
215 BridgeDisabled,
216 #[error("model: {0}")]
217 Model(#[from] tahuantinsuyu_model::ModelError),
218 #[error("eternal: {0}")]
219 Eternal(String),
220 #[error("kind {0:?} todavía no implementado")]
221 UnsupportedKind(ChartKind),
222}
223
224// =====================================================================
225// API pública
226// =====================================================================
227
228/// Pedidos que el host (Shell) eleva a la engine para componer un
229/// `RenderModel`. La capa natal **siempre** se computa; estos requests
230/// son **overlays adicionales**.
231///
232/// Cada variante mapea 1-a-1 con un Module declarado en
233/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
234/// dueña única del cómputo (no depende del trait Module — los módulos
235/// son sólo metadata + UI controls).
236/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
237/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
238/// prender uno, el shell debe apagar los otros. Single source of truth
239/// — el shell y el canvas leen de acá en vez de hardcodear listas.
240pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
241
242#[derive(Debug, Clone)]
243pub enum PipelineRequest {
244 /// `module_id = "transit"` — anillo externo con planetas al
245 /// instante actual (reloj de pared) + cross aspects natal × transit.
246 Transit,
247 /// `module_id = "progression"` — anillo interno con los planetas
248 /// progresados (método secundario "día por año") a la edad pedida
249 /// + cross aspects natal × progresada.
250 SecondaryProgression {
251 /// Edad simbólica en años a la que avanzar la carta. Para "la
252 /// edad de hoy", el shell la calcula a partir de `birth_data` +
253 /// `SystemTime::now`.
254 target_age_years: f64,
255 },
256 /// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
257 /// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
258 /// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
259 /// + cross aspects natal × dirigida.
260 SolarArc {
261 target_age_years: f64,
262 },
263 /// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
264 /// carta del partner en el anillo externo (compartido con Transit
265 /// — mutuamente excluyentes), cross aspects natal × partner.
266 /// El partner viene como `Chart` completo del shell.
267 Synastry {
268 partner_chart: Box<Chart>,
269 },
270 /// `module_id = "planetary_return"` — carta natal fresca al
271 /// instante del próximo retorno del cuerpo elegido a su posición
272 /// natal, para la edad pedida. Sun = retorno solar anual, Moon =
273 /// mensual, Júpiter/Saturno = generacionales. Anillo externo
274 /// compartido con Transit/Synastry — mutuamente excluyentes a
275 /// nivel de Shell.
276 PlanetaryReturn {
277 /// Identificador agnóstico del cuerpo ("sun", "moon",
278 /// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
279 body: String,
280 target_age_years: f64,
281 /// Días extra que se suman al anchor de búsqueda (birth +
282 /// age*año). Para Solar return suele ser 0 (el return cae cerca
283 /// del cumpleaños); para Lunar return permite saltar de un
284 /// retorno mensual al siguiente (~28 días por click).
285 shift_days: i64,
286 },
287 /// `module_id = "midpoints"` — anillo de puntos medios entre pares
288 /// de cuerpos natales. Por simplicidad filtramos a los que
289 /// involucran al Sol o a la Luna (~10 puntos).
290 Midpoints,
291 /// `module_id = "composite"` — carta compuesta (midpoint composite,
292 /// método Davison) entre dos sujetos. Renderea los planetas
293 /// compuestos en un anillo interno propio (radio 0.36, entre solar
294 /// arc 0.40 y aspects). Útil para análisis de relaciones.
295 Composite {
296 partner_chart: Box<Chart>,
297 },
298 /// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
299 /// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
300 /// dentro de una tolerancia (~2°). El resultado se publica en
301 /// `RenderModel.uranian_groups` para que la UI lo liste como
302 /// fórmulas analíticas. La visualización geométrica completa del
303 /// dial de 90° queda pendiente para una fase posterior.
304 Uranian,
305}
306
307/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
308/// multiplicador de orbe usar). Es independiente de los overlays.
309#[derive(Debug, Clone)]
310pub struct NatalOptions {
311 /// Incluir aspectos mayores (conj/opp/trine/square/sextile).
312 pub show_majors: bool,
313 /// Incluir aspectos menores (quincunx/semi-sextile/etc).
314 pub show_minors: bool,
315 /// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
316 /// modern_western; `0.5` = tight; `2.0` = wide.
317 pub orb_multiplier: f64,
318 /// Si `true`, anota cada cuerpo natal con su dignidad esencial
319 /// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
320 /// renderea como sufijo del glifo.
321 pub show_dignities: bool,
322}
323
324impl Default for NatalOptions {
325 fn default() -> Self {
326 Self {
327 show_majors: true,
328 show_minors: false,
329 orb_multiplier: 1.0,
330 show_dignities: false,
331 }
332 }
333}
334
335/// Composición canónica: carta natal + todos los overlays pedidos.
336/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
337pub fn compose(
338 chart: &Chart,
339 offset_minutes: i64,
340 requests: &[PipelineRequest],
341) -> Result<RenderModel, EngineError> {
342 compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
343}
344
345/// Variante que permite controlar qué aspectos natales se computan y
346/// con qué multiplicador de orbe.
347pub fn compose_with_options(
348 chart: &Chart,
349 offset_minutes: i64,
350 requests: &[PipelineRequest],
351 natal_options: &NatalOptions,
352) -> Result<RenderModel, EngineError> {
353 #[cfg(feature = "eternal-bridge")]
354 {
355 bridge::compose(chart, offset_minutes, requests, natal_options)
356 }
357 #[cfg(not(feature = "eternal-bridge"))]
358 {
359 let _ = (offset_minutes, requests, natal_options);
360 Ok(compute_mock(chart))
361 }
362}
363
364/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
365pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
366 compose(chart, 0, &[])
367}
368
369/// Atajo: natal con time-scrubbing pero sin overlays.
370pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
371 compose(chart, offset_minutes, &[])
372}
373
374/// Atajo: natal + overlay de tránsitos al instante actual.
375pub fn compute_with_transits_at_now(
376 chart: &Chart,
377 offset_minutes: i64,
378) -> Result<RenderModel, EngineError> {
379 compose(chart, offset_minutes, &[PipelineRequest::Transit])
380}
381
382/// Helper retrocompatible: construye un `PlanetaryReturn` con
383/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
384/// fino (todos los Solar return y muchos casos básicos).
385pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
386 PipelineRequest::PlanetaryReturn {
387 body,
388 target_age_years,
389 shift_days: 0,
390 }
391}
392
393/// Stub determinista — útil para tests + para la UI sin eternal.
394pub fn compute_mock(chart: &Chart) -> RenderModel {
395 use std::time::Instant;
396 let t0 = Instant::now();
397
398 let sign_dial = Layer {
399 module_id: "natal".into(),
400 kind: LayerKind::SignDial,
401 ring: 1.0,
402 z: 0,
403 geometry: Geometry::Ring {
404 cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
405 },
406 glyphs: (0..12)
407 .map(|i| Glyph {
408 deg: (i as f32) * 30.0 + 15.0,
409 symbol: ZODIAC_GLYPHS[i].into(),
410 annotation: None,
411 retrograde: false,
412 house: None,
413 dignity_marker: None,
414 })
415 .collect(),
416 };
417
418 RenderModel {
419 chart_id: chart.id,
420 chart_kind: chart.kind,
421 title: chart.label.clone(),
422 subtitle: chart.birth_data.birthplace_label.clone(),
423 compute_ms: t0.elapsed().as_millis() as u64,
424 ascendant_deg: 0.0,
425 midheaven_deg: 270.0,
426 descendant_deg: 180.0,
427 imum_coeli_deg: 90.0,
428 layers: vec![sign_dial],
429 overlays: Vec::new(),
430 aspect_summary: Vec::new(),
431 uranian_groups: Vec::new(),
432 }
433}
434
435const ZODIAC_GLYPHS: [&str; 12] = [
436 "aries",
437 "taurus",
438 "gemini",
439 "cancer",
440 "leo",
441 "virgo",
442 "libra",
443 "scorpio",
444 "sagittarius",
445 "capricorn",
446 "aquarius",
447 "pisces",
448];
449
450// =====================================================================
451// Tests
452// =====================================================================
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use tahuantinsuyu_model::{
458 Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
459 };
460
461 fn sample_chart() -> Chart {
462 Chart {
463 id: ChartId::new(),
464 contact_id: ContactId::new(),
465 kind: ChartKind::Natal,
466 label: "test".into(),
467 birth_data: StoredBirthData {
468 year: 1987,
469 month: 3,
470 day: 14,
471 hour: 5,
472 minute: 22,
473 second: 0.0,
474 tz_offset_minutes: -240,
475 latitude_deg: 10.4806,
476 longitude_deg: -66.9036,
477 altitude_m: 900.0,
478 time_certainty: Default::default(),
479 subject_name: None,
480 birthplace_label: None,
481 },
482 config: StoredChartConfig::default(),
483 related_chart_id: None,
484 created_at_ms: 0,
485 }
486 }
487
488 #[test]
489 fn mock_emits_sign_dial() {
490 let model = compute_mock(&sample_chart());
491 assert_eq!(model.layers.len(), 1);
492 assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
493 assert_eq!(model.layers[0].glyphs.len(), 12);
494 }
495
496 #[cfg(feature = "eternal-bridge")]
497 #[test]
498 fn real_compute_natal_demo() {
499 let model = compute(&sample_chart()).expect("compute con eternal");
500 assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
501 assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
502 assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
503 // El Asc debe ser un grado válido.
504 assert!(model.ascendant_deg.is_finite());
505 assert!((0.0..360.0).contains(&model.ascendant_deg));
506 }
507}