a92fa15777
Tres mejoras de UX para manejar conjunciones (stelium) y dar más control sobre el sistema GR: 1. `spread_angles(angles, min_sep_deg)`: reposiciona angularmente los glyphs adyacentes para que ningún par caiga más cerca que el threshold visual (derivado del ancho del label pill al radio del ring). Iterativo (≤60 pasos), re-ordena cada iteración para preservar el orden circular, devuelve también `residual` ∈ [0,1] = fracción de presión no resuelta. Las posiciones REALES no se tocan — solo afecta la geometría visual del glyph. 5 tests cubren: empty, separados intactos, cluster cerrado, orden preservado, cluster infactible. 2. Aplicación al render de Bodies (natal/topo/pd/outer): cada layer pasa por spread_angles antes de iterar glyphs. Si residual queda alta, los discos y fonts se encogen proporcionalmente (0.55..1.0×) y los coord labels se omiten — evita pillas montadas sobre el bloque. 3. `find_clusters(angles, threshold_deg)`: detecta grupos angularmente cercanos (incluye wrap-around 359°→1°). Glyphs en cluster de ≥3 miembros NO llevan coord label individual; en su lugar, al final del loop se pinta UN solo label compartido con los símbolos concatenados (ej. "☉ ☿ ♀ 14°56'♈") posicionado en el centroide angular del cluster. El usuario sigue viendo cada planeta con su disco, pero no se ahoga en pills superpuestas. 4. Selector Naibod/Ptolomeo en PrimaryDirectionsModule via `Control::Select`. Default Naibod (0°59'08.33″/año, moderno). El shell extrae `module_configs["primary_directions"]["key"]` y lo pasa en `PipelineRequest::PrimaryDirections { key }`; el bridge mapea string → `DirectionKey` y pasa al cómputo. El overlay meta muestra qué clave se usó: "GR Direcciones · 30.5a · Naibod". Tests: 16 verdes (6 shell + 5 spread + 5 coord). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
596 lines
22 KiB
Rust
596 lines
22 KiB
Rust
//! `tahuantinsuyu-engine` — bridge entre el modelo agnóstico y
|
||
//! `eternal-astrology`.
|
||
//!
|
||
//! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un
|
||
//! [`RenderModel`] que describe la geometría a pintar **sin** acoplar
|
||
//! el canvas a tipos de la librería astronómica. El canvas habla
|
||
//! grados decimales, radios normalizados y kinds simbólicos.
|
||
//!
|
||
//! ## Por qué un RenderModel intermedio
|
||
//!
|
||
//! 1. El canvas no debería caer si cambia el shape de `NatalChart`
|
||
//! upstream.
|
||
//! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin
|
||
//! arrancar eternal.
|
||
//! 3. Cada `ChartKind` produce el mismo shape genérico → el render
|
||
//! coordina N módulos sin saber qué calcularon.
|
||
//!
|
||
//! ## Feature `eternal-bridge`
|
||
//!
|
||
//! - **on** (default): [`compute`] abre una `EphemerisSession` VSOP2013
|
||
//! compartida y corre la pipeline real.
|
||
//! - **off**: [`compute`] cae a [`compute_mock`] — útil para tests +
|
||
//! builds sin eternal checked out.
|
||
|
||
#![forbid(unsafe_code)]
|
||
#![warn(rust_2018_idioms)]
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use thiserror::Error;
|
||
|
||
pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
|
||
|
||
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
||
// transporta — el caller (shell) lee del Store y pasa el Chart entero
|
||
// para que el bridge construya su NatalChart en eternal.
|
||
|
||
#[cfg(feature = "eternal-bridge")]
|
||
mod bridge;
|
||
#[cfg(feature = "eternal-bridge")]
|
||
mod dignity;
|
||
#[cfg(feature = "eternal-bridge")]
|
||
mod natal_cache;
|
||
#[cfg(feature = "eternal-bridge")]
|
||
pub mod svg_export;
|
||
|
||
// =====================================================================
|
||
// RenderModel — lo que el canvas necesita pintar
|
||
// =====================================================================
|
||
|
||
/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct RenderModel {
|
||
pub chart_id: ChartId,
|
||
pub chart_kind: ChartKind,
|
||
pub title: String,
|
||
#[serde(default)]
|
||
pub subtitle: Option<String>,
|
||
pub compute_ms: u64,
|
||
|
||
// ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
|
||
/// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
|
||
/// de modo que el Asc cae a las 9 (lado izquierdo).
|
||
pub ascendant_deg: f32,
|
||
pub midheaven_deg: f32,
|
||
pub descendant_deg: f32,
|
||
pub imum_coeli_deg: f32,
|
||
|
||
/// Capas a pintar. Orden = z-order ascendente.
|
||
pub layers: Vec<Layer>,
|
||
/// Metadata humana por overlay activo (transit, progresión,
|
||
/// sinastría, retorno...). Vacío para una carta natal pura. La UI
|
||
/// la pinta como badges en el footer.
|
||
#[serde(default)]
|
||
pub overlays: Vec<OverlayMeta>,
|
||
/// Lista paralela a las LineSeg de aspectos — uno por aspecto
|
||
/// natal o cross. Ordenado por `orb_deg` ascendente (los más
|
||
/// cerrados primero). La UI lo usa para la lista textual.
|
||
#[serde(default)]
|
||
pub aspect_summary: Vec<AspectSummary>,
|
||
/// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
|
||
/// Vacío sino se activó el módulo Uranian.
|
||
#[serde(default)]
|
||
pub uranian_groups: Vec<UranianGroup>,
|
||
}
|
||
|
||
/// Etiqueta legible de un overlay para el footer del canvas. La engine
|
||
/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct OverlayMeta {
|
||
pub module_id: String,
|
||
/// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
|
||
/// "Sinastría · Ana", "Saturn return 29a".
|
||
pub label: String,
|
||
}
|
||
|
||
/// Grupo de cuerpos natales que caen en la misma posición del
|
||
/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
|
||
/// muy cercana). En la astrología uraniana esto es una "fórmula" o
|
||
/// "axis" — los cuerpos están en correspondencia simbólica directa
|
||
/// porque comparten un cuadrante simétrico.
|
||
///
|
||
/// Solo se emiten grupos con 2+ miembros (los singletons no son
|
||
/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct UranianGroup {
|
||
/// Identificadores agnósticos de los cuerpos en el grupo
|
||
/// (ej. `["sun", "jupiter", "saturn"]`).
|
||
pub bodies: Vec<String>,
|
||
/// Posición en el dial de 90° (la longitud módulo 90).
|
||
pub mod90_deg: f64,
|
||
}
|
||
|
||
/// Resumen textual de un aspecto para listas legibles. La engine lo
|
||
/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
|
||
/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AspectSummary {
|
||
/// Module al que pertenece — "natal", "transit", "synastry",
|
||
/// "progression", "solar_arc", "planetary_return".
|
||
pub module_id: String,
|
||
/// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
|
||
pub from_body: String,
|
||
pub to_body: String,
|
||
/// Identificador del aspecto — "conjunction", "trine", etc.
|
||
pub kind: String,
|
||
pub orb_deg: f64,
|
||
/// `Some(true)` = applying, `Some(false)` = separating. `None` para
|
||
/// cross-aspects (sinastría/return) donde no se computa.
|
||
#[serde(default)]
|
||
pub applying: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Layer {
|
||
pub module_id: String,
|
||
pub kind: LayerKind,
|
||
/// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
|
||
/// a píxeles. Permite stack de anillos.
|
||
pub ring: f32,
|
||
#[serde(default)]
|
||
pub z: i32,
|
||
pub geometry: Geometry,
|
||
#[serde(default)]
|
||
pub glyphs: Vec<Glyph>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum LayerKind {
|
||
SignDial,
|
||
Houses,
|
||
Bodies,
|
||
Aspects,
|
||
Lots,
|
||
FixedStars,
|
||
Midpoints,
|
||
Outer,
|
||
Custom,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub enum Geometry {
|
||
GlyphsOnly,
|
||
/// Anillo dividido en sectores. `cusps_deg` son los grados
|
||
/// zodiacales donde van las divisiones radiales.
|
||
Ring { cusps_deg: Vec<f32> },
|
||
Lines(Vec<LineSeg>),
|
||
Points(Vec<PointMark>),
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct LineSeg {
|
||
/// Grados zodiacales del extremo "a".
|
||
pub from_deg: f32,
|
||
/// Grados zodiacales del extremo "b".
|
||
pub to_deg: f32,
|
||
/// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
|
||
/// resuelve a color.
|
||
pub kind: String,
|
||
pub opacity: f32,
|
||
/// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
|
||
/// (natal × natal, cross con overlays). Vacío en `Default::default`
|
||
/// para serde back-compat.
|
||
#[serde(default)]
|
||
pub from_body: String,
|
||
/// Cuerpo en el extremo "b".
|
||
#[serde(default)]
|
||
pub to_body: String,
|
||
/// Orb absoluto en grados (para tooltips).
|
||
#[serde(default)]
|
||
pub orb_deg: f32,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct PointMark {
|
||
pub deg: f32,
|
||
pub label: String,
|
||
pub tag: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct Glyph {
|
||
/// Grado eclíptico [0, 360).
|
||
pub deg: f32,
|
||
/// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
|
||
/// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
|
||
pub symbol: String,
|
||
#[serde(default)]
|
||
pub annotation: Option<String>,
|
||
#[serde(default)]
|
||
pub retrograde: bool,
|
||
#[serde(default)]
|
||
pub house: Option<u8>,
|
||
/// Marker de dignidad esencial, set solo cuando
|
||
/// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
|
||
/// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
|
||
#[serde(default)]
|
||
pub dignity_marker: Option<String>,
|
||
}
|
||
|
||
// =====================================================================
|
||
// Errores
|
||
// =====================================================================
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum EngineError {
|
||
#[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")]
|
||
BridgeDisabled,
|
||
#[error("model: {0}")]
|
||
Model(#[from] tahuantinsuyu_model::ModelError),
|
||
#[error("eternal: {0}")]
|
||
Eternal(String),
|
||
#[error("kind {0:?} todavía no implementado")]
|
||
UnsupportedKind(ChartKind),
|
||
}
|
||
|
||
// =====================================================================
|
||
// API pública
|
||
// =====================================================================
|
||
|
||
/// Pedidos que el host (Shell) eleva a la engine para componer un
|
||
/// `RenderModel`. La capa natal **siempre** se computa; estos requests
|
||
/// son **overlays adicionales**.
|
||
///
|
||
/// Cada variante mapea 1-a-1 con un Module declarado en
|
||
/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
|
||
/// dueña única del cómputo (no depende del trait Module — los módulos
|
||
/// son sólo metadata + UI controls).
|
||
/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
|
||
/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
|
||
/// prender uno, el shell debe apagar los otros. Single source of truth
|
||
/// — el shell y el canvas leen de acá en vez de hardcodear listas.
|
||
pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum PipelineRequest {
|
||
/// `module_id = "transit"` — anillo externo con planetas al
|
||
/// instante actual (reloj de pared) + cross aspects natal × transit.
|
||
Transit,
|
||
/// `module_id = "progression"` — anillo interno con los planetas
|
||
/// progresados (método secundario "día por año") a la edad pedida
|
||
/// + cross aspects natal × progresada.
|
||
SecondaryProgression {
|
||
/// Edad simbólica en años a la que avanzar la carta. Para "la
|
||
/// edad de hoy", el shell la calcula a partir de `birth_data` +
|
||
/// `SystemTime::now`.
|
||
target_age_years: f64,
|
||
},
|
||
/// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
|
||
/// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
|
||
/// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
|
||
/// + cross aspects natal × dirigida.
|
||
SolarArc {
|
||
target_age_years: f64,
|
||
},
|
||
/// `module_id = "synastry"` — bi-wheel: la natal en el centro, la
|
||
/// carta del partner en el anillo externo (compartido con Transit
|
||
/// — mutuamente excluyentes), cross aspects natal × partner.
|
||
/// El partner viene como `Chart` completo del shell.
|
||
Synastry {
|
||
partner_chart: Box<Chart>,
|
||
},
|
||
/// `module_id = "planetary_return"` — carta natal fresca al
|
||
/// instante del próximo retorno del cuerpo elegido a su posición
|
||
/// natal, para la edad pedida. Sun = retorno solar anual, Moon =
|
||
/// mensual, Júpiter/Saturno = generacionales. Anillo externo
|
||
/// compartido con Transit/Synastry — mutuamente excluyentes a
|
||
/// nivel de Shell.
|
||
PlanetaryReturn {
|
||
/// Identificador agnóstico del cuerpo ("sun", "moon",
|
||
/// "jupiter", …). El bridge lo mapea a `eternal_sky::Body`.
|
||
body: String,
|
||
target_age_years: f64,
|
||
/// Días extra que se suman al anchor de búsqueda (birth +
|
||
/// age*año). Para Solar return suele ser 0 (el return cae cerca
|
||
/// del cumpleaños); para Lunar return permite saltar de un
|
||
/// retorno mensual al siguiente (~28 días por click).
|
||
shift_days: i64,
|
||
},
|
||
/// `module_id = "midpoints"` — anillo de puntos medios entre pares
|
||
/// de cuerpos natales. Por simplicidad filtramos a los que
|
||
/// involucran al Sol o a la Luna (~10 puntos).
|
||
Midpoints,
|
||
/// `module_id = "composite"` — carta compuesta (midpoint composite,
|
||
/// método Davison) entre dos sujetos. Renderea los planetas
|
||
/// compuestos en un anillo interno propio (radio 0.36, entre solar
|
||
/// arc 0.40 y aspects). Útil para análisis de relaciones.
|
||
Composite {
|
||
partner_chart: Box<Chart>,
|
||
},
|
||
/// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
|
||
/// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
|
||
/// dentro de una tolerancia (~2°). El resultado se publica en
|
||
/// `RenderModel.uranian_groups` para que la UI lo liste como
|
||
/// fórmulas analíticas. La visualización geométrica completa del
|
||
/// dial de 90° queda pendiente para una fase posterior.
|
||
Uranian,
|
||
/// `module_id = "lots"` — Lots arábigos (helenísticos) calculados
|
||
/// via `eternal_astrology::compute_lot`: Fortune, Spirit, Eros,
|
||
/// Necessity, Courage, Victory, Nemesis. Renderea cada lot como
|
||
/// un texto pequeño en el ring de bodies natales.
|
||
Lots,
|
||
/// `module_id = "fixed_stars"` — overlay con ~9 estrellas fijas
|
||
/// notables (Aldebaran, Regulus, Antares, Fomalhaut, Spica,
|
||
/// Sirius, Algol, Vega, Pollux). Posiciones tropicales J2000
|
||
/// aproximadas + precesión simple (~50.29″/año). Renderea como
|
||
/// marcadores chicos justo afuera del sign dial.
|
||
FixedStars,
|
||
/// `module_id = "topocentric"` — capa "ascensional": planetas
|
||
/// re-proyectados a longitud eclíptica topocéntrica (con paralaje
|
||
/// horizontal aplicada por cuerpo) + casas Polich-Page (sistema
|
||
/// topocéntrico de domificación). Visible sobre todo en la Luna
|
||
/// (~1° de shift); imperceptible en planetas exteriores. La capa
|
||
/// convive con la natal geocéntrica como overlay comparativo.
|
||
Topocentric,
|
||
/// `module_id = "pd_direct"` + `"pd_converse"` — Direcciones
|
||
/// Primarias del Sistema GR (García Rosas). Cada cuerpo natal se
|
||
/// proyecta dos veces: hacia adelante en el tiempo diurno
|
||
/// (direct) y hacia atrás (converse). Los dos resultados a la
|
||
/// edad pedida pintan un dual-ring para rectificación en vivo.
|
||
///
|
||
/// `key` controla la conversión arco↔año: "naibod" (default
|
||
/// moderno, 0°59'08.33″/año) o "ptolemy" (clásica, 1°/año).
|
||
PrimaryDirections {
|
||
target_age_years: f64,
|
||
key: String,
|
||
},
|
||
}
|
||
|
||
/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
|
||
/// multiplicador de orbe usar). Es independiente de los overlays.
|
||
#[derive(Debug, Clone)]
|
||
pub struct NatalOptions {
|
||
/// Incluir aspectos mayores (conj/opp/trine/square/sextile).
|
||
pub show_majors: bool,
|
||
/// Incluir aspectos menores (quincunx/semi-sextile/etc).
|
||
pub show_minors: bool,
|
||
/// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
|
||
/// modern_western; `0.5` = tight; `2.0` = wide.
|
||
pub orb_multiplier: f64,
|
||
/// Si `true`, anota cada cuerpo natal con su dignidad esencial
|
||
/// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
|
||
/// renderea como sufijo del glifo.
|
||
pub show_dignities: bool,
|
||
}
|
||
|
||
impl Default for NatalOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
show_majors: true,
|
||
show_minors: false,
|
||
orb_multiplier: 1.0,
|
||
show_dignities: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Composición canónica: carta natal + todos los overlays pedidos.
|
||
/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
|
||
pub fn compose(
|
||
chart: &Chart,
|
||
offset_minutes: i64,
|
||
requests: &[PipelineRequest],
|
||
) -> Result<RenderModel, EngineError> {
|
||
compose_with_options(chart, offset_minutes, requests, &NatalOptions::default())
|
||
}
|
||
|
||
/// Variante que permite controlar qué aspectos natales se computan y
|
||
/// con qué multiplicador de orbe.
|
||
pub fn compose_with_options(
|
||
chart: &Chart,
|
||
offset_minutes: i64,
|
||
requests: &[PipelineRequest],
|
||
natal_options: &NatalOptions,
|
||
) -> Result<RenderModel, EngineError> {
|
||
#[cfg(feature = "eternal-bridge")]
|
||
{
|
||
bridge::compose(chart, offset_minutes, requests, natal_options)
|
||
}
|
||
#[cfg(not(feature = "eternal-bridge"))]
|
||
{
|
||
let _ = (offset_minutes, requests, natal_options);
|
||
Ok(compute_mock(chart))
|
||
}
|
||
}
|
||
|
||
/// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`.
|
||
pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
|
||
compose(chart, 0, &[])
|
||
}
|
||
|
||
/// Atajo: natal con time-scrubbing pero sin overlays.
|
||
pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
|
||
compose(chart, offset_minutes, &[])
|
||
}
|
||
|
||
/// Atajo: natal + overlay de tránsitos al instante actual.
|
||
pub fn compute_with_transits_at_now(
|
||
chart: &Chart,
|
||
offset_minutes: i64,
|
||
) -> Result<RenderModel, EngineError> {
|
||
compose(chart, offset_minutes, &[PipelineRequest::Transit])
|
||
}
|
||
|
||
/// Helper retrocompatible: construye un `PlanetaryReturn` con
|
||
/// `shift_days = 0`. Útil para llamadores que no necesitan ajuste
|
||
/// fino (todos los Solar return y muchos casos básicos).
|
||
pub fn planetary_return_request(body: String, target_age_years: f64) -> PipelineRequest {
|
||
PipelineRequest::PlanetaryReturn {
|
||
body,
|
||
target_age_years,
|
||
shift_days: 0,
|
||
}
|
||
}
|
||
|
||
/// Stub determinista — útil para tests + para la UI sin eternal.
|
||
pub fn compute_mock(chart: &Chart) -> RenderModel {
|
||
use std::time::Instant;
|
||
let t0 = Instant::now();
|
||
|
||
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_GLYPHS[i].into(),
|
||
annotation: None,
|
||
retrograde: false,
|
||
house: None,
|
||
dignity_marker: None,
|
||
})
|
||
.collect(),
|
||
};
|
||
|
||
RenderModel {
|
||
chart_id: chart.id,
|
||
chart_kind: chart.kind,
|
||
title: chart.label.clone(),
|
||
subtitle: chart.birth_data.birthplace_label.clone(),
|
||
compute_ms: t0.elapsed().as_millis() as u64,
|
||
ascendant_deg: 0.0,
|
||
midheaven_deg: 270.0,
|
||
descendant_deg: 180.0,
|
||
imum_coeli_deg: 90.0,
|
||
layers: vec![sign_dial],
|
||
overlays: Vec::new(),
|
||
aspect_summary: Vec::new(),
|
||
uranian_groups: Vec::new(),
|
||
}
|
||
}
|
||
|
||
const ZODIAC_GLYPHS: [&str; 12] = [
|
||
"aries",
|
||
"taurus",
|
||
"gemini",
|
||
"cancer",
|
||
"leo",
|
||
"virgo",
|
||
"libra",
|
||
"scorpio",
|
||
"sagittarius",
|
||
"capricorn",
|
||
"aquarius",
|
||
"pisces",
|
||
];
|
||
|
||
// =====================================================================
|
||
// Tests
|
||
// =====================================================================
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use tahuantinsuyu_model::{
|
||
Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig,
|
||
};
|
||
|
||
fn sample_chart() -> Chart {
|
||
Chart {
|
||
id: ChartId::new(),
|
||
contact_id: ContactId::new(),
|
||
kind: ChartKind::Natal,
|
||
label: "test".into(),
|
||
birth_data: StoredBirthData {
|
||
year: 1987,
|
||
month: 3,
|
||
day: 14,
|
||
hour: 5,
|
||
minute: 22,
|
||
second: 0.0,
|
||
tz_offset_minutes: -240,
|
||
latitude_deg: 10.4806,
|
||
longitude_deg: -66.9036,
|
||
altitude_m: 900.0,
|
||
time_certainty: Default::default(),
|
||
subject_name: None,
|
||
birthplace_label: None,
|
||
},
|
||
config: StoredChartConfig::default(),
|
||
related_chart_id: None,
|
||
created_at_ms: 0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mock_emits_sign_dial() {
|
||
let model = compute_mock(&sample_chart());
|
||
assert_eq!(model.layers.len(), 1);
|
||
assert!(matches!(model.layers[0].kind, LayerKind::SignDial));
|
||
assert_eq!(model.layers[0].glyphs.len(), 12);
|
||
}
|
||
|
||
#[cfg(feature = "eternal-bridge")]
|
||
#[test]
|
||
fn real_compute_natal_demo() {
|
||
let model = compute(&sample_chart()).expect("compute con eternal");
|
||
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial)));
|
||
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses)));
|
||
assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies)));
|
||
// El Asc debe ser un grado válido.
|
||
assert!(model.ascendant_deg.is_finite());
|
||
assert!((0.0..360.0).contains(&model.ascendant_deg));
|
||
}
|
||
|
||
/// El cache de NatalChart debe hacer que la segunda llamada con
|
||
/// inputs idénticos sea sustancialmente más rápida que la primera.
|
||
/// Verificamos un piso del 4× — en práctica el ratio suele ser
|
||
/// >10× porque la primera carga VSOP2013 también.
|
||
#[cfg(feature = "eternal-bridge")]
|
||
#[test]
|
||
fn natal_cache_hits_are_faster() {
|
||
let chart = sample_chart();
|
||
// Warmup: abre la sesión de efemérides y puebla el cache.
|
||
let _ = compute(&chart).expect("warmup");
|
||
|
||
// Reset implícito: insertar una clave distinta no botaría la
|
||
// nuestra (cap=8) pero la marcaría como más vieja. Como solo
|
||
// tenemos 1 entrada, sigue al frente.
|
||
let t1 = std::time::Instant::now();
|
||
let _ = compute(&chart).expect("primera medida");
|
||
let cold_or_hot_1 = t1.elapsed();
|
||
|
||
let t2 = std::time::Instant::now();
|
||
let _ = compute(&chart).expect("segunda medida");
|
||
let hot = t2.elapsed();
|
||
|
||
// Después del warmup, las dos llamadas son hot. Para validar el
|
||
// efecto del cache, modificamos el offset_minutes para forzar
|
||
// un MISS y comparar contra un HIT.
|
||
use crate::PipelineRequest;
|
||
let t3 = std::time::Instant::now();
|
||
let _ = compose(&chart, 17, &[] as &[PipelineRequest])
|
||
.expect("miss con offset distinto");
|
||
let miss = t3.elapsed();
|
||
|
||
let t4 = std::time::Instant::now();
|
||
let _ = compose(&chart, 17, &[] as &[PipelineRequest])
|
||
.expect("hit con mismo offset");
|
||
let hit = t4.elapsed();
|
||
|
||
// Sanity: el hit debe ser estrictamente más rápido que el miss.
|
||
assert!(
|
||
hit < miss,
|
||
"cache hit ({:?}) debería ser más rápido que miss ({:?}); \
|
||
warmup={:?}, repeat={:?}",
|
||
hit, miss, cold_or_hot_1, hot
|
||
);
|
||
}
|
||
}
|