From 15e45ace9b89436578ab4c99b257c6775415585a Mon Sep 17 00:00:00 2001 From: sergio Date: Fri, 22 May 2026 13:40:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(cosmobiologia):=20GR=20=E2=80=94=20c=C3=B3?= =?UTF-8?q?mputo=20de=20triggers=20y=20eventos=20de=20rectificaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primer incremento del Sistema GR (García Rosas): la engine, además del dual-ring directo/converso, ahora computa los triggers de rectificación y detecta las convergencias directo+converso sobre un mismo punto natal. - cosmobiologia-render: módulo `gr` agnóstico — tipos GrTrigger/GrDirection + compute_gr_triggers (emparejamiento puro, 7 tests). Campo gr_triggers en RenderModel (serde-default, back-compat). - cosmobiologia-engine: build_primary_directions_overlay computa los triggers contra cuerpos natales + 4 ángulos; orbe HUD 2°, micro-orbe de evento 5'. Test end-to-end con eternal. Falta: resaltado del evento en el canvas, HUD lateral, scrubbing live. Co-Authored-By: Claude Opus 4.7 --- .../cosmobiologia-engine/src/bridge.rs | 105 +++++--- .../cosmobiologia-engine/src/lib.rs | 37 ++- .../cosmobiologia-render/src/gr.rs | 245 ++++++++++++++++++ .../cosmobiologia-render/src/lib.rs | 8 + 4 files changed, 363 insertions(+), 32 deletions(-) create mode 100644 crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs index 5162770..3702338 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs @@ -21,8 +21,8 @@ use cosmobiologia_model::{Chart, HouseSystem, StoredChartConfig, Zodiac}; use crate::dignity::essential_dignity; use crate::{ - AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, - RenderModel, UranianGroup, + compute_gr_triggers, AspectSummary, EngineError, Geometry, Glyph, GrDirection, Layer, + LayerKind, LineSeg, OverlayMeta, RenderModel, UranianGroup, }; // ===================================================================== @@ -586,13 +586,28 @@ fn build_topocentric_overlay( Ok(()) } +/// Orbe máximo (grados) para que una proyección primaria entre al HUD +/// de triggers. ~2° ≈ 2 años de vida con el key Naibod. +const GR_HUD_ORB_DEG: f32 = 2.0; +/// Micro-orbe de convergencia GR: 5 minutos de arco. Un punto natal +/// tocado a la vez por un directo y un converso dentro de este orbe +/// es un evento de rectificación. +const GR_EVENT_ORB_DEG: f32 = 5.0 / 60.0; +/// Tope de triggers en el HUD tras ordenar por orbe. +const GR_MAX_TRIGGERS: usize = 60; + /// GR dual-ring de Direcciones Primarias: a la edad pedida, cada /// cuerpo natal se proyecta dos veces — directa (rotación diurna /// forward, anillo afuera) y conversa (rotación inversa, anillo /// dentro). En rectificación, los dos rings se ven simultáneamente -/// y si un evento real cayó cerca de un ángulo, debe aparecer +/// y si un evento real cayó cerca de un punto natal, debe aparecer /// "cruzado" con ambos arcos coincidentes — eso valida la hora. /// +/// Además de los dos rings, computa `render.gr_triggers`: cada +/// proyección que cae cerca de un punto natal (cuerpo o ángulo), y +/// marca las convergencias directo+converso. La UI lo usa para el +/// HUD de rectificación y el resaltado de eventos. +/// /// Usa el key Naibod (0°59'08″/año) como default — convención GR. fn build_primary_directions_overlay( natal: &NatalChart, @@ -602,8 +617,17 @@ fn build_primary_directions_overlay( ) { let eps = natal.obliquity_rad; - let project = |dir: PrimaryDirection| -> Vec { - natal + let directions = [ + (GrDirection::Direct, PrimaryDirection::Direct), + (GrDirection::Converse, PrimaryDirection::Converse), + ]; + + // Posiciones dirigidas acumuladas para el emparejamiento posterior: + // `(promisor, dirección, longitud)`. + let mut directed: Vec<(String, GrDirection, f32)> = Vec::new(); + + for (gr_dir, pd_dir) in directions { + let glyphs: Vec = natal .placements .iter() .map(|p| { @@ -611,42 +635,62 @@ fn build_primary_directions_overlay( p.right_ascension_rad, p.declination_rad, target_age_years, - dir, + pd_dir, key, eps, ); - let new_lon_deg = new_lon_rad.to_degrees() as f32; + let directed_deg = (new_lon_rad.to_degrees() as f32).rem_euclid(360.0); + let symbol = body_symbol(p.body); + directed.push((symbol.to_string(), gr_dir, directed_deg)); Glyph { - deg: new_lon_deg, - symbol: body_symbol(p.body).into(), - annotation: Some(format!("{:.2}°", new_lon_deg)), + deg: directed_deg, + symbol: symbol.into(), + annotation: Some(format!("{:.2}°", directed_deg)), retrograde: p.longitude_rate_rad_per_day < 0.0, house: None, dignity_marker: None, } }) - .collect() - }; + .collect(); - let direct_glyphs = project(PrimaryDirection::Direct); - let converse_glyphs = project(PrimaryDirection::Converse); + let (module_id, z) = match gr_dir { + GrDirection::Direct => ("pd_direct", 10), + GrDirection::Converse => ("pd_converse", 11), + }; + render.layers.push(Layer { + module_id: module_id.into(), + kind: LayerKind::Bodies, + ring: 0.0, + z, + geometry: Geometry::GlyphsOnly, + glyphs, + }); + } - render.layers.push(Layer { - module_id: "pd_direct".into(), - kind: LayerKind::Bodies, - ring: 0.0, - z: 10, - geometry: Geometry::GlyphsOnly, - glyphs: direct_glyphs, - }); - render.layers.push(Layer { - module_id: "pd_converse".into(), - kind: LayerKind::Bodies, - ring: 0.0, - z: 11, - geometry: Geometry::GlyphsOnly, - glyphs: converse_glyphs, - }); + // Puntos natales objetivo: los cuerpos + los cuatro ángulos. Los + // ángulos son los anclajes clave de la rectificación. + let mut natal_targets: Vec<(String, f32)> = natal + .placements + .iter() + .map(|p| { + ( + body_symbol(p.body).to_string(), + p.longitude.longitude_deg() as f32, + ) + }) + .collect(); + natal_targets.push(("asc".into(), render.ascendant_deg)); + natal_targets.push(("mc".into(), render.midheaven_deg)); + natal_targets.push(("desc".into(), render.descendant_deg)); + natal_targets.push(("ic".into(), render.imum_coeli_deg)); + + render.gr_triggers = compute_gr_triggers( + &directed, + &natal_targets, + GR_HUD_ORB_DEG, + GR_EVENT_ORB_DEG, + GR_MAX_TRIGGERS, + ); } fn build_progression_overlay( @@ -1446,6 +1490,7 @@ fn build_render_model( overlays: Vec::new(), aspect_summary: Vec::new(), uranian_groups: Vec::new(), + gr_triggers: Vec::new(), } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs index ee3fc17..b79aa49 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs @@ -36,8 +36,8 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind}; // (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar // imports en el shell, canvas, modules, tree, panel... pub use cosmobiologia_render::{ - AspectSummary, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, PointMark, - RenderModel, UranianGroup, OUTER_RING_MODULES, + compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger, Layer, LayerKind, + LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup, OUTER_RING_MODULES, }; // `Chart` reexportado arriba es lo que `PipelineRequest::Synastry` @@ -336,6 +336,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel { overlays: Vec::new(), aspect_summary: Vec::new(), uranian_groups: Vec::new(), + gr_triggers: Vec::new(), } } @@ -456,4 +457,36 @@ mod tests { hit, miss, cold_or_hot_1, hot ); } + + /// El overlay GR debe emitir el dual-ring (`pd_direct` + + /// `pd_converse`) y una lista de triggers ordenada por orbe y + /// acotada al orbe del HUD. + #[cfg(feature = "eternal-bridge")] + #[test] + fn primary_directions_emit_dual_ring_and_triggers() { + use crate::PipelineRequest; + let model = compose( + &sample_chart(), + 0, + &[PipelineRequest::PrimaryDirections { + target_age_years: 30.0, + key: "naibod".into(), + }], + ) + .expect("compose con overlay GR"); + + assert!(model.layers.iter().any(|l| l.module_id == "pd_direct")); + assert!(model.layers.iter().any(|l| l.module_id == "pd_converse")); + + let mut prev = 0.0_f32; + for t in &model.gr_triggers { + assert!(t.orb_deg <= 2.0 + 1e-3, "orbe {} fuera del HUD", t.orb_deg); + assert!(t.orb_deg + 1e-3 >= prev, "triggers desordenados"); + prev = t.orb_deg; + if t.event { + // Un evento exige orbe de micro-escala (≤ 5'). + assert!(t.orb_deg <= 5.0 / 60.0 + 1e-3, "evento con orbe ancho"); + } + } + } } diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs new file mode 100644 index 0000000..307ede2 --- /dev/null +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/gr.rs @@ -0,0 +1,245 @@ +//! Sistema GR (García Rosas) — detección de *triggers* de rectificación. +//! +//! Un trigger GR es un cuerpo natal proyectado por dirección primaria +//! —directa o conversa— que cae cerca de un punto natal. La +//! rectificación horaria se valida observando estos contactos: un +//! evento real de la vida del sujeto debe coincidir con un trigger +//! ajustado si la hora natal es correcta. +//! +//! Cuando un mismo punto natal recibe a la vez un trigger directo y +//! otro converso dentro del micro-orbe de evento, hay una +//! **convergencia GR**: la señal fuerte de rectificación. +//! +//! Esta lógica es pura: el engine computa las longitudes dirigidas +//! (eso sí necesita `eternal-astrology`) y delega aquí el +//! emparejamiento contra los puntos natales. Así la parte que define +//! *qué cuenta como trigger* vive en un crate liviano y testeable. + +use serde::{Deserialize, Serialize}; + +/// Dirección de una proyección primaria del Sistema GR. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GrDirection { + /// Directa — rotación diurna hacia adelante en el tiempo. + Direct, + /// Conversa — rotación diurna inversa. + Converse, +} + +impl GrDirection { + /// Etiqueta de una letra para el HUD (`D` / `C`). + pub fn short(self) -> &'static str { + match self { + GrDirection::Direct => "D", + GrDirection::Converse => "C", + } + } + + /// Etiqueta legible. + pub fn label(self) -> &'static str { + match self { + GrDirection::Direct => "directa", + GrDirection::Converse => "conversa", + } + } +} + +/// Un contacto del Sistema GR: un cuerpo promisor dirigido que cae +/// cerca de un punto natal. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GrTrigger { + /// Símbolo del cuerpo promisor (el que se dirige). Ej. `"mars"`. + pub promissor: String, + /// Si la proyección es directa o conversa. + pub direction: GrDirection, + /// Punto natal contactado: símbolo de cuerpo (`"sun"`) o ángulo + /// (`"asc"`, `"mc"`, `"desc"`, `"ic"`). + pub natal_target: String, + /// Longitud eclíptica [0,360) del punto natal contactado. + pub natal_deg: f32, + /// Longitud eclíptica [0,360) donde cayó el promisor dirigido. + pub directed_deg: f32, + /// Orbe absoluto del contacto, en grados (separación circular). + pub orb_deg: f32, + /// `true` si el trigger forma parte de una convergencia GR + /// (directo + converso sobre el mismo punto natal, ambos dentro + /// del micro-orbe de evento). La UI lo resalta. + #[serde(default)] + pub event: bool, +} + +/// Separación circular mínima entre dos longitudes eclípticas, en +/// grados (rango `0..=180`). +fn circular_sep(a: f32, b: f32) -> f32 { + let d = (a - b).rem_euclid(360.0); + d.min(360.0 - d) +} + +/// Empareja cada posición dirigida contra cada punto natal y produce +/// la lista de triggers GR. +/// +/// - `directed`: `(promisor, dirección, longitud_dirigida)`. +/// - `natal_targets`: `(nombre, longitud_natal)`. +/// - `hud_orb_deg`: orbe máximo para que un contacto entre a la lista. +/// - `event_orb_deg`: micro-orbe de convergencia (ver [`mark_events`]). +/// - `max_triggers`: tope de la lista tras ordenar por orbe. +/// +/// El resultado va ordenado por `orb_deg` ascendente (los contactos +/// más cerrados primero) y truncado a `max_triggers`. +pub fn compute_gr_triggers( + directed: &[(String, GrDirection, f32)], + natal_targets: &[(String, f32)], + hud_orb_deg: f32, + event_orb_deg: f32, + max_triggers: usize, +) -> Vec { + let mut triggers = Vec::new(); + for (promissor, direction, raw_directed) in directed { + let directed_deg = raw_directed.rem_euclid(360.0); + for (name, raw_natal) in natal_targets { + let natal_deg = raw_natal.rem_euclid(360.0); + let orb = circular_sep(directed_deg, natal_deg); + if orb <= hud_orb_deg { + triggers.push(GrTrigger { + promissor: promissor.clone(), + direction: *direction, + natal_target: name.clone(), + natal_deg, + directed_deg, + orb_deg: orb, + event: false, + }); + } + } + } + + mark_events(&mut triggers, event_orb_deg); + + triggers.sort_by(|a, b| { + a.orb_deg + .partial_cmp(&b.orb_deg) + .unwrap_or(core::cmp::Ordering::Equal) + }); + triggers.truncate(max_triggers); + triggers +} + +/// Marca como `event` los triggers que forman una convergencia GR: un +/// mismo punto natal tocado por un trigger directo y otro converso, +/// ambos dentro de `event_orb_deg`. +fn mark_events(triggers: &mut [GrTrigger], event_orb_deg: f32) { + use std::collections::HashSet; + let mut has_direct: HashSet = HashSet::new(); + let mut has_converse: HashSet = HashSet::new(); + for t in triggers.iter() { + if t.orb_deg <= event_orb_deg { + match t.direction { + GrDirection::Direct => { + has_direct.insert(t.natal_target.clone()); + } + GrDirection::Converse => { + has_converse.insert(t.natal_target.clone()); + } + } + } + } + for t in triggers.iter_mut() { + if t.orb_deg <= event_orb_deg + && has_direct.contains(&t.natal_target) + && has_converse.contains(&t.natal_target) + { + t.event = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(promissor: &str, dir: GrDirection, deg: f32) -> (String, GrDirection, f32) { + (promissor.to_string(), dir, deg) + } + + #[test] + fn contact_within_hud_orb_becomes_a_trigger() { + let directed = vec![d("mars", GrDirection::Direct, 101.5)]; + let targets = vec![("sun".to_string(), 100.0)]; + let out = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60); + assert_eq!(out.len(), 1); + assert_eq!(out[0].promissor, "mars"); + assert_eq!(out[0].natal_target, "sun"); + assert!((out[0].orb_deg - 1.5).abs() < 1e-3); + assert!(!out[0].event); + } + + #[test] + fn contact_beyond_hud_orb_is_dropped() { + let directed = vec![d("mars", GrDirection::Direct, 103.0)]; + let targets = vec![("sun".to_string(), 100.0)]; + assert!(compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60).is_empty()); + } + + #[test] + fn direct_and_converse_within_micro_orb_form_an_event() { + // Marte directo y Venus converso, ambos sobre el Sol natal a + // <5' de orbe: convergencia GR. + let directed = vec![ + d("mars", GrDirection::Direct, 100.04), + d("venus", GrDirection::Converse, 99.97), + ]; + let targets = vec![("sun".to_string(), 100.0)]; + let out = compute_gr_triggers(&directed, &targets, 2.0, 5.0 / 60.0, 60); + assert_eq!(out.len(), 2); + assert!(out.iter().all(|t| t.event), "ambos triggers son evento"); + } + + #[test] + fn lone_direct_within_micro_orb_is_not_an_event() { + // Un solo toque directo, sin converso: no hay convergencia. + let directed = vec![ + d("mars", GrDirection::Direct, 100.02), + d("venus", GrDirection::Direct, 99.98), + ]; + let targets = vec![("sun".to_string(), 100.0)]; + let out = compute_gr_triggers(&directed, &targets, 2.0, 5.0 / 60.0, 60); + assert!(out.iter().all(|t| !t.event), "sin converso no hay evento"); + } + + #[test] + fn converging_pair_must_share_the_same_natal_target() { + // Directo sobre el Sol, converso sobre la Luna: no convergen. + let directed = vec![ + d("mars", GrDirection::Direct, 100.01), + d("venus", GrDirection::Converse, 200.01), + ]; + let targets = vec![("sun".to_string(), 100.0), ("moon".to_string(), 200.0)]; + let out = compute_gr_triggers(&directed, &targets, 2.0, 5.0 / 60.0, 60); + assert!(out.iter().all(|t| !t.event)); + } + + #[test] + fn results_are_sorted_by_orb_and_capped() { + let directed = vec![ + d("a", GrDirection::Direct, 101.8), + d("b", GrDirection::Direct, 100.3), + d("c", GrDirection::Direct, 101.0), + ]; + let targets = vec![("sun".to_string(), 100.0)]; + let out = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 2); + assert_eq!(out.len(), 2, "truncado a max_triggers"); + assert!(out[0].orb_deg <= out[1].orb_deg, "ordenado por orbe"); + assert_eq!(out[0].promissor, "b", "el más cerrado primero"); + } + + #[test] + fn circular_sep_handles_wraparound() { + // 359° y 1° están a 2°, no a 358°. + let directed = vec![d("mars", GrDirection::Direct, 359.0)]; + let targets = vec![("asc".to_string(), 1.0)]; + let out = compute_gr_triggers(&directed, &targets, 3.0, 0.083, 60); + assert_eq!(out.len(), 1); + assert!((out[0].orb_deg - 2.0).abs() < 1e-3); + } +} diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs index 360f169..066058d 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs @@ -31,12 +31,14 @@ use serde::{Deserialize, Serialize}; pub use cosmobiologia_model::{Chart, ChartId, ChartKind}; pub mod draw; +pub mod gr; pub mod math; pub mod palette; pub use draw::{ compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor, }; +pub use gr::{compute_gr_triggers, GrDirection, GrTrigger}; pub use math::{ find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii, }; @@ -82,6 +84,12 @@ pub struct RenderModel { /// Vacío sino se activó el módulo Uranian. #[serde(default)] pub uranian_groups: Vec, + /// Triggers del Sistema GR (direcciones primarias). Poblado sólo + /// cuando el módulo `primary_directions` está activo; ordenado por + /// `orb_deg` ascendente. La UI lo lista en el HUD de rectificación + /// y resalta los `event = true` (convergencias directo+converso). + #[serde(default)] + pub gr_triggers: Vec, } /// Etiqueta legible de un overlay para el footer del canvas. La engine