feat(cosmobiologia): carta armónica — el slider de armónico ahora pinta

El slider "Armónico" del NatalModule existía pero no hacía nada.
Ahora re-renderiza la carta en el armónico de orden N.

- cosmobiologia-render: módulo `harmonic` agnóstico — apply_harmonic
  transforma los cuerpos natales a (longitud·N) mod 360 y recomputa
  los aspectos sobre las posiciones armónicas (conjunción, oposición,
  trígono, cuadratura, sextil). Las casas se conservan como marco.
  6 tests (incluye: quintil natal → conjunción en H5).
- cosmobiologia-engine: NatalOptions.harmonic; compose lo aplica tras
  la pasada natal, antes de los overlays. Test end-to-end.
- shell: build_natal_options lee el slider del módulo natal.

El título anota "· HN". Falta: histograma de fuerza por armónico.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-22 13:54:02 +00:00
parent ed4d5ffe4c
commit 968255f4cd
5 changed files with 326 additions and 2 deletions
+1
View File
@@ -945,6 +945,7 @@ impl Shell {
show_minors: read_bool("aspect_minors", false), show_minors: read_bool("aspect_minors", false),
orb_multiplier: read_f64("orb_multiplier", 1.0), orb_multiplier: read_f64("orb_multiplier", 1.0),
show_dignities: read_bool("show_dignities", false), show_dignities: read_bool("show_dignities", false),
harmonic: read_f64("harmonic", 1.0).round().clamp(1.0, 64.0) as u32,
} }
} }
@@ -282,6 +282,12 @@ pub fn compose(
} }
populate_natal_aspect_summary(&aspects, &mut render); populate_natal_aspect_summary(&aspects, &mut render);
// Carta armónica: re-renderiza los cuerpos natales en su armónico
// de orden N y recomputa sus aspectos. Se aplica antes de los
// overlays — éstos quedan en coordenadas natales (la armónica es
// un análisis de la carta natal pura).
crate::apply_harmonic(&mut render, natal_options.harmonic);
for req in requests { for req in requests {
match req { match req {
crate::PipelineRequest::Transit => { crate::PipelineRequest::Transit => {
@@ -36,8 +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::{
compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger, Layer, LayerKind, apply_harmonic, compute_gr_triggers, AspectSummary, Geometry, Glyph, GrDirection, GrTrigger,
LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup, OUTER_RING_MODULES, Layer, LayerKind, LineSeg, OverlayMeta, PointMark, RenderModel, UranianGroup,
OUTER_RING_MODULES,
}; };
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry` // `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
@@ -191,6 +192,10 @@ pub struct NatalOptions {
/// (domicilio +, exaltación ·, exilio , caída *). El canvas lo /// (domicilio +, exaltación ·, exilio , caída *). El canvas lo
/// renderea como sufijo del glifo. /// renderea como sufijo del glifo.
pub show_dignities: bool, pub show_dignities: bool,
/// Orden de la carta armónica. `1` = carta natal sin transformar;
/// `N > 1` re-renderiza los cuerpos en `(longitud · N) mod 360` y
/// recomputa los aspectos sobre esas posiciones.
pub harmonic: u32,
} }
impl Default for NatalOptions { impl Default for NatalOptions {
@@ -200,6 +205,7 @@ impl Default for NatalOptions {
show_minors: false, show_minors: false,
orb_multiplier: 1.0, orb_multiplier: 1.0,
show_dignities: false, show_dignities: false,
harmonic: 1,
} }
} }
} }
@@ -489,4 +495,42 @@ mod tests {
} }
} }
} }
/// La carta armónica debe mover los cuerpos respecto de la natal y
/// anotar el orden en el título.
#[cfg(feature = "eternal-bridge")]
#[test]
fn harmonic_chart_transforms_bodies_and_title() {
let chart = sample_chart();
let natal = compose_with_options(&chart, 0, &[], &NatalOptions::default())
.expect("compose natal");
let h5 = compose_with_options(
&chart,
0,
&[],
&NatalOptions {
harmonic: 5,
..NatalOptions::default()
},
)
.expect("compose H5");
assert!(h5.title.ends_with("· H5"), "título anota el armónico");
let pick = |m: &RenderModel| -> Vec<f32> {
m.layers
.iter()
.find(|l| matches!(l.kind, LayerKind::Bodies))
.map(|l| l.glyphs.iter().map(|g| g.deg).collect())
.unwrap_or_default()
};
let natal_degs = pick(&natal);
let h5_degs = pick(&h5);
assert_eq!(natal_degs.len(), h5_degs.len());
let moved = natal_degs
.iter()
.zip(&h5_degs)
.any(|(a, b)| (a - b).abs() > 0.01);
assert!(moved, "el armónico debe mover los cuerpos");
}
} }
@@ -0,0 +1,271 @@
//! Carta armónica — transforma un `RenderModel` natal a su armónico
//! de orden N.
//!
//! La carta armónica multiplica cada longitud eclíptica por N (mod
//! 360). Es la herramienta de John Addey / David Cochrane para
//! revelar patrones de aspecto: dos cuerpos que forman el aspecto de
//! la N-ésima armónica natal (p. ej. un quintil en N=5) caen
//! conjuntos en la carta armónica N — los clusters armónicos saltan
//! a la vista.
//!
//! Lógica pura, agnóstica de surface: el engine produce el
//! `RenderModel` natal y delega aquí la transformación. Reutilizable
//! por el canvas gpui y por el cliente web.
use crate::{AspectSummary, Geometry, LayerKind, LineSeg, RenderModel};
/// Aspectos que se buscan en la carta armónica: `(id, ángulo, orbe)`.
/// Conjunción y oposición llevan orbe más amplio, como es convención.
const HARMONIC_ASPECTS: &[(&str, f32, f32)] = &[
("conjunction", 0.0, 8.0),
("opposition", 180.0, 7.0),
("trine", 120.0, 6.0),
("square", 90.0, 6.0),
("sextile", 60.0, 4.0),
];
/// Transforma `model` —una carta natal ya compuesta— en su carta
/// armónica de orden `n`. `n <= 1` la deja intacta.
///
/// Sólo afecta las capas `module_id == "natal"`: los cuerpos pasan a
/// `(lon · n) mod 360` y la capa de aspectos se recomputa sobre las
/// posiciones armónicas. Las casas y los ángulos natales se conservan
/// como marco espacial de referencia (variante "armónicos en casas
/// radicales"); los overlays, si los hubiera, quedan intactos.
pub fn apply_harmonic(model: &mut RenderModel, n: u32) {
if n <= 1 {
return;
}
let nf = n as f32;
// 1. Transformar los cuerpos natales; recolectar `(símbolo, lon)`.
let mut bodies: Vec<(String, f32)> = Vec::new();
for layer in &mut model.layers {
if layer.module_id != "natal" || layer.kind != LayerKind::Bodies {
continue;
}
for g in &mut layer.glyphs {
g.deg = (g.deg * nf).rem_euclid(360.0);
bodies.push((g.symbol.clone(), g.deg));
}
if let Geometry::Points(points) = &mut layer.geometry {
for p in points {
p.deg = (p.deg * nf).rem_euclid(360.0);
}
}
}
// 2. Recomputar la capa de aspectos natal sobre las posiciones
// armónicas.
let lines = harmonic_aspect_lines(&bodies);
for layer in &mut model.layers {
if layer.module_id == "natal" && layer.kind == LayerKind::Aspects {
layer.geometry = Geometry::Lines(lines.clone());
}
}
// 3. Rehacer el `aspect_summary`. En este punto del pipeline sólo
// contiene aspectos natales (los overlays agregan los suyos
// después de esta transformación).
model.aspect_summary = lines
.iter()
.map(|l| AspectSummary {
module_id: "natal".into(),
from_body: l.from_body.clone(),
to_body: l.to_body.clone(),
kind: l.kind.clone(),
orb_deg: l.orb_deg as f64,
applying: None,
})
.collect();
// 4. Anotar el armónico en el título.
model.title = format!("{} · H{}", model.title, n);
}
/// Separación circular mínima entre dos longitudes (rango `0..=180`).
fn circular_sep(a: f32, b: f32) -> f32 {
let d = (a - b).rem_euclid(360.0);
d.min(360.0 - d)
}
/// Busca aspectos entre cada par de cuerpos por sus longitudes (ya
/// armónicas). Devuelve los segmentos ordenados por orbe ascendente.
fn harmonic_aspect_lines(bodies: &[(String, f32)]) -> Vec<LineSeg> {
let mut lines = Vec::new();
for i in 0..bodies.len() {
for j in (i + 1)..bodies.len() {
let (a_sym, a_deg) = &bodies[i];
let (b_sym, b_deg) = &bodies[j];
let sep = circular_sep(*a_deg, *b_deg);
for (id, angle, orb) in HARMONIC_ASPECTS {
let delta = (sep - angle).abs();
if delta <= *orb {
lines.push(LineSeg {
from_deg: *a_deg,
to_deg: *b_deg,
kind: (*id).to_string(),
opacity: (1.0 - delta / orb * 0.65).clamp(0.30, 1.0),
from_body: a_sym.clone(),
to_body: b_sym.clone(),
orb_deg: delta,
});
break; // un par no forma dos aspectos a la vez
}
}
}
}
lines.sort_by(|x, y| {
x.orb_deg
.partial_cmp(&y.orb_deg)
.unwrap_or(core::cmp::Ordering::Equal)
});
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ChartId, ChartKind, Geometry, Glyph, Layer, LayerKind, PointMark, RenderModel};
fn body(symbol: &str, deg: f32) -> Glyph {
Glyph {
deg,
symbol: symbol.to_string(),
..Default::default()
}
}
fn natal_model(bodies: &[(&str, f32)]) -> RenderModel {
let glyphs: Vec<Glyph> = bodies.iter().map(|(s, d)| body(s, *d)).collect();
let points: Vec<PointMark> = bodies
.iter()
.map(|(s, d)| PointMark {
deg: *d,
label: s.to_string(),
tag: s.to_string(),
})
.collect();
RenderModel {
chart_id: ChartId::new(),
chart_kind: ChartKind::Natal,
title: "Test".to_string(),
subtitle: None,
compute_ms: 0,
ascendant_deg: 0.0,
midheaven_deg: 270.0,
descendant_deg: 180.0,
imum_coeli_deg: 90.0,
layers: vec![
Layer {
module_id: "natal".into(),
kind: LayerKind::Houses,
ring: 0.86,
z: 1,
geometry: Geometry::Ring {
cusps_deg: (0..12).map(|i| i as f32 * 30.0).collect(),
},
glyphs: Vec::new(),
},
Layer {
module_id: "natal".into(),
kind: LayerKind::Bodies,
ring: 0.72,
z: 2,
geometry: Geometry::Points(points),
glyphs,
},
Layer {
module_id: "natal".into(),
kind: LayerKind::Aspects,
ring: 0.58,
z: 3,
geometry: Geometry::Lines(Vec::new()),
glyphs: Vec::new(),
},
],
overlays: Vec::new(),
aspect_summary: Vec::new(),
uranian_groups: Vec::new(),
gr_triggers: Vec::new(),
}
}
fn bodies_layer(model: &RenderModel) -> &Layer {
model
.layers
.iter()
.find(|l| l.module_id == "natal" && l.kind == LayerKind::Bodies)
.expect("capa de cuerpos")
}
#[test]
fn harmonic_one_is_identity() {
let mut model = natal_model(&[("sun", 30.0), ("moon", 200.0)]);
let before = model.clone();
apply_harmonic(&mut model, 1);
assert_eq!(bodies_layer(&model).glyphs[0].deg, before.layers[1].glyphs[0].deg);
assert_eq!(model.title, "Test");
}
#[test]
fn harmonic_two_doubles_longitudes_mod_360() {
let mut model = natal_model(&[("sun", 30.0), ("moon", 200.0)]);
apply_harmonic(&mut model, 2);
let g = &bodies_layer(&model).glyphs;
assert!((g[0].deg - 60.0).abs() < 1e-3, "30·2 = 60");
assert!((g[1].deg - 40.0).abs() < 1e-3, "200·2 = 400 ≡ 40");
}
#[test]
fn harmonic_two_also_transforms_point_marks() {
let mut model = natal_model(&[("sun", 100.0)]);
apply_harmonic(&mut model, 2);
let Geometry::Points(points) = &bodies_layer(&model).geometry else {
panic!("la capa de cuerpos debe seguir siendo Points");
};
assert!((points[0].deg - 200.0).abs() < 1e-3);
}
#[test]
fn quintile_natally_becomes_conjunction_in_h5() {
// 0° y 72° forman un quintil (72°). En H5: 0·5=0, 72·5=360≡0
// → conjunción exacta.
let mut model = natal_model(&[("sun", 0.0), ("venus", 72.0)]);
apply_harmonic(&mut model, 5);
let Geometry::Lines(lines) = &model
.layers
.iter()
.find(|l| l.kind == LayerKind::Aspects)
.unwrap()
.geometry
else {
panic!("capa de aspectos");
};
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].kind, "conjunction");
assert!(lines[0].orb_deg < 0.01, "orbe ~0");
}
#[test]
fn harmonic_annotates_title_and_summary() {
let mut model = natal_model(&[("sun", 0.0), ("venus", 72.0)]);
apply_harmonic(&mut model, 5);
assert_eq!(model.title, "Test · H5");
assert_eq!(model.aspect_summary.len(), 1);
assert_eq!(model.aspect_summary[0].kind, "conjunction");
}
#[test]
fn houses_layer_is_preserved() {
let mut model = natal_model(&[("sun", 10.0)]);
apply_harmonic(&mut model, 3);
assert!(
model
.layers
.iter()
.any(|l| l.kind == LayerKind::Houses),
"las casas se conservan como marco de referencia"
);
}
}
@@ -32,6 +32,7 @@ pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
pub mod draw; pub mod draw;
pub mod gr; pub mod gr;
pub mod harmonic;
pub mod math; pub mod math;
pub mod palette; pub mod palette;
@@ -39,6 +40,7 @@ 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, GrDirection, GrTrigger};
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,
}; };