feat(cosmobiologia): rectificador automático — escaneo GR (núcleo)
Primer incremento del rectificador automático (#67): dado un conjunto de eventos conocidos de la vida del sujeto, barre las horas de nacimiento candidatas y devuelve la que mejor los explica vía el Sistema GR. La killer feature pro — desbloqueada al completar el GR. - cosmobiologia-render: `convergencia_minima` — medida CONTINUA de qué tan bien una carta explica un evento (suma de orbes del directo + converso más cerrados sobre un punto natal). 3 tests. - cosmobiologia-engine: módulo `rectify` — `rectificar` barre la ventana de horas candidatas; por candidata computa la carta (una vez, cacheada) y mide la convergencia GR a la edad de cada evento; elige el puntaje mínimo. Devuelve el perfil completo del barrido para que la UI lo dibuje como curva. Test end-to-end con eternal. - bridge: `compute_natal_chart`/`body_symbol`/consts GR → pub(crate). Falta: la UI (capturar eventos conocidos, lanzar el barrido, mostrar la curva y la hora rectificada). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -129,7 +129,7 @@ fn map_body(name: &str) -> Option<Body> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn body_symbol(b: Body) -> &'static str {
|
pub(crate) fn body_symbol(b: Body) -> &'static str {
|
||||||
match b {
|
match b {
|
||||||
Body::Sun => "sun",
|
Body::Sun => "sun",
|
||||||
Body::Moon => "moon",
|
Body::Moon => "moon",
|
||||||
@@ -239,7 +239,7 @@ fn build_eternal_inputs(
|
|||||||
/// La clave incluye todos los campos de `StoredBirthData` y
|
/// La clave incluye todos los campos de `StoredBirthData` y
|
||||||
/// `StoredChartConfig` que afectan el cómputo; editar la carta invalida
|
/// `StoredChartConfig` que afectan el cómputo; editar la carta invalida
|
||||||
/// automáticamente la entrada.
|
/// automáticamente la entrada.
|
||||||
fn compute_natal_chart(
|
pub(crate) fn compute_natal_chart(
|
||||||
chart: &Chart,
|
chart: &Chart,
|
||||||
offset_minutes: i64,
|
offset_minutes: i64,
|
||||||
) -> Result<(Arc<NatalChart>, ChartConfig, Observer), EngineError> {
|
) -> Result<(Arc<NatalChart>, ChartConfig, Observer), EngineError> {
|
||||||
@@ -594,13 +594,13 @@ fn build_topocentric_overlay(
|
|||||||
|
|
||||||
/// Orbe máximo (grados) para que una proyección primaria entre al HUD
|
/// 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.
|
/// de triggers. ~2° ≈ 2 años de vida con el key Naibod.
|
||||||
const GR_HUD_ORB_DEG: f32 = 2.0;
|
pub(crate) const GR_HUD_ORB_DEG: f32 = 2.0;
|
||||||
/// Micro-orbe de convergencia GR: 5 minutos de arco. Un punto natal
|
/// 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
|
/// tocado a la vez por un directo y un converso dentro de este orbe
|
||||||
/// es un evento de rectificación.
|
/// es un evento de rectificación.
|
||||||
const GR_EVENT_ORB_DEG: f32 = 5.0 / 60.0;
|
pub(crate) const GR_EVENT_ORB_DEG: f32 = 5.0 / 60.0;
|
||||||
/// Tope de triggers en el HUD tras ordenar por orbe.
|
/// Tope de triggers en el HUD tras ordenar por orbe.
|
||||||
const GR_MAX_TRIGGERS: usize = 60;
|
pub(crate) 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
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ 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::{
|
||||||
apply_harmonic, compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger,
|
apply_harmonic, compute_gr_triggers, convergencia_minima, AspectSummary, Geometry, Glyph,
|
||||||
Layer, LayerKind, LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup,
|
GrDirection, GrTrigger, Layer, LayerKind, LineSeg, OverlayMeta, PointMark, RenderModel,
|
||||||
OUTER_RING_MODULES,
|
UranianGroup, OUTER_RING_MODULES,
|
||||||
};
|
};
|
||||||
|
|
||||||
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
||||||
@@ -52,6 +52,8 @@ mod dignity;
|
|||||||
#[cfg(feature = "eternal-bridge")]
|
#[cfg(feature = "eternal-bridge")]
|
||||||
mod natal_cache;
|
mod natal_cache;
|
||||||
#[cfg(feature = "eternal-bridge")]
|
#[cfg(feature = "eternal-bridge")]
|
||||||
|
mod rectify;
|
||||||
|
#[cfg(feature = "eternal-bridge")]
|
||||||
pub mod svg_export;
|
pub mod svg_export;
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -210,6 +212,53 @@ impl Default for NatalOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Rectificador automático (Sistema GR)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Un evento conocido de la vida del sujeto — el ancla de la
|
||||||
|
/// rectificación. La hora de nacimiento verdadera es la que hace caer
|
||||||
|
/// los eventos reales sobre convergencias GR cerradas.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct EventoConocido {
|
||||||
|
/// Edad del sujeto, en años, cuando ocurrió el evento.
|
||||||
|
pub edad_years: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado de un barrido de rectificación (ver [`rectificar`]).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Rectificacion {
|
||||||
|
/// Desplazamiento, en minutos, sobre la hora registrada, que mejor
|
||||||
|
/// explica los eventos. `0` = la hora registrada ya es la mejor.
|
||||||
|
pub mejor_offset_minutos: i64,
|
||||||
|
/// Puntaje del mejor candidato: la suma de orbes de convergencia GR
|
||||||
|
/// sobre todos los eventos. Menor = mejor; es la «tensión» residual.
|
||||||
|
pub mejor_puntaje: f32,
|
||||||
|
/// El barrido completo: `(offset_minutos, puntaje)` por candidato,
|
||||||
|
/// ordenado por offset ascendente. La UI lo dibuja como una curva —
|
||||||
|
/// su valle marca la hora rectificada.
|
||||||
|
pub perfil: Vec<(i64, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rectifica la hora de nacimiento por el Sistema GR. Barre las horas
|
||||||
|
/// candidatas en `[-ventana_min, +ventana_min]` minutos sobre la
|
||||||
|
/// registrada, paso a paso (`paso_min`); para cada candidata computa la
|
||||||
|
/// carta y, por cada evento conocido, mide la convergencia GR más
|
||||||
|
/// cerrada a esa edad. La hora del puntaje mínimo es la rectificada.
|
||||||
|
///
|
||||||
|
/// `key` es la clave arco↔año: `"naibod"` (default) o `"ptolemy"`.
|
||||||
|
/// `Err` si la lista de eventos está vacía — sin anclas no hay búsqueda.
|
||||||
|
#[cfg(feature = "eternal-bridge")]
|
||||||
|
pub fn rectificar(
|
||||||
|
chart: &Chart,
|
||||||
|
eventos: &[EventoConocido],
|
||||||
|
ventana_min: i64,
|
||||||
|
paso_min: i64,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Rectificacion, EngineError> {
|
||||||
|
rectify::rectificar(chart, eventos, ventana_min, paso_min, key)
|
||||||
|
}
|
||||||
|
|
||||||
/// Composición canónica: carta natal + todos los overlays pedidos.
|
/// Composición canónica: carta natal + todos los overlays pedidos.
|
||||||
/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
|
/// Equivalente a `compose_with_options` con `NatalOptions::default()`.
|
||||||
pub fn compose(
|
pub fn compose(
|
||||||
@@ -535,4 +584,32 @@ mod tests {
|
|||||||
.any(|(a, b)| (a - b).abs() > 0.01);
|
.any(|(a, b)| (a - b).abs() > 0.01);
|
||||||
assert!(moved, "el armónico debe mover los cuerpos");
|
assert!(moved, "el armónico debe mover los cuerpos");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// El rectificador barre la ventana entera, devuelve un perfil
|
||||||
|
/// ordenado y elige como mejor el candidato de puntaje mínimo.
|
||||||
|
#[cfg(feature = "eternal-bridge")]
|
||||||
|
#[test]
|
||||||
|
fn rectificar_barre_la_ventana_y_elige_el_minimo() {
|
||||||
|
let chart = sample_chart();
|
||||||
|
let eventos = [
|
||||||
|
EventoConocido { edad_years: 20.0 },
|
||||||
|
EventoConocido { edad_years: 35.0 },
|
||||||
|
];
|
||||||
|
let r = rectificar(&chart, &eventos, 10, 2, "naibod").expect("rectificar");
|
||||||
|
|
||||||
|
// Ventana ±10 min, paso 2 → offsets -10,-8,…,10 = 11 candidatos.
|
||||||
|
assert_eq!(r.perfil.len(), 11);
|
||||||
|
// El perfil va ordenado por offset ascendente.
|
||||||
|
for par in r.perfil.windows(2) {
|
||||||
|
assert!(par[0].0 < par[1].0, "perfil desordenado");
|
||||||
|
}
|
||||||
|
// El mejor offset cae dentro de la ventana.
|
||||||
|
assert!(r.mejor_offset_minutos.abs() <= 10);
|
||||||
|
// Y su puntaje es, en efecto, el mínimo del perfil.
|
||||||
|
let minimo = r.perfil.iter().map(|(_, p)| *p).fold(f32::INFINITY, f32::min);
|
||||||
|
assert!((r.mejor_puntaje - minimo).abs() < 1e-4);
|
||||||
|
|
||||||
|
// Sin eventos no hay ancla — debe ser un error.
|
||||||
|
assert!(rectificar(&chart, &[], 10, 2, "naibod").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! Rectificador automático — Sistema GR.
|
||||||
|
//!
|
||||||
|
//! La rectificación horaria responde a una pregunta vieja: si la hora de
|
||||||
|
//! nacimiento registrada es incierta, ¿cuál es la verdadera? El método GR
|
||||||
|
//! (García Rosas) la ataca con direcciones primarias: en la hora correcta,
|
||||||
|
//! los eventos reales de la vida del sujeto caen sobre **convergencias** —
|
||||||
|
//! un promisor directo y otro converso que se cruzan sobre un mismo punto
|
||||||
|
//! natal.
|
||||||
|
//!
|
||||||
|
//! Este módulo automatiza la búsqueda. Dada una carta, una ventana de horas
|
||||||
|
//! candidatas alrededor de la registrada, y una lista de eventos conocidos
|
||||||
|
//! (cada uno, una edad), **barre** las candidatas: para cada hora, computa
|
||||||
|
//! la carta y mide —con [`convergencia_minima`]— qué tan cerrada es la mejor
|
||||||
|
//! convergencia GR a la edad de cada evento. La hora cuyo puntaje total es
|
||||||
|
//! mínimo es la rectificada.
|
||||||
|
//!
|
||||||
|
//! El cómputo pesado —la carta natal por hora candidata— se delega a
|
||||||
|
//! `bridge::compute_natal_chart`, que cachea; la proyección primaria por
|
||||||
|
//! cuerpo es aritmética barata. La función de puntaje, [`convergencia_minima`],
|
||||||
|
//! es lógica pura y vive en `cosmobiologia-render`.
|
||||||
|
|
||||||
|
use eternal_astrology::{
|
||||||
|
directed_longitude, primary_direction::PrimaryDirection, DirectionKey as EDirectionKey,
|
||||||
|
NatalChart,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::bridge::{
|
||||||
|
body_symbol, compute_natal_chart, GR_EVENT_ORB_DEG, GR_HUD_ORB_DEG, GR_MAX_TRIGGERS,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
compute_gr_triggers, convergencia_minima, Chart, EngineError, EventoConocido, GrDirection,
|
||||||
|
GrTrigger, Rectificacion,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Puntaje que se imputa a un evento cuando la carta candidata no halla
|
||||||
|
/// convergencia GR alguna a esa edad. Debe superar a cualquier suma real
|
||||||
|
/// de orbes (el HUD acota cada orbe a 2°, así que una convergencia real
|
||||||
|
/// nunca pasa de ~4°): así un candidato sin convergencias queda
|
||||||
|
/// inequívocamente por detrás de uno que sí las tiene.
|
||||||
|
const SIN_CONVERGENCIA: f32 = 8.0;
|
||||||
|
|
||||||
|
/// Computa los triggers GR de una carta natal ya calculada, a una edad
|
||||||
|
/// dada. Proyecta cada cuerpo en ambos sentidos (directo y converso) y los
|
||||||
|
/// empareja contra los puntos natales —cuerpos y los cuatro ángulos—.
|
||||||
|
///
|
||||||
|
/// Es la misma matemática que `bridge::build_primary_directions_overlay`,
|
||||||
|
/// pero sin construir el dual-ring de glifos: el rectificador sólo necesita
|
||||||
|
/// los triggers, no la capa visual.
|
||||||
|
fn gr_triggers_de_natal(
|
||||||
|
natal: &NatalChart,
|
||||||
|
edad_years: f64,
|
||||||
|
key: EDirectionKey,
|
||||||
|
) -> Vec<GrTrigger> {
|
||||||
|
let eps = natal.obliquity_rad;
|
||||||
|
|
||||||
|
// Proyectar cada cuerpo natal por dirección primaria, en ambos sentidos.
|
||||||
|
let mut directed: Vec<(String, GrDirection, f32)> = Vec::new();
|
||||||
|
for (gr_dir, pd_dir) in [
|
||||||
|
(GrDirection::Direct, PrimaryDirection::Direct),
|
||||||
|
(GrDirection::Converse, PrimaryDirection::Converse),
|
||||||
|
] {
|
||||||
|
for p in &natal.placements {
|
||||||
|
let lon_rad = directed_longitude(
|
||||||
|
p.right_ascension_rad,
|
||||||
|
p.declination_rad,
|
||||||
|
edad_years,
|
||||||
|
pd_dir,
|
||||||
|
key,
|
||||||
|
eps,
|
||||||
|
);
|
||||||
|
let deg = (lon_rad.to_degrees() as f32).rem_euclid(360.0);
|
||||||
|
directed.push((body_symbol(p.body).to_string(), gr_dir, deg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puntos natales objetivo: los cuerpos + los cuatro ángulos.
|
||||||
|
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(), natal.ascendant().longitude_deg() as f32));
|
||||||
|
natal_targets.push(("mc".into(), natal.midheaven().longitude_deg() as f32));
|
||||||
|
natal_targets.push(("desc".into(), natal.descendant().longitude_deg() as f32));
|
||||||
|
natal_targets.push(("ic".into(), natal.imum_coeli().longitude_deg() as f32));
|
||||||
|
|
||||||
|
compute_gr_triggers(
|
||||||
|
&directed,
|
||||||
|
&natal_targets,
|
||||||
|
GR_HUD_ORB_DEG,
|
||||||
|
GR_EVENT_ORB_DEG,
|
||||||
|
GR_MAX_TRIGGERS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Barre las horas candidatas y devuelve la rectificación. Ver
|
||||||
|
/// [`crate::rectificar`] para la documentación pública.
|
||||||
|
pub(crate) fn rectificar(
|
||||||
|
chart: &Chart,
|
||||||
|
eventos: &[EventoConocido],
|
||||||
|
ventana_min: i64,
|
||||||
|
paso_min: i64,
|
||||||
|
key_str: &str,
|
||||||
|
) -> Result<Rectificacion, EngineError> {
|
||||||
|
if eventos.is_empty() {
|
||||||
|
return Err(EngineError::Eternal(
|
||||||
|
"rectificar: sin eventos conocidos que anclar la búsqueda".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ventana = ventana_min.max(0);
|
||||||
|
let paso = paso_min.max(1);
|
||||||
|
let key = match key_str {
|
||||||
|
"ptolemy" => EDirectionKey::Ptolemy,
|
||||||
|
_ => EDirectionKey::Naibod,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Barrer las horas candidatas: cada offset es una hora de nacimiento a
|
||||||
|
// probar, en minutos sobre la registrada.
|
||||||
|
let mut perfil: Vec<(i64, f32)> = Vec::new();
|
||||||
|
let mut offset = -ventana;
|
||||||
|
while offset <= ventana {
|
||||||
|
// Una sola carta natal por hora candidata (cacheada en el bridge);
|
||||||
|
// la proyección por edad de evento es barata sobre ella.
|
||||||
|
let (natal, _, _) = compute_natal_chart(chart, offset)?;
|
||||||
|
let mut puntaje = 0.0_f32;
|
||||||
|
for evento in eventos {
|
||||||
|
let triggers = gr_triggers_de_natal(&natal, evento.edad_years, key);
|
||||||
|
// Menor orbe de convergencia = mejor explicación del evento;
|
||||||
|
// sin convergencia, la penalización.
|
||||||
|
puntaje += convergencia_minima(&triggers).unwrap_or(SIN_CONVERGENCIA);
|
||||||
|
}
|
||||||
|
perfil.push((offset, puntaje));
|
||||||
|
offset += paso;
|
||||||
|
}
|
||||||
|
|
||||||
|
// El mejor candidato: puntaje mínimo. Ante empate, el offset más
|
||||||
|
// cercano a 0 — la hora registrada se respeta si nada la mejora.
|
||||||
|
let (mejor_offset_minutos, mejor_puntaje) = perfil
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.min_by(|(oa, pa), (ob, pb)| {
|
||||||
|
pa.partial_cmp(pb)
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
.then(oa.abs().cmp(&ob.abs()))
|
||||||
|
})
|
||||||
|
.expect("el perfil tiene al menos un candidato — la ventana incluye el 0");
|
||||||
|
|
||||||
|
Ok(Rectificacion {
|
||||||
|
mejor_offset_minutos,
|
||||||
|
mejor_puntaje,
|
||||||
|
perfil,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -154,6 +154,40 @@ fn mark_events(triggers: &mut [GrTrigger], event_orb_deg: f32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Orbe de la convergencia GR más cerrada de un conjunto de triggers
|
||||||
|
/// computado a una edad dada: por cada punto natal tocado a la vez por
|
||||||
|
/// un trigger directo y otro converso, la suma de los dos orbes; se
|
||||||
|
/// devuelve la MENOR de esas sumas. `None` si ningún punto natal recibe
|
||||||
|
/// ambas direcciones — no hubo convergencia.
|
||||||
|
///
|
||||||
|
/// Es la medida **continua** de «qué tan bien una carta explica un
|
||||||
|
/// evento»: a diferencia del flag binario `event` (dentro o fuera del
|
||||||
|
/// micro-orbe), esta suma decrece de forma suave a medida que la hora
|
||||||
|
/// candidata acerca el directo y el converso al punto natal. El
|
||||||
|
/// rectificador automático la minimiza barriendo horas de nacimiento.
|
||||||
|
pub fn convergencia_minima(triggers: &[GrTrigger]) -> Option<f32> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
// Por punto natal: el mejor orbe directo y el mejor orbe converso.
|
||||||
|
let mut por_objetivo: HashMap<&str, (Option<f32>, Option<f32>)> = HashMap::new();
|
||||||
|
for t in triggers {
|
||||||
|
let (directo, converso) = por_objetivo
|
||||||
|
.entry(t.natal_target.as_str())
|
||||||
|
.or_insert((None, None));
|
||||||
|
let ranura = match t.direction {
|
||||||
|
GrDirection::Direct => directo,
|
||||||
|
GrDirection::Converse => converso,
|
||||||
|
};
|
||||||
|
*ranura = Some(ranura.map_or(t.orb_deg, |previo| previo.min(t.orb_deg)));
|
||||||
|
}
|
||||||
|
por_objetivo
|
||||||
|
.values()
|
||||||
|
.filter_map(|par| match par {
|
||||||
|
(Some(directo), Some(converso)) => Some(directo + converso),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.reduce(f32::min)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -242,4 +276,45 @@ mod tests {
|
|||||||
assert_eq!(out.len(), 1);
|
assert_eq!(out.len(), 1);
|
||||||
assert!((out[0].orb_deg - 2.0).abs() < 1e-3);
|
assert!((out[0].orb_deg - 2.0).abs() < 1e-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convergencia_minima_suma_directo_y_converso() {
|
||||||
|
// Marte directo a 0.1° del Sol, Venus converso a 0.2°.
|
||||||
|
let directed = vec![
|
||||||
|
d("mars", GrDirection::Direct, 100.1),
|
||||||
|
d("venus", GrDirection::Converse, 99.8),
|
||||||
|
];
|
||||||
|
let targets = vec![("sun".to_string(), 100.0)];
|
||||||
|
let triggers = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60);
|
||||||
|
let conv = convergencia_minima(&triggers).expect("hay convergencia");
|
||||||
|
assert!((conv - 0.3).abs() < 1e-3, "0.1 directo + 0.2 converso: {conv}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convergencia_minima_none_sin_ambas_direcciones() {
|
||||||
|
// Sólo toques directos sobre el Sol: no hay convergencia.
|
||||||
|
let directed = vec![
|
||||||
|
d("mars", GrDirection::Direct, 100.1),
|
||||||
|
d("venus", GrDirection::Direct, 99.9),
|
||||||
|
];
|
||||||
|
let targets = vec![("sun".to_string(), 100.0)];
|
||||||
|
let triggers = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60);
|
||||||
|
assert!(convergencia_minima(&triggers).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convergencia_minima_elige_el_punto_natal_mas_cerrado() {
|
||||||
|
// Convergencia floja sobre el Sol (0.5+0.5), cerrada sobre la
|
||||||
|
// Luna (0.05+0.05): gana la de la Luna.
|
||||||
|
let directed = vec![
|
||||||
|
d("mars", GrDirection::Direct, 100.5),
|
||||||
|
d("venus", GrDirection::Converse, 99.5),
|
||||||
|
d("jupiter", GrDirection::Direct, 200.05),
|
||||||
|
d("saturn", GrDirection::Converse, 199.95),
|
||||||
|
];
|
||||||
|
let targets = vec![("sun".to_string(), 100.0), ("moon".to_string(), 200.0)];
|
||||||
|
let triggers = compute_gr_triggers(&directed, &targets, 2.0, 0.083, 60);
|
||||||
|
let conv = convergencia_minima(&triggers).expect("hay convergencia");
|
||||||
|
assert!((conv - 0.1).abs() < 1e-3, "gana la Luna (0.05+0.05): {conv}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ 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 gr::{compute_gr_triggers, convergencia_minima, GrDirection, GrTrigger};
|
||||||
pub use harmonic::apply_harmonic;
|
pub use harmonic::apply_harmonic;
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user