feat(cosmobiologia): GR — cómputo de triggers y eventos de rectificación
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 <noreply@anthropic.com>
This commit is contained in:
@@ -21,8 +21,8 @@ use cosmobiologia_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
|
|||||||
|
|
||||||
use crate::dignity::essential_dignity;
|
use crate::dignity::essential_dignity;
|
||||||
use crate::{
|
use crate::{
|
||||||
AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
|
compute_gr_triggers, AspectSummary, EngineError, Geometry, Glyph, GrDirection, Layer,
|
||||||
RenderModel, UranianGroup,
|
LayerKind, LineSeg, OverlayMeta, RenderModel, UranianGroup,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -586,13 +586,28 @@ fn build_topocentric_overlay(
|
|||||||
Ok(())
|
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
|
/// GR dual-ring de Direcciones Primarias: a la edad pedida, cada
|
||||||
/// cuerpo natal se proyecta dos veces — directa (rotación diurna
|
/// cuerpo natal se proyecta dos veces — directa (rotación diurna
|
||||||
/// forward, anillo afuera) y conversa (rotación inversa, anillo
|
/// forward, anillo afuera) y conversa (rotación inversa, anillo
|
||||||
/// dentro). En rectificación, los dos rings se ven simultáneamente
|
/// 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.
|
/// "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.
|
/// Usa el key Naibod (0°59'08″/año) como default — convención GR.
|
||||||
fn build_primary_directions_overlay(
|
fn build_primary_directions_overlay(
|
||||||
natal: &NatalChart,
|
natal: &NatalChart,
|
||||||
@@ -602,8 +617,17 @@ fn build_primary_directions_overlay(
|
|||||||
) {
|
) {
|
||||||
let eps = natal.obliquity_rad;
|
let eps = natal.obliquity_rad;
|
||||||
|
|
||||||
let project = |dir: PrimaryDirection| -> Vec<Glyph> {
|
let directions = [
|
||||||
natal
|
(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<Glyph> = natal
|
||||||
.placements
|
.placements
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
@@ -611,42 +635,62 @@ fn build_primary_directions_overlay(
|
|||||||
p.right_ascension_rad,
|
p.right_ascension_rad,
|
||||||
p.declination_rad,
|
p.declination_rad,
|
||||||
target_age_years,
|
target_age_years,
|
||||||
dir,
|
pd_dir,
|
||||||
key,
|
key,
|
||||||
eps,
|
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 {
|
Glyph {
|
||||||
deg: new_lon_deg,
|
deg: directed_deg,
|
||||||
symbol: body_symbol(p.body).into(),
|
symbol: symbol.into(),
|
||||||
annotation: Some(format!("{:.2}°", new_lon_deg)),
|
annotation: Some(format!("{:.2}°", directed_deg)),
|
||||||
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
retrograde: p.longitude_rate_rad_per_day < 0.0,
|
||||||
house: None,
|
house: None,
|
||||||
dignity_marker: None,
|
dignity_marker: None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
};
|
|
||||||
|
|
||||||
let direct_glyphs = project(PrimaryDirection::Direct);
|
let (module_id, z) = match gr_dir {
|
||||||
let converse_glyphs = project(PrimaryDirection::Converse);
|
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 {
|
// Puntos natales objetivo: los cuerpos + los cuatro ángulos. Los
|
||||||
module_id: "pd_direct".into(),
|
// ángulos son los anclajes clave de la rectificación.
|
||||||
kind: LayerKind::Bodies,
|
let mut natal_targets: Vec<(String, f32)> = natal
|
||||||
ring: 0.0,
|
.placements
|
||||||
z: 10,
|
.iter()
|
||||||
geometry: Geometry::GlyphsOnly,
|
.map(|p| {
|
||||||
glyphs: direct_glyphs,
|
(
|
||||||
});
|
body_symbol(p.body).to_string(),
|
||||||
render.layers.push(Layer {
|
p.longitude.longitude_deg() as f32,
|
||||||
module_id: "pd_converse".into(),
|
)
|
||||||
kind: LayerKind::Bodies,
|
})
|
||||||
ring: 0.0,
|
.collect();
|
||||||
z: 11,
|
natal_targets.push(("asc".into(), render.ascendant_deg));
|
||||||
geometry: Geometry::GlyphsOnly,
|
natal_targets.push(("mc".into(), render.midheaven_deg));
|
||||||
glyphs: converse_glyphs,
|
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(
|
fn build_progression_overlay(
|
||||||
@@ -1446,6 +1490,7 @@ fn build_render_model(
|
|||||||
overlays: Vec::new(),
|
overlays: Vec::new(),
|
||||||
aspect_summary: Vec::new(),
|
aspect_summary: Vec::new(),
|
||||||
uranian_groups: Vec::new(),
|
uranian_groups: Vec::new(),
|
||||||
|
gr_triggers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
|||||||
// (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar
|
// (`cosmobiologia_engine::Layer`, etc.) sin tener que cambiar
|
||||||
// imports en el shell, canvas, modules, tree, panel...
|
// imports en el shell, canvas, modules, tree, panel...
|
||||||
pub use cosmobiologia_render::{
|
pub use cosmobiologia_render::{
|
||||||
AspectSummary, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, PointMark,
|
compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger, Layer, LayerKind,
|
||||||
RenderModel, UranianGroup, OUTER_RING_MODULES,
|
LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup, OUTER_RING_MODULES,
|
||||||
};
|
};
|
||||||
|
|
||||||
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
||||||
@@ -336,6 +336,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
|
|||||||
overlays: Vec::new(),
|
overlays: Vec::new(),
|
||||||
aspect_summary: Vec::new(),
|
aspect_summary: Vec::new(),
|
||||||
uranian_groups: Vec::new(),
|
uranian_groups: Vec::new(),
|
||||||
|
gr_triggers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,4 +457,36 @@ mod tests {
|
|||||||
hit, miss, cold_or_hot_1, hot
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<GrTrigger> {
|
||||||
|
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<String> = HashSet::new();
|
||||||
|
let mut has_converse: HashSet<String> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,14 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
|
||||||
|
|
||||||
pub mod draw;
|
pub mod draw;
|
||||||
|
pub mod gr;
|
||||||
pub mod math;
|
pub mod math;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
|
|
||||||
pub use draw::{
|
pub use draw::{
|
||||||
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
|
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
|
||||||
};
|
};
|
||||||
|
pub use gr::{compute_gr_triggers, GrDirection, GrTrigger};
|
||||||
pub use math::{
|
pub use math::{
|
||||||
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
|
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.
|
/// Vacío sino se activó el módulo Uranian.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub uranian_groups: Vec<UranianGroup>,
|
pub uranian_groups: Vec<UranianGroup>,
|
||||||
|
/// 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<GrTrigger>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Etiqueta legible de un overlay para el footer del canvas. La engine
|
/// Etiqueta legible de un overlay para el footer del canvas. La engine
|
||||||
|
|||||||
Reference in New Issue
Block a user