feat: cosmos standalone — motor astrométrico/astrológico sobre Llimphi (git-dep al monorepo)

Efemérides + cielo + reloj de sol + mareas + tránsitos + apuntado, más motor
de cartas y UI GPU (app + canvas, demo dense_starfield vía pineal). Front-door:
solo crates cosmos-*; Llimphi y lo fundacional por git-dep del monorepo
gioser.git. cargo check pasa (31 crates, 0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:08:40 +00:00
commit 0cab5e018c
585 changed files with 316208 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+97
View File
@@ -0,0 +1,97 @@
# cosmos
> Astronomía con precisión astronómica. Tiempo · efemérides · coordenadas · imágenes · astrología.
Suite Rust de cálculo astronómico validada contra ephemerides oficiales (JPL DE440/441, IAU 2006/2000A, IERS). Cubre desde escalas de tiempo (UTC/TT/TAI/UT1) hasta proyecciones WCS pasando por catálogos estelares, posiciones planetarias, eclipses, tránsitos, reloj de sol, mareas, astrología tropical y sideral.
## Instalación
```sh
# CLI
cargo run --release -p cosmos-cli -- --help
# App Llimphi (mapa del cielo + ephemerides interactivas)
cargo run --release -p cosmos-app-llimphi
# Server HTTP
cargo run --release -p cosmos-server
```
## Compatibilidad
- **Linux / macOS / Windows** — todos los crates `core` compilan sin dep de sistema.
- **Wawa** — los core compilan a WASM (`cosmos-core`, `cosmos-time`, `cosmos-coords`, ...).
- **Web** — `cosmos-web` expone subset por WASM/JS.
- Validación contra **JPL Horizons** y **AstroPy** en `cosmos-validation`.
## Crates
| Crate | Rol |
|---|---|
| [`cosmos-core`](cosmos-core/README.md) | Tipos base; sin gráficos. |
| [`cosmos-time`](cosmos-time/README.md) | Escalas de tiempo IAU + ΔT histórico. |
| [`cosmos-coords`](cosmos-coords/README.md) | Transformaciones de coordenadas. |
| [`cosmos-ephemeris`](cosmos-ephemeris/README.md) | Posición planetaria via JPL DE. |
| [`cosmos-pointing`](cosmos-pointing/README.md) | Reducción topocéntrica (paralaje, refracción). |
| [`cosmos-catalog`](cosmos-catalog/README.md) | Catálogos estelares (HIP/Tycho/Gaia). |
| [`cosmos-sky`](cosmos-sky/README.md) | Fachada ergonómica (`Instant`/`Observer`/`EphemerisSession`). |
| [`cosmos-wcs`](cosmos-wcs/README.md) | World Coordinate System (FITS-compatible). |
| [`cosmos-images`](cosmos-images/README.md) | Carga + display de imágenes astronómicas (FITS). |
| [`cosmos-astrology`](cosmos-astrology/README.md) | Astrología tropical y sideral. |
| [`cosmos-rise-set`](cosmos-rise-set/README.md) | Salida/puesta de astros. |
| [`cosmos-transits`](cosmos-transits/README.md) | Tránsitos planetarios. |
| [`cosmos-eclipses`](cosmos-eclipses/README.md) | Eclipses solares/lunares. |
| [`cosmos-sundial`](cosmos-sundial/README.md) | Reloj de sol; tiempo aparente local. |
| [`cosmos-tides`](cosmos-tides/README.md) | Mareas (modelo simplificado luna+sol). |
| [`cosmos-skywatch`](cosmos-skywatch/README.md) | Observación general (constelaciones visibles, mejor hora). |
| [`cosmos-leo`](cosmos-leo/README.md) | Órbitas LEO (TLE). |
| [`cosmos-corpus`](cosmos-corpus/README.md) | Corpus textual astronómico ([GUIA](cosmos-corpus/GUIA.md)). |
| [`cosmos-model`](cosmos-model/README.md) | Tipos modelo compartidos. |
| [`cosmos-modules`](cosmos-modules/README.md) | Registro de módulos. |
| [`cosmos-engine`](cosmos-engine/README.md) | Engine genérico de cálculo. |
| [`cosmos-render`](cosmos-render/README.md) | Render agnóstico (skymap + 3D). |
| [`cosmos-canvas-llimphi`](cosmos-canvas-llimphi/README.md) | Backend Llimphi (vello). |
| [`cosmos-app-llimphi`](cosmos-app-llimphi/README.md) | App escritorio. |
| [`cosmos-card`](cosmos-card/README.md) | Card resumen para escritorio. |
| [`cosmos-cli`](cosmos-cli/README.md) | CLI. |
| [`cosmos-store`](cosmos-store/README.md) | Cache local (DE files, catálogos). |
| [`cosmos-server`](cosmos-server/README.md) | HTTP server (REST). |
| [`cosmos-validation`](cosmos-validation/README.md) | Regression harness vs Horizons/AstroPy. |
| [`cosmos-web`](cosmos-web/README.md) | Bindings WASM. |
## Consideraciones
- **Cero ejecución cliente con datos sensibles del usuario.** Latitud/longitud nunca dejan el binario sin permiso.
- Los DE files se descargan **explícitamente** vía `cosmos-cli download`.
- Astrología es separable: si no la querés, no enlazás `cosmos-astrology`.
## Estado (2026-05-31)
### Hecho
- Suite astrométrica madura: tiempo IAU, coordenadas, efemérides JPL DE,
reducción topocéntrica, catálogos, WCS, salida/puesta, tránsitos, eclipses,
reloj de sol, mareas, órbitas LEO — validada contra Horizons/AstroPy
(`cosmos-validation`).
- Refactor astrométrico puro consolidado: `cosmos-{ephemeris,skywatch,sundial,
tides,transits}` extraídos del motor astrológico (`cosmos-engine`).
- Megafiles >1.5k LOC splitteados en módulos (`cosmos-images` xisf/fits,
`cosmos-render` sphere3d, `cosmos-coords` topocentric, `cosmos-engine` bridge).
- App de escritorio `cosmos-app-llimphi`: shell profesional de 3 zonas
redimensionables (datos | gráfica | herramientas), menú principal + menús
contextuales, pestañas y gráficas astronómicas.
- Árbol de datos jerárquico (grupos → contactos → cartas) sobre SQLite
(`cosmos-store`) con esfera 3D viva.
- CRUD completo del árbol desde la UI: crear/renombrar inline/eliminar
(borrado recursivo) y menú Archivo Guardar/Duplicar/Eliminar contra el
store.
- `cosmos-cli` y `cosmos-server` (REST) operativos; bindings WASM (`cosmos-web`).
### Pendiente
- Cerrar el ciclo de edición de cartas en la UI (formularios de datos natales
ricos, no sólo nombre).
- Visualizaciones astrológicas avanzadas (ruedas de aspectos, tránsitos
animados) en el canvas Llimphi.
- Ampliar cobertura de `cosmos-validation` a los núcleos recién extraídos.
- Pulir `cosmos-card` y los kernels de notebook como vistas embebibles.
+35
View File
@@ -0,0 +1,35 @@
# cosmos
> Astronomy with astronomical precision. Time · ephemerides · coordinates · images · astrology.
Rust suite for astronomical computation, validated against official ephemerides (JPL DE440/441, IAU 2006/2000A, IERS). Covers everything from time scales (UTC/TT/TAI/UT1) to WCS projections, through star catalogs, planetary positions, eclipses, transits, sundials, tides, tropical and sidereal astrology.
## Install
```sh
# CLI
cargo run --release -p cosmos-cli -- --help
# Llimphi app (sky map + interactive ephemerides)
cargo run --release -p cosmos-app-llimphi
# HTTP server
cargo run --release -p cosmos-server
```
## Compatibility
- **Linux / macOS / Windows** — all `core` crates compile without system deps.
- **Wawa** — cores compile to WASM (`cosmos-core`, `cosmos-time`, `cosmos-coords`, ...).
- **Web** — `cosmos-web` exposes a subset via WASM/JS.
- Validation against **JPL Horizons** and **AstroPy** in `cosmos-validation`.
## Crates
See the table in [README.md](README.md). Highlights: `cosmos-time`, `cosmos-coords`, `cosmos-ephemeris`, `cosmos-pointing`, `cosmos-catalog`, `cosmos-sky` (ergonomic facade), `cosmos-wcs`, `cosmos-astrology`, `cosmos-rise-set`, `cosmos-transits`, `cosmos-eclipses`, `cosmos-sundial`, `cosmos-tides`, `cosmos-leo`, plus `cosmos-cli`, `cosmos-server`, `cosmos-app-llimphi`, `cosmos-web`, `cosmos-validation`.
## Considerations
- **Zero client-side execution with user-sensitive data.** Latitude/longitude never leaves the binary without permission.
- DE files are downloaded **explicitly** via `cosmos-cli download`.
- Astrology is separable: if you don't want it, you don't link `cosmos-astrology`.
+37
View File
@@ -0,0 +1,37 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# cosmos
> Astronomía cheqaq tupukuywan. Pacha · efemérides · coordenadas · siq'ikuna · astrología.
Rust suite astronómiko yupanapaq, oficial ephemerideswan tupachisqa (JPL DE440/441, IAU 2006/2000A, IERS). Pacha escalakuna (UTC/TT/TAI/UT1)-manta WCS proyecciones-kama, hanaq pacha catálogos, planeta posiciones, eclipsekuna, tránsitos, inti pacha, qucha-pacha, tropikal sideral astrología.
## Churay
```sh
# CLI
cargo run --release -p cosmos-cli -- --help
# Llimphi (hanaq mapa + ephemerides kawsaqkuna)
cargo run --release -p cosmos-app-llimphi
# HTTP server
cargo run --release -p cosmos-server
```
## Tinkuy
- **Linux / macOS / Windows** — `core` crateskuna sistema deps illaqta wiñakun.
- **Wawa** — corekuna WASM-man wiñankun.
- **Web** — `cosmos-web` subset.
- **JPL Horizons** + **AstroPy** validation `cosmos-validation`-pi.
## Crateskuna
Sumaq tabla [README.md](README.md)-pi. Importantekuna: `cosmos-{time,coords,ephemeris,pointing,catalog,sky,wcs,astrology,rise-set,transits,eclipses,sundial,tides,leo}`, hinaspa `cosmos-{cli,server,app-llimphi,web,validation}`.
## Yuyaykunaq
- **Mana runaq sensible-datos hawapi ruwana.** Lat/lon manaña binario manta lloqsinchu mana runaq munaynin.
- DE files **sutilla** wasi-chayasqa `cosmos-cli download`-rayku.
- Astrología t'aqasqa: mana munanki chayqa, `cosmos-astrology` mana huñukuy.
+96
View File
@@ -0,0 +1,96 @@
# Rectificador de hora — manual de uso
El **rectificador** estima la hora de nacimiento verdadera cuando la
registrada es incierta. Usa el método de **direcciones primarias** del
**Sistema GR (Germán Rosas)**: en la hora correcta, los eventos reales de
la vida del sujeto **coinciden** con la perfección de una dirección
primaria (el arco que la esfera celeste rota tras el nacimiento hasta que
un promisor alcanza la posición mundana de un significador).
La trigonometría esférica de esos arcos (método Placidus-mundano,
semi-arcos diurnos/nocturnos bajo el polo de cada cuerpo) la aporta
`eternal-astrology`; el rectificador es la capa de **optimización**: barre
las horas candidatas y minimiza el desajuste entre los eventos conocidos y
los arcos teóricos.
## Dónde está
Panel **«Rectificador de hora»**, en la categoría **Sistema** (engranaje)
del panel de herramientas.
## Flujo de trabajo
1. **Cargá la carta** del sujeto (la hora registrada/estimada es el punto
de partida del barrido).
2. **Jog de hora** — los botones `-60 -10 -1 +1 +10 +60` corren la hora de
nacimiento en minutos **sin tocar la carta guardada**. Sirve para
explorar a mano: mirá cómo se mueven el Ascendente, el MC y las casas en
la rueda mientras ajustás. `0` vuelve al offset cero.
3. **Eventos conocidos**`+ evento` agrega un ancla; cada fila es la
**edad del sujeto** (en años) cuando ocurrió un hecho fuerte y datable
(matrimonio, mudanza, muerte de un padre, nacimiento de un hijo,
accidente…). Ajustá con `-1 / +1` (años) y `-0.1 / +0.1` (≈ mes y
medio). `quitar` borra la fila.
Cuantos más eventos buenos cargues, más nítido el valle. Con uno solo,
el barrido puede tener varios mínimos: usá 35.
4. **Rectificar** — corre el barrido de **dos pasadas** sobre ±2 h:
- **gruesa**, minuto a minuto sobre toda la ventana (es la curva de
perfil que se dibuja);
- **fina**, segundo a segundo alrededor del mejor minuto (de ahí la
precisión de segundo).
5. **Resultado** — se muestra el mejor offset (`+s`, su equivalente en
`min s`) y el **error** del candidato (suma, en años, del desajuste de
cada evento a su dirección primaria más cercana; **menor = mejor**).
Debajo, la **curva de perfil**: el eje X es el offset y el **valle**
(marcado con la línea de acento) es la hora rectificada.
6. **Aplicar al nacimiento** — escribe el mejor offset en la hora de la
carta (`hour:minute:second`), marca la certeza como *exacta*, persiste
la carta y recomputa. El jog vuelve a `0`.
## Clave arco↔año
El selector **Naibod / Ptolomeo** elige la conversión arco→tiempo:
- **Naibod** — 0°5908.33″/año (movimiento solar medio). Default moderno.
- **Ptolomeo** — 1°/año (clásica).
Afecta tanto el barrido (*Rectificar*) como los triggers GR. Cambiarla con
triggers en pantalla los recalcula.
## Triggers GR (HUD)
Debajo del barrido, el HUD lista los **contactos del Sistema GR** a una
**edad de inspección** (`-5 -1 +1 +5` años + `ver triggers`): cada fila es
un promisor dirigido que cae sobre un punto natal —
`promisor · D/C · objetivo · orbe`
donde **D** = dirección directa y **C** = conversa. Las filas marcadas
**«convergencia»** (en acento) son las señales fuertes: el mismo punto
natal tocado por una directa y una conversa dentro del micro-orbe — el
indicio de rectificación que el Sistema GR busca. Ajustá la edad a la de
cada evento conocido y mirá si hay convergencia cerca.
## Lectura de la curva
- Un **valle único y profundo** → rectificación confiable.
- **Varios valles parecidos** → faltan anclas: agregá más eventos o usá
eventos más separados en el tiempo.
- **Curva casi plana** → los eventos no discriminan la hora (poco rango de
declinaciones tocadas); revisá las edades.
## Notas
- La clave arco↔año se elige en el panel (Naibod por defecto).
- La ventana del barrido es de **±2 h**. Si la hora registrada puede estar
más lejos, conviene primero acercarse con el jog y luego rectificar.
- El jog y el barrido **no modifican la carta** hasta que tocás *Aplicar*.
- El motor (`cosmos-engine::rectificar` + `cosmos-render::gr` — los
*triggers* GR de convergencia directo/converso) está disponible siempre
que el feature `eternal-bridge` esté activo (lo está por defecto).
@@ -0,0 +1,54 @@
[package]
name = "cosmos-app-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "cosmos-app-llimphi — visor del lienzo astrológico sobre Llimphi. Llama a `cosmos-engine::compose` (VSOP2013 vía eternal-bridge) con un `Chart` sample y pinta el `RenderModel` con `cosmos-canvas-llimphi`. Toolbar de overlays (Transit/Progression/SolarArc) en la barra superior. Biblioteca de cartas como árbol (`llimphi-widget-tree`) en el sidebar tiled."
[[bin]]
name = "cosmos-app-llimphi"
path = "src/main.rs"
[dependencies]
cosmos-canvas-llimphi = { path = "../cosmos-canvas-llimphi" }
cosmos-engine = { path = "../cosmos-engine" }
cosmos-model = { path = "../cosmos-model" }
cosmos-store = { path = "../cosmos-store" }
cosmos-render = { path = "../cosmos-render" }
cosmos-core = { workspace = true }
cosmos-time = { path = "../cosmos-time" }
cosmos-skywatch = { path = "../cosmos-skywatch" }
cosmos-sundial = { path = "../cosmos-sundial" }
cosmos-tides = { path = "../cosmos-tides" }
cosmos-rise-set = { path = "../cosmos-rise-set" }
cosmos-eclipses = { path = "../cosmos-eclipses" }
nahual-geo-core = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-button = { workspace = true }
llimphi-widget-panel = { workspace = true }
llimphi-widget-tree = { workspace = true }
llimphi-widget-tabs = { workspace = true }
llimphi-widget-splitter = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-segmented = { workspace = true }
llimphi-widget-dock-rail = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-switch = { workspace = true }
llimphi-widget-slider = { workspace = true }
llimphi-widget-scroll = { workspace = true }
llimphi-motion = { workspace = true }
notify = { workspace = true }
pollster = { workspace = true }
png = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rimay-localize = { workspace = true }
wawa-config = { workspace = true }
wawa-config-llimphi = { workspace = true }
# Rail hospedado: cosmos puede delegar su sidebar al marco pata (sus dientes
# aparecen en el rail global cuando tiene foco; su ventana queda puro canvas).
pata-host = { workspace = true }
@@ -0,0 +1,16 @@
# cosmos-app-llimphi
> App de escritorio de [cosmos](../README.md): mapa del cielo + ephemerides interactivas.
Binario para uso humano: mapa del cielo desde tu ubicación (auto-detect o manual), tabla de ephemerides en vivo (planetas, luna, sol), próximos eventos (eclipses, tránsitos, rise/set), búsqueda fuzzy de objetos del catálogo. Panel "esta noche" con la mejor hora de cada constelación visible.
## Uso
```sh
cargo run --release -p cosmos-app-llimphi
```
## Deps
- Todos los `cosmos-*` core + [`cosmos-canvas-llimphi`](../cosmos-canvas-llimphi/README.md), [`cosmos-engine`](../cosmos-engine/README.md), [`cosmos-skywatch`](../cosmos-skywatch/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/)
@@ -0,0 +1,16 @@
# cosmos-app-llimphi
> Desktop app of [cosmos](../README.md): sky map + interactive ephemerides.
Binary for human use: sky map from your location (auto-detect or manual), live ephemerides table (planets, moon, sun), upcoming events (eclipses, transits, rise/set), fuzzy catalog search. "Tonight" panel with the best time for each visible constellation.
## Usage
```sh
cargo run --release -p cosmos-app-llimphi
```
## Deps
- All `cosmos-*` core + [`cosmos-canvas-llimphi`](../cosmos-canvas-llimphi/README.md), [`cosmos-engine`](../cosmos-engine/README.md), [`cosmos-skywatch`](../cosmos-skywatch/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/)
@@ -0,0 +1,447 @@
//! Tile AstroCarto — MC/IC + Asc/Desc líneas sobre un mapa equirectangular.
//!
//! MVP sin fondo de continentes — solo grilla lat/long y las líneas de los
//! cuerpos clásicos. La aproximación supone latitud eclíptica β=0 para
//! todos los cuerpos (válido para AstroCarto a este zoom; Luna y Plutón
//! se separan unos grados pero la silueta de líneas es la misma). La
//! obliquidad usa ε₂₀₀₀ = 23.4393° fijo — el error a 100 años es <0.01°.
use cosmos_model::Chart;
use cosmos_render::{LayerKind, RenderModel};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Size, Style};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use crate::model::Msg;
use crate::view::line;
const ASTROCARTO_OBLIQUITY: f64 = 23.4393;
const ASTROCARTO_W: f32 = 320.0;
const ASTROCARTO_H: f32 = 160.0;
fn julian_day_utc(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: f64) -> f64 {
let (y, m) = if month <= 2 {
(year - 1, (month + 12) as i32)
} else {
(year, month as i32)
};
let a = (y as f64 / 100.0).floor();
let b = 2.0 - a + (a / 4.0).floor();
let jd0 = (365.25 * (y as f64 + 4716.0)).floor()
+ (30.6001 * (m as f64 + 1.0)).floor()
+ day as f64
+ b
- 1524.5;
let frac = (hour as f64 + minute as f64 / 60.0 + second / 3600.0) / 24.0;
jd0 + frac
}
/// GMST en grados [0, 360) — Meeus 12.4.
fn gmst_deg(jd_ut: f64) -> f64 {
let t = (jd_ut - 2451545.0) / 36525.0;
let g = 280.46061837
+ 360.98564736629 * (jd_ut - 2451545.0)
+ 0.000387933 * t * t
- t * t * t / 38710000.0;
g.rem_euclid(360.0)
}
/// Conversión ecliptica → ecuatorial con β=0 fijo. Retorna (RA°, Dec°).
fn ecliptic_to_equatorial(lon_deg: f64) -> (f64, f64) {
let l = lon_deg.to_radians();
let e = ASTROCARTO_OBLIQUITY.to_radians();
let ra = (l.sin() * e.cos()).atan2(l.cos()).to_degrees().rem_euclid(360.0);
let dec = (e.sin() * l.sin()).asin().to_degrees();
(ra, dec)
}
/// Color por cuerpo en el AstroCarto. Hue distintivo para que las líneas
/// se diferencien aun cuando se cruzan.
fn color_de_cuerpo(name: &str) -> Color {
match name {
"sun" => Color::from_rgba8(255, 200, 60, 255),
"moon" => Color::from_rgba8(200, 210, 220, 255),
"mercury" => Color::from_rgba8(180, 180, 180, 255),
"venus" => Color::from_rgba8(120, 220, 130, 255),
"mars" => Color::from_rgba8(230, 90, 90, 255),
"jupiter" => Color::from_rgba8(240, 170, 80, 255),
"saturn" => Color::from_rgba8(180, 150, 90, 255),
"uranus" => Color::from_rgba8(100, 220, 220, 255),
"neptune" => Color::from_rgba8(100, 130, 230, 255),
"pluto" => Color::from_rgba8(170, 90, 130, 255),
_ => Color::from_rgba8(140, 140, 140, 255),
}
}
/// Proyección equirectangular a coordenadas locales del canvas.
fn project_lon_lat(lon_deg: f64, lat_deg: f64) -> (f32, f32) {
let x = ((lon_deg + 180.0) / 360.0) as f32 * ASTROCARTO_W;
let y = ((90.0 - lat_deg) / 180.0) as f32 * ASTROCARTO_H;
(x.clamp(0.0, ASTROCARTO_W), y.clamp(0.0, ASTROCARTO_H))
}
pub(crate) fn tile_astrocarto(
chart: &Chart,
render: &RenderModel,
theme: &Theme,
zoom: f32,
pan: (f32, f32),
rect_cell: std::sync::Arc<std::sync::Mutex<Option<(f32, f32, f32, f32)>>>,
) -> View<Msg> {
let bd = &chart.birth_data;
// Local → UTC.
let total_minutes_local = bd.hour as i64 * 60 + bd.minute as i64;
let total_minutes_utc = total_minutes_local - bd.tz_offset_minutes as i64;
let h_utc = total_minutes_utc as f64 / 60.0;
let jd = julian_day_utc(bd.year, bd.month, bd.day, 0, 0, bd.second) + h_utc / 24.0;
let gmst = gmst_deg(jd);
// Cosas owned para meter dentro de la closure 'static.
let natal_lat = bd.latitude_deg;
let natal_lon = bd.longitude_deg;
// Cuerpos natales con su longitud eclíptica.
let bodies: Vec<(String, f64)> = render
.layers
.iter()
.filter(|l| l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies))
.flat_map(|l| l.glyphs.iter())
.map(|g| (g.symbol.clone(), g.deg as f64))
.collect();
let bg = theme.bg_panel_alt;
let grid = theme.fg_muted;
let zoom = zoom.max(0.1) as f64;
let pan = (pan.0 as f64, pan.1 as f64);
let canvas = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(0.0_f32),
},
flex_grow: 1.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(bg)
.radius(3.0)
.clip(true)
// Arrastrar panea el mapa (la rueda hace zoom vía App::on_wheel).
.draggable_at(|phase, dx, dy, _lx, _ly| match phase {
llimphi_ui::DragPhase::Move => Some(Msg::WheelPan(dx, dy)),
llimphi_ui::DragPhase::End => None,
})
.paint_with(move |scene, ts, rect: llimphi_ui::PaintRect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color as PColor;
use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment, TextBlock};
// Deja el rect del lienzo para que `on_wheel` haga zoom al cursor.
if let Ok(mut g) = rect_cell.lock() {
*g = Some((rect.x, rect.y, rect.w, rect.h));
}
// Aspect-fit centrado + zoom/paneo del usuario.
let scale_x = rect.w as f64 / ASTROCARTO_W as f64;
let scale_y = rect.h as f64 / ASTROCARTO_H as f64;
let scale = scale_x.min(scale_y) * zoom;
let disp_w = ASTROCARTO_W as f64 * scale;
let disp_h = ASTROCARTO_H as f64 * scale;
let off_x = rect.x as f64 + (rect.w as f64 - disp_w) * 0.5 + pan.0;
let off_y = rect.y as f64 + (rect.h as f64 - disp_h) * 0.5 + pan.1;
let xform = Affine::translate((off_x, off_y)) * Affine::scale(scale);
// Grosor de trazo medido en PÍXELES de pantalla: las líneas no
// engordan con el zoom (el `scale` las inflaría), apenas crecen
// un pelo (zoom^0.15) para acompañar el acercamiento. Como la
// escena va escalada por `scale`, dividimos por `scale` para que
// el ancho final en pantalla sea el pedido.
let px_w = move |screen_px: f64| screen_px * zoom.powf(0.15) / scale;
// Mapa de fondo: continentes (world-countries.geojson vía
// nahual-geo-core). Relleno tenue de tierra + contorno de costas.
let land_fill = PColor::from_rgba8(
(grid.components[0] * 255.0) as u8,
(grid.components[1] * 255.0) as u8,
(grid.components[2] * 255.0) as u8,
38,
);
let coast = PColor::from_rgba8(
(grid.components[0] * 255.0) as u8,
(grid.components[1] * 255.0) as u8,
(grid.components[2] * 255.0) as u8,
150,
);
for poly in &nahual_geo_core::world_base().polygons {
for ring in poly {
if ring.len() < 2 {
continue;
}
let mut path = BezPath::new();
for (i, c) in ring.iter().enumerate() {
let (x, y) = project_lon_lat(c[0], c[1]);
if i == 0 {
path.move_to((x as f64, y as f64));
} else {
path.line_to((x as f64, y as f64));
}
}
path.close_path();
scene.fill(
llimphi_ui::llimphi_raster::peniko::Fill::NonZero,
xform,
land_fill,
None,
&path,
);
scene.stroke(&Stroke::new(px_w(0.6)), xform, coast, None, &path);
}
}
// Grilla (graticule). El acercamiento ABRE detalle: aparece una
// grilla fina entre las líneas mayores. Mayores cada 30°/30°;
// las finas cada 10° (zoom ≥ 2.5) o 5° (zoom ≥ 5).
let grid_color = PColor::from_rgba8(
(grid.components[0] * 255.0) as u8,
(grid.components[1] * 255.0) as u8,
(grid.components[2] * 255.0) as u8,
80,
);
let minor_color = PColor::from_rgba8(
(grid.components[0] * 255.0) as u8,
(grid.components[1] * 255.0) as u8,
(grid.components[2] * 255.0) as u8,
38,
);
let minor_step = if zoom >= 5.0 {
5.0_f64
} else if zoom >= 2.5 {
10.0
} else {
0.0
};
// Paralelos finos.
if minor_step > 0.0 {
let mut lat = -85.0_f64;
while lat <= 85.0 {
if (lat / 30.0).fract().abs() > 1e-6 {
let (_, y) = project_lon_lat(0.0, lat);
let mut p = BezPath::new();
p.move_to((0.0, y as f64));
p.line_to((ASTROCARTO_W as f64, y as f64));
scene.stroke(&Stroke::new(px_w(0.4)), xform, minor_color, None, &p);
}
lat += minor_step;
}
let mut lon = -180.0_f64;
while lon <= 180.0 {
if (lon / 30.0).fract().abs() > 1e-6 {
let (x, _) = project_lon_lat(lon, 0.0);
let mut p = BezPath::new();
p.move_to((x as f64, 0.0));
p.line_to((x as f64, ASTROCARTO_H as f64));
scene.stroke(&Stroke::new(px_w(0.4)), xform, minor_color, None, &p);
}
lon += minor_step;
}
}
// Paralelos mayores cada 30°.
for lat in [-60.0_f64, -30.0, 0.0, 30.0, 60.0] {
let (_, y) = project_lon_lat(0.0, lat);
let mut p = BezPath::new();
p.move_to((0.0, y as f64));
p.line_to((ASTROCARTO_W as f64, y as f64));
let w = if lat.abs() < 0.5 { px_w(0.9) } else { px_w(0.5) };
scene.stroke(&Stroke::new(w), xform, grid_color, None, &p);
}
// Meridianos mayores cada 30°.
let mut lon = -150.0_f64;
while lon <= 180.0 {
let (x, _) = project_lon_lat(lon, 0.0);
let mut p = BezPath::new();
p.move_to((x as f64, 0.0));
p.line_to((x as f64, ASTROCARTO_H as f64));
let w = if lon.abs() < 0.5 { px_w(0.9) } else { px_w(0.5) };
scene.stroke(&Stroke::new(w), xform, grid_color, None, &p);
lon += 30.0;
}
for (name, ecl_lon) in &bodies {
let (ra, dec) = ecliptic_to_equatorial(*ecl_lon);
let mc_lon = wrap_lon(ra - gmst);
let ic_lon = wrap_lon(mc_lon + 180.0);
let body_color = color_de_cuerpo(name);
// MC: línea vertical a lo largo del canvas.
let (x_mc, _) = project_lon_lat(mc_lon, 0.0);
let mut p = BezPath::new();
p.move_to((x_mc as f64, 0.0));
p.line_to((x_mc as f64, ASTROCARTO_H as f64));
scene.stroke(&Stroke::new(px_w(1.5)), xform, body_color, None, &p);
// IC: línea vertical punteada para distinguir.
let (x_ic, _) = project_lon_lat(ic_lon, 0.0);
let mut p = BezPath::new();
p.move_to((x_ic as f64, 0.0));
p.line_to((x_ic as f64, ASTROCARTO_H as f64));
scene.stroke(
&Stroke::new(px_w(1.1)).with_dashes(0.0, [px_w(4.0), px_w(4.0)]),
xform,
body_color,
None,
&p,
);
// Asc/Desc: curvas paramétricas en latitud.
if dec.abs() < 89.9 {
let mut rise = BezPath::new();
let mut set = BezPath::new();
let mut rise_started = false;
let mut set_started = false;
let dec_r = dec.to_radians();
let mut phi_deg = -85.0_f64;
while phi_deg <= 85.0 {
let phi_r = phi_deg.to_radians();
let cos_h = -phi_r.tan() * dec_r.tan();
if cos_h.abs() <= 1.0 {
let h_deg = cos_h.acos().to_degrees();
let lon_r = wrap_lon(ra - h_deg - gmst);
let lon_s = wrap_lon(ra + h_deg - gmst);
let (xr, yr) = project_lon_lat(lon_r, phi_deg);
let (xs, ys) = project_lon_lat(lon_s, phi_deg);
if rise_started {
rise.line_to((xr as f64, yr as f64));
} else {
rise.move_to((xr as f64, yr as f64));
rise_started = true;
}
if set_started {
set.line_to((xs as f64, ys as f64));
} else {
set.move_to((xs as f64, ys as f64));
set_started = true;
}
} else if rise_started || set_started {
// Cruzamos región circumpolar — corta la línea.
rise_started = false;
set_started = false;
}
phi_deg += 3.0;
}
if rise_started {
scene.stroke(&Stroke::new(px_w(0.9)), xform, body_color, None, &rise);
}
if set_started {
scene.stroke(&Stroke::new(px_w(0.9)), xform, body_color, None, &set);
}
}
}
// Marca del lugar de nacimiento.
let (px, py) = project_lon_lat(natal_lon, natal_lat);
let mark = llimphi_ui::llimphi_raster::kurbo::Circle::new(
(px as f64, py as f64),
px_w(3.0),
);
scene.fill(
llimphi_ui::llimphi_raster::peniko::Fill::NonZero,
xform,
PColor::from_rgba8(255, 255, 255, 230),
None,
&mark,
);
// Etiquetas de coordenadas — tamaño FIJO en pantalla (no escalan
// con el zoom): se dibujan en coordenadas de pantalla aplicando
// `xform` al punto del mapa. Paralelos sobre el borde izquierdo,
// meridianos sobre el borde inferior del mapa.
let label_col = PColor::from_rgba8(
(grid.components[0] * 255.0) as u8,
(grid.components[1] * 255.0) as u8,
(grid.components[2] * 255.0) as u8,
210,
);
let draw_label = |scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
wx: f64,
wy: f64,
text: &str| {
let sp = xform * Point::new(wx, wy);
let block = TextBlock {
text,
size_px: 9.5,
color: label_col,
origin: (sp.x + 2.0, sp.y - 5.0),
max_width: None,
alignment: Alignment::Start,
line_height: 1.0,
italic: false,
font_family: None,
};
let layout = layout_block(ts, &block);
draw_layout(scene, &layout, label_col, block.origin);
};
for lat in [-60.0_f64, -30.0, 0.0, 30.0, 60.0] {
let (_, y) = project_lon_lat(2.0, lat);
let txt = if lat.abs() < 0.5 {
"Ec.".to_string()
} else if lat > 0.0 {
format!("{}°N", lat as i32)
} else {
format!("{}°S", (-lat) as i32)
};
draw_label(scene, ts, 2.0, y as f64, &txt);
}
for lon in [-120.0_f64, -60.0, 0.0, 60.0, 120.0] {
let (x, _) = project_lon_lat(lon, -86.0);
let txt = if lon.abs() < 0.5 {
"".to_string()
} else if lon > 0.0 {
format!("{}°E", lon as i32)
} else {
format!("{}°O", (-lon) as i32)
};
draw_label(scene, ts, x as f64, 158.0, &txt);
}
});
// Columna a alto completo: el lienzo ocupa todo el espacio (base más
// grande), la leyenda abajo.
let legend = line(
rimay_localize::t("cosmos-astrocarto-leyenda"),
9.0,
theme.fg_muted,
);
View::new(Style {
flex_direction: llimphi_ui::llimphi_layout::taffy::prelude::FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
padding: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(6.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(4.0_f32),
},
..Default::default()
})
.children(vec![canvas, legend])
}
fn wrap_lon(lon: f64) -> f64 {
let l = lon.rem_euclid(360.0);
if l > 180.0 {
l - 360.0
} else {
l
}
}
@@ -0,0 +1,379 @@
//! Gráficas astronómicas (no astrológicas) sobre el mismo motor de
//! efemérides: cielo (alt/az), orto/ocaso, reloj de sol, mareas,
//! eclipses y efemérides. El cómputo es puntual para el instante de la
//! carta (o "ahora", según `CosmosConfig::use_now`) y la ubicación del
//! lugar de nacimiento.
//!
//! `AstroState` cachea las lecturas: se recalcula sólo al cambiar carta
//! o instante (ver `main::recompute_astro`), nunca por frame.
use cosmos_core::Location;
use cosmos_eclipses::{
lunar_reading_at, solar_reading_at, LunarEclipseKind, LunarEclipseReading, SolarEclipseKind,
SolarEclipseReading,
};
use cosmos_model::Chart;
use cosmos_rise_set::{rise_transit_set, Horizon, RiseTransitSet};
use cosmos_skywatch::{sky_positions_all, Body, SkyPosition};
use cosmos_sundial::{sundial_reading, SundialReading};
use cosmos_tides::{tide_reading, TideReading};
use cosmos_time::{utc_from_calendar, JulianDate, UTC, TDB};
use llimphi_theme::Theme;
use llimphi_ui::View;
use crate::format::simbolo_cuerpo;
use crate::model::Msg;
use crate::view::{line, section_label, tile_container};
/// Lecturas astronómicas cacheadas para el instante/lugar vigente.
#[derive(Clone)]
pub(crate) struct AstroState {
pub(crate) instant_iso: String,
pub(crate) place_label: String,
/// Tiempo sidéreo local (grados) y latitud del observador — para
/// proyectar las constelaciones al cielo del observador (alt/az).
pub(crate) lst_deg: f64,
pub(crate) lat_deg: f64,
pub(crate) sky: Vec<(Body, SkyPosition)>,
pub(crate) sundial: SundialReading,
pub(crate) tide: TideReading,
/// Orto/tránsito/ocaso por cuerpo, con el horizonte usado.
pub(crate) riseset: Vec<(Body, RiseTransitSet)>,
pub(crate) solar: SolarEclipseReading,
pub(crate) lunar: LunarEclipseReading,
}
fn build_instant(chart: &Chart, use_now: bool) -> (TDB, String, f64) {
let utc = if use_now {
UTC::now()
} else {
let bd = &chart.birth_data;
// El calendario guardado es local; restamos el offset para
// obtener UTC real antes de pasar a TDB.
utc_from_calendar(
bd.year,
bd.month as u8,
bd.day as u8,
bd.hour as u8,
bd.minute as u8,
bd.second,
)
.add_seconds(-(bd.tz_offset_minutes as f64) * 60.0)
};
let jd_ut = utc.to_julian_date().to_f64();
let tdb = TDB::from(utc.to_julian_date());
(tdb, utc.to_iso8601(), jd_ut)
}
/// GMST en grados (fórmula IAU 1982, suficiente para ubicar figuras).
fn gmst_deg(jd_ut: f64) -> f64 {
let t = (jd_ut - 2451545.0) / 36525.0;
let g = 280.46061837 + 360.98564736629 * (jd_ut - 2451545.0)
+ 0.000387933 * t * t
- t * t * t / 38710000.0;
g.rem_euclid(360.0)
}
fn build_location(chart: &Chart) -> Location {
let bd = &chart.birth_data;
Location::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m)
.unwrap_or_else(|_| Location::from_degrees(0.0, 0.0, 0.0).expect("loc 0,0"))
}
/// Horizonte estándar por cuerpo (refracción + semidiámetro).
fn horizon_for(body: Body) -> Horizon {
match body {
Body::Sun => Horizon::SunStandard,
Body::Moon => Horizon::MoonStandard,
_ => Horizon::Geometric,
}
}
pub(crate) fn compute_astro(chart: &Chart, use_now: bool) -> AstroState {
let (tdb, instant_iso, jd_ut) = build_instant(chart, use_now);
let loc = build_location(chart);
let lst_deg = (gmst_deg(jd_ut) + chart.birth_data.longitude_deg).rem_euclid(360.0);
let lat_deg = chart.birth_data.latitude_deg;
let sky: Vec<(Body, SkyPosition)> = sky_positions_all(&tdb, &loc).to_vec();
let sundial = sundial_reading(&tdb, &loc);
let tide = tide_reading(&tdb, &loc);
let riseset: Vec<(Body, RiseTransitSet)> = Body::all()
.iter()
.map(|b| (*b, rise_transit_set(b, &tdb, &loc, horizon_for(*b))))
.collect();
let solar = solar_reading_at(&tdb);
let lunar = lunar_reading_at(&tdb);
let place_label = chart
.birth_data
.birthplace_label
.clone()
.unwrap_or_else(|| {
format!(
"{:.3}°, {:.3}°",
chart.birth_data.latitude_deg, chart.birth_data.longitude_deg
)
});
AstroState {
instant_iso,
place_label,
lst_deg,
lat_deg,
sky,
sundial,
tide,
riseset,
solar,
lunar,
}
}
// =====================================================================
// Renderers
// =====================================================================
/// Placeholder mientras el cómputo astronómico (orto/ocaso/efemérides, la
/// parte cara: 144 muestras × 10 cuerpos) corre en un worker. La UI nunca se
/// bloquea esperándolo; se reemplaza por las lecturas reales al reentrar el
/// `Msg::AstroComputed`.
pub(crate) fn calculando(theme: &Theme) -> View<Msg> {
tile_container(
vec![line("calculando…".to_string(), 12.0, theme.fg_muted)],
theme,
)
}
/// Cabecera común: instante + lugar.
fn astro_header(a: &AstroState, theme: &Theme) -> View<Msg> {
line(
format!("{} · {}", a.instant_iso, a.place_label),
10.0,
theme.fg_muted,
)
}
/// `HH:MM` UTC de un instante TDB (aprox: ignora TDBUTC ~ms).
fn hhmm(tdb: &TDB) -> String {
let iso = UTC::from_julian_date(jd_of(tdb)).to_iso8601();
// ISO: YYYY-MM-DDTHH:MM:SS… → tomamos HH:MM.
iso.get(11..16).unwrap_or("--:--").to_string()
}
fn jd_of(tdb: &TDB) -> JulianDate {
tdb.to_julian_date()
}
/// Cielo: tabla alt/az de los 10 cuerpos, ordenada por altitud.
pub(crate) fn view_cielo(a: &AstroState, theme: &Theme) -> View<Msg> {
let mut rows: Vec<View<Msg>> = vec![astro_header(a, theme)];
rows.push(line(
format!("{:<5}{:>8}{:>8}{:>10}", "cuer", "alt", "az", "dist(au)"),
10.0,
theme.fg_muted,
));
let mut sky = a.sky.clone();
sky.sort_by(|x, y| {
y.1.visibility_score()
.partial_cmp(&x.1.visibility_score())
.unwrap_or(std::cmp::Ordering::Equal)
});
for (b, p) in &sky {
let color = if p.above_horizon {
theme.fg_text
} else {
theme.fg_muted
};
let txt = format!(
"{:<5}{:>7.1}°{:>7.1}°{:>10.3}",
simbolo_cuerpo(b.canonical()),
p.altitude_deg,
p.azimuth_deg,
p.distance_au
);
rows.push(line(txt, 11.0, color));
}
tile_container(rows, theme)
}
/// Orto/tránsito/ocaso por cuerpo (horarios en UTC).
pub(crate) fn view_ortoocaso(a: &AstroState, theme: &Theme) -> View<Msg> {
let mut rows: Vec<View<Msg>> = vec![astro_header(a, theme)];
rows.push(line(
format!(
"{:<5}{:>8}{:>8}{:>8}{:>8}",
"cuer", "orto", "culm", "alt°", "ocaso"
),
10.0,
theme.fg_muted,
));
for (b, r) in &a.riseset {
let orto = if r.never_rises {
"----".to_string()
} else if r.never_sets {
"circ".to_string()
} else {
r.rise.as_ref().map(hhmm).unwrap_or_else(|| "----".into())
};
let culm = hhmm(&r.transit);
let ocaso = r.set.as_ref().map(hhmm).unwrap_or_else(|| "----".into());
let txt = format!(
"{:<5}{:>8}{:>8}{:>7.1}{:>8}",
simbolo_cuerpo(b.canonical()),
orto,
culm,
r.transit_altitude_deg,
ocaso
);
rows.push(line(txt, 11.0, theme.fg_text));
}
tile_container(rows, theme)
}
/// Reloj de sol: azimut y largo de sombra del gnomon.
pub(crate) fn view_sundial(a: &AstroState, theme: &Theme) -> View<Msg> {
let s = &a.sundial;
let mut rows: Vec<View<Msg>> = vec![astro_header(a, theme)];
rows.push(section_label("Sol".to_string(), theme));
rows.push(line(
format!(
"altitud {:.2}° azimut {:.2}°",
s.sun.altitude_deg, s.sun.azimuth_deg
),
11.0,
theme.fg_text,
));
rows.push(line(
format!("ángulo horario {:.2}°", s.hour_angle_deg),
11.0,
theme.fg_muted,
));
rows.push(section_label("Sombra del gnomon".to_string(), theme));
match (s.shadow_azimuth_deg, s.shadow_length_ratio) {
(Some(az), Some(ratio)) => {
rows.push(line(
format!("azimut de sombra {az:.2}°"),
11.0,
theme.fg_text,
));
rows.push(line(
format!("largo / altura del gnomon = {ratio:.2}"),
11.0,
theme.fg_text,
));
rows.push(line(
format!("(gnomon de 1 m -> sombra de {:.2} m)", ratio),
10.0,
theme.fg_muted,
));
}
_ => {
rows.push(line(
"Sol bajo el horizonte — sin sombra".to_string(),
11.0,
theme.fg_muted,
));
}
}
tile_container(rows, theme)
}
/// Mareas de equilibrio (Sol + Luna).
pub(crate) fn view_mareas(a: &AstroState, theme: &Theme) -> View<Msg> {
let t = &a.tide;
let mut rows: Vec<View<Msg>> = vec![astro_header(a, theme)];
rows.push(section_label("Marea de equilibrio".to_string(), theme));
rows.push(line(
format!("total {:+.3} m", t.total_height_m),
13.0,
theme.fg_text,
));
let comp = |label: &str, c: &cosmos_tides::ComponentReading| -> String {
format!(
"{label:<6} {:+.3} m cenital {:.1}° alt {:.1}°",
c.height_m, c.zenith_deg, c.sky.altitude_deg
)
};
rows.push(section_label("Componentes".to_string(), theme));
rows.push(line(comp("Luna", &t.lunar), 11.0, theme.fg_text));
rows.push(line(comp("Sol", &t.solar), 11.0, theme.fg_text));
rows.push(line(
"MVP: fuerza generadora, sin respuesta hidrodinámica de la cuenca."
.to_string(),
9.0,
theme.fg_muted,
));
tile_container(rows, theme)
}
/// Eclipses: lectura puntual solar y lunar para el instante.
pub(crate) fn view_eclipses(a: &AstroState, theme: &Theme) -> View<Msg> {
let s = &a.solar;
let l = &a.lunar;
let solar_kind = match s.kind {
SolarEclipseKind::None => "ninguno",
SolarEclipseKind::Partial => "parcial",
SolarEclipseKind::Annular => "anular",
SolarEclipseKind::Total => "total",
};
let lunar_kind = match l.kind {
LunarEclipseKind::None => "ninguno",
LunarEclipseKind::Penumbral => "penumbral",
LunarEclipseKind::Partial => "parcial",
LunarEclipseKind::Total => "total",
};
let mut rows: Vec<View<Msg>> = vec![astro_header(a, theme)];
rows.push(section_label("Eclipse solar".to_string(), theme));
rows.push(line(
format!("tipo: {solar_kind} magnitud {:.3}", s.magnitude),
11.0,
theme.fg_text,
));
rows.push(line(
format!(
"separación Sol·Luna {:.3}° (r Sol {:.3}°, r Luna {:.3}°)",
s.separation_deg, s.sun_apparent_radius_deg, s.moon_apparent_radius_deg
),
11.0,
theme.fg_muted,
));
rows.push(section_label("Eclipse lunar".to_string(), theme));
rows.push(line(
format!("tipo: {lunar_kind} magnitud umbral {:.3}", l.umbral_magnitude),
11.0,
theme.fg_text,
));
rows.push(line(
format!(
"γ {:.0} km umbra {:.0} km penumbra {:.0} km",
l.gamma_km, l.umbra_radius_km, l.penumbra_radius_km
),
11.0,
theme.fg_muted,
));
tile_container(rows, theme)
}
/// Efemérides: RA/dec/distancia de los cuerpos para el instante.
pub(crate) fn view_efemerides(a: &AstroState, theme: &Theme) -> View<Msg> {
let mut rows: Vec<View<Msg>> = vec![astro_header(a, theme)];
rows.push(line(
format!("{:<5}{:>10}{:>9}{:>11}", "cuer", "AR(h)", "dec°", "dist(au)"),
10.0,
theme.fg_muted,
));
for (b, p) in &a.sky {
let ra_h = p.right_ascension_deg / 15.0;
let txt = format!(
"{:<5}{:>9.3}h{:>8.2}°{:>11.4}",
simbolo_cuerpo(b.canonical()),
ra_h,
p.declination_deg,
p.distance_au
);
rows.push(line(txt, 11.0, theme.fg_text));
}
tile_container(rows, theme)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
//! Diálogos modales de creación: **contacto** y **carta**.
//!
//! Rescatado del cosmos GPUI (cosmos-tree, "Fase 2 — CRUD UX", borrado en
//! la migración a Llimphi 2026-05-26): el form de carta con los campos
//! mínimos de `StoredBirthData` y el **atlas de ciudades** que autocompleta
//! lat/lon/tz al elegir un lugar de nacimiento.
//!
//! Se renderea como overlay (`App::view_overlay`): un scrim a pantalla
//! completa + una card centrada. Un solo `TextInputState` en el `Model`
//! edita el campo enfocado; el valor vive en el form y se escribe en cada
//! tecla. La confirmación valida/parsea y crea en el store.
use cosmos_model::{ContactId, GroupId};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use crate::glyphs::{self, Icon};
use crate::model::{Model, Msg};
/// Preset de ciudad: autocompleta lat/lon/tz al elegirlo. TZ es la zona
/// estándar (sin DST). Rescatado del cosmos GPUI.
#[derive(Clone, Debug)]
pub(crate) struct CityPreset {
pub name: &'static str,
pub lat: f64,
pub lon: f64,
pub tz: i32,
}
/// Campo del diálogo con foco de teclado.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum DialogField {
#[default]
Name,
Label,
Date,
Time,
City,
}
/// Estado de un diálogo abierto.
pub(crate) enum Dialog {
NewContact(NewContactForm),
NewChart(NewChartForm),
}
pub(crate) struct NewContactForm {
pub group: Option<GroupId>,
pub name: String,
}
pub(crate) struct NewChartForm {
pub contact: ContactId,
pub label: String,
/// `YYYY-MM-DD`.
pub date: String,
/// `HH:MM`.
pub time: String,
pub city_query: String,
pub place: String,
pub lat: f64,
pub lon: f64,
pub tz: i32,
}
impl Dialog {
/// Lee el valor textual del campo `f`.
pub(crate) fn field(&self, f: DialogField) -> String {
match (self, f) {
(Dialog::NewContact(c), DialogField::Name) => c.name.clone(),
(Dialog::NewChart(c), DialogField::Label) => c.label.clone(),
(Dialog::NewChart(c), DialogField::Date) => c.date.clone(),
(Dialog::NewChart(c), DialogField::Time) => c.time.clone(),
(Dialog::NewChart(c), DialogField::City) => c.city_query.clone(),
_ => String::new(),
}
}
/// Escribe `v` en el campo `f`.
pub(crate) fn set_field(&mut self, f: DialogField, v: String) {
match (self, f) {
(Dialog::NewContact(c), DialogField::Name) => c.name = v,
(Dialog::NewChart(c), DialogField::Label) => c.label = v,
(Dialog::NewChart(c), DialogField::Date) => c.date = v,
(Dialog::NewChart(c), DialogField::Time) => c.time = v,
(Dialog::NewChart(c), DialogField::City) => c.city_query = v,
_ => {}
}
}
}
/// Ciudades que matchean la query (case-insensitive, substring).
pub(crate) fn city_matches(query: &str) -> Vec<(usize, &'static CityPreset)> {
let q = query.trim().to_lowercase();
CITY_PRESETS
.iter()
.enumerate()
.filter(|(_, c)| q.is_empty() || c.name.to_lowercase().contains(&q))
.take(8)
.collect()
}
// =====================================================================
// Render
// =====================================================================
pub(crate) fn dialog_overlay(model: &Model, theme: &Theme) -> Option<View<Msg>> {
let dialog = model.dialog.as_ref()?;
let (title, body): (&str, Vec<View<Msg>>) = match dialog {
Dialog::NewContact(_) => ("Nuevo contacto", contact_body(model, theme)),
Dialog::NewChart(_) => ("Nueva carta", chart_body(model, theme)),
};
// Card centrada.
let mut kids: Vec<View<Msg>> = Vec::new();
kids.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(26.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(title.to_string(), 14.0, theme.fg_text, Alignment::Start)]),
);
kids.extend(body);
kids.push(dialog_buttons(theme));
let card = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(420.0_f32),
height: Dimension::auto(),
},
flex_shrink: 0.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(14.0_f32),
bottom: length(14.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.paint_with(panel_signature_painter(PanelStyle::from_theme_large(theme)))
.radius(PanelStyle::from_theme_large(theme).radius)
.clip(true)
.children(kids);
// Scrim a pantalla completa: click afuera = cancelar.
Some(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(scrim(theme))
.on_click(Msg::DialogCancel)
.children(vec![card]),
)
}
fn scrim(theme: &Theme) -> llimphi_ui::llimphi_raster::peniko::Color {
let [r, g, b, _] = theme.bg_app.components;
llimphi_ui::llimphi_raster::peniko::Color::new([r, g, b, 0.6])
}
fn contact_body(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
vec![field_row(model, theme, "Nombre", DialogField::Name)]
}
fn chart_body(model: &Model, theme: &Theme) -> Vec<View<Msg>> {
let mut rows = vec![
field_row(model, theme, "Etiqueta", DialogField::Label),
field_row(model, theme, "Fecha (AAAA-MM-DD)", DialogField::Date),
field_row(model, theme, "Hora (HH:MM)", DialogField::Time),
field_row(model, theme, "Ciudad", DialogField::City),
];
// Lista de ciudades que matchean (al editar el campo Ciudad).
if model.dialog_field == DialogField::City {
if let Some(Dialog::NewChart(c)) = &model.dialog {
for (idx, city) in city_matches(&c.city_query) {
rows.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(22.0_f32),
},
flex_shrink: 0.0,
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.hover_fill(theme.bg_row_hover)
.radius(3.0)
.on_click(Msg::DialogPickCity(idx))
.children(vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(city.name.to_string(), 11.0, theme.fg_muted, Alignment::Start)]),
);
}
}
}
// Resumen del lugar elegido.
if let Some(Dialog::NewChart(c)) = &model.dialog {
if !c.place.is_empty() {
rows.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(
format!(
"{} · {:.2}°, {:.2}° · UTC{:+}",
c.place,
c.lat,
c.lon,
c.tz as f32 / 60.0
),
10.0,
theme.accent,
Alignment::Start,
)]),
);
}
}
rows
}
/// Una fila etiqueta + campo de texto. El campo enfocado usa el
/// `dialog_input` vivo; el resto muestra su valor (clickeable para
/// enfocar).
fn field_row(model: &Model, theme: &Theme, label: &str, field: DialogField) -> View<Msg> {
let lbl = View::new(Style {
size: Size {
width: length(132.0_f32),
height: Dimension::auto(),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(label.to_string(), 12.0, theme.fg_muted, Alignment::Start);
let focused = model.dialog_field == field;
let input: View<Msg> = if focused {
text_input_view(
&model.dialog_input,
"",
true,
&TextInputPalette::from_theme(theme),
Msg::DialogFocus(field),
)
} else {
let val = model
.dialog
.as_ref()
.map(|d| d.field(field))
.unwrap_or_default();
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(0.0_f32),
height: length(26.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel)
.radius(4.0)
.hover_fill(theme.bg_row_hover)
.on_click(Msg::DialogFocus(field))
.children(vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(val, 12.0, theme.fg_text, Alignment::Start)])
};
let input_box = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(0.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![input]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(30.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![lbl, input_box])
}
fn dialog_buttons(theme: &Theme) -> View<Msg> {
let btn = |label: &str, icon: Icon, msg: Msg, accent: bool| -> View<Msg> {
let fg = if accent { theme.bg_app } else { theme.fg_text };
let bg = if accent { theme.accent } else { theme.bg_panel };
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: Dimension::auto(),
height: length(28.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size {
width: length(5.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(bg)
.radius(5.0)
.hover_fill(theme.bg_row_hover)
.on_click(msg)
.children(vec![
glyphs::icon_view(icon, 13.0, fg),
View::new(Style {
size: Size {
width: Dimension::auto(),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(label.to_string(), 12.0, fg, Alignment::Center),
])
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(30.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::End),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
margin: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.children(vec![
btn("Cancelar", Icon::Close, Msg::DialogCancel, false),
btn("Crear", Icon::Plus, Msg::DialogConfirm, true),
])
}
/// Atlas hardcoded — ciudades canónicas que cubren la mayoría de casos.
/// (Rescatado de `cosmos-tree::default_city_presets`.)
pub(crate) const CITY_PRESETS: &[CityPreset] = &[
CityPreset { name: "Buenos Aires, AR", lat: -34.6037, lon: -58.3816, tz: -180 },
CityPreset { name: "Córdoba, AR", lat: -31.4201, lon: -64.1888, tz: -180 },
CityPreset { name: "Rosario, AR", lat: -32.9587, lon: -60.6930, tz: -180 },
CityPreset { name: "Mendoza, AR", lat: -32.8908, lon: -68.8272, tz: -180 },
CityPreset { name: "Caracas, VE", lat: 10.4806, lon: -66.9036, tz: -240 },
CityPreset { name: "Maracaibo, VE", lat: 10.6427, lon: -71.6125, tz: -240 },
CityPreset { name: "Valencia, VE", lat: 10.1620, lon: -68.0078, tz: -240 },
CityPreset { name: "Bogotá, CO", lat: 4.7110, lon: -74.0721, tz: -300 },
CityPreset { name: "Medellín, CO", lat: 6.2442, lon: -75.5812, tz: -300 },
CityPreset { name: "Cali, CO", lat: 3.4516, lon: -76.5320, tz: -300 },
CityPreset { name: "Lima, PE", lat: -12.0464, lon: -77.0428, tz: -300 },
CityPreset { name: "Cusco, PE", lat: -13.5319, lon: -71.9675, tz: -300 },
CityPreset { name: "Santiago, CL", lat: -33.4489, lon: -70.6693, tz: -240 },
CityPreset { name: "Valparaíso, CL", lat: -33.0472, lon: -71.6127, tz: -240 },
CityPreset { name: "Quito, EC", lat: -0.1807, lon: -78.4678, tz: -300 },
CityPreset { name: "Guayaquil, EC", lat: -2.1709, lon: -79.9224, tz: -300 },
CityPreset { name: "Montevideo, UY", lat: -34.9011, lon: -56.1645, tz: -180 },
CityPreset { name: "Asunción, PY", lat: -25.2637, lon: -57.5759, tz: -240 },
CityPreset { name: "La Paz, BO", lat: -16.4897, lon: -68.1193, tz: -240 },
CityPreset { name: "Ciudad de México", lat: 19.4326, lon: -99.1332, tz: -360 },
CityPreset { name: "Guadalajara, MX", lat: 20.6597, lon: -103.3496, tz: -360 },
CityPreset { name: "Monterrey, MX", lat: 25.6866, lon: -100.3161, tz: -360 },
CityPreset { name: "Habana, CU", lat: 23.1136, lon: -82.3666, tz: -300 },
CityPreset { name: "San Juan, PR", lat: 18.4655, lon: -66.1057, tz: -240 },
CityPreset { name: "San José, CR", lat: 9.9281, lon: -84.0907, tz: -360 },
CityPreset { name: "Panamá, PA", lat: 8.9824, lon: -79.5199, tz: -300 },
CityPreset { name: "San Salvador, SV", lat: 13.6929, lon: -89.2182, tz: -360 },
CityPreset { name: "Guatemala, GT", lat: 14.6349, lon: -90.5069, tz: -360 },
CityPreset { name: "Tegucigalpa, HN", lat: 14.0723, lon: -87.1921, tz: -360 },
CityPreset { name: "Managua, NI", lat: 12.1149, lon: -86.2362, tz: -360 },
CityPreset { name: "Santo Domingo, DO", lat: 18.4861, lon: -69.9312, tz: -240 },
CityPreset { name: "São Paulo, BR", lat: -23.5505, lon: -46.6333, tz: -180 },
CityPreset { name: "Rio de Janeiro, BR", lat: -22.9068, lon: -43.1729, tz: -180 },
CityPreset { name: "Brasília, BR", lat: -15.8267, lon: -47.9218, tz: -180 },
CityPreset { name: "Salvador, BR", lat: -12.9777, lon: -38.5016, tz: -180 },
CityPreset { name: "Madrid, ES", lat: 40.4168, lon: -3.7038, tz: 60 },
CityPreset { name: "Barcelona, ES", lat: 41.3851, lon: 2.1734, tz: 60 },
CityPreset { name: "Sevilla, ES", lat: 37.3891, lon: -5.9845, tz: 60 },
CityPreset { name: "Valencia, ES", lat: 39.4699, lon: -0.3763, tz: 60 },
CityPreset { name: "Bilbao, ES", lat: 43.2630, lon: -2.9350, tz: 60 },
CityPreset { name: "London, UK", lat: 51.5074, lon: -0.1278, tz: 0 },
CityPreset { name: "Paris, FR", lat: 48.8566, lon: 2.3522, tz: 60 },
CityPreset { name: "Berlin, DE", lat: 52.5200, lon: 13.4050, tz: 60 },
CityPreset { name: "München, DE", lat: 48.1351, lon: 11.5820, tz: 60 },
CityPreset { name: "Roma, IT", lat: 41.9028, lon: 12.4964, tz: 60 },
CityPreset { name: "Milano, IT", lat: 45.4642, lon: 9.1900, tz: 60 },
CityPreset { name: "Amsterdam, NL", lat: 52.3676, lon: 4.9041, tz: 60 },
CityPreset { name: "Bruxelles, BE", lat: 50.8503, lon: 4.3517, tz: 60 },
CityPreset { name: "Wien, AT", lat: 48.2082, lon: 16.3738, tz: 60 },
CityPreset { name: "Zürich, CH", lat: 47.3769, lon: 8.5417, tz: 60 },
CityPreset { name: "Lisboa, PT", lat: 38.7223, lon: -9.1393, tz: 0 },
CityPreset { name: "Dublin, IE", lat: 53.3498, lon: -6.2603, tz: 0 },
CityPreset { name: "Stockholm, SE", lat: 59.3293, lon: 18.0686, tz: 60 },
CityPreset { name: "Oslo, NO", lat: 59.9139, lon: 10.7522, tz: 60 },
CityPreset { name: "København, DK", lat: 55.6761, lon: 12.5683, tz: 60 },
CityPreset { name: "Helsinki, FI", lat: 60.1699, lon: 24.9384, tz: 120 },
CityPreset { name: "Warszawa, PL", lat: 52.2297, lon: 21.0122, tz: 60 },
CityPreset { name: "Praha, CZ", lat: 50.0755, lon: 14.4378, tz: 60 },
CityPreset { name: "Budapest, HU", lat: 47.4979, lon: 19.0402, tz: 60 },
CityPreset { name: "Athina, GR", lat: 37.9838, lon: 23.7275, tz: 120 },
CityPreset { name: "İstanbul, TR", lat: 41.0082, lon: 28.9784, tz: 180 },
CityPreset { name: "Moskva, RU", lat: 55.7558, lon: 37.6173, tz: 180 },
CityPreset { name: "New York, US", lat: 40.7128, lon: -74.0060, tz: -300 },
CityPreset { name: "Los Angeles, US", lat: 34.0522, lon: -118.2437, tz: -480 },
CityPreset { name: "Chicago, US", lat: 41.8781, lon: -87.6298, tz: -360 },
CityPreset { name: "Miami, US", lat: 25.7617, lon: -80.1918, tz: -300 },
CityPreset { name: "Houston, US", lat: 29.7604, lon: -95.3698, tz: -360 },
CityPreset { name: "San Francisco, US", lat: 37.7749, lon: -122.4194, tz: -480 },
CityPreset { name: "Seattle, US", lat: 47.6062, lon: -122.3321, tz: -480 },
CityPreset { name: "Boston, US", lat: 42.3601, lon: -71.0589, tz: -300 },
CityPreset { name: "Washington DC", lat: 38.9072, lon: -77.0369, tz: -300 },
CityPreset { name: "Toronto, CA", lat: 43.6532, lon: -79.3832, tz: -300 },
CityPreset { name: "Montreal, CA", lat: 45.5017, lon: -73.5673, tz: -300 },
CityPreset { name: "Vancouver, CA", lat: 49.2827, lon: -123.1207, tz: -480 },
CityPreset { name: "Tokyo, JP", lat: 35.6762, lon: 139.6503, tz: 540 },
CityPreset { name: "Beijing, CN", lat: 39.9042, lon: 116.4074, tz: 480 },
CityPreset { name: "Shanghai, CN", lat: 31.2304, lon: 121.4737, tz: 480 },
CityPreset { name: "Hong Kong", lat: 22.3193, lon: 114.1694, tz: 480 },
CityPreset { name: "Singapore", lat: 1.3521, lon: 103.8198, tz: 480 },
CityPreset { name: "Seoul, KR", lat: 37.5665, lon: 126.9780, tz: 540 },
CityPreset { name: "Bangkok, TH", lat: 13.7563, lon: 100.5018, tz: 420 },
CityPreset { name: "Jakarta, ID", lat: -6.2088, lon: 106.8456, tz: 420 },
CityPreset { name: "Manila, PH", lat: 14.5995, lon: 120.9842, tz: 480 },
CityPreset { name: "Mumbai, IN", lat: 19.0760, lon: 72.8777, tz: 330 },
CityPreset { name: "Delhi, IN", lat: 28.7041, lon: 77.1025, tz: 330 },
CityPreset { name: "Bangalore, IN", lat: 12.9716, lon: 77.5946, tz: 330 },
CityPreset { name: "Karachi, PK", lat: 24.8607, lon: 67.0011, tz: 300 },
CityPreset { name: "Tehran, IR", lat: 35.6892, lon: 51.3890, tz: 210 },
CityPreset { name: "Dubai, AE", lat: 25.2048, lon: 55.2708, tz: 240 },
CityPreset { name: "Tel Aviv, IL", lat: 32.0853, lon: 34.7818, tz: 120 },
CityPreset { name: "Cairo, EG", lat: 30.0444, lon: 31.2357, tz: 120 },
CityPreset { name: "Lagos, NG", lat: 6.5244, lon: 3.3792, tz: 60 },
CityPreset { name: "Nairobi, KE", lat: -1.2921, lon: 36.8219, tz: 180 },
CityPreset { name: "Johannesburg, ZA", lat: -26.2041, lon: 28.0473, tz: 120 },
CityPreset { name: "Cape Town, ZA", lat: -33.9249, lon: 18.4241, tz: 120 },
CityPreset { name: "Casablanca, MA", lat: 33.5731, lon: -7.5898, tz: 60 },
CityPreset { name: "Sydney, AU", lat: -33.8688, lon: 151.2093, tz: 600 },
CityPreset { name: "Melbourne, AU", lat: -37.8136, lon: 144.9631, tz: 600 },
CityPreset { name: "Auckland, NZ", lat: -36.8485, lon: 174.7633, tz: 720 },
];
@@ -0,0 +1,71 @@
//! Puente a `cosmos-engine`: la carta de ejemplo y el `compute` que arma el
//! `RenderModel` desde un `Chart` + overlays + armónico.
use cosmos_engine::{compose, NatalOptions, PipelineRequest};
use cosmos_model::{
Chart, ChartId, ChartKind, ContactId, StoredBirthData, StoredChartConfig, TimeCertainty,
};
use cosmos_render::RenderModel;
use crate::model::OverlayKind;
pub(crate) fn sample_chart() -> Chart {
Chart {
id: ChartId::new(),
contact_id: ContactId::new(),
kind: ChartKind::Natal,
label: rimay_localize::t("cosmos-demo-title"),
birth_data: StoredBirthData {
year: 1990,
month: 6,
day: 21,
hour: 12,
minute: 0,
second: 0.0,
tz_offset_minutes: -300,
latitude_deg: -12.0464,
longitude_deg: -77.0428,
altitude_m: 154.0,
time_certainty: TimeCertainty::Estimated,
subject_name: None,
birthplace_label: Some("Lima".into()),
},
config: StoredChartConfig::default(),
related_chart_id: None,
created_at_ms: 0,
}
}
pub(crate) fn compute(
chart: &Chart,
overlays: &[OverlayKind],
harmonic: u32,
show_minors: bool,
offset_min: i64,
) -> (RenderModel, Option<String>) {
let target_age = 35.0;
let requests: Vec<PipelineRequest> = overlays
.iter()
.map(|k| k.to_request(target_age))
.collect();
let opts = NatalOptions {
show_majors: true,
show_minors,
orb_multiplier: 1.0,
show_dignities: true,
harmonic,
};
// `offset_min` = jog de rectificación: corre la hora de nacimiento sin
// tocar la carta guardada, para ver moverse ángulos/casas en vivo.
match cosmos_engine::compose_with_options(chart, offset_min, &requests, &opts) {
Ok(r) => (r, None),
Err(e) => {
let msg = format!("{e}");
(
compose(chart, offset_min, &[])
.unwrap_or_else(|_| cosmos_engine::compute_mock(chart)),
Some(msg),
)
}
}
}
@@ -0,0 +1,42 @@
//! Helpers de formateo de longitudes y códigos de cuerpo/aspecto.
//!
//! **Por qué letras y no unicode** (☉☽☿… ☌☍△□⚹): las fuentes default del
//! sistema (LiberationSans, AdwaitaSans) no traen `U+2609..U+265F`, así que
//! cualquier glyph astrológico cae como `.notdef`. En el wheel ya se dibujan
//! como path (`cosmos_render::glyphs`); acá son texto plano en filas, así que
//! usamos códigos cortos.
pub(crate) fn fmt_dms(deg: f64) -> String {
let total_min = (deg.abs() * 60.0).round() as i64;
let d = total_min / 60;
let m = total_min % 60;
format!("{:>2}°{:02}'", d, m)
}
/// Códigos alfabéticos para mostrar cuerpos en los tiles del sidebar.
pub(crate) fn simbolo_cuerpo(s: &str) -> &'static str {
match s {
"sun" => "Sol",
"moon" => "Lun",
"mercury" => "Mer",
"venus" => "Ven",
"mars" => "Mar",
"jupiter" => "Jup",
"saturn" => "Sat",
"uranus" => "Ura",
"neptune" => "Nep",
"pluto" => "Plu",
"earth" => "Tie",
"north_node" | "ascending_node" => "NoN",
"south_node" | "descending_node" => "NoS",
"lilith" => "Lil",
"chiron" => "Qui",
"mean_node" => "NoN",
"asc" => "Asc",
"desc" => "Dsc",
"mc" => "MC",
"ic" => "IC",
_ => "·",
}
}
@@ -0,0 +1,707 @@
//! Glyphs e iconos como **mini-canvas vectorial** — la pieza que mata
//! los tofus de la app.
//!
//! Nada de unicode astrológico (☉☽♈…☌△) ni dingbats (✎✂🗑⚙) como texto:
//! las fuentes default del sistema (LiberationSans/AdwaitaSans) no traen
//! esos bloques y caen como `.notdef`. En su lugar todo se dibuja como
//! geometría (`DrawCommand`) y se pinta con el mismo canvas vello que la
//! rueda (`cosmos_canvas_llimphi::canvas_view`).
//!
//! Tres familias:
//! - **cuerpos** (`body_view`) — planetas/luminarias/nodos vía
//! `cosmos_render::glyphs::planet_commands`; los puntos del chart
//! (Asc/MC/…) caen a texto ASCII corto.
//! - **signos** (`sign_view`) y **aspectos** (`aspect_view`) — paths
//! propios de `cosmos_render::glyphs`.
//! - **iconos de chrome** (`icon_view`) — set vectorial hecho a mano
//! para la botonera, el rail, las pestañas y el árbol.
use cosmos_canvas_llimphi::canvas_view;
use cosmos_model::ChartKind;
use cosmos_render::glyphs::{aspect_commands, planet_commands, sign_commands};
use cosmos_render::{DrawCommand, Palette, Rgba};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use crate::format::simbolo_cuerpo;
/// Ids zodiacales en orden — index = longitud / 30.
pub(crate) const SIGN_IDS: [&str; 12] = [
"aries",
"taurus",
"gemini",
"cancer",
"leo",
"virgo",
"libra",
"scorpio",
"sagittarius",
"capricorn",
"aquarius",
"pisces",
];
/// Id del signo (en inglés, para los glyph paths) de una longitud.
pub(crate) fn sign_id(deg: f32) -> &'static str {
SIGN_IDS[((deg.rem_euclid(360.0) / 30.0) as usize) % 12]
}
/// Cuerpos con glyph vectorial propio en `planet_commands`.
const PLANET_GLYPHS: &[&str] = &[
"sun",
"moon",
"mercury",
"venus",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune",
"pluto",
"earth",
"north_node",
"south_node",
"chiron",
"lilith",
];
/// Normaliza alias de cuerpos al id que entiende `planet_commands`.
fn canon_body(name: &str) -> &str {
match name {
"ascending_node" | "mean_node" => "north_node",
"descending_node" => "south_node",
other => other,
}
}
fn rgba(c: Color) -> Rgba {
let [r, g, b, a] = c.components;
Rgba { r, g, b, a }
}
/// Grosor de trazo proporcional al tamaño de la celda.
fn sw(px: f32) -> f32 {
(px * 0.085).clamp(1.1, 3.0)
}
/// Caja cuadrada `px` que pinta `cmds` (centrados en `px/2`) con vello.
fn cell<Msg: Clone + 'static>(cmds: Vec<DrawCommand>, px: f32) -> View<Msg> {
View::new(Style {
size: Size {
width: length(px),
height: length(px),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![canvas_view::<Msg>(cmds, px, None)])
}
/// Celda de texto corto (para puntos del chart sin glyph: Asc/MC/…).
fn text_cell<Msg: Clone + 'static>(txt: &str, w: f32, px: f32, color: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: length(w),
height: length(px),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text_aligned(txt.to_string(), (px * 0.62).clamp(9.0, 12.0), color, Alignment::Center)
}
/// Glyph de un cuerpo. Planetas/nodos → path vectorial; puntos del chart
/// (Asc/MC/…) → texto ASCII corto.
pub(crate) fn body_view<Msg: Clone + 'static>(name: &str, px: f32, color: Color) -> View<Msg> {
let canon = canon_body(name);
if PLANET_GLYPHS.contains(&canon) {
cell(
planet_commands(canon, px / 2.0, px / 2.0, px * 0.82, rgba(color), sw(px)),
px,
)
} else {
text_cell(simbolo_cuerpo(name), px * 1.3, px, color)
}
}
/// Glyph de un signo zodiacal (por id inglés: `"aries"`…).
pub(crate) fn sign_view<Msg: Clone + 'static>(name: &str, px: f32, color: Color) -> View<Msg> {
cell(
sign_commands(name, px / 2.0, px / 2.0, px * 0.82, rgba(color), sw(px)),
px,
)
}
/// Glyph de un aspecto, coloreado por la paleta (oscura).
pub(crate) fn aspect_view<Msg: Clone + 'static>(kind: &str, px: f32) -> View<Msg> {
let c = Palette::dark().aspect(kind);
cell(
aspect_commands(kind, px / 2.0, px / 2.0, px * 0.82, c, sw(px)),
px,
)
}
// =====================================================================
// Iconos de chrome (botonera, rail, pestañas, controles, árbol)
// =====================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Icon {
Plus,
Pencil,
Scissors,
Clipboard,
Trash,
Close,
Gear,
Star,
Refresh,
ChevronDown,
ChevronRight,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowDown,
Grid,
Window,
Folder,
FolderOpen,
Moon,
Triangle,
ZoomIn,
ZoomOut,
/// Dirección de un aspecto: aplicando (◄) / separando (►).
Applying,
Separating,
}
/// Icono de chrome como mini-canvas `px` del color dado.
pub(crate) fn icon_view<Msg: Clone + 'static>(icon: Icon, px: f32, color: Color) -> View<Msg> {
cell(icon_cmds(icon, px / 2.0, px / 2.0, px, rgba(color)), px)
}
// =====================================================================
// Iconos coloridos para el árbol (grupo / contacto / tipo de carta)
// =====================================================================
const fn rg(r: f32, g: f32, b: f32) -> Rgba {
Rgba { r, g, b, a: 1.0 }
}
/// Carpeta (grupo) — ámbar.
pub(crate) fn group_icon_view<Msg: Clone + 'static>(px: f32) -> View<Msg> {
let (cx, cy, r) = (px / 2.0, px / 2.0, px * 0.5);
let body = rg(0.96, 0.78, 0.33);
let tab = rg(0.86, 0.62, 0.22);
let left = cx - r * 0.66;
let right = cx + r * 0.66;
let top = cy - r * 0.28;
let bot = cy + r * 0.46;
let cmds = vec![
// Pestaña de la carpeta (atrás).
DrawCommand::Polygon {
points: vec![
(left, top - r * 0.22),
(left + r * 0.5, top - r * 0.22),
(left + r * 0.66, top),
(left, top),
],
fill: Some(tab),
stroke: None,
stroke_w: 0.0,
},
// Cuerpo.
DrawCommand::Polygon {
points: vec![(left, top), (right, top), (right, bot), (left, bot)],
fill: Some(body),
stroke: None,
stroke_w: 0.0,
},
];
cell(cmds, px)
}
/// Persona (contacto) — turquesa.
pub(crate) fn contact_icon_view<Msg: Clone + 'static>(px: f32) -> View<Msg> {
let (cx, cy, r) = (px / 2.0, px / 2.0, px * 0.5);
let c = rg(0.32, 0.72, 0.82);
let cmds = vec![
DrawCommand::Circle {
cx,
cy: cy - r * 0.34,
r: r * 0.26,
stroke: None,
fill: Some(c),
stroke_w: 0.0,
},
DrawCommand::Polygon {
points: vec![
(cx - r * 0.46, cy + r * 0.58),
(cx - r * 0.30, cy + r * 0.04),
(cx + r * 0.30, cy + r * 0.04),
(cx + r * 0.46, cy + r * 0.58),
],
fill: Some(c),
stroke: None,
stroke_w: 0.0,
},
];
cell(cmds, px)
}
/// Icono colorido del tipo de carta (rueda natal, torta de cumpleaños
/// para la revolución solar, luna para la lunar, etc.).
pub(crate) fn chart_kind_colored<Msg: Clone + 'static>(kind: ChartKind, px: f32) -> View<Msg> {
let (cx, cy, r) = (px / 2.0, px / 2.0, px * 0.5);
let w = sw(px);
let cmds = match kind {
ChartKind::SolarReturn => birthday_cake_cmds(cx, cy, r),
ChartKind::LunarReturn => {
planet_commands("moon", cx, cy, px * 0.78, rg(0.80, 0.84, 0.92), w)
}
ChartKind::Natal | ChartKind::Mundane => natal_wheel_cmds(cx, cy, r, w),
ChartKind::Transit | ChartKind::Synastry | ChartKind::Composite | ChartKind::Davison => {
vec![
DrawCommand::Circle {
cx,
cy,
r: r * 0.42,
stroke: Some(rg(0.58, 0.52, 0.86)),
fill: None,
stroke_w: w,
},
DrawCommand::Circle {
cx,
cy,
r: r * 0.22,
stroke: Some(rg(0.36, 0.74, 0.82)),
fill: None,
stroke_w: w,
},
]
}
_ => vec![
DrawCommand::Circle {
cx,
cy,
r: r * 0.40,
stroke: Some(rg(0.95, 0.70, 0.35)),
fill: None,
stroke_w: w,
},
DrawCommand::Circle {
cx,
cy,
r: r * 0.10,
stroke: None,
fill: Some(rg(0.95, 0.70, 0.35)),
stroke_w: 0.0,
},
],
};
cell(cmds, px)
}
/// Ruedita natal: aro violeta + cruz de ejes dorada + punto central.
fn natal_wheel_cmds(cx: f32, cy: f32, r: f32, w: f32) -> Vec<DrawCommand> {
let ring = rg(0.62, 0.52, 0.88);
let cross = rg(0.95, 0.80, 0.42);
let rr = r * 0.44;
vec![
DrawCommand::Circle {
cx,
cy,
r: rr,
stroke: Some(ring),
fill: None,
stroke_w: w,
},
DrawCommand::Line {
x1: cx - rr,
y1: cy,
x2: cx + rr,
y2: cy,
color: cross,
width: w * 0.8,
dash: None,
},
DrawCommand::Line {
x1: cx,
y1: cy - rr,
x2: cx,
y2: cy + rr,
color: cross,
width: w * 0.8,
dash: None,
},
DrawCommand::Circle {
cx,
cy,
r: r * 0.09,
stroke: None,
fill: Some(cross),
stroke_w: 0.0,
},
]
}
/// Torta de cumpleaños (revolución solar): plato, bizcocho, glaseado
/// rosa, velas y llamas.
fn birthday_cake_cmds(cx: f32, cy: f32, r: f32) -> Vec<DrawCommand> {
let plate = rg(0.80, 0.82, 0.88);
let cake = rg(0.82, 0.58, 0.40);
let frosting = rg(0.96, 0.62, 0.72);
let candle = rg(0.45, 0.70, 0.95);
let flame = rg(0.99, 0.75, 0.25);
let rect = |x0: f32, y0: f32, x1: f32, y1: f32, c: Rgba| DrawCommand::Polygon {
points: vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)],
fill: Some(c),
stroke: None,
stroke_w: 0.0,
};
let mut out = vec![
// Plato.
rect(cx - r * 0.7, cy + r * 0.5, cx + r * 0.7, cy + r * 0.62, plate),
// Bizcocho.
rect(cx - r * 0.55, cy - r * 0.02, cx + r * 0.55, cy + r * 0.5, cake),
// Glaseado.
rect(cx - r * 0.55, cy - r * 0.18, cx + r * 0.55, cy - r * 0.02, frosting),
];
// Velas + llamas.
for dx in [-r * 0.28, 0.0, r * 0.28] {
out.push(DrawCommand::Line {
x1: cx + dx,
y1: cy - r * 0.18,
x2: cx + dx,
y2: cy - r * 0.52,
color: candle,
width: (r * 0.12).max(1.4),
dash: None,
});
out.push(DrawCommand::Circle {
cx: cx + dx,
cy: cy - r * 0.62,
r: r * 0.10,
stroke: None,
fill: Some(flame),
stroke_w: 0.0,
});
}
out
}
fn ring(cx: f32, cy: f32, r: f32, c: Rgba, box_px: f32) -> DrawCommand {
DrawCommand::Circle {
cx,
cy,
r,
stroke: Some(c),
fill: None,
stroke_w: sw(box_px),
}
}
/// Geometría de cada icono, centrada en `(cx, cy)` dentro de una caja de
/// lado `box_px`. Coordenadas absolutas dentro de `[0, box_px]`.
fn icon_cmds(icon: Icon, cx: f32, cy: f32, box_px: f32, c: Rgba) -> Vec<DrawCommand> {
let r = box_px * 0.5;
let w = sw(box_px);
let line = |x1: f32, y1: f32, x2: f32, y2: f32| DrawCommand::Line {
x1,
y1,
x2,
y2,
color: c,
width: w,
dash: None,
};
match icon {
Icon::Plus => vec![
line(cx - r * 0.6, cy, cx + r * 0.6, cy),
line(cx, cy - r * 0.6, cx, cy + r * 0.6),
],
Icon::Close => vec![
line(cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55),
line(cx + r * 0.55, cy - r * 0.55, cx - r * 0.55, cy + r * 0.55),
],
Icon::ChevronDown => vec![
line(cx - r * 0.5, cy - r * 0.25, cx, cy + r * 0.3),
line(cx, cy + r * 0.3, cx + r * 0.5, cy - r * 0.25),
],
Icon::ChevronRight => vec![
line(cx - r * 0.25, cy - r * 0.5, cx + r * 0.3, cy),
line(cx + r * 0.3, cy, cx - r * 0.25, cy + r * 0.5),
],
Icon::ArrowLeft => vec![
line(cx + r * 0.6, cy, cx - r * 0.55, cy),
line(cx - r * 0.55, cy, cx - r * 0.05, cy - r * 0.45),
line(cx - r * 0.55, cy, cx - r * 0.05, cy + r * 0.45),
],
Icon::ArrowRight | Icon::Separating => vec![
line(cx - r * 0.6, cy, cx + r * 0.55, cy),
line(cx + r * 0.55, cy, cx + r * 0.05, cy - r * 0.45),
line(cx + r * 0.55, cy, cx + r * 0.05, cy + r * 0.45),
],
Icon::ArrowUp => vec![
line(cx, cy + r * 0.6, cx, cy - r * 0.55),
line(cx, cy - r * 0.55, cx - r * 0.45, cy - r * 0.05),
line(cx, cy - r * 0.55, cx + r * 0.45, cy - r * 0.05),
],
Icon::ArrowDown => vec![
line(cx, cy - r * 0.6, cx, cy + r * 0.55),
line(cx, cy + r * 0.55, cx - r * 0.45, cy + r * 0.05),
line(cx, cy + r * 0.55, cx + r * 0.45, cy + r * 0.05),
],
// Aplicando: triángulo izquierdo relleno.
Icon::Applying => vec![DrawCommand::Polygon {
points: vec![
(cx - r * 0.5, cy),
(cx + r * 0.4, cy - r * 0.5),
(cx + r * 0.4, cy + r * 0.5),
],
fill: Some(c),
stroke: None,
stroke_w: 0.0,
}],
Icon::Triangle => vec![DrawCommand::Polygon {
points: vec![
(cx, cy - r * 0.6),
(cx + r * 0.6, cy + r * 0.5),
(cx - r * 0.6, cy + r * 0.5),
],
fill: None,
stroke: Some(c),
stroke_w: w,
}],
Icon::Pencil => vec![
// Cuerpo diagonal del lápiz + punta.
line(cx - r * 0.45, cy + r * 0.5, cx + r * 0.35, cy - r * 0.4),
line(cx + r * 0.35, cy - r * 0.4, cx + r * 0.5, cy - r * 0.55),
line(cx - r * 0.45, cy + r * 0.5, cx - r * 0.6, cy + r * 0.62),
],
Icon::Scissors => {
let h = box_px * 0.07;
vec![
DrawCommand::Circle {
cx: cx - r * 0.35,
cy: cy + r * 0.45,
r: h,
stroke: Some(c),
fill: None,
stroke_w: w * 0.8,
},
DrawCommand::Circle {
cx: cx + r * 0.35,
cy: cy + r * 0.45,
r: h,
stroke: Some(c),
fill: None,
stroke_w: w * 0.8,
},
line(cx - r * 0.28, cy + r * 0.38, cx + r * 0.55, cy - r * 0.55),
line(cx + r * 0.28, cy + r * 0.38, cx - r * 0.55, cy - r * 0.55),
]
}
Icon::Clipboard => {
let bw = r * 0.55;
let top = cy - r * 0.55;
let bot = cy + r * 0.6;
vec![
DrawCommand::Polygon {
points: vec![
(cx - bw, top),
(cx + bw, top),
(cx + bw, bot),
(cx - bw, bot),
],
fill: None,
stroke: Some(c),
stroke_w: w,
},
// Pestaña superior.
DrawCommand::Polygon {
points: vec![
(cx - r * 0.22, top - r * 0.18),
(cx + r * 0.22, top - r * 0.18),
(cx + r * 0.22, top + r * 0.1),
(cx - r * 0.22, top + r * 0.1),
],
fill: Some(c),
stroke: None,
stroke_w: 0.0,
},
]
}
Icon::Trash => {
let bw = r * 0.45;
let top = cy - r * 0.35;
let bot = cy + r * 0.6;
vec![
// Cuerpo (trapecio).
DrawCommand::Polygon {
points: vec![
(cx - bw, top),
(cx + bw, top),
(cx + bw * 0.78, bot),
(cx - bw * 0.78, bot),
],
fill: None,
stroke: Some(c),
stroke_w: w,
},
// Tapa.
line(cx - r * 0.62, top, cx + r * 0.62, top),
// Asa.
line(cx - r * 0.2, top, cx - r * 0.12, cy - r * 0.6),
line(cx + r * 0.2, top, cx + r * 0.12, cy - r * 0.6),
line(cx - r * 0.12, cy - r * 0.6, cx + r * 0.12, cy - r * 0.6),
]
}
Icon::Gear => {
let mut out = vec![
ring(cx, cy, r * 0.42, c, box_px),
DrawCommand::Circle {
cx,
cy,
r: r * 0.16,
stroke: None,
fill: Some(c),
stroke_w: 0.0,
},
];
for k in 0..8 {
let a = std::f32::consts::PI * (k as f32) / 4.0;
let (s, co) = a.sin_cos();
out.push(line(
cx + co * r * 0.42,
cy + s * r * 0.42,
cx + co * r * 0.7,
cy + s * r * 0.7,
));
}
out
}
Icon::Star => {
let mut pts = Vec::with_capacity(10);
for k in 0..10 {
let a = std::f32::consts::PI * (k as f32) / 5.0 - std::f32::consts::FRAC_PI_2;
let rad = if k % 2 == 0 { r * 0.72 } else { r * 0.3 };
pts.push((cx + a.cos() * rad, cy + a.sin() * rad));
}
vec![DrawCommand::Polygon {
points: pts,
fill: None,
stroke: Some(c),
stroke_w: w,
}]
}
Icon::Refresh => {
// Arco ~270° + cabeza de flecha.
let rr = r * 0.5;
let d = format!(
"M {} {} A {rr} {rr} 0 1 1 {} {}",
cx,
cy - rr,
cx + rr,
cy,
);
vec![
DrawCommand::Path {
d,
stroke: Some(c),
fill: None,
stroke_w: w,
},
line(cx + rr, cy, cx + rr * 0.45, cy - rr * 0.55),
line(cx + rr, cy, cx + rr * 1.05, cy - rr * 0.55),
]
}
Icon::Grid => {
let s = r * 0.6;
vec![
DrawCommand::Polygon {
points: vec![
(cx - s, cy - s),
(cx + s, cy - s),
(cx + s, cy + s),
(cx - s, cy + s),
],
fill: None,
stroke: Some(c),
stroke_w: w,
},
line(cx, cy - s, cx, cy + s),
line(cx - s, cy, cx + s, cy),
]
}
Icon::Window => {
let s = r * 0.6;
vec![
DrawCommand::Polygon {
points: vec![
(cx - s, cy - s),
(cx + s, cy - s),
(cx + s, cy + s),
(cx - s, cy + s),
],
fill: None,
stroke: Some(c),
stroke_w: w,
},
line(cx - s, cy - s * 0.45, cx + s, cy - s * 0.45),
]
}
Icon::Folder | Icon::FolderOpen => {
let left = cx - r * 0.62;
let right = cx + r * 0.62;
let top = cy - r * 0.32;
let bot = cy + r * 0.45;
let mut out = vec![DrawCommand::Polygon {
points: vec![
(left, top),
(cx - r * 0.1, top),
(cx + r * 0.02, top - r * 0.18),
(right, top - r * 0.18),
(right, bot),
(left, bot),
],
fill: None,
stroke: Some(c),
stroke_w: w,
}];
if icon == Icon::FolderOpen {
out.push(line(left, cy, right, cy));
}
out
}
Icon::Moon => planet_commands("moon", cx, cy, box_px * 0.82, c, w),
Icon::ZoomIn | Icon::ZoomOut => {
let lens = r * 0.38;
let lcx = cx - r * 0.12;
let lcy = cy - r * 0.12;
let mut out = vec![
DrawCommand::Circle {
cx: lcx,
cy: lcy,
r: lens,
stroke: Some(c),
fill: None,
stroke_w: w,
},
line(lcx + lens * 0.7, lcy + lens * 0.7, cx + r * 0.6, cy + r * 0.6),
line(lcx - lens * 0.5, lcy, lcx + lens * 0.5, lcy),
];
if icon == Icon::ZoomIn {
out.push(line(lcx, lcy - lens * 0.5, lcx, lcy + lens * 0.5));
}
out
}
}
}
@@ -0,0 +1,229 @@
//! Biblioteca de cartas sobre `cosmos-store` (SQLite): abre el store,
//! lo siembra/migra en la primera corrida y arma un **snapshot
//! jerárquico** plano (grupo → subgrupos → contactos → cartas) que el
//! árbol izquierdo pinta como un explorador de archivos clásico.
//!
//! El árbol no consulta SQLite por frame: `snapshot()` se llama al
//! arrancar (y tras mutaciones) y deja un `Vec<NavNode>` cacheado en el
//! `Model`. Cargar una carta sí va al store por id (`get_chart`).
pub(crate) use cosmos_model::ChartKind;
use cosmos_model::{Chart, ChartId, ContactId, GroupId};
use cosmos_store::Store;
use crate::persist::{list_cards, load_card};
/// Parsea la parte `<id>` de una clave `"<prefijo>:<id>"`.
fn key_id(key: &str, prefix: &str) -> Option<String> {
key.strip_prefix(prefix).map(|s| s.to_string())
}
pub(crate) fn parse_group_key(key: &str) -> Option<GroupId> {
key_id(key, "g:")?.parse().ok()
}
pub(crate) fn parse_contact_key(key: &str) -> Option<ContactId> {
key_id(key, "c:")?.parse().ok()
}
pub(crate) fn parse_chart_key(key: &str) -> Option<ChartId> {
key_id(key, "h:")?.parse().ok()
}
/// Borra un contacto y todas sus cartas.
pub(crate) fn delete_contact_recursive(store: &Store, id: ContactId) {
for ch in store.list_charts(id).unwrap_or_default() {
let _ = store.delete_chart(ch.id);
}
let _ = store.delete_contact(id);
}
/// Borra un grupo, sus subgrupos, contactos y cartas (en cascada manual —
/// `delete_group` del store no cascadea).
pub(crate) fn delete_group_recursive(store: &Store, id: GroupId) {
for sub in store.list_groups(Some(id)).unwrap_or_default() {
delete_group_recursive(store, sub.id);
}
for c in store.list_contacts(Some(id)).unwrap_or_default() {
delete_contact_recursive(store, c.id);
}
let _ = store.delete_group(id);
}
/// Tipo de nodo del árbol de datos.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NavKind {
Group,
Contact,
Chart,
}
/// Un nodo del snapshot jerárquico, ya aplanado en orden de display con
/// su profundidad. La visibilidad real (colapsado/expandido) la resuelve
/// el árbol contra el set de nodos expandidos del `Model`.
#[derive(Debug, Clone)]
pub(crate) struct NavNode {
/// Clave única y estable: `"g:<id>"`, `"c:<id>"`, `"h:<id>"`.
pub(crate) key: String,
/// Clave del padre (grupo o contacto). `None` = raíz.
pub(crate) parent: Option<String>,
pub(crate) depth: usize,
pub(crate) label: String,
pub(crate) kind: NavKind,
/// Id de la carta (sólo en nodos `Chart`) para `get_chart`.
pub(crate) chart_id: Option<String>,
/// Tipo de carta (sólo en nodos `Chart`) — define su icono en el árbol.
pub(crate) chart_kind: Option<ChartKind>,
}
/// Abre (o crea) el store SQLite en el config dir de wawa. `None` si no
/// hay config dir o SQLite falla — el árbol queda vacío pero la app sigue.
pub(crate) fn open_store() -> Option<Store> {
let path = wawa_config::config_dir()?.join("cosmos.db");
Store::open(&path)
.map_err(|e| eprintln!("cosmos · store: no se pudo abrir {path:?}: {e}"))
.ok()
}
/// Siembra el store si está vacío: migra las cartas JSON existentes
/// (`cosmos-charts/*.json`) bajo un grupo «Cartas» / contacto
/// «Importadas»; si no hay ninguna, crea una de ejemplo desde `fallback`.
pub(crate) fn ensure_seed(store: &Store, fallback: &Chart) {
let empty = store
.list_groups(None)
.map(|g| g.is_empty())
.unwrap_or(true)
&& store
.list_all_charts()
.map(|c| c.is_empty())
.unwrap_or(true);
if !empty {
return;
}
let group = match store.create_group(None, "Cartas", None) {
Ok(g) => g,
Err(e) => {
eprintln!("cosmos · store: seed grupo: {e}");
return;
}
};
let contact = match store.create_contact(Some(group.id), "Importadas", None) {
Ok(c) => c,
Err(e) => {
eprintln!("cosmos · store: seed contacto: {e}");
return;
}
};
// Migrar la biblioteca JSON existente.
let mut migradas = 0usize;
for name in list_cards() {
if let Some(ch) = load_card(&name) {
if store
.create_chart(
contact.id,
ChartKind::Natal,
&ch.label,
&ch.birth_data,
&ch.config,
None,
)
.is_ok()
{
migradas += 1;
}
}
}
// Si no había nada que migrar, sembrar la carta actual de ejemplo.
if migradas == 0 {
let _ = store.create_chart(
contact.id,
ChartKind::Natal,
&fallback.label,
&fallback.birth_data,
&fallback.config,
None,
);
}
}
/// Arma el snapshot jerárquico completo (grupos anidados → contactos →
/// cartas) en orden de display.
pub(crate) fn snapshot(store: &Store) -> Vec<NavNode> {
let mut out = Vec::new();
walk_groups(store, None, None, 0, &mut out);
// Contactos sin grupo, a la raíz.
add_contacts(store, None, None, 0, &mut out);
out
}
fn walk_groups(
store: &Store,
parent_id: Option<GroupId>,
parent_key: Option<String>,
depth: usize,
out: &mut Vec<NavNode>,
) {
let groups = store.list_groups(parent_id).unwrap_or_default();
for g in groups {
let gkey = format!("g:{}", g.id);
out.push(NavNode {
key: gkey.clone(),
parent: parent_key.clone(),
depth,
label: g.name.clone(),
kind: NavKind::Group,
chart_id: None,
chart_kind: None,
});
// Subgrupos primero, luego contactos del grupo.
walk_groups(store, Some(g.id), Some(gkey.clone()), depth + 1, out);
add_contacts(store, Some(g.id), Some(gkey.clone()), depth + 1, out);
}
}
fn add_contacts(
store: &Store,
group_id: Option<GroupId>,
parent_key: Option<String>,
depth: usize,
out: &mut Vec<NavNode>,
) {
let contacts = store.list_contacts(group_id).unwrap_or_default();
for c in contacts {
let ckey = format!("c:{}", c.id);
out.push(NavNode {
key: ckey.clone(),
parent: parent_key.clone(),
depth,
label: c.name.clone(),
kind: NavKind::Contact,
chart_id: None,
chart_kind: None,
});
let charts = store.list_charts(c.id).unwrap_or_default();
for ch in charts {
out.push(NavNode {
key: format!("h:{}", ch.id),
parent: Some(ckey.clone()),
depth: depth + 1,
label: ch.label.clone(),
kind: NavKind::Chart,
chart_id: Some(ch.id.to_string()),
chart_kind: Some(ch.kind),
});
}
}
}
/// Claves de todos los nodos contenedores (grupos + contactos) — usado
/// para expandir todo en la primera carga.
pub(crate) fn container_keys(nodes: &[NavNode]) -> Vec<String> {
nodes
.iter()
.filter(|n| n.kind != NavKind::Chart)
.map(|n| n.key.clone())
.collect()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,884 @@
//! Modelo del shell, mensajes del bucle Elm y las taxonomías de
//! vistas/capas/menús.
//!
//! El shell es un IDE astronómico/astrológico: barra de menú principal
//! arriba, árbol de navegación a la izquierda (cartas + catálogo de
//! gráficas), pestañas en el área central (una por gráfica abierta) y
//! barra de estado abajo. Menús contextuales (click derecho) sobre la
//! rueda. Todo lo configurable vive en la vista `Configuración` y en el
//! menú `Capas`/`Armónico`.
use std::collections::HashSet;
use cosmos_engine::{Corpus, PipelineRequest};
use cosmos_model::Chart;
use cosmos_render::RenderModel;
use cosmos_store::Store;
use llimphi_motion::Tween;
use llimphi_theme::Theme;
use llimphi_widget_text_input::TextInputState;
use serde::{Deserialize, Serialize};
use crate::astroview::AstroState;
use crate::library::NavNode;
pub(crate) const WHEEL_SIZE: f32 = 720.0;
pub(crate) const NAV_WIDTH: f32 = 232.0;
pub(crate) const TOOLS_WIDTH: f32 = 360.0;
/// Rail de categorías del panel derecho (tabs verticales estilo Photoshop).
pub(crate) const TOOLS_RAIL_W: f32 = 40.0;
pub(crate) const MENU_BAR_H: f32 = 30.0;
pub(crate) const TAB_BAR_H: f32 = 30.0;
pub(crate) const STATUS_H: f32 = 22.0;
pub(crate) const HARMONICS: &[u32] = &[1, 4, 5, 7, 9];
/// Límites de arrastre de los paneles laterales guardables.
pub(crate) const NAV_MIN: f32 = 160.0;
pub(crate) const NAV_MAX: f32 = 460.0;
pub(crate) const TOOLS_MIN: f32 = 240.0;
pub(crate) const TOOLS_MAX: f32 = 620.0;
// =====================================================================
// Tipo de gráfica del centro (switcheable)
// =====================================================================
/// Qué dibuja el panel central. El usuario alterna con un segmented en la
/// cabecera del centro. La rueda estándar es el default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ChartView {
/// Rueda natal 2D clásica (zodíaco + casas + aspectos).
#[default]
Estandar,
/// Dial uraniano de 90° (Escuela de Hamburgo / Witte-Ebertin).
Uraniano,
/// Rueda armónica (Cochrane / Addey): longitudes × N mod 360.
Armonica,
/// Mapa equirectangular (Astrocartografía, Jim Lewis).
Carto,
/// Esfera celeste 3D (wireframe).
Esfera3d,
/// Cielo como lo ve el observador (alt/az).
Cielo,
/// Hoja imprimible: cabecera de la carta + tabla de aspectos en B/N,
/// con un botón para mandarla a imprimir (vía el navegador del SO).
Impresion,
}
impl ChartView {
pub(crate) fn title(self) -> &'static str {
match self {
ChartView::Estandar => "Estándar",
ChartView::Uraniano => "Dial 90°",
ChartView::Armonica => "Armónica",
ChartView::Carto => "Astrocarto",
ChartView::Esfera3d => "3D",
ChartView::Cielo => "Cielo",
ChartView::Impresion => "Hoja",
}
}
pub(crate) fn all() -> &'static [ChartView] {
&[
ChartView::Estandar,
ChartView::Uraniano,
ChartView::Armonica,
ChartView::Carto,
ChartView::Esfera3d,
ChartView::Cielo,
ChartView::Impresion,
]
}
}
// =====================================================================
// Dock — items acoplables que viven en el sidebar izquierdo o derecho
// =====================================================================
/// Lado del dock.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DockSide {
Left,
Right,
}
/// Un panel acoplable: el árbol de datos o una de las categorías de
/// herramientas. Cada uno es una pestaña (diente del rail) que puede
/// vivir en cualquiera de los dos sidebars y arrastrarse entre ellos.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum DockItem {
Arbol,
Principal,
Analisis,
Astronomia,
Sistema,
}
impl DockItem {
/// El item de dock que corresponde a una categoría de herramientas.
pub(crate) fn from_tool_cat(tc: ToolCat) -> DockItem {
match tc {
ToolCat::Principal => DockItem::Principal,
ToolCat::Analisis => DockItem::Analisis,
ToolCat::Astronomia => DockItem::Astronomia,
ToolCat::Sistema => DockItem::Sistema,
}
}
/// La categoría de herramientas asociada (None para el árbol).
pub(crate) fn tool_cat(self) -> Option<ToolCat> {
match self {
DockItem::Arbol => None,
DockItem::Principal => Some(ToolCat::Principal),
DockItem::Analisis => Some(ToolCat::Analisis),
DockItem::Astronomia => Some(ToolCat::Astronomia),
DockItem::Sistema => Some(ToolCat::Sistema),
}
}
pub(crate) fn to_u64(self) -> u64 {
match self {
DockItem::Arbol => 0,
DockItem::Principal => 1,
DockItem::Analisis => 2,
DockItem::Astronomia => 3,
DockItem::Sistema => 4,
}
}
pub(crate) fn from_u64(v: u64) -> Option<DockItem> {
Some(match v {
0 => DockItem::Arbol,
1 => DockItem::Principal,
2 => DockItem::Analisis,
3 => DockItem::Astronomia,
4 => DockItem::Sistema,
_ => return None,
})
}
}
/// Reparto por defecto: la biblioteca a la izquierda, las herramientas a
/// la derecha.
pub(crate) fn default_dock_left() -> Vec<DockItem> {
vec![DockItem::Arbol]
}
pub(crate) fn default_dock_right() -> Vec<DockItem> {
vec![
DockItem::Principal,
DockItem::Analisis,
DockItem::Astronomia,
DockItem::Sistema,
]
}
/// Por debajo de este ancho de ventana los sidebars se colapsan a sólo el
/// rail (auto-colapso responsive).
pub(crate) const DOCK_COLLAPSE_W: f32 = 920.0;
// =====================================================================
// Categorías del panel de herramientas (derecha)
// =====================================================================
/// Cada categoría es un contenedor de paneles que se intercambia con las
/// tabs verticales. `Principal` es la más usada (aspectos + cuerpos) y el
/// default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ToolCat {
/// Lo más usado: aspectos (geocéntrico + topocéntrico) y cuerpos.
#[default]
Principal,
/// Análisis astrológico avanzado (cualidades, uraniano, lotes…).
Analisis,
/// Lecturas astronómicas (cielo, orto/ocaso, mareas, eclipses…).
Astronomia,
/// Configuración del visor.
Sistema,
}
impl ToolCat {
pub(crate) fn title(self) -> &'static str {
match self {
ToolCat::Principal => "Principal",
ToolCat::Analisis => "Análisis",
ToolCat::Astronomia => "Astronomía",
ToolCat::Sistema => "Sistema",
}
}
pub(crate) fn all() -> &'static [ToolCat] {
&[
ToolCat::Principal,
ToolCat::Analisis,
ToolCat::Astronomia,
ToolCat::Sistema,
]
}
/// Paneles que viven en esta categoría, en orden de aparición.
pub(crate) fn panels(self) -> &'static [ToolPanel] {
match self {
ToolCat::Principal => &[
ToolPanel::Carta,
ToolPanel::Aspectos,
ToolPanel::Cuerpos,
],
ToolCat::Analisis => &[
ToolPanel::Cualidades,
ToolPanel::Uraniano,
ToolPanel::BoxGraph,
ToolPanel::Lotes,
ToolPanel::EstrellasFijas,
ToolPanel::PuntosMedios,
ToolPanel::Corpus,
],
ToolCat::Astronomia => &[
ToolPanel::Cielo,
ToolPanel::OrtoOcaso,
ToolPanel::Sundial,
ToolPanel::Mareas,
ToolPanel::Eclipses,
ToolPanel::Efemerides,
],
ToolCat::Sistema => &[ToolPanel::Rectificador, ToolPanel::Configuracion],
}
}
}
// =====================================================================
// Paneles de herramientas (colapsables) del panel derecho
// =====================================================================
/// Cada panel es una sección colapsable (acordeón) dentro de una
/// categoría. `Aspectos` y `AspectosTopo` arrancan expandidos.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ToolPanel {
Carta,
Aspectos,
AspectosTopo,
Cuerpos,
Cualidades,
Uraniano,
BoxGraph,
Lotes,
EstrellasFijas,
PuntosMedios,
Corpus,
Cielo,
OrtoOcaso,
Sundial,
Mareas,
Eclipses,
Efemerides,
Rectificador,
Configuracion,
}
impl ToolPanel {
pub(crate) fn title(self) -> &'static str {
match self {
ToolPanel::Carta => "Datos de la carta",
ToolPanel::Aspectos => "Aspectos (geo · topo)",
ToolPanel::AspectosTopo => "Aspectos (geo · topo)",
ToolPanel::Cuerpos => "Cuerpos",
ToolPanel::Cualidades => "Cualidades",
ToolPanel::Uraniano => "Uraniano",
ToolPanel::BoxGraph => "Aspectario",
ToolPanel::Lotes => "Lotes",
ToolPanel::EstrellasFijas => "Estrellas fijas",
ToolPanel::PuntosMedios => "Puntos medios",
ToolPanel::Corpus => "Interpretación",
ToolPanel::Cielo => "Cielo (alt/az)",
ToolPanel::OrtoOcaso => "Orto y ocaso",
ToolPanel::Sundial => "Reloj de sol",
ToolPanel::Mareas => "Mareas",
ToolPanel::Eclipses => "Eclipses",
ToolPanel::Efemerides => "Efemérides",
ToolPanel::Rectificador => "Rectificador de hora",
ToolPanel::Configuracion => "Configuración",
}
}
/// Paneles abiertos por defecto en una instalación nueva: los dos
/// primeros de cada categoría. El estado luego se recuerda por panel
/// (se persiste en cada toggle).
pub(crate) fn defaults_expanded() -> Vec<ToolPanel> {
ToolCat::all()
.iter()
.flat_map(|c| c.panels().iter().take(2).copied())
.collect()
}
}
/// Origen X de la primera entrada de menú (después del pill "cosmos").
pub(crate) const MENU_X0: f32 = 84.0;
/// Ancho fijo de cada botón de la barra de menú — fija el anclaje del
/// dropdown sin medir el texto.
pub(crate) const MENU_BTN_W: f32 = 84.0;
/// Viewport asumido para clamping de overlays. La ventana puede
/// redimensionarse; usamos el tamaño inicial como aproximación (el
/// trait `App` no expone resize). Suficiente para que el dropdown no se
/// salga por abajo/derecha en el tamaño por defecto.
pub(crate) const VIEWPORT: (f32, f32) = (1200.0, 860.0);
// =====================================================================
// Cartas abiertas (tabs del centro) — multi-carta
// =====================================================================
/// Una carta abierta como pestaña del centro. Guarda la carta completa
/// para poder alternar sin volver al store (y soporta cartas «scratch»
/// sin id). `render`/`astro` se recomputan al activar la pestaña.
#[derive(Debug, Clone)]
pub(crate) struct OpenTab {
/// Id de la carta en el store (`None` = scratch / ejemplo no guardado).
pub(crate) id: Option<String>,
pub(crate) chart: Chart,
/// Render cacheado de esta carta — permite pintar varias en mosaico
/// sin recomputar por frame. Se recomputa al cambiar capas/armónico.
pub(crate) render: RenderModel,
}
impl OpenTab {
pub(crate) fn label(&self) -> &str {
&self.chart.label
}
}
// =====================================================================
// Capas (overlays) que se superponen a la carta natal
// =====================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum OverlayKind {
Transit,
Progression,
SolarArc,
Uranian,
Lots,
FixedStars,
Midpoints,
/// Capa ascensional/topocéntrica: planetas en coordenadas del lugar.
/// Activa por default — habilita la tabla de aspectos topocéntricos.
Topocentric,
}
impl OverlayKind {
pub(crate) fn all() -> &'static [OverlayKind] {
&[
OverlayKind::Transit,
OverlayKind::Progression,
OverlayKind::SolarArc,
OverlayKind::Uranian,
OverlayKind::Lots,
OverlayKind::FixedStars,
OverlayKind::Midpoints,
OverlayKind::Topocentric,
]
}
/// Nombre legible en español para el menú `Capas` y la vista de
/// configuración. (Los keys fluent siguen en `cosmos-overlay-*` pero
/// el chrome nuevo usa literales para no acoplar a la i18n.)
pub(crate) fn nombre(self) -> &'static str {
match self {
OverlayKind::Transit => "Tránsitos",
OverlayKind::Progression => "Progresiones",
OverlayKind::SolarArc => "Arco solar",
OverlayKind::Uranian => "Uraniano",
OverlayKind::Lots => "Lotes",
OverlayKind::FixedStars => "Estrellas fijas",
OverlayKind::Midpoints => "Puntos medios",
OverlayKind::Topocentric => "Topocéntrico",
}
}
pub(crate) fn to_request(self, target_age: f64) -> PipelineRequest {
match self {
OverlayKind::Transit => PipelineRequest::Transit,
OverlayKind::Progression => PipelineRequest::SecondaryProgression {
target_age_years: target_age,
},
OverlayKind::SolarArc => PipelineRequest::SolarArc {
target_age_years: target_age,
},
OverlayKind::Uranian => PipelineRequest::Uranian,
OverlayKind::Lots => PipelineRequest::Lots,
OverlayKind::FixedStars => PipelineRequest::FixedStars,
OverlayKind::Midpoints => PipelineRequest::Midpoints,
OverlayKind::Topocentric => PipelineRequest::Topocentric,
}
}
}
// =====================================================================
// Menú principal y opciones configurables
// =====================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MenuKind {
Archivo,
Editar,
Vista,
Capas,
Armonico,
Ayuda,
}
impl MenuKind {
pub(crate) fn label(self) -> &'static str {
match self {
MenuKind::Archivo => "Archivo",
MenuKind::Editar => "Editar",
MenuKind::Vista => "Vista",
MenuKind::Capas => "Capas",
MenuKind::Armonico => "Armónico",
MenuKind::Ayuda => "Ayuda",
}
}
pub(crate) fn order() -> &'static [MenuKind] {
&[
MenuKind::Archivo,
MenuKind::Editar,
MenuKind::Vista,
MenuKind::Capas,
MenuKind::Armonico,
MenuKind::Ayuda,
]
}
/// X de anclaje del dropdown bajo el botón de la barra.
pub(crate) fn anchor_x(self) -> f32 {
let idx = Self::order().iter().position(|m| *m == self).unwrap_or(0);
MENU_X0 + idx as f32 * MENU_BTN_W
}
}
/// Opción booleana del wheel — togglada desde el menú contextual y la
/// vista de configuración.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WheelOpt {
MinorAspects,
CoordLabels,
Dial3d,
AscCross,
}
/// Configuración persistente del visor: tema, opciones del wheel,
/// instante de cómputo astronómico.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CosmosConfig {
pub(crate) theme_dark: bool,
/// Modo impresión: tema blanco y negro de alto contraste. Cuando está
/// activo prevalece sobre `theme_dark` (que sólo recuerda la base
/// claro/oscuro a la que volver). `#[serde(default)]` para no romper
/// configs viejas que no lo traían.
#[serde(default)]
pub(crate) print_mode: bool,
pub(crate) minor_aspects: bool,
pub(crate) coord_labels: bool,
pub(crate) dial_3d: bool,
pub(crate) asc_cross: bool,
pub(crate) rot_offset_deg: f32,
/// `true` = las gráficas astronómicas usan el instante actual;
/// `false` = usan el instante de la carta cargada.
pub(crate) use_now: bool,
}
impl Default for CosmosConfig {
fn default() -> Self {
Self {
theme_dark: true,
print_mode: false,
minor_aspects: false,
coord_labels: true,
dial_3d: true,
asc_cross: true,
rot_offset_deg: 0.0,
use_now: false,
}
}
}
impl CosmosConfig {
/// Índice del segmented de tema: 0 = Oscuro, 1 = Claro, 2 = Impresión.
pub(crate) fn theme_idx(&self) -> usize {
if self.print_mode {
2
} else if self.theme_dark {
0
} else {
1
}
}
/// Aplica una selección del segmented de tema (0/1/2). Impresión
/// preserva la base claro/oscuro para poder volver a ella.
pub(crate) fn set_theme_idx(&mut self, idx: usize) {
match idx {
2 => self.print_mode = true,
1 => {
self.print_mode = false;
self.theme_dark = false;
}
_ => {
self.print_mode = false;
self.theme_dark = true;
}
}
}
/// El `Theme` activo según el modo. Impresión gana sobre claro/oscuro.
pub(crate) fn active_theme(&self) -> llimphi_theme::Theme {
if self.print_mode {
llimphi_theme::Theme::print()
} else if self.theme_dark {
llimphi_theme::Theme::dark()
} else {
llimphi_theme::Theme::light()
}
}
}
// =====================================================================
// Mensajes del bucle Elm
// =====================================================================
#[derive(Clone)]
pub(crate) enum Msg {
WawaConfigChanged(Box<wawa_config::WawaConfig>),
// multi-carta (tabs del centro)
ActivateChartTab(usize),
CloseChartTab(usize),
/// Alterna entre vista de pestañas y mosaico (cartas lado a lado).
ToggleTileMode,
/// Rota la esfera 3D por pasos (Δyaw, Δpitch) desde los botones.
SphereRotate(f32, f32),
/// Resetea la orientación de la esfera 3D.
SphereReset,
/// Paneo del lienzo de la rueda (Δx, Δy en píxeles de pantalla) —
/// emitido por el drag y por la rueda del ratón.
WheelPan(f32, f32),
/// Multiplica el zoom del lienzo de la rueda por el factor dado.
WheelZoom(f32),
/// Restaura zoom 1× y paneo 0 (encuadre).
WheelResetView,
/// Fija zoom y paneo del lienzo de una (para zoom hacia el cursor):
/// (zoom, pan_x, pan_y).
WheelSetView(f32, f32, f32),
/// Alterna la cúpula del Cielo entre cénit y nadir.
ToggleSkyNadir,
/// Cambió el tamaño de la ventana (ancho, alto en px lógicos).
Resized(f32, f32),
/// Desplaza el contenedor de paneles (derecha) en `delta` px.
ToolsScroll(f32),
/// Expande/colapsa un nodo (grupo o contacto) del árbol de datos.
ToggleNavNode(String),
/// Selecciona un nodo del árbol; carta→carga, contenedor→toggle.
NavClick(String),
// CRUD del árbol de datos (cosmos-store)
NewGroup,
DeleteSelected,
/// Marca el nodo seleccionado para mover (cortar).
CutNode,
/// Mueve el nodo cortado bajo el seleccionado (pegar).
PasteNode,
RenameStart,
RenameKey(llimphi_ui::KeyEvent),
RenameCommit,
RenameCancel,
/// `cosmos-chart.json` cambió en disco — recargar.
ChartFileChanged,
SelectBody(Option<String>),
// capas / armónico / configuración
ToggleOverlay(OverlayKind),
SetHarmonic(u32),
/// Elige el modo de tema: 0 = Oscuro, 1 = Claro, 2 = Impresión.
SetThemeMode(usize),
/// Genera la hoja imprimible (cabecera + aspectos) y la abre en el
/// navegador del sistema para usar su diálogo de impresión.
PrintSheet,
ToggleWheelOpt(WheelOpt),
SetRotOffset(f32),
SetUseNow(bool),
// menú principal
OpenMenu(MenuKind),
MenuPick(MenuKind, usize),
/// Navegación de teclado en el dropdown del menú principal (±1 fila,
/// salta separadores y deshabilitados).
MenuNav(i32),
/// Enter sobre la fila activa del menú principal.
MenuActivate,
/// Tick de re-render para la animación de aparición del dropdown.
MenuTick,
CloseMenu,
// menú contextual sobre la rueda
OpenCanvasCtx(f32, f32),
CtxPick(usize),
CloseCtx,
// menú contextual sobre una fila del árbol de datos
OpenNavCtx(String),
NavCtxPick(usize),
/// Desplaza el árbol de datos (izquierda) en `delta` px.
NavScroll(f32),
// rectificador de hora
/// Corre el jog de la hora en `delta` minutos (puede ser negativo).
RectifyNudge(i64),
/// Restaura el jog a 0.
RectifyResetOffset,
/// Agrega un evento conocido (edad por defecto).
RectifyAddEvent,
/// Cambia la edad del evento `idx` en `delta` años.
RectifyEventDelta(usize, f64),
/// Quita el evento `idx`.
RectifyRemoveEvent(usize),
/// Corre el barrido de rectificación con los eventos cargados.
RectifyRun,
/// Aplica el mejor offset hallado a la hora de nacimiento de la carta.
RectifyApply,
/// Elige la clave arco↔año (`true` = Naibod, `false` = Ptolomeo).
RectifySetKey(bool),
/// Cambia la edad de inspección de triggers en `delta` años.
RectifyAgeDelta(f64),
/// Recalcula los triggers GR a la edad de inspección.
RectifyTriggers,
// diálogos modales (crear contacto / crear carta)
OpenNewContactDialog,
OpenNewChartDialog,
DialogFocus(crate::dialog::DialogField),
DialogKey(llimphi_ui::KeyEvent),
DialogPickCity(usize),
DialogConfirm,
DialogCancel,
// layout guardable (paneles laterales tipo móvil)
SetNavWidth(f32),
SetToolsWidth(f32),
PersistLayout,
// panel de herramientas (derecha)
ToggleToolPanel(ToolPanel),
// dock: activar una pestaña de un sidebar / moverla de lado (drop)
DockActivate(DockSide, DockItem),
DockDrop(DockSide, u64),
/// Rail hospedado (modo delegado): pata reenvió un clic en un diente que
/// cosmos le prestó. El `u32` es el `DockItem` codificado (`DockItem::to_u64`).
HostActivate(u32),
// tipo de gráfica del centro
SetChartView(ChartView),
/// Resultado del cómputo astronómico PESADO (orto/ocaso/efemérides),
/// hecho en un worker en vez de bloquear el hilo de UI. `u64` es la
/// generación: `update` descarta resultados viejos si entretanto se pidió
/// otro recálculo. `Arc` evita que `Msg: Clone` copie el `AstroState`.
AstroComputed(u64, std::sync::Arc<crate::astroview::AstroState>),
}
// =====================================================================
// Modelo
// =====================================================================
pub(crate) struct Model {
pub(crate) chart: Chart,
pub(crate) overlays: Vec<OverlayKind>,
pub(crate) harmonic: u32,
pub(crate) render: RenderModel,
/// Lecturas astronómicas cacheadas (alt/az, sundial, mareas, orto/ocaso,
/// eclipses). `None` mientras el worker las calcula —la UI pinta
/// "calculando…" en vez de bloquearse—. El cómputo (caro: 144 muestras ×
/// 10 cuerpos) corre SIEMPRE fuera del hilo de UI.
pub(crate) astro: Option<AstroState>,
/// `astro` está sucio y hay que recalcularlo. Lo marca `recompute_astro`
/// dentro de `update`; el despacho al worker ocurre al final de `update`
/// (que tiene el Handle). La generación evita que un resultado tardío pise
/// a uno más nuevo.
pub(crate) astro_dirty: bool,
pub(crate) astro_gen: u64,
pub(crate) corpus: Corpus,
pub(crate) cfg: CosmosConfig,
pub(crate) theme: Theme,
pub(crate) error: Option<String>,
/// Nota efímera en la barra de estado (confirmaciones, "acerca de").
pub(crate) status_note: Option<String>,
// multi-carta (tabs del centro)
pub(crate) open: Vec<OpenTab>,
pub(crate) active_tab: usize,
/// `true` = mosaico (todas las cartas lado a lado); `false` = pestañas.
pub(crate) tile_mode: bool,
pub(crate) selected_card: Option<String>,
pub(crate) selected_body: Option<String>,
// árbol de datos (cosmos-store)
pub(crate) store: Option<Store>,
pub(crate) nav_nodes: Vec<NavNode>,
pub(crate) nav_expanded: HashSet<String>,
/// Nodo seleccionado en el árbol (clave de [`NavNode`]).
pub(crate) nav_selected: Option<String>,
/// Clave del nodo en edición de nombre (`None` = no se renombra).
pub(crate) nav_rename: Option<String>,
pub(crate) rename_input: TextInputState,
/// Clave del nodo cortado, pendiente de pegar (mover).
pub(crate) nav_cut: Option<String>,
// esfera 3D (orientación)
pub(crate) sphere_yaw: f32,
pub(crate) sphere_pitch: f32,
// Cielo del observador (vista alt/az)
/// `false` = cénit al centro (cielo visible); `true` = nadir al
/// centro (el hemisferio bajo el horizonte).
pub(crate) sky_nadir: bool,
// rueda 2D: zoom + paneo del lienzo (transitorio, no se persiste)
pub(crate) wheel_zoom: f32,
pub(crate) wheel_pan: (f32, f32),
/// Rect (x, y, w, h en px de ventana) del último lienzo de
/// astrocarto pintado. Lo escribe el `paint_with` y lo lee
/// `on_wheel` para hacer zoom hacia la posición del cursor (el
/// `update` no conoce el layout computado; el paint sí).
pub(crate) carto_rect: std::sync::Arc<std::sync::Mutex<Option<(f32, f32, f32, f32)>>>,
/// Tamaño actual de la ventana (px lógicos). Para gating de la rueda
/// y el alto del scroll de paneles. Arranca en [`VIEWPORT`].
pub(crate) viewport: (f32, f32),
/// Desplazamiento vertical del contenedor de paneles (derecha).
pub(crate) tools_scroll: f32,
// layout guardable (3 zonas resizables)
pub(crate) nav_w: f32,
pub(crate) tools_w: f32,
pub(crate) nav_open: bool,
pub(crate) tools_open: bool,
// centro + herramientas
pub(crate) chart_view: ChartView,
pub(crate) tool_cat: ToolCat,
pub(crate) expanded_panels: Vec<ToolPanel>,
// dock: qué paneles viven en cada sidebar + cuál está activo
pub(crate) dock_left: Vec<DockItem>,
pub(crate) dock_right: Vec<DockItem>,
pub(crate) active_left: Option<DockItem>,
pub(crate) active_right: Option<DockItem>,
/// En modo colapsado (ventana angosta), qué sidebar está desplegado
/// temporalmente (al hacer clic en un diente). `None` = ambos a rail.
pub(crate) dock_expanded: Option<DockSide>,
// chrome
pub(crate) menu_open: Option<MenuKind>,
/// Fila activa (resaltada por teclado) del dropdown del menú principal.
pub(crate) menu_active: usize,
/// Animación de aparición/swap del dropdown del menú principal (0→1).
pub(crate) menu_anim: Tween<f32>,
pub(crate) ctx_open: Option<(f32, f32)>,
/// Menú contextual de una fila del árbol: clave del nodo (el ancla se
/// calcula en `overlay_view` desde su índice visible).
pub(crate) nav_ctx: Option<String>,
/// Desplazamiento vertical del árbol de datos (izquierda).
pub(crate) nav_scroll: f32,
// rectificador de hora (direcciones primarias)
/// Jog de la hora de nacimiento en minutos (no toca la carta guardada
/// hasta «Aplicar»). Mueve ángulos/casas en vivo.
pub(crate) rectify_offset_min: i64,
/// Eventos conocidos (edades en años) que anclan la rectificación.
pub(crate) rectify_events: Vec<f64>,
/// Resultado del último barrido de rectificación.
pub(crate) rectify_result: Option<cosmos_engine::Rectificacion>,
/// Clave arco↔año: `true` = Naibod (default), `false` = Ptolomeo.
pub(crate) rectify_naibod: bool,
/// Edad (años) a la que inspeccionar los triggers GR.
pub(crate) rectify_age: f64,
/// Triggers GR (contactos directo/converso) calculados a `rectify_age`.
pub(crate) rectify_triggers: Vec<cosmos_render::GrTrigger>,
/// Diálogo modal abierto (crear contacto / crear carta), si lo hay.
pub(crate) dialog: Option<crate::dialog::Dialog>,
/// Campo del diálogo que tiene el foco de teclado.
pub(crate) dialog_field: crate::dialog::DialogField,
/// Buffer de edición del campo enfocado del diálogo.
pub(crate) dialog_input: TextInputState,
// rail hospedado (sidebar delegado a pata)
/// `true` si cosmos delega su sidebar al marco pata: no pinta sus propios
/// rails (queda puro canvas) y sus dientes aparecen en el rail de pata
/// cuando cosmos tiene foco. Lo enciende `COSMOS_DELEGATE_SIDEBAR`.
pub(crate) delegated: bool,
/// Cliente del rail hospedado (mantiene viva la conexión a pata + el hilo
/// que recibe las activaciones). `None` si no se delega o pata no escucha.
/// Sólo se retiene (las activaciones llegan por callback); `_` evita el lint.
pub(crate) _host: Option<pata_host::HostClient>,
// watchers
pub(crate) _wawa_watcher: Option<wawa_config::ConfigWatcher>,
pub(crate) _chart_watcher: Option<notify::RecommendedWatcher>,
}
impl Model {
/// Etiqueta de la carta activa (para la barra de estado).
pub(crate) fn active_label(&self) -> &str {
self.open
.get(self.active_tab)
.map(|t| t.label())
.unwrap_or("")
}
pub(crate) fn toggle_nav(&mut self, key: String) {
if !self.nav_expanded.remove(&key) {
self.nav_expanded.insert(key);
}
}
/// El nodo actualmente seleccionado en el árbol, si existe.
pub(crate) fn selected_node(&self) -> Option<&NavNode> {
let key = self.nav_selected.as_deref()?;
self.nav_nodes.iter().find(|n| n.key == key)
}
/// Busca un nodo por su clave.
pub(crate) fn node(&self, key: &str) -> Option<&NavNode> {
self.nav_nodes.iter().find(|n| n.key == key)
}
pub(crate) fn panel_expanded(&self, p: ToolPanel) -> bool {
self.expanded_panels.contains(&p)
}
pub(crate) fn toggle_panel(&mut self, p: ToolPanel) {
if let Some(i) = self.expanded_panels.iter().position(|x| *x == p) {
self.expanded_panels.remove(i);
} else {
self.expanded_panels.push(p);
}
}
/// Pestaña activa de un sidebar (con fallback a la primera del lado).
pub(crate) fn dock_active(&self, side: DockSide) -> Option<DockItem> {
let (items, active) = match side {
DockSide::Left => (&self.dock_left, self.active_left),
DockSide::Right => (&self.dock_right, self.active_right),
};
active
.filter(|a| items.contains(a))
.or_else(|| items.first().copied())
}
/// Mueve `item` al `side` indicado (lo saca del otro), y lo activa.
/// Mantiene cada lado en orden canónico (Biblioteca, Principal,
/// Análisis, Astronomía, Sistema) — Principal primero, Sistema último.
pub(crate) fn dock_move(&mut self, item: DockItem, side: DockSide) {
self.dock_left.retain(|x| *x != item);
self.dock_right.retain(|x| *x != item);
match side {
DockSide::Left => {
self.dock_left.push(item);
self.dock_left.sort_by_key(|i| i.to_u64());
self.active_left = Some(item);
}
DockSide::Right => {
self.dock_right.push(item);
self.dock_right.sort_by_key(|i| i.to_u64());
self.active_right = Some(item);
}
}
}
pub(crate) fn nudge_nav(&mut self, dx: f32) {
self.nav_w = (self.nav_w + dx).clamp(NAV_MIN, NAV_MAX);
}
/// El divisor entre centro y herramientas: arrastrar a la derecha
/// (dx>0) achica el panel de herramientas.
pub(crate) fn nudge_tools(&mut self, dx: f32) {
self.tools_w = (self.tools_w - dx).clamp(TOOLS_MIN, TOOLS_MAX);
}
}
@@ -0,0 +1,290 @@
//! Persistencia en disco: estado de UI (`cosmos-ui.json`), carta cargada
//! (`cosmos-chart.json`) con su watcher, y la librería multi-archivo de
//! cartas en el subdirectorio `cosmos-charts/`.
use std::path::PathBuf;
use cosmos_model::{Chart, ChartId, ChartKind, ContactId, StoredBirthData, StoredChartConfig};
use llimphi_ui::Handle;
use serde::{Deserialize, Serialize};
use crate::model::{
ChartView, CosmosConfig, Msg, OverlayKind, ToolCat, ToolPanel, NAV_WIDTH, TOOLS_WIDTH,
};
/// Subdirectorio dentro del config dir donde viven las cartas guardadas
/// como archivos individuales `<nombre>.json`. El usuario lo gestiona
/// con su file manager — la app solo lista, lee y escribe.
const CHARTS_SUBDIR: &str = "cosmos-charts";
/// Nombre del archivo JSON donde persiste el estado de la UI (orden de
/// tiles + overlays activos + armónico). Vive en el config dir de wawa
/// para no acoplar a un dirs propio por app — un solo árbol de config.
const UI_STATE_FILE: &str = "cosmos-ui.json";
/// Nombre del archivo JSON donde persiste la carta cargada. El usuario
/// edita ESTE archivo con su editor para cambiar fecha/lat/long/label;
/// la app reacciona vía watcher (mismo patrón que wawa-config). Sin
/// form de UI hasta que llegue la fase de store de cartas.
const CHART_FILE: &str = "cosmos-chart.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UiState {
#[serde(default = "default_overlays")]
pub(crate) overlays: Vec<OverlayKind>,
#[serde(default = "default_harmonic")]
pub(crate) harmonic: u32,
#[serde(default)]
pub(crate) cfg: CosmosConfig,
// layout guardable (paneles laterales tipo móvil)
#[serde(default = "default_nav_w")]
pub(crate) nav_w: f32,
#[serde(default = "default_tools_w")]
pub(crate) tools_w: f32,
#[serde(default = "default_true")]
pub(crate) nav_open: bool,
#[serde(default = "default_true")]
pub(crate) tools_open: bool,
#[serde(default)]
pub(crate) chart_view: ChartView,
#[serde(default)]
pub(crate) tool_cat: ToolCat,
#[serde(default = "ToolPanel::defaults_expanded")]
pub(crate) expanded_panels: Vec<ToolPanel>,
#[serde(default)]
pub(crate) tile_mode: bool,
// dock (reparto de paneles por sidebar)
#[serde(default = "crate::model::default_dock_left")]
pub(crate) dock_left: Vec<crate::model::DockItem>,
#[serde(default = "crate::model::default_dock_right")]
pub(crate) dock_right: Vec<crate::model::DockItem>,
#[serde(default = "default_yaw")]
pub(crate) sphere_yaw: f32,
#[serde(default = "default_pitch")]
pub(crate) sphere_pitch: f32,
/// Cielo: `false` = mira al cénit (cielo visible), `true` = mira al
/// nadir (el hemisferio bajo el horizonte).
#[serde(default)]
pub(crate) sky_nadir: bool,
}
fn default_harmonic() -> u32 {
1
}
fn default_yaw() -> f32 {
26.0
}
fn default_pitch() -> f32 {
-64.0
}
/// Topocéntrico activo por default — habilita la tabla de aspectos
/// topocéntricos que el usuario quiere ver de entrada.
fn default_overlays() -> Vec<OverlayKind> {
vec![OverlayKind::Topocentric]
}
fn default_nav_w() -> f32 {
NAV_WIDTH
}
fn default_tools_w() -> f32 {
TOOLS_WIDTH
}
fn default_true() -> bool {
true
}
impl Default for UiState {
fn default() -> Self {
Self {
overlays: default_overlays(),
harmonic: 1,
cfg: CosmosConfig::default(),
nav_w: NAV_WIDTH,
tools_w: TOOLS_WIDTH,
nav_open: true,
tools_open: true,
chart_view: ChartView::default(),
tool_cat: ToolCat::default(),
expanded_panels: ToolPanel::defaults_expanded(),
tile_mode: false,
dock_left: crate::model::default_dock_left(),
dock_right: crate::model::default_dock_right(),
sphere_yaw: default_yaw(),
sphere_pitch: default_pitch(),
sky_nadir: false,
}
}
}
fn ui_state_path() -> Option<PathBuf> {
wawa_config::config_dir().map(|d| d.join(UI_STATE_FILE))
}
pub(crate) fn chart_path() -> Option<PathBuf> {
wawa_config::config_dir().map(|d| d.join(CHART_FILE))
}
/// Forma serializada minimal de un Chart natal. Pierde `id`/`contact_id`
/// (se regeneran al cargar) y `created_at_ms` — son metadata interna que
/// no aporta al usuario que edita el JSON a mano.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChartFile {
label: String,
birth_data: StoredBirthData,
#[serde(default)]
config: StoredChartConfig,
}
impl From<&Chart> for ChartFile {
fn from(c: &Chart) -> Self {
Self {
label: c.label.clone(),
birth_data: c.birth_data.clone(),
config: c.config.clone(),
}
}
}
impl ChartFile {
fn into_chart(self) -> Chart {
Chart {
id: ChartId::new(),
contact_id: ContactId::new(),
kind: ChartKind::Natal,
label: self.label,
birth_data: self.birth_data,
config: self.config,
related_chart_id: None,
created_at_ms: 0,
}
}
}
pub(crate) fn load_chart_from_disk() -> Option<Chart> {
let path = chart_path()?;
let bytes = std::fs::read(&path).ok()?;
let f: ChartFile = serde_json::from_slice(&bytes)
.map_err(|e| eprintln!("cosmos · chart-file: no se pudo parsear {path:?}: {e}"))
.ok()?;
Some(f.into_chart())
}
/// Arranca un watcher sobre `cosmos-chart.json` que dispara
/// `Msg::ChartFileChanged` al detectar `Modify`/`Create` en el archivo.
/// Devuelve `None` si no hay config dir disponible o si notify falla —
/// la app sigue funcionando sin hot-reload, solo no reaccionará a edits
/// externos hasta el próximo arranque.
pub(crate) fn spawn_chart_watcher(handle: &Handle<Msg>) -> Option<notify::RecommendedWatcher> {
let path = chart_path()?;
// Asegurá que el dir existe y el archivo está sembrado antes de
// watchearlo — notify exige que el path exista al `watch`.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if !path.exists() {
// Sembrado lazy: si nunca pasó por init(), no hay archivo. Lo
// creamos vacío para que el watcher tenga algo que mirar; init
// lo sobreescribirá con el sample al arrancar.
let _ = std::fs::write(&path, b"{}");
}
let h = handle.clone();
wawa_config::watch_path(&path, move |ev: notify::Event| {
use notify::EventKind;
if matches!(
ev.kind,
EventKind::Modify(_) | EventKind::Create(_)
) {
h.dispatch(Msg::ChartFileChanged);
}
})
.map_err(|e| eprintln!("cosmos · chart-watcher: {e}"))
.ok()
}
// =====================================================================
// Store de cartas (multi-archivo)
// =====================================================================
pub(crate) fn charts_dir() -> Option<PathBuf> {
wawa_config::config_dir().map(|d| d.join(CHARTS_SUBDIR))
}
/// Lista los nombres de las cartas guardadas (sin `.json`), ordenados
/// alfabéticamente. Lee el directorio en cada call — barato porque son
/// pocos archivos y la app no es hot-path.
pub(crate) fn list_cards() -> Vec<String> {
let Some(dir) = charts_dir() else {
return Vec::new();
};
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) == Some("json") {
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
out.push(stem.to_string());
}
}
}
}
out.sort();
out
}
pub(crate) fn load_card(name: &str) -> Option<Chart> {
let path = charts_dir()?.join(format!("{name}.json"));
let bytes = std::fs::read(&path).ok()?;
serde_json::from_slice::<ChartFile>(&bytes)
.map_err(|e| eprintln!("cosmos · load_card({name}): {e}"))
.ok()
.map(|f| f.into_chart())
}
/// Elimina el archivo de una carta de la biblioteca. No toca la carta
/// cargada (`cosmos-chart.json`).
pub(crate) fn save_chart_to_disk(chart: &Chart) {
let Some(path) = chart_path() else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let f: ChartFile = chart.into();
if let Ok(json) = serde_json::to_vec_pretty(&f) {
if let Err(e) = std::fs::write(&path, json) {
eprintln!("cosmos · chart-file: write fallido {path:?}: {e}");
}
}
}
pub(crate) fn load_ui_state() -> UiState {
let Some(path) = ui_state_path() else {
return UiState::default();
};
let Ok(bytes) = std::fs::read(&path) else {
return UiState::default();
};
match serde_json::from_slice::<UiState>(&bytes) {
Ok(s) => s,
Err(e) => {
eprintln!("cosmos · ui-state: no se pudo parsear {path:?}: {e}");
UiState::default()
}
}
}
pub(crate) fn save_ui_state(s: &UiState) {
let Some(path) = ui_state_path() else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(json) = serde_json::to_vec_pretty(s) else {
return;
};
if let Err(e) = std::fs::write(&path, json) {
eprintln!("cosmos · ui-state: write fallido {path:?}: {e}");
}
}
@@ -0,0 +1,248 @@
//! Hoja imprimible con **fidelidad gráfica**: rasteriza el MISMO árbol de
//! `View` que se ve en pantalla (rueda + cabecera + aspectos) a un PNG de
//! alta resolución, reutilizando la tubería vello+wgpu de Llimphi, y lo
//! abre en el visor de imágenes del sistema para imprimir.
//!
//! **Por qué render real y no HTML.** El HTML reconstruía la carta con
//! tipografía del navegador — perdía la fidelidad del motor (glyphs
//! vectoriales propios, layout exacto, la rueda). Acá montamos el `View`,
//! lo pintamos a una `vello::Scene` y lo escalamos ×N sobre una textura
//! offscreen: lo impreso es pixel-fiel a lo que pinta la app, a cualquier
//! DPI (los vectores no pixelan al ampliar).
//!
//! El render abre una segunda instancia headless de wgpu (`Hal::new(None)`)
//! para no tocar el device de la ventana — cuesta ~1 s de cold-start de
//! shaders, aceptable para una acción manual de "imprimir".
use std::path::PathBuf;
use std::process::Command;
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::{taffy, LayoutTree};
use llimphi_ui::llimphi_raster::kurbo::Affine;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::llimphi_text::Typesetter;
use llimphi_ui::{measure_text_node, mount, paint};
use crate::model::Model;
/// Ancho lógico de la hoja (debe coincidir con `chrome::PRINT_SHEET_W` +
/// padding del contenedor). Damos un poco de aire a los lados.
const SHEET_LOGICAL_W: f32 = 616.0;
/// Factor de escala del render — vectores, así que sube el DPI sin pixelar.
const SCALE: f32 = 2.5;
/// Límite de lado de textura (los GPUs suelen topar en 8192/16384).
const MAX_PX: u32 = 8192;
/// Arma la hoja, la rasteriza a PNG de alta resolución y la abre en el
/// visor del sistema. Devuelve la ruta escrita o un mensaje de error.
pub(crate) fn imprimir_carta(model: &Model) -> Result<PathBuf, String> {
let view = crate::chrome::print_page_content(model);
let png = render_view_to_png(view, SHEET_LOGICAL_W, SCALE)?;
let path = std::env::temp_dir().join("cosmos-hoja.png");
std::fs::write(&path, &png).map_err(|e| format!("no se pudo escribir {path:?}: {e}"))?;
abrir(&path)?;
Ok(path)
}
/// Monta un `View`, lo pinta a una escena vello y la rasteriza a un PNG
/// (RGBA8) ampliada ×`scale` sobre una textura offscreen.
fn render_view_to_png(
view: llimphi_ui::View<crate::model::Msg>,
logical_w: f32,
scale: f32,
) -> Result<Vec<u8>, String> {
// GPU headless (sin surface) + rasterizador + tipografía.
let hal = pollster::block_on(Hal::new(None)).map_err(|e| format!("gpu init: {e}"))?;
let mut renderer = Renderer::new(&hal).map_err(|e| e.to_string())?;
let mut ts = Typesetter::new();
// Mount + layout. Alto disponible enorme → el alto real lo fija el
// contenido (la hoja es `height: auto`).
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, view);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (logical_w, 100_000.0), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.map_err(|e| format!("layout: {e}"))?
};
// Tamaño real de la hoja según el layout (ancho fijo, alto por
// contenido) — el PNG queda justo, sin márgenes muertos.
let root = computed.get(mounted.root).ok_or("sin layout de raíz")?;
let logical_w_real = root.w.max(1.0);
let logical_h = root.h.max(1.0);
// Pintar a coords lógicas, luego escalar la escena entera ×scale.
let mut inner = vello::Scene::new();
paint(&mut inner, &mounted, &computed, &mut ts, None, None);
let mut scene = vello::Scene::new();
scene.append(&inner, Some(Affine::scale(scale as f64)));
let w_px = ((logical_w_real * scale).ceil() as u32).clamp(1, MAX_PX);
let h_px = ((logical_h * scale).ceil() as u32).clamp(1, MAX_PX);
// Textura offscreen (mismas usages que el gpu-bench: vello escribe por
// STORAGE_BINDING, leemos por COPY_SRC).
let tex = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("cosmos-print-target"),
size: wgpu::Extent3d {
width: w_px,
height: h_px,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let tview = tex.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &tview, w_px, h_px, Color::from_rgba8(255, 255, 255, 255))
.map_err(|e| e.to_string())?;
leer_textura_png(&hal, &tex, w_px, h_px)
}
/// Copia la textura a un buffer mapeable (stride alineado a 256 B como pide
/// wgpu), desempaqueta las filas y codifica un PNG RGBA8 en memoria.
fn leer_textura_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32) -> Result<Vec<u8>, String> {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf_size = (padded * h as usize) as u64;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("cosmos-print-readback"),
size: buf_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("cosmos-print-copy"),
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(encoder.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::Maintain::Wait);
rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h as usize {
let start = row * padded;
pixels.extend_from_slice(&data[start..start + unpadded]);
}
drop(data);
buf.unmap();
let mut out = Vec::new();
{
let mut enc = png::Encoder::new(&mut out, w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header().map_err(|e| e.to_string())?;
writer.write_image_data(&pixels).map_err(|e| e.to_string())?;
}
Ok(out)
}
/// Abre `path` con el visor/imagen por defecto del SO. Linux `xdg-open`,
/// macOS `open`, Windows `cmd /C start`.
fn abrir(path: &PathBuf) -> Result<(), String> {
let p = path.to_string_lossy().to_string();
let res = if cfg!(target_os = "macos") {
Command::new("open").arg(&p).spawn()
} else if cfg!(target_os = "windows") {
Command::new("cmd").args(["/C", "start", "", &p]).spawn()
} else {
Command::new("xdg-open").arg(&p).spawn()
};
res.map(|_| ())
.map_err(|e| format!("no se pudo abrir el visor: {e} (la hoja quedó en {p})"))
}
#[cfg(test)]
mod tests {
use super::*;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, Size, Style};
use llimphi_ui::View;
/// Smoke del pipeline headless: monta un `View` con texto + relleno,
/// lo rasteriza y verifica que sale un PNG válido del tamaño esperado
/// y con contenido (no todo blanco). Requiere GPU — se ignora por
/// defecto para no romper CI sin display; correr con `--ignored`.
#[test]
#[ignore = "necesita GPU/headless wgpu"]
fn rasteriza_view_a_png_valido() {
let view: View<crate::model::Msg> = View::new(Style {
size: Size {
width: length(200.0),
height: length(80.0),
},
..Default::default()
})
.fill(Color::from_rgba8(255, 255, 255, 255))
.text_aligned(
"Cosmos ☉♈ test".to_string(),
24.0,
Color::from_rgba8(0, 0, 0, 255),
llimphi_ui::llimphi_text::Alignment::Start,
);
let scale = 2.0;
let png = render_view_to_png(view, 200.0, scale).expect("render");
// Firma PNG.
assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']);
// Decodificar y comprobar dimensiones + que hay píxeles no-blancos
// (el texto negro dejó marca).
let decoder = png::Decoder::new(std::io::Cursor::new(&png));
let mut reader = decoder.read_info().expect("png info");
assert_eq!(reader.info().width, (200.0 * scale) as u32);
let mut buf = vec![0u8; reader.output_buffer_size().expect("buffer size")];
let info = reader.next_frame(&mut buf).expect("frame");
let any_dark = buf[..info.buffer_size() as usize]
.chunks_exact(4)
.any(|px| px[0] < 200 && px[1] < 200 && px[2] < 200);
assert!(any_dark, "la imagen salió toda blanca — el texto no pintó");
}
}
@@ -0,0 +1,368 @@
//! Panel de herramientas (derecha): rail vertical de categorías (tabs
//! estilo Photoshop) + acordeón de paneles colapsables dentro de la
//! categoría activa.
//!
//! Cada [`ToolPanel`] es una sección colapsable cuyo cuerpo reusa las
//! mismas funciones de tabla que ya existían (`view::tile_*`,
//! `astroview::view_*`). Aspectos (geocéntrico) y Aspectos topocéntrico
//! arrancan expandidos. El panel completo vive en una zona resizable
//! guardable; el usuario alterna categoría con el rail y abre/cierra cada
//! panel con su cabecera.
use cosmos_render::LayerKind;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
use llimphi_widget_scroll::{clamp_offset, scroll_y, ScrollPalette};
use crate::astroview;
use crate::chrome;
use crate::glyphs::{self, Icon};
use crate::model::{Model, Msg, ToolCat, ToolPanel, MENU_BAR_H, STATUS_H};
use crate::view;
/// Alto visible del contenedor de paneles (de bajo la barra de menú a
/// sobre la barra de estado).
pub(crate) fn tools_viewport_h(model: &Model) -> f32 {
(model.viewport.1 - MENU_BAR_H - STATUS_H).max(60.0)
}
/// Alto total estimado del acordeón (cabecera de categoría + paneles).
/// Aproximado a partir del nº de filas de cada tabla — suficiente para
/// dimensionar la barra de scroll y acotar el offset.
pub(crate) fn tools_content_h(cat: ToolCat, model: &Model) -> f32 {
let mut h = 24.0 + 8.0; // cabecera de categoría + padding del acordeón
for panel in cat.panels() {
h += HEAD_H + 6.0; // cabecera de la card + gap
if model.panel_expanded(*panel) {
h += panel_rows(*panel, model) as f32 * 20.0 + 22.0; // filas + padding
}
}
h
}
/// Estimación del nº de filas (~20 px) del cuerpo de cada panel.
fn panel_rows(panel: ToolPanel, model: &Model) -> usize {
let r = &model.render;
let bodies = || {
r.layers
.iter()
.filter(|l| l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies))
.flat_map(|l| l.glyphs.iter())
.count()
};
let layer = |k: LayerKind| {
r.layers
.iter()
.filter(|l| std::mem::discriminant(&l.kind) == std::mem::discriminant(&k))
.flat_map(|l| l.glyphs.iter())
.count()
};
match panel {
ToolPanel::Carta => 10,
ToolPanel::Aspectos | ToolPanel::AspectosTopo => 1 + r.aspect_summary.len().min(60),
ToolPanel::Cuerpos => bodies().max(1),
ToolPanel::Cualidades => 12,
ToolPanel::Uraniano => r.uranian_groups.len().max(1),
ToolPanel::BoxGraph => bodies().max(1),
ToolPanel::Lotes => layer(LayerKind::Lots).max(1),
ToolPanel::EstrellasFijas => layer(LayerKind::FixedStars).max(1),
ToolPanel::PuntosMedios => layer(LayerKind::Midpoints).max(1),
ToolPanel::Corpus => 14,
ToolPanel::Cielo => 12,
ToolPanel::OrtoOcaso => 12,
ToolPanel::Sundial => 8,
ToolPanel::Mareas => 10,
ToolPanel::Eclipses => 10,
ToolPanel::Efemerides => 14,
ToolPanel::Rectificador => {
18 + model.rectify_events.len() + model.rectify_triggers.len()
}
ToolPanel::Configuracion => 22,
}
}
/// Icono del rail vertical para cada categoría.
pub(crate) fn cat_icon(cat: ToolCat) -> Icon {
match cat {
ToolCat::Principal => Icon::Triangle,
ToolCat::Analisis => Icon::Star,
ToolCat::Astronomia => Icon::Moon,
ToolCat::Sistema => Icon::Gear,
}
}
/// Contenido de una categoría de herramientas (acordeón scrolleable),
/// para montar en un sidebar del dock. El rail de pestañas lo arma el
/// dock en `chrome`.
pub(crate) fn dock_tool_content(cat: ToolCat, model: &Model, theme: &Theme) -> View<Msg> {
let accordion = accordion_view(cat, model, theme);
let viewport = tools_viewport_h(model);
let content = tools_content_h(cat, model);
let offset = clamp_offset(model.tools_scroll, content, viewport);
let scroll = scroll_y(
offset,
content,
viewport,
accordion,
Msg::ToolsScroll,
&ScrollPalette::from_theme(theme),
);
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(0.0_f32),
},
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![scroll])
}
// =====================================================================
// Acordeón de paneles colapsables
// =====================================================================
fn accordion_view(cat: ToolCat, model: &Model, theme: &Theme) -> View<Msg> {
// Cabecera de la categoría activa (texto centrado vertical: nodo de
// alto auto dentro de una fila centrada).
let header = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(24.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.children(vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(
cat.title().to_uppercase(),
10.0,
theme.fg_muted,
Alignment::Start,
)]);
let mut kids: Vec<View<Msg>> = vec![header];
for panel in cat.panels() {
kids.push(collapsible(*panel, model, theme));
}
// Alto natural (lo guía el contenido) — el scroll del contenedor lo
// recorta. No `flex_grow` ni `clip` aquí.
View::new(Style {
flex_direction: FlexDirection::Column,
flex_shrink: 0.0,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(6.0_f32),
},
..Default::default()
})
.children(kids)
}
const HEAD_H: f32 = 28.0;
/// Una sección colapsable como **card** con firma de panel (gradiente +
/// hairline) en la caja y una tira de cabecera con su propio gradiente.
/// El alto lo guía el contenido (auto), no el espacio disponible.
fn collapsible(panel: ToolPanel, model: &Model, theme: &Theme) -> View<Msg> {
let expanded = model.panel_expanded(panel);
let box_style = PanelStyle::from_theme(theme);
// Cabecera: gradiente propio sobre bg_panel_alt; hairline sólo cuando
// está expandida (refuerza la separación con el cuerpo).
let mut head_style = PanelStyle::from_theme(theme);
head_style.bg_base = theme.bg_panel_alt;
head_style.radius = 0.0;
head_style.hairline_alpha = if expanded { 0.30 } else { 0.0 };
let chevron_box = View::new(Style {
size: Size {
width: length(18.0_f32),
height: length(HEAD_H),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.children(vec![glyphs::icon_view(
if expanded { Icon::ChevronDown } else { Icon::ChevronRight },
12.0,
theme.fg_muted,
)]);
// Título: alto auto → centrado vertical por el align_items de la fila.
let title = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(0.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(panel.title().to_string(), 12.0, theme.fg_text, Alignment::Start);
let head = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(HEAD_H),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.paint_with(panel_signature_painter(head_style))
.hover_fill(theme.bg_row_hover)
.on_click(Msg::ToggleToolPanel(panel))
.children(vec![chevron_box, title]);
let mut kids = vec![head];
if expanded {
kids.push(
View::new(Style {
flex_direction: FlexDirection::Column,
flex_shrink: 0.0,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![body_for(panel, model, theme)]),
);
}
// Card: gradiente de caja + esquinas redondeadas + clip.
View::new(Style {
flex_direction: FlexDirection::Column,
flex_shrink: 0.0,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.paint_with(panel_signature_painter(box_style))
.radius(box_style.radius)
.clip(true)
.children(kids)
}
/// Cuerpo de cada panel — reusa las tablas existentes.
fn body_for(panel: ToolPanel, model: &Model, theme: &Theme) -> View<Msg> {
let r = &model.render;
match panel {
ToolPanel::Carta => view::tile_carta(model, theme),
ToolPanel::Aspectos | ToolPanel::AspectosTopo => view::tile_aspectos(r, theme),
ToolPanel::Cuerpos => view::tile_cuerpos(r, theme),
ToolPanel::Cualidades => view::tile_cualidades(r, theme),
ToolPanel::Uraniano => view::tile_uraniano(&r.uranian_groups, theme),
ToolPanel::BoxGraph => view::tile_box_graph(r, theme),
ToolPanel::Lotes => view::tile_layer_glyphs(
r,
LayerKind::Lots,
"lots",
"Activá la capa «Lotes» (menú Capas).",
theme,
),
ToolPanel::EstrellasFijas => view::tile_layer_glyphs(
r,
LayerKind::FixedStars,
"fixed_stars",
"Activá la capa «Estrellas fijas» (menú Capas).",
theme,
),
ToolPanel::PuntosMedios => view::tile_layer_glyphs(
r,
LayerKind::Midpoints,
"midpoints",
"Activá la capa «Puntos medios» (menú Capas).",
theme,
),
ToolPanel::Corpus => view::tile_corpus(r, &model.corpus, theme),
// Paneles astronómicos: si `astro` aún se calcula en el worker,
// pintamos "calculando…" en vez de bloquear el hilo de UI.
ToolPanel::Cielo => match &model.astro {
Some(a) => astroview::view_cielo(a, theme),
None => astroview::calculando(theme),
},
ToolPanel::OrtoOcaso => match &model.astro {
Some(a) => astroview::view_ortoocaso(a, theme),
None => astroview::calculando(theme),
},
ToolPanel::Sundial => match &model.astro {
Some(a) => astroview::view_sundial(a, theme),
None => astroview::calculando(theme),
},
ToolPanel::Mareas => match &model.astro {
Some(a) => astroview::view_mareas(a, theme),
None => astroview::calculando(theme),
},
ToolPanel::Eclipses => match &model.astro {
Some(a) => astroview::view_eclipses(a, theme),
None => astroview::calculando(theme),
},
ToolPanel::Efemerides => match &model.astro {
Some(a) => astroview::view_efemerides(a, theme),
None => astroview::calculando(theme),
},
ToolPanel::Rectificador => chrome::rectify_view(model, theme),
ToolPanel::Configuracion => chrome::config_view(model, theme),
}
}
@@ -0,0 +1,734 @@
//! Renderers de contenido de los paneles astrológicos. Cada función
//! devuelve el `View` que se monta en el panel de herramientas cuando su
//! sección está expandida (carta, cuerpos, aspectos, cualidades,
//! uraniano, lotes/estrellas/puntos como layers genéricas, corpus).
//!
//! **Sin tofus**: cuerpos, signos y aspectos se pintan como glyphs
//! vectoriales (mini-canvas) vía [`crate::glyphs`] — nunca unicode
//! astrológico ni abreviaturas tipo "Sag". El chrome (menú, árbol,
//! pestañas, barra de estado, menús contextuales) vive en
//! [`crate::chrome`]; las gráficas astronómicas en [`crate::astroview`].
use std::collections::HashMap;
use cosmos_engine::{combinaciones_de_carta, corpus_inputs, Corpus};
use cosmos_render::{LayerKind, Palette, RenderModel, Rgba, UranianGroup};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use crate::format::fmt_dms;
use crate::glyphs::{self, sign_id};
use crate::model::{Model, Msg};
/// Alto de fila estándar de las tablas.
const ROW_H: f32 = 20.0;
/// Lado del glyph de cuerpo/aspecto en las filas.
const GLYPH: f32 = 16.0;
/// Lado del glyph de signo (un poco menor para diferenciar).
const SGN: f32 = 14.0;
// =====================================================================
// Helpers compartidos
// =====================================================================
pub(crate) fn tile_container<I>(rows: I, theme: &Theme) -> View<Msg>
where
I: IntoIterator<Item = View<Msg>>,
{
let _ = theme;
let children: Vec<View<Msg>> = rows.into_iter().collect();
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
// Alto guiado por el contenido (los paneles del acordeón se
// autoajustan a su tabla), no por el espacio disponible.
flex_grow: 0.0,
flex_shrink: 0.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(8.0_f32),
bottom: length(10.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(3.0_f32),
},
..Default::default()
})
.children(children)
}
pub(crate) fn line(text: String, size: f32, color: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(size + 4.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text, size, color, Alignment::Start)
}
pub(crate) fn section_label(text: String, theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
flex_shrink: 0.0,
margin: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text, 11.0, theme.accent, Alignment::Start)
}
/// Una fila horizontal de celdas, alto [`ROW_H`].
fn cells_row(cells: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(ROW_H),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
gap: Size {
width: length(3.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(cells)
}
/// Celda de texto de ancho fijo. Alto `auto` (= alto del texto) para que
/// el `align_items: Center` de la fila lo centre verticalmente — un texto
/// `Start` se ancla arriba si su nodo es más alto que el glifo.
fn txt_cell(text: String, w: f32, size: f32, color: Color, align: Alignment) -> View<Msg> {
View::new(Style {
size: Size {
width: length(w),
height: Dimension::auto(),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(text, size, color, align)
}
fn rgba_to_color(c: Rgba) -> Color {
let to_byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
Color::from_rgba8(to_byte(c.r), to_byte(c.g), to_byte(c.b), to_byte(c.a))
}
/// Color elemental del signo en la longitud dada.
fn sign_color(deg: f32) -> Color {
rgba_to_color(Palette::dark().sign(sign_id(deg)))
}
/// Grupo compacto cuerpo+signo (glyph del cuerpo seguido del glyph del
/// signo donde cae, coloreado por elemento). `lon = None` → sólo cuerpo.
fn body_sign(name: &str, lon: Option<f32>, theme: &Theme) -> View<Msg> {
let mut kids = vec![glyphs::body_view(name, GLYPH, theme.fg_text)];
if let Some(d) = lon {
kids.push(glyphs::sign_view(sign_id(d), SGN, sign_color(d)));
}
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(GLYPH + SGN + 4.0),
height: length(ROW_H),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(kids)
}
/// Mapa cuerpo→longitud eclíptica desde la capa natal de cuerpos. Se usa
/// para resolver el signo de cada extremo de un aspecto.
fn body_lons(render: &RenderModel) -> HashMap<String, f32> {
let mut m = HashMap::new();
for l in &render.layers {
if l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies) {
for g in &l.glyphs {
m.insert(g.symbol.clone(), g.deg);
}
}
}
// Ángulos del chart, por si un aspecto los referencia.
m.entry("asc".into()).or_insert(render.ascendant_deg);
m.entry("mc".into()).or_insert(render.midheaven_deg);
m
}
// =====================================================================
// Carta (datos del nacimiento)
// =====================================================================
pub(crate) fn tile_carta(model: &Model, theme: &Theme) -> View<Msg> {
let bd = &model.chart.birth_data;
let lugar = bd
.birthplace_label
.clone()
.unwrap_or_else(|| "(sin lugar)".into());
let fecha = format!(
"{:04}-{:02}-{:02} {:02}:{:02} UTC{:+}",
bd.year,
bd.month,
bd.day,
bd.hour,
bd.minute,
bd.tz_offset_minutes as f32 / 60.0
);
let lat_long = format!(
"{:.4}°{} · {:.4}°{}",
bd.latitude_deg.abs(),
if bd.latitude_deg >= 0.0 { "N" } else { "S" },
bd.longitude_deg.abs(),
if bd.longitude_deg >= 0.0 { "E" } else { "W" }
);
let r = &model.render;
let angles = [
("Asc", r.ascendant_deg),
("MC", r.midheaven_deg),
("Dc", r.descendant_deg),
("IC", r.imum_coeli_deg),
];
let mut rows: Vec<View<Msg>> = vec![
line(model.chart.label.clone(), 14.0, theme.fg_text),
line(lugar, 11.0, theme.fg_muted),
line(fecha, 11.0, theme.fg_muted),
line(lat_long, 11.0, theme.fg_muted),
section_label("Ángulos".to_string(), theme),
];
for (name, deg) in angles {
rows.push(cells_row(vec![
txt_cell(name.to_string(), 32.0, 12.0, theme.fg_text, Alignment::Start),
txt_cell(
fmt_dms((deg.rem_euclid(30.0)) as f64),
56.0,
12.0,
theme.fg_muted,
Alignment::Start,
),
glyphs::sign_view(sign_id(deg), SGN, sign_color(deg)),
]));
}
tile_container(rows, theme)
}
// =====================================================================
// Cuerpos
// =====================================================================
pub(crate) fn tile_cuerpos(render: &RenderModel, theme: &Theme) -> View<Msg> {
let rows: Vec<View<Msg>> = render
.layers
.iter()
.filter(|l| l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies))
.flat_map(|l| l.glyphs.iter())
.map(|g| {
let dms = fmt_dms(g.deg.rem_euclid(30.0) as f64);
let house = g
.house
.map(|h| format!("h{h}"))
.unwrap_or_default();
let retro = if g.retrograde { "R" } else { "" };
let dignity = g.dignity_marker.clone().unwrap_or_default();
cells_row(vec![
glyphs::body_view(&g.symbol, GLYPH, theme.fg_text),
txt_cell(dms, 56.0, 12.0, theme.fg_text, Alignment::Start),
glyphs::sign_view(sign_id(g.deg), SGN, sign_color(g.deg)),
txt_cell(house, 30.0, 11.0, theme.fg_muted, Alignment::Start),
txt_cell(retro.to_string(), 14.0, 11.0, theme.fg_destructive, Alignment::Center),
txt_cell(dignity, 16.0, 11.0, theme.accent, Alignment::Center),
])
})
.collect();
tile_container(rows, theme)
}
// =====================================================================
// Aspectos — tabla unificada geocéntrico + topocéntrico
// =====================================================================
/// Una fila de la tabla unificada: un par (de cuerpos, aspecto) con su
/// orbe geocéntrico y/o topocéntrico.
struct AspRow {
kind: String,
from: String,
to: String,
geo: Option<f64>,
topo: Option<f64>,
applying: Option<bool>,
}
fn sorted_pair(a: &str, b: &str) -> (String, String) {
if a <= b {
(a.into(), b.into())
} else {
(b.into(), a.into())
}
}
/// Tabla unificada de aspectos: geocéntrico (módulo `natal`) y
/// topocéntrico (módulo `topocentric`) en la misma grilla, con la
/// diferencia de orbe entre ambos y los glyphs del aspecto, los cuerpos
/// y sus signos.
pub(crate) fn tile_aspectos(render: &RenderModel, theme: &Theme) -> View<Msg> {
let lons = body_lons(render);
let mut map: HashMap<(String, String, String), AspRow> = HashMap::new();
for a in &render.aspect_summary {
let topo = a.module_id == "topocentric";
if !topo && a.module_id != "natal" {
continue;
}
let (from, to) = sorted_pair(&a.from_body, &a.to_body);
let key = (from.clone(), to.clone(), a.kind.clone());
let row = map.entry(key).or_insert_with(|| AspRow {
kind: a.kind.clone(),
from,
to,
geo: None,
topo: None,
applying: None,
});
if topo {
row.topo = Some(a.orb_deg);
} else {
row.geo = Some(a.orb_deg);
row.applying = a.applying;
}
}
let mut rows: Vec<AspRow> = map.into_values().collect();
// Orden por intensidad: el orbe más cerrado (aspecto más exacto y
// fuerte) primero, sin importar mayor/menor.
rows.sort_by(|a, b| {
let oa = a.geo.or(a.topo).unwrap_or(99.0);
let ob = b.geo.or(b.topo).unwrap_or(99.0);
oa.partial_cmp(&ob).unwrap_or(std::cmp::Ordering::Equal)
});
if rows.is_empty() {
return tile_container(
vec![line(rimay_localize::t("cosmos-empty"), 12.0, theme.fg_muted)],
theme,
);
}
// Cabecera de columnas.
let header = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
gap: Size {
width: length(3.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
txt_cell(String::new(), 4.0, 10.0, theme.fg_muted, Alignment::Start),
txt_cell(String::new(), GLYPH, 10.0, theme.fg_muted, Alignment::Start),
txt_cell(String::new(), GLYPH + SGN + 4.0, 10.0, theme.fg_muted, Alignment::Start),
txt_cell(String::new(), GLYPH + SGN + 4.0, 10.0, theme.fg_muted, Alignment::Start),
txt_cell("geo".to_string(), 46.0, 10.0, theme.fg_muted, Alignment::Start),
txt_cell("topo".to_string(), 46.0, 10.0, theme.fg_muted, Alignment::Start),
txt_cell("Δ".to_string(), 40.0, 10.0, theme.fg_muted, Alignment::Start),
]);
let mut out: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 1);
out.push(header);
for row in rows.into_iter().take(60) {
let orb = row.geo.or(row.topo).unwrap_or(8.0);
let intensity = (1.0 - orb / 8.0).clamp(0.15, 1.0) as f32;
let geo = row
.geo
.map(fmt_dms)
.unwrap_or_else(|| "".to_string());
let topo = row
.topo
.map(fmt_dms)
.unwrap_or_else(|| "".to_string());
let diff = match (row.geo, row.topo) {
(Some(g), Some(t)) => format!("{:+.0}'", (t - g) * 60.0),
_ => "".to_string(),
};
let dir = match row.applying {
Some(true) => glyphs::icon_view(glyphs::Icon::Applying, 12.0, theme.fg_muted),
Some(false) => glyphs::icon_view(glyphs::Icon::Separating, 12.0, theme.fg_muted),
None => txt_cell(String::new(), 12.0, 10.0, theme.fg_muted, Alignment::Center),
};
// Texto del orbe a más contraste cuanto más fuerte el aspecto.
let orb_col = if intensity > 0.55 { theme.fg_text } else { theme.fg_muted };
out.push(cells_row(vec![
intensity_bar(&row.kind, intensity),
glyphs::aspect_view(&row.kind, GLYPH),
body_sign(&row.from, lons.get(&row.from).copied(), theme),
body_sign(&row.to, lons.get(&row.to).copied(), theme),
txt_cell(geo, 46.0, 11.0, orb_col, Alignment::Start),
txt_cell(topo, 46.0, 11.0, orb_col, Alignment::Start),
txt_cell(diff, 40.0, 11.0, theme.fg_muted, Alignment::Start),
dir,
]));
}
tile_container(out, theme)
}
/// Color del aspecto (paleta oscura) con la opacidad dada.
fn aspect_color_intensity(kind: &str, intensity: f32) -> Color {
let c = Palette::dark().aspect(kind);
let to = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
Color::from_rgba8(to(c.r), to(c.g), to(c.b), to(intensity))
}
/// Barra vertical en el color del aspecto cuya opacidad marca la
/// intensidad — los aspectos exactos se ven más fuertes en la lista,
/// igual que sus líneas en la carta.
fn intensity_bar(kind: &str, intensity: f32) -> View<Msg> {
View::new(Style {
size: Size {
width: length(4.0),
height: length(ROW_H - 6.0),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(aspect_color_intensity(kind, intensity))
.radius(2.0)
}
// =====================================================================
// Uraniano (grupos del dial de 90°)
// =====================================================================
pub(crate) fn tile_uraniano(groups: &[UranianGroup], theme: &Theme) -> View<Msg> {
if groups.is_empty() {
return tile_container(
vec![line(
"Activá la capa «Uraniano» (menú Capas) para ver los grupos del dial de 90°."
.to_string(),
12.0,
theme.fg_muted,
)],
theme,
);
}
let rows: Vec<View<Msg>> = groups
.iter()
.take(40)
.map(|g| {
let mut cells: Vec<View<Msg>> = vec![txt_cell(
format!("{:.1}°", g.mod90_deg),
52.0,
12.0,
theme.fg_text,
Alignment::Start,
)];
for b in &g.bodies {
cells.push(glyphs::body_view(b, GLYPH, theme.fg_text));
}
cells_row(cells)
})
.collect();
tile_container(rows, theme)
}
// =====================================================================
// Cualidades (elementos + modalidades + polaridad)
// =====================================================================
pub(crate) fn tile_cualidades(render: &RenderModel, theme: &Theme) -> View<Msg> {
let bodies: Vec<(&str, f32)> = render
.layers
.iter()
.filter(|l| l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies))
.flat_map(|l| l.glyphs.iter())
.map(|g| (g.symbol.as_str(), g.deg))
.collect();
let mut elementos: [Vec<&str>; 4] = Default::default();
let mut modalidades: [Vec<&str>; 3] = Default::default();
let mut polaridad: [Vec<&str>; 2] = Default::default();
for (name, deg) in &bodies {
let sign_idx = ((deg.rem_euclid(360.0) / 30.0) as usize) % 12;
elementos[sign_idx % 4].push(name);
modalidades[sign_idx % 3].push(name);
polaridad[sign_idx % 2].push(name);
}
let elem_labels = ["Fuego", "Tierra", "Aire", "Agua"];
let mod_labels = ["Cardinal", "Fijo", "Mutable"];
let pol_labels = ["Yang", "Yin"];
let mut rows: Vec<View<Msg>> = Vec::new();
rows.push(section_label("Elementos".to_string(), theme));
for (i, label) in elem_labels.iter().enumerate() {
rows.push(fila_cualidad(label, &elementos[i], theme));
}
rows.push(section_label("Modalidades".to_string(), theme));
for (i, label) in mod_labels.iter().enumerate() {
rows.push(fila_cualidad(label, &modalidades[i], theme));
}
rows.push(section_label("Polaridad".to_string(), theme));
for (i, label) in pol_labels.iter().enumerate() {
rows.push(fila_cualidad(label, &polaridad[i], theme));
}
tile_container(rows, theme)
}
/// Una fila de cualidad: etiqueta + barra (rect rellena sobre track) +
/// los glyphs de los cuerpos que caen ahí.
fn fila_cualidad(label: &str, bodies: &[&str], theme: &Theme) -> View<Msg> {
let count = bodies.len();
let frac = (count as f32 / 10.0).clamp(0.0, 1.0);
let lbl = txt_cell(label.to_string(), 56.0, 11.0, theme.fg_text, Alignment::Start);
// Barra: track + relleno proporcional.
let bar = View::new(Style {
size: Size {
width: length(64.0_f32),
height: length(8.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(theme.bg_panel_alt)
.radius(3.0)
.children(vec![View::new(Style {
size: Size {
width: percent(frac),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.accent)
.radius(3.0)]);
let bar_box = View::new(Style {
size: Size {
width: length(64.0_f32),
height: length(ROW_H),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![bar]);
let cnt = txt_cell(count.to_string(), 16.0, 11.0, theme.fg_muted, Alignment::Center);
let mut cells = vec![lbl, bar_box, cnt];
for b in bodies.iter().take(8) {
cells.push(glyphs::body_view(b, GLYPH, theme.fg_text));
}
cells_row(cells)
}
// =====================================================================
// Aspectario triangular
// =====================================================================
pub(crate) fn tile_box_graph(render: &RenderModel, theme: &Theme) -> View<Msg> {
let bodies: Vec<String> = render
.layers
.iter()
.filter(|l| l.module_id == "natal" && matches!(l.kind, LayerKind::Bodies))
.flat_map(|l| l.glyphs.iter())
.map(|g| g.symbol.clone())
.collect();
if bodies.len() < 2 {
return tile_container(
vec![line(rimay_localize::t("cosmos-empty"), 12.0, theme.fg_muted)],
theme,
);
}
let mut aspects: HashMap<(String, String), String> = HashMap::new();
for a in &render.aspect_summary {
if a.module_id != "natal" {
continue;
}
let key = sorted_pair(&a.from_body, &a.to_body);
aspects.insert(key, a.kind.clone());
}
const CELL: f32 = 24.0;
let rows: Vec<View<Msg>> = bodies
.iter()
.enumerate()
.map(|(i, body_i)| {
let mut cells: Vec<View<Msg>> =
vec![box_cell(Some(glyphs::body_view(body_i, GLYPH, theme.fg_text)), None)];
for body_j in bodies.iter().take(i) {
let pair = sorted_pair(body_i, body_j);
match aspects.get(&pair) {
Some(k) => cells.push(box_cell(
Some(glyphs::aspect_view(k, GLYPH)),
Some(theme.bg_panel_alt),
)),
None => cells.push(box_cell(None, None)),
}
}
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: Dimension::auto(),
height: length(CELL),
},
flex_shrink: 0.0,
..Default::default()
})
.children(cells)
})
.collect();
tile_container(rows, theme)
}
fn box_cell(content: Option<View<Msg>>, bg: Option<Color>) -> View<Msg> {
const CELL: f32 = 24.0;
let mut v = View::new(Style {
size: Size {
width: length(CELL),
height: length(CELL),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
});
if let Some(c) = bg {
v = v.fill(c).radius(2.0);
}
if let Some(child) = content {
v = v.children(vec![child]);
}
v
}
// =====================================================================
// Layer genérica (lotes / estrellas fijas / puntos medios)
// =====================================================================
pub(crate) fn tile_layer_glyphs(
render: &RenderModel,
kind: LayerKind,
module_id: &str,
hint: &str,
theme: &Theme,
) -> View<Msg> {
let glyphs_v: Vec<&cosmos_render::Glyph> = render
.layers
.iter()
.filter(|l| {
l.module_id == module_id
&& std::mem::discriminant(&l.kind) == std::mem::discriminant(&kind)
})
.flat_map(|l| l.glyphs.iter())
.collect();
if glyphs_v.is_empty() {
return tile_container(vec![line(hint.to_string(), 12.0, theme.fg_muted)], theme);
}
let rows: Vec<View<Msg>> = glyphs_v
.into_iter()
.take(40)
.map(|g| {
let casa = g.house.map(|h| format!("h{h}")).unwrap_or_default();
let dms = fmt_dms(g.deg.rem_euclid(30.0) as f64);
// Lotes y estrellas traen una anotación textual; los puntos
// medios y demás son cuerpos con glyph.
let lead: View<Msg> = if g.symbol.starts_with("lot:") || g.symbol.starts_with('✦') {
let label = g.annotation.clone().unwrap_or_else(|| g.symbol.clone());
txt_cell(label, 96.0, 11.0, theme.fg_text, Alignment::Start)
} else {
glyphs::body_view(&g.symbol, GLYPH, theme.fg_text)
};
cells_row(vec![
lead,
txt_cell(dms, 56.0, 12.0, theme.fg_text, Alignment::Start),
glyphs::sign_view(sign_id(g.deg), SGN, sign_color(g.deg)),
txt_cell(casa, 30.0, 11.0, theme.fg_muted, Alignment::Start),
])
})
.collect();
tile_container(rows, theme)
}
// =====================================================================
// Corpus (pasajes interpretativos)
// =====================================================================
pub(crate) fn tile_corpus(render: &RenderModel, corpus: &Corpus, theme: &Theme) -> View<Msg> {
let (colocaciones, aspectos) = corpus_inputs(render);
let combinaciones = combinaciones_de_carta(&colocaciones, &aspectos);
let pasajes = corpus.interpretar(&combinaciones);
let huecos = corpus.huecos(&combinaciones);
let header_txt = rimay_localize::t_args(
"cosmos-corpus-header",
&[
("pasajes", pasajes.len().to_string().into()),
("huecos", huecos.len().to_string().into()),
("total", combinaciones.len().to_string().into()),
],
);
let mut rows: Vec<View<Msg>> = Vec::with_capacity(pasajes.len() * 2 + 1);
rows.push(line(header_txt, 11.0, theme.fg_muted));
if pasajes.is_empty() {
rows.push(line(
rimay_localize::t("cosmos-corpus-vacio"),
12.0,
theme.fg_muted,
));
} else {
for p in pasajes.iter().take(16) {
rows.push(line(p.combinacion.to_string(), 10.0, theme.accent));
let txt = recortar(&p.texto, 200);
rows.push(line(txt, 12.0, theme.fg_text));
}
}
tile_container(rows, theme)
}
fn recortar(s: &str, max_chars: usize) -> String {
let mut out = String::new();
for (i, ch) in s.chars().enumerate() {
if i >= max_chars {
out.push('…');
return out;
}
out.push(ch);
}
out
}
@@ -0,0 +1,22 @@
[package]
name = "cosmos-astrology"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Astrology-specific layer built on the cosmos-sky façade: birth data, natal charts, house systems, ayanamshas, aspects."
# Kept unpublishable while cosmos-sky is. Will flip in lockstep at v1.0.
publish = false
[dependencies]
cosmos-core.workspace = true
cosmos-ephemeris.workspace = true
cosmos-time.workspace = true
cosmos-sky = { path = "../cosmos-sky" }
cosmos-validation = { path = "../cosmos-validation" }
libm.workspace = true
thiserror.workspace = true
[dev-dependencies]
approx.workspace = true
+193
View File
@@ -0,0 +1,193 @@
# cosmos-astrology
The astrology-specific layer of the `eternal` workspace, built on the [`cosmos-sky`](../cosmos-sky/) façade.
[![License: Apache 2.0](https://img.shields.io/crates/l/cosmos-astrology)](https://gitea.gioser.net/sergio/eternal)
A typed pipeline that turns *(when, where)* into a `NatalChart`: four angles, twelve house cusps in the chosen system, every requested body placed in its sign and house with retrograde flag — plus a full forecasting toolkit: aspects, returns, progressions, solar arc, the classical primary-direction trilogy (Placidus, Regiomontanus, Campanus), transits, stations, synastry, midpoint composites, Arabic Parts, Hellenistic profections, lunar phases, and eclipses-on-natal.
## Disclaimer
Astrology is a symbolic system with deep cultural and personal significance for many people. This crate computes its traditional constructs faithfully but **takes no position** on whether those constructs describe, predict, or explain anything about an individual's life. Treat the output as a *language*, not as data. The precision claims in this README refer strictly to the astronomical inputs (planet positions, time scales, IAU rotations); they say nothing about the validity of the astrological interpretations the user may build on top.
## Installation
```toml
[dependencies]
cosmos-astrology = "0.1"
cosmos-sky = "0.1"
```
## Feature matrix
| Concept | API | Tests |
|---|---|---|
| Natal chart (7 house systems, tropical or sidereal) | `NatalChart::compute` | ✅ |
| Whole-Sign, Equal, Porphyry, Placidus, Koch, Regiomontanus, Campanus | `HouseSystem` | ✅ |
| 8 ayanamshas (Lahiri, Fagan-Bradley, Krishnamurti, Raman, …) | `Zodiac::Sidereal(Ayanamsha::*)` | ✅ |
| 22 bodies — luminaries, planets, nodes m+v, Lilith m+v, asteroids | `BodySet` | ✅ |
| Mundane helpers (DA, semi-arcs, Placidus quadrant `m`) | `mundane::*` | ✅ |
| Aspects (12 kinds, applying/separating, orb table) | `find_aspects` | ✅ |
| Planetary returns (Sun / Moon / any body) | `next_return` | ✅ |
| Progressions: Secondary, Tertiary, Minor | `secondary_progression`, … | ✅ |
| Solar Arc directions (TrueProgressedSun, Naibod) | `solar_arc_true`, `solar_arc_naibod` | ✅ |
| Primary directions — Placidus mundane, **Regiomontanus**, **Campanus** | `direct`, `direct_to_aspect`, `all_directions_with_aspects` | ✅ |
| Direction keys (Ptolemy 1°/yr, Naibod 0°59'08"/yr) | `DirectionKey` | ✅ |
| Transits — current snapshot + next exact root-finder | `find_current_transits`, `find_next_exact_transit` | ✅ |
| Planetary stations (retrograde / direct) | `next_station`, `all_stations` | ✅ |
| Synastry — cross-aspects between two charts | `find_synastry_aspects` | ✅ |
| Composite — midpoint chart | `composite` | ✅ |
| Arabic Parts (7 canonical Lots + custom) | `compute_lot`, `all_lots`, `custom_lot` | ✅ |
| Hellenistic profections (annual + monthly + Lord of the Year) | `annual_profection`, `monthly_profection`, `profection_at` | ✅ |
| Lunar phases (4 canonical + 8-fold lunation classification) | `next_lunar_phase`, `next_canonical_phase`, `classify_lunation_phase` | ✅ |
| Eclipses (solar / lunar) on natal points | `eclipses_on_natal`, `next_solar_eclipse`, `next_lunar_eclipse` | ✅ |
| Generic event root-finder over time | `eternal_sky::find_root` | ✅ |
102 tests across `cosmos-sky` + `cosmos-astrology` gate the precision and behaviour of these features against direct calls into the validated underlying machinery.
## Quick start: a complete natal chart
```rust
use eternal_astrology::{
find_aspects, BirthData, ChartConfig, HouseSystem, NatalChart, OrbTable, Zodiac,
};
use eternal_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
let session = EphemerisSession::open(SessionConfig::vsop2013())?;
let birth = BirthData::new(
Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240)?,
Observer::from_degrees(10.4806, -66.9036, 900.0),
).with_name("Subject A");
let config = ChartConfig {
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
..ChartConfig::default()
};
let chart = NatalChart::compute(&birth, &config, &session)?;
println!("Ascendant: {}", chart.ascendant().to_chart_format());
println!("Midheaven: {}", chart.midheaven().to_chart_format());
for placement in &chart.placements {
println!("{:>8} {} House {:>2} {}",
placement.body.name(),
placement.longitude.to_chart_format(),
placement.house_number,
if placement.is_retrograde() { "R" } else { " " },
);
}
let aspects = find_aspects(&chart, &OrbTable::modern_western());
for a in &aspects {
println!("{:>10} {:?} {:<10} orb {:>5.2}° {}",
a.a.name(), a.kind, a.b.name(),
a.orb_abs_deg(),
if a.applying { "applying" } else { "separating" },
);
}
# Ok::<_, eternal_astrology::AstrologyError>(())
```
## Forecasting
```rust
use eternal_astrology::*;
use eternal_sky::{Body, Instant};
// Solar Return for 2025:
let natal_sun = chart.placement(Body::Sun).unwrap().longitude.longitude_rad();
let after_birthday = Instant::from_civil_utc(2025, 3, 1, 0, 0, 0.0)?;
let solar_return_2025 = next_return(&session, Body::Sun, natal_sun, after_birthday, None)?;
// Secondary progression at age 30:
let prog = secondary_progression(&chart, &session, 30.0)?;
// Solar arc directions at age 30:
let arc = solar_arc_true(&chart, &session, 30.0)?;
// All primary directions in the first 80 years of life:
let dirs = all_directions(
&chart,
DirectionMethod::PlacidusMundane,
DirectionKey::Naibod,
80.0,
);
// Current transits to natal:
let now = Instant::from_civil_utc(2026, 5, 15, 12, 0, 0.0)?;
let targets = default_natal_targets(&chart);
let transits = find_current_transits(
&chart, &session, now,
&[Body::Mars, Body::Saturn, Body::Jupiter,
Body::Uranus, Body::Neptune, Body::Pluto],
&targets,
&OrbTable::modern_western(),
AspectKind::MAJORS,
)?;
// Synastry between two charts:
let sync = find_synastry_aspects(
&chart_a, &chart_b,
&OrbTable::modern_western(),
AspectKind::MAJORS,
);
```
## Modules
| Module | Purpose |
|----------------------|-------------------------------------------------------------------------|
| `angles` | Shared `signed_delta_*`, `wrap_two_pi`, `unsigned_arc_deg` helpers |
| `birth_data` | `BirthData` + `TimeCertainty` |
| `chart_config` | `ChartConfig`, `BodySet` |
| `chart` | `NatalChart::compute` and accessors |
| `zodiac` | `Sign` enum, `Zodiac` (Tropical/Sidereal), `SignedLongitude` |
| `house_system` | `HouseSystem` enum + `Houses::compute` |
| `placement` | `BodyPlacement` (sign, house, RA/Dec, derived `is_retrograde()`) |
| `mundane` | DA, semi-arcs, Placidus quadrant `m` |
| `aspect` | `AspectKind`, `OrbTable`, `find_aspects` |
| `returns` | `next_return` (planetary returns) |
| `progression` | Secondary / Tertiary / Minor progressions |
| `solar_arc` | Solar Arc directions (true / Naibod) |
| `primary_direction` | Placidus mundane, Regiomontanus, and Campanus directions |
| `transits` | Current snapshot + next-exact transit |
| `stations` | Retrograde / direct station finder |
| `synastry` | Cross-chart aspect grid |
| `composite` | Midpoint composite chart |
| `lots` | Arabic Parts (Hellenistic Lots) with sect-aware reversal |
| `profections` | Annual + monthly profections with traditional / modern rulerships |
| `lunar_phase` | 4 canonical phases + 8-fold lunation classification |
| `eclipses` | Solar / lunar eclipse search and on-natal proximity filter |
## Design
- **Astronomy first.** Every astrology routine forwards to `cosmos-sky` and ultimately to the validated `cosmos-validation::oracle::Oracle`. No parallel ephemerides, no shortcuts.
- **Lazy where it matters.** `BodyPlacement` carries forward longitude rate + RA/Dec from `ApparentPosition`, so the aspect/applying engine and the mundane helpers do not re-query the ephemeris.
- **Interpretation-free.** No body has a "rulership", no aspect has a "meaning". Configure orbs, house systems, ayanamshas and bodies; pattern-match on the results in your own application layer.
- **Reusable primitives.** `find_root` from `cosmos-sky` powers returns, transits, and future timing queries — adding a new "find next X" is ~30 lines.
## License
Licensed under the Apache License, Version 2.0
([LICENSE-APACHE](../LICENSE-APACHE) or
<https://www.apache.org/licenses/LICENSE-2.0>).
## Acknowledgements
This crate was added to the `eternal` workspace by Sergio Velásquez
Zeballos in collaboration with Claude (Anthropic). It builds on the
upstream [celestial](https://github.com/gaker/celestial) project by
Greg Aker and on the validated astronomy of `cosmos-validation`.
### With thanks to
For their guidance, conversations, and inspiration that shaped the
direction of this astrology pipeline:
- **Roberto Reiley**
- **Germán Rosas**
- **Juan Velásquez**
- **Guillermo Velásquez**
@@ -0,0 +1,96 @@
//! Print a complete natal chart: angles, houses, placements, aspects.
//!
//! Run with `cargo run --example natal_chart -p eternal-astrology` — uses
//! the analytical VSOP2013 backend, so no kernels need to be downloaded.
//! For sub-mas precision you would swap in a JPL SPK kernel via
//! `SessionConfig::with_spk(...)`.
use cosmos_astrology::{
find_aspects, AspectKind, BirthData, ChartConfig, HouseSystem, NatalChart, OrbTable, Zodiac,
};
use cosmos_sky::{EphemerisSession, Instant, Observer, SessionConfig};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── Birth data ────────────────────────────────────────────────────
// Demo subject: 14 March 1987, 05:22 local time in Caracas (UTC4).
// Change these constants to compute another chart.
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240)?;
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
let birth = BirthData::new(instant, observer).with_name("Demo Subject");
// ── Session + configuration ───────────────────────────────────────
let session = EphemerisSession::open(SessionConfig::vsop2013())?;
let config = ChartConfig {
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
..ChartConfig::default()
};
let chart = NatalChart::compute(&birth, &config, &session)?;
// ── Header ────────────────────────────────────────────────────────
println!("Natal Chart — {}", birth.name.as_deref().unwrap_or("(unnamed)"));
println!(" UTC instant : {}", chart.birth.instant);
println!(
" Location : lat {:+.4}° lon {:+.4}° elev {} m",
birth.observer.lat_rad.to_degrees(),
birth.observer.lon_rad.to_degrees(),
birth.observer.elev_m as i32,
);
println!(" House system: {:?}", config.house_system);
println!(" Zodiac : {:?}", config.zodiac);
println!();
// ── Angles ────────────────────────────────────────────────────────
println!("Angles");
println!(" Asc : {}", chart.ascendant().to_chart_format());
println!(" MC : {}", chart.midheaven().to_chart_format());
println!(" Desc: {}", chart.descendant().to_chart_format());
println!(" IC : {}", chart.imum_coeli().to_chart_format());
println!();
// ── Houses ────────────────────────────────────────────────────────
println!("House Cusps");
for (i, cusp) in chart.houses.cusps.iter().enumerate() {
let sl = cosmos_astrology::SignedLongitude::from_radians(*cusp);
println!(" H{:>2}: {}", i + 1, sl.to_chart_format());
}
println!();
// ── Placements ────────────────────────────────────────────────────
println!("Placements");
println!(" {:<12} {:<14} {:>5} {:>4}",
"Body", "Position", "House", "Mode");
for p in &chart.placements {
println!(" {:<12} {:<14} H{:>3} {:>4}",
p.body.name(),
p.longitude.to_chart_format(),
p.house_number,
if p.is_retrograde() { "R" } else { "" },
);
}
println!();
// ── Aspects ───────────────────────────────────────────────────────
println!("Aspects (modern Western orbs)");
let aspects = find_aspects(&chart, &OrbTable::modern_western());
let majors: Vec<_> = aspects
.iter()
.filter(|a| AspectKind::MAJORS.contains(&a.kind))
.collect();
println!(" {:<10} {:<14} {:<10} {:>6} {}", "A", "Aspect", "B", "Orb", "Phase");
for a in &majors {
println!(" {:<10} {:<14} {:<10} {:>5.2}° {}",
a.a.name(),
format!("{:?}", a.kind),
a.b.name(),
a.orb_abs_deg(),
if a.applying { "applying" } else { "separating" },
);
}
if majors.is_empty() {
println!(" (no major aspects within configured orbs)");
}
Ok(())
}
@@ -0,0 +1,88 @@
//! Shared angle helpers used across the astrology layer.
//!
//! Pulled out so each forecasting module (aspects, returns, transits,
//! synastry, composite, solar arc, lunar phase, eclipses) can use the
//! same wrap/delta math without each defining its own private copy.
const TAU: f64 = std::f64::consts::TAU;
const PI: f64 = std::f64::consts::PI;
/// Signed angular delta `a b` in radians, normalised to `[-π, π]`.
#[inline]
pub(crate) fn signed_delta_rad(a: f64, b: f64) -> f64 {
let mut d = a - b;
while d > PI {
d -= TAU;
}
while d < -PI {
d += TAU;
}
d
}
/// Signed angular delta `a b` in degrees, normalised to `[-180°, 180°]`.
#[inline]
pub(crate) fn signed_delta_deg(a_deg: f64, b_deg: f64) -> f64 {
let mut d = a_deg - b_deg;
while d > 180.0 {
d -= 360.0;
}
while d < -180.0 {
d += 360.0;
}
d
}
/// Unsigned angular distance in `[0°, 180°]`.
#[inline]
pub(crate) fn unsigned_arc_deg(a_deg: f64, b_deg: f64) -> f64 {
let mut d = (a_deg - b_deg).rem_euclid(360.0);
if d > 180.0 {
d = 360.0 - d;
}
d
}
/// Wrap an angle (radians) into `[0, 2π)`.
#[inline]
pub(crate) fn wrap_two_pi(x: f64) -> f64 {
let v = x.rem_euclid(TAU);
if v < 0.0 {
v + TAU
} else {
v
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signed_delta_deg_wraps_to_shorter_arc() {
assert!((signed_delta_deg(350.0, 10.0) + 20.0).abs() < 1e-12);
assert!((signed_delta_deg(10.0, 350.0) - 20.0).abs() < 1e-12);
}
#[test]
fn signed_delta_rad_matches_deg_form() {
let a = 350.0_f64.to_radians();
let b = 10.0_f64.to_radians();
let d = signed_delta_rad(a, b);
assert!((d + 20.0_f64.to_radians()).abs() < 1e-12);
}
#[test]
fn unsigned_arc_deg_picks_shorter_distance() {
assert!((unsigned_arc_deg(350.0, 10.0) - 20.0).abs() < 1e-12);
assert!((unsigned_arc_deg(10.0, 350.0) - 20.0).abs() < 1e-12);
assert!((unsigned_arc_deg(0.0, 180.0) - 180.0).abs() < 1e-12);
}
#[test]
fn wrap_two_pi_normalises() {
assert!((wrap_two_pi(0.0) - 0.0).abs() < 1e-12);
assert!((wrap_two_pi(-PI) - PI).abs() < 1e-12);
assert!((wrap_two_pi(3.0 * TAU) - 0.0).abs() < 1e-12);
}
}
@@ -0,0 +1,415 @@
//! Aspect engine: detect angular relationships between bodies in a chart.
//!
//! An *aspect* is an angular distance close (within an "orb") to a
//! traditional ratio of the circle. The classical majors are
//! conjunction (0°), opposition (180°), trine (120°), square (90°), and
//! sextile (60°); the harmonic minors (quincunx, semi-square, quintile,
//! septile, …) are wired in too for completeness.
//!
//! Each aspect carries:
//! * the two bodies involved (commutative — `a` ≤ `b` by NAIF ID),
//! * the [`AspectKind`] family,
//! * the *signed* delta from exact: `+` means the smaller-longitude
//! body is below the exact angle,
//! * the orb used (the threshold the pair was tested against),
//! * whether the aspect is **applying** (closing toward exact) or
//! **separating** (already past).
use std::collections::HashMap;
use cosmos_sky::Body;
use crate::angles::signed_delta_deg;
use crate::chart::NatalChart;
use crate::placement::BodyPlacement;
/// Family of aspects. The exact angle of each is fixed; their orbs are
/// configured via [`OrbTable`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AspectKind {
Conjunction,
Opposition,
Trine,
Square,
Sextile,
Quincunx,
SemiSextile,
SemiSquare,
Sesquiquadrate,
Quintile,
BiQuintile,
Septile,
}
impl AspectKind {
pub const MAJORS: &'static [AspectKind] = &[
AspectKind::Conjunction,
AspectKind::Opposition,
AspectKind::Trine,
AspectKind::Square,
AspectKind::Sextile,
];
pub const MINORS: &'static [AspectKind] = &[
AspectKind::Quincunx,
AspectKind::SemiSextile,
AspectKind::SemiSquare,
AspectKind::Sesquiquadrate,
AspectKind::Quintile,
AspectKind::BiQuintile,
AspectKind::Septile,
];
pub const ALL: &'static [AspectKind] = &[
AspectKind::Conjunction,
AspectKind::Opposition,
AspectKind::Trine,
AspectKind::Square,
AspectKind::Sextile,
AspectKind::Quincunx,
AspectKind::SemiSextile,
AspectKind::SemiSquare,
AspectKind::Sesquiquadrate,
AspectKind::Quintile,
AspectKind::BiQuintile,
AspectKind::Septile,
];
/// Exact angle in degrees.
pub fn exact_angle_deg(self) -> f64 {
match self {
AspectKind::Conjunction => 0.0,
AspectKind::Opposition => 180.0,
AspectKind::Trine => 120.0,
AspectKind::Square => 90.0,
AspectKind::Sextile => 60.0,
AspectKind::Quincunx => 150.0,
AspectKind::SemiSextile => 30.0,
AspectKind::SemiSquare => 45.0,
AspectKind::Sesquiquadrate => 135.0,
AspectKind::Quintile => 72.0,
AspectKind::BiQuintile => 144.0,
AspectKind::Septile => 360.0 / 7.0,
}
}
pub fn name(self) -> &'static str {
match self {
AspectKind::Conjunction => "conjunction",
AspectKind::Opposition => "opposition",
AspectKind::Trine => "trine",
AspectKind::Square => "square",
AspectKind::Sextile => "sextile",
AspectKind::Quincunx => "quincunx",
AspectKind::SemiSextile => "semi-sextile",
AspectKind::SemiSquare => "semi-square",
AspectKind::Sesquiquadrate => "sesquiquadrate",
AspectKind::Quintile => "quintile",
AspectKind::BiQuintile => "bi-quintile",
AspectKind::Septile => "septile",
}
}
}
/// Per-aspect base orbs (in degrees) and optional per-body luminary
/// multipliers. Designed to be cheap to copy and serialise.
#[derive(Debug, Clone)]
pub struct OrbTable {
base_orb_deg: HashMap<AspectKind, f64>,
body_multiplier: HashMap<Body, f64>,
/// Multiplier used when *neither* body is in [`Self::body_multiplier`].
pub default_body_multiplier: f64,
}
impl OrbTable {
/// A reasonably tight modern Western set:
/// 8° for conjunctions/oppositions, 7° for trines/squares, 5° for
/// sextiles, 2° for minors; Sun and Moon get a 1.25× multiplier.
pub fn modern_western() -> Self {
let mut base = HashMap::new();
base.insert(AspectKind::Conjunction, 8.0);
base.insert(AspectKind::Opposition, 8.0);
base.insert(AspectKind::Trine, 7.0);
base.insert(AspectKind::Square, 7.0);
base.insert(AspectKind::Sextile, 5.0);
base.insert(AspectKind::Quincunx, 2.5);
base.insert(AspectKind::SemiSextile, 2.0);
base.insert(AspectKind::SemiSquare, 2.0);
base.insert(AspectKind::Sesquiquadrate, 2.0);
base.insert(AspectKind::Quintile, 1.5);
base.insert(AspectKind::BiQuintile, 1.5);
base.insert(AspectKind::Septile, 1.5);
let mut mult = HashMap::new();
mult.insert(Body::Sun, 1.25);
mult.insert(Body::Moon, 1.25);
Self {
base_orb_deg: base,
body_multiplier: mult,
default_body_multiplier: 1.0,
}
}
/// Tight orbs: ~half of [`Self::modern_western`]. Good for
/// progressions / directions where wider orbs become meaningless.
pub fn tight() -> Self {
let mut t = Self::modern_western();
for v in t.base_orb_deg.values_mut() {
*v *= 0.5;
}
t
}
/// Set the base orb for a specific aspect family.
pub fn set_orb(&mut self, kind: AspectKind, orb_deg: f64) -> &mut Self {
self.base_orb_deg.insert(kind, orb_deg);
self
}
/// Set a per-body orb multiplier (useful for luminaries, chart-ruler,
/// or stellium reductions).
pub fn set_body_multiplier(&mut self, body: Body, mult: f64) -> &mut Self {
self.body_multiplier.insert(body, mult);
self
}
/// Effective allowed orb for an aspect between `a` and `b`.
/// Uses the *maximum* of the two body multipliers — convention is
/// that a Sun-aspect-Mercury gets the Sun's wider orb, not the
/// Mercury orb.
pub fn orb_for(&self, a: Body, b: Body, kind: AspectKind) -> f64 {
let base = self.base_orb_deg.get(&kind).copied().unwrap_or(0.0);
let ma = self
.body_multiplier
.get(&a)
.copied()
.unwrap_or(self.default_body_multiplier);
let mb = self
.body_multiplier
.get(&b)
.copied()
.unwrap_or(self.default_body_multiplier);
base * ma.max(mb)
}
/// Build a flat lookup snapshot for use in tight pair-iteration
/// loops. The snapshot replaces three HashMap hashings per
/// `orb_for` call with two array indexes — meaningful when the
/// outer loop is N² in chart placements × `AspectKind::ALL`.
pub(crate) fn snapshot(&self) -> OrbSnapshot {
let mut base = [0.0_f64; AspectKind::ALL.len()];
for (i, &kind) in AspectKind::ALL.iter().enumerate() {
base[i] = self.base_orb_deg.get(&kind).copied().unwrap_or(0.0);
}
OrbSnapshot {
base_orb_deg: base,
body_multiplier: self
.body_multiplier
.iter()
.map(|(&b, &m)| (b, m))
.collect(),
default_body_multiplier: self.default_body_multiplier,
}
}
}
/// Flat, fixed-size view of an [`OrbTable`]'s contents, suitable for
/// inner pair-iteration loops.
pub(crate) struct OrbSnapshot {
base_orb_deg: [f64; AspectKind::ALL.len()],
body_multiplier: Vec<(Body, f64)>,
default_body_multiplier: f64,
}
impl OrbSnapshot {
#[inline]
pub(crate) fn orb_for(&self, a: Body, b: Body, kind: AspectKind) -> f64 {
let idx = AspectKind::ALL.iter().position(|k| *k == kind).unwrap_or(0);
let base = self.base_orb_deg[idx];
let ma = self.lookup_mult(a);
let mb = self.lookup_mult(b);
base * ma.max(mb)
}
#[inline]
fn lookup_mult(&self, body: Body) -> f64 {
for &(b, m) in &self.body_multiplier {
if b == body {
return m;
}
}
self.default_body_multiplier
}
}
impl Default for OrbTable {
fn default() -> Self {
Self::modern_western()
}
}
/// A single aspect detected in a chart.
#[derive(Debug, Clone, Copy)]
pub struct Aspect {
pub a: Body,
pub b: Body,
pub kind: AspectKind,
/// Signed distance from exact, degrees. Positive means the pair is
/// past the exact angle (`|Δλ| > exact_angle`); negative means it
/// is short of exact. Useful for "how exact is this aspect?" reports.
pub orb_signed_deg: f64,
/// Allowed orb at the time of detection (degrees). The aspect is
/// reported iff `orb_signed_deg.abs() <= allowed_orb_deg`.
pub allowed_orb_deg: f64,
/// `true` if the angular distance between `a` and `b` is closing
/// toward the exact angle; `false` if it is widening.
pub applying: bool,
}
impl Aspect {
pub fn orb_abs_deg(&self) -> f64 {
self.orb_signed_deg.abs()
}
/// How "tight" the aspect is, normalised to the allowed orb.
/// 0.0 = exact; 1.0 = at the edge of the orb.
pub fn tightness(&self) -> f64 {
if self.allowed_orb_deg == 0.0 {
0.0
} else {
self.orb_abs_deg() / self.allowed_orb_deg
}
}
}
/// Scan every pair of body placements in `chart` and return all
/// aspects whose orb sits within the table's allowance. The returned
/// list is sorted by tightness (most exact first).
pub fn find_aspects(chart: &NatalChart, orbs: &OrbTable) -> Vec<Aspect> {
find_aspects_filtered(chart, orbs, AspectKind::ALL)
}
/// Same as [`find_aspects`] but restricted to a subset of [`AspectKind`].
pub fn find_aspects_filtered(
chart: &NatalChart,
orbs: &OrbTable,
kinds: &[AspectKind],
) -> Vec<Aspect> {
let placements = &chart.placements;
let snapshot = orbs.snapshot();
// Upper bound on aspects: every pair × every kind (worst case).
let mut out = Vec::with_capacity(
placements.len() * (placements.len() - 1) / 2 * kinds.len(),
);
for i in 0..placements.len() {
for j in (i + 1)..placements.len() {
for &kind in kinds {
if let Some(asp) = test_pair(&placements[i], &placements[j], kind, &snapshot) {
out.push(asp);
}
}
}
}
out.sort_by(|x, y| {
x.orb_abs_deg()
.partial_cmp(&y.orb_abs_deg())
.unwrap_or(std::cmp::Ordering::Equal)
});
out
}
fn test_pair(
a: &BodyPlacement,
b: &BodyPlacement,
kind: AspectKind,
orbs: &OrbSnapshot,
) -> Option<Aspect> {
// Same-body pairs (e.g. mean node duplicated as ascending + descending
// in `BodySet::include_south_node`) would otherwise trigger spurious
// conjunctions/oppositions to themselves. Skip them.
if a.body == b.body {
return None;
}
let allowed = orbs.orb_for(a.body, b.body, kind);
if allowed <= 0.0 {
return None;
}
// Signed angular separation `lon_b lon_a`, normalised to
// `[-180°, 180°]`. The unsigned separation is what we compare
// against the exact angle. (We pass `(b, a)` to the helper which
// computes `arg0 arg1`.)
let raw_delta_deg = signed_delta_deg(
b.longitude.longitude_deg(),
a.longitude.longitude_deg(),
);
let separation = raw_delta_deg.abs();
let exact = kind.exact_angle_deg();
let diff = separation - exact;
if diff.abs() > allowed {
return None;
}
// Applying / separating: signed separation `raw_delta_deg` evolves
// at `(b_rate a_rate)`. The unsigned separation evolves at
// `sign(raw_delta) × (b_rate a_rate)`. The aspect is closing
// (applying) when (separation exact) and d(separation)/dt have
// opposite signs.
let rate_b_minus_a_deg_per_day =
(b.longitude_rate_rad_per_day - a.longitude_rate_rad_per_day).to_degrees();
let dseparation_dt = if raw_delta_deg >= 0.0 {
rate_b_minus_a_deg_per_day
} else {
-rate_b_minus_a_deg_per_day
};
let applying = if diff > 0.0 {
// sep > exact → closing means d sep / dt < 0.
dseparation_dt < 0.0
} else if diff < 0.0 {
// sep < exact → closing means d sep / dt > 0.
dseparation_dt > 0.0
} else {
// Exactly on the angle.
false
};
// Normalise the body order so the aspect is canonical regardless of
// input pair order: alphabetise by name (cheap, stable).
let (canon_a, canon_b) = if a.body.name() <= b.body.name() {
(a.body, b.body)
} else {
(b.body, a.body)
};
Some(Aspect {
a: canon_a,
b: canon_b,
kind,
orb_signed_deg: diff,
allowed_orb_deg: allowed,
applying,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aspect_exact_angles_round_to_traditional_values() {
assert!((AspectKind::Trine.exact_angle_deg() - 120.0).abs() < 1e-12);
assert!((AspectKind::Septile.exact_angle_deg() - 360.0 / 7.0).abs() < 1e-12);
}
#[test]
fn orb_table_modern_western_luminary_multiplier() {
let orbs = OrbTable::modern_western();
// Sun-Mercury conjunction: 8 × 1.25 = 10°.
let sun_mercury = orbs.orb_for(Body::Sun, Body::Mercury, AspectKind::Conjunction);
assert!((sun_mercury - 10.0).abs() < 1e-12);
// Mercury-Venus conjunction: 8 × 1.0 = 8°.
let m_v = orbs.orb_for(Body::Mercury, Body::Venus, AspectKind::Conjunction);
assert!((m_v - 8.0).abs() < 1e-12);
}
}
@@ -0,0 +1,58 @@
//! Input for a natal-chart computation: a moment in time and a place,
//! plus a small bag of metadata so the chart can carry its provenance.
use cosmos_sky::{Instant, Observer};
/// How confident the astrologer is in the recorded birth time. Carried
/// forward into the chart metadata so rectification work can mark its
/// best-known time without losing the original.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TimeCertainty {
/// The birth time is taken at face value with no asserted uncertainty.
#[default]
Exact,
/// The birth time is approximate; `minutes` is the half-width of the
/// uncertainty interval (e.g. `30` means ±30 minutes).
Approximate { minutes: u32 },
/// The birth time has been adjusted by the astrologer via rectification.
Rectified,
}
/// Birth (or event) data — everything the chart computer needs to know
/// from the *subject's* side, before the astrologer adds chart-style
/// preferences.
#[derive(Debug, Clone)]
pub struct BirthData {
pub instant: Instant,
pub observer: Observer,
pub name: Option<String>,
pub time_certainty: TimeCertainty,
pub note: Option<String>,
}
impl BirthData {
pub fn new(instant: Instant, observer: Observer) -> Self {
Self {
instant,
observer,
name: None,
time_certainty: TimeCertainty::Exact,
note: None,
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_time_certainty(mut self, certainty: TimeCertainty) -> Self {
self.time_certainty = certainty;
self
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
}
@@ -0,0 +1,293 @@
//! The `NatalChart`: assembly of birth data → angles → houses → placements.
use cosmos_core::Location;
use cosmos_sky::{ApparentPosition, Body, EphemerisSession, HorizonCoord, Instant, Observer};
use cosmos_time::sidereal::GAST;
use cosmos_time::scales::conversions::ToUT1WithDeltaT;
use cosmos_validation::sidereal::{ayanamsha as ayanamsha_value, true_obliquity_iau2006a};
use crate::birth_data::BirthData;
use crate::chart_config::ChartConfig;
use crate::error::{AstrologyError, AstrologyResult};
use crate::house_system::Houses;
use crate::placement::BodyPlacement;
use crate::zodiac::{SignedLongitude, Zodiac};
/// A computed angle (Ascendant, MC, Descendant, IC). Wraps a
/// [`SignedLongitude`] so callers can speak in sign-decimal form.
#[derive(Debug, Clone, Copy)]
pub struct Angle {
inner: SignedLongitude,
}
impl Angle {
fn new(longitude_rad: f64) -> Self {
Self {
inner: SignedLongitude::from_radians(longitude_rad),
}
}
/// Construct an angle from a raw zodiacal longitude in radians.
/// Internal helper exposed for directed charts (Solar Arc).
pub fn from_radians(longitude_rad: f64) -> Self {
Self::new(longitude_rad)
}
pub fn longitude_rad(&self) -> f64 {
self.inner.longitude_rad()
}
pub fn longitude_deg(&self) -> f64 {
self.inner.longitude_deg()
}
pub fn sign(&self) -> crate::zodiac::Sign {
self.inner.sign()
}
pub fn degree_in_sign(&self) -> u32 {
self.inner.degree_in_sign()
}
pub fn degree_in_sign_decimal(&self) -> f64 {
self.inner.degree_in_sign_decimal()
}
pub fn to_chart_format(&self) -> String {
self.inner.to_chart_format()
}
}
/// A computed natal chart. All longitudes are stored in radians; signed
/// decompositions are derived via [`SignedLongitude`].
#[derive(Debug, Clone)]
pub struct NatalChart {
pub birth: BirthData,
pub config: ChartConfig,
// ─── Core geometry ────────────────────────────────────────────────
/// True obliquity of date (mean + nutation in obliquity), radians.
pub obliquity_rad: f64,
/// Local Apparent Sidereal Time at the observer's longitude, radians.
pub local_apparent_sidereal_time_rad: f64,
/// Ayanamsha applied for sidereal mode, radians. `0.0` for tropical.
pub ayanamsha_rad: f64,
// ─── Angles ───────────────────────────────────────────────────────
ascendant: Angle,
midheaven: Angle,
descendant: Angle,
imum_coeli: Angle,
// ─── Houses ───────────────────────────────────────────────────────
pub houses: Houses,
// ─── Placements (parallel to `config.bodies.bodies`) ───────────────
pub placements: Vec<BodyPlacement>,
}
impl NatalChart {
pub fn ascendant(&self) -> Angle {
self.ascendant
}
pub fn midheaven(&self) -> Angle {
self.midheaven
}
pub fn descendant(&self) -> Angle {
self.descendant
}
pub fn imum_coeli(&self) -> Angle {
self.imum_coeli
}
/// Lookup a placement by body. `None` if the requested body was not
/// in the configured [`crate::BodySet`].
pub fn placement(&self, body: Body) -> Option<&BodyPlacement> {
self.placements.iter().find(|p| p.body == body)
}
/// Overwrite this chart's four angles with another chart's, leaving
/// every other field untouched. Used by [`crate::progress`] when the
/// caller asks for `ProgressedHouses::Natal`: angles and cusps freeze
/// to the natal values while placements advance with the progressed
/// chart.
pub fn replace_angles_with(&mut self, other: &NatalChart) {
self.ascendant = other.ascendant;
self.midheaven = other.midheaven;
self.descendant = other.descendant;
self.imum_coeli = other.imum_coeli;
}
/// Overwrite the four angles explicitly. Used by Solar Arc to apply
/// the uniform-rotation shift after copying from the natal chart.
pub fn set_directed_angles(
&mut self,
ascendant: Angle,
midheaven: Angle,
descendant: Angle,
imum_coeli: Angle,
) {
self.ascendant = ascendant;
self.midheaven = midheaven;
self.descendant = descendant;
self.imum_coeli = imum_coeli;
}
/// Compute a natal chart end-to-end.
pub fn compute(
birth: &BirthData,
config: &ChartConfig,
session: &EphemerisSession,
) -> AstrologyResult<Self> {
let last_rad = compute_last_rad(&birth.instant, &birth.observer)?;
let tt = birth.instant.tt()?;
let obliquity_rad = true_obliquity_iau2006a(&tt).map_err(|e| {
AstrologyError::Sky(cosmos_sky::SkyError::Ephemeris(
cosmos_validation::oracle::OracleError::Inner(format!("obliquity: {}", e)),
))
})?;
// Houses + angles are always tropical (ecliptic of date).
let houses = Houses::compute(
config.house_system,
last_rad,
birth.observer.lat_rad,
obliquity_rad,
)?;
let ayanamsha_rad = match config.zodiac {
Zodiac::Tropical => 0.0,
Zodiac::Sidereal(mode) => ayanamsha_value(mode, &tt),
};
let zodiac_offset = |tropical_rad: f64| -> f64 {
const TAU: f64 = std::f64::consts::TAU;
let v = tropical_rad - ayanamsha_rad;
let v = v.rem_euclid(TAU);
if v < 0.0 {
v + TAU
} else {
v
}
};
let asc_for_zodiac = zodiac_offset(houses.ascendant_rad);
let mc_for_zodiac = zodiac_offset(houses.midheaven_rad);
let ascendant = Angle::new(asc_for_zodiac);
let midheaven = Angle::new(mc_for_zodiac);
let descendant = Angle::new(zodiac_offset(houses.ascendant_rad + std::f64::consts::PI));
let imum_coeli = Angle::new(zodiac_offset(houses.midheaven_rad + std::f64::consts::PI));
let observer = if config.include_horizon {
Some(&birth.observer)
} else {
None
};
let mut placements = Vec::with_capacity(config.bodies.bodies.len() + 1);
for &body in &config.bodies.bodies {
let apparent = compute_body(body, birth.instant, observer, session)?;
let tropical_lon = apparent.ecliptic_of_date.longitude_rad;
let zodiac_lon = zodiac_offset(tropical_lon);
let house = houses.house_containing(tropical_lon);
placements.push(BodyPlacement::from_apparent(
body, &apparent, zodiac_lon, house,
));
}
// Auto-add South Node opposite the (ascending) Mean / True node.
if config.bodies.include_south_node {
if let Some(node) = placements.iter().find(|p| {
matches!(p.body, Body::MeanNode | Body::TrueNode)
}) {
let south_lon_zodiac =
(node.longitude.longitude_rad() + std::f64::consts::PI)
.rem_euclid(std::f64::consts::TAU);
let south_lon_tropical = (south_lon_zodiac + ayanamsha_rad)
.rem_euclid(std::f64::consts::TAU);
let south_house = houses.house_containing(south_lon_tropical);
let south_horizon = node.horizon.map(|h| HorizonCoord {
// South node is the antipode direction; we don't
// recompute horizon for it. Mark altitude as the
// anti-altitude (180° around in azimuth).
altitude_rad: -h.altitude_rad,
azimuth_rad: (h.azimuth_rad + std::f64::consts::PI)
.rem_euclid(std::f64::consts::TAU),
});
// South node has the antipode RA / Dec of the north node.
let south_ra = (node.right_ascension_rad + std::f64::consts::PI)
.rem_euclid(std::f64::consts::TAU);
let south_dec = -node.declination_rad;
placements.push(BodyPlacement {
body: south_node_body_for(node.body),
longitude: SignedLongitude::from_radians(south_lon_zodiac),
latitude_rad: 0.0,
distance_km: 0.0,
longitude_rate_rad_per_day: node.longitude_rate_rad_per_day,
right_ascension_rad: south_ra,
declination_rad: south_dec,
house_number: south_house,
horizon: south_horizon,
});
}
}
Ok(Self {
birth: birth.clone(),
config: config.clone(),
obliquity_rad,
local_apparent_sidereal_time_rad: last_rad,
ayanamsha_rad,
ascendant,
midheaven,
descendant,
imum_coeli,
houses,
placements,
})
}
}
/// The South Node is the opposition of the ascending node, so we
/// preserve which family the node came from (Mean / True) and just
/// label the placement accordingly. We use the same `Body::MeanNode` /
/// `Body::TrueNode` identifier with a synthetic `is_retrograde` left
/// to match the ascending node, since the two nodes share motion by
/// construction.
fn south_node_body_for(ascending_body: Body) -> Body {
// No `Body::SouthNode` variant in the sky façade yet; for now we
// reuse the ascending-node identifier. Consumers wanting to
// distinguish should check the placement's longitude (south is
// exactly +180° opposite). When the sky façade grows dedicated
// South Node variants, this mapping becomes trivial.
ascending_body
}
fn compute_body(
body: Body,
instant: Instant,
observer: Option<&Observer>,
session: &EphemerisSession,
) -> AstrologyResult<ApparentPosition> {
session.body_apparent(body, instant, observer).map_err(|e| {
AstrologyError::BodyUnavailable(format!("{}: {}", body.name(), e))
})
}
/// Local Apparent Sidereal Time at the observer's longitude, radians.
fn compute_last_rad(instant: &Instant, observer: &Observer) -> AstrologyResult<f64> {
let tt = instant.tt()?;
let ut1 = tt
.to_ut1_with_delta_t(instant.delta_t_seconds())
.map_err(|e| AstrologyError::Sky(cosmos_sky::SkyError::Time(e)))?;
let location = Location::from_degrees(
observer.lat_rad.to_degrees(),
observer.lon_rad.to_degrees(),
observer.elev_m,
)
.map_err(|e| {
AstrologyError::Sky(cosmos_sky::SkyError::Ephemeris(
cosmos_validation::oracle::OracleError::Inner(format!("Location: {:?}", e)),
))
})?;
let gast = GAST::from_ut1_and_tt(&ut1, &tt).map_err(|e| {
AstrologyError::Sky(cosmos_sky::SkyError::Ephemeris(
cosmos_validation::oracle::OracleError::Inner(format!("GAST: {:?}", e)),
))
})?;
Ok(gast.to_last(&location).angle().radians())
}
@@ -0,0 +1,87 @@
//! User-controlled options for a natal-chart computation.
use cosmos_sky::Body;
use crate::house_system::HouseSystem;
use crate::zodiac::Zodiac;
/// Which bodies to include in a chart.
#[derive(Debug, Clone)]
pub struct BodySet {
pub bodies: Vec<Body>,
/// Append the South Node automatically as `mean_node + 180°`?
/// (`Body::MeanNode` and `Body::TrueNode` give the *ascending* node.)
pub include_south_node: bool,
}
impl BodySet {
/// The ten luminaries + planets used in most modern Western charts,
/// plus the mean lunar node (ascending). This is the baseline most
/// astrologers expect when no extra configuration is supplied.
pub fn classical_modern() -> Self {
Self {
bodies: vec![
Body::Sun,
Body::Moon,
Body::Mercury,
Body::Venus,
Body::Mars,
Body::Jupiter,
Body::Saturn,
Body::Uranus,
Body::Neptune,
Body::Pluto,
Body::MeanNode,
],
include_south_node: true,
}
}
/// Classical-modern set plus mean Lilith.
pub fn with_lilith(mut self) -> Self {
self.bodies.push(Body::MeanLilith);
self
}
/// Add the four main-belt asteroids (Ceres, Pallas, Juno, Vesta).
/// Requires an asteroid SPK kernel attached to the
/// [`cosmos_sky::EphemerisSession`].
pub fn with_main_belt_asteroids(mut self) -> Self {
self.bodies.push(Body::Ceres);
self.bodies.push(Body::Pallas);
self.bodies.push(Body::Juno);
self.bodies.push(Body::Vesta);
self
}
}
impl Default for BodySet {
fn default() -> Self {
Self::classical_modern()
}
}
/// Combined chart configuration. The defaults produce a Placidus
/// tropical chart with the classical-modern body set.
#[derive(Debug, Clone)]
pub struct ChartConfig {
pub house_system: HouseSystem,
pub zodiac: Zodiac,
pub bodies: BodySet,
/// If `true`, request topocentric horizon coordinates for every body
/// in addition to the geocentric ecliptic position. Slightly more
/// expensive but useful for charts that care about local visibility
/// (rising / setting, mundane positions).
pub include_horizon: bool,
}
impl Default for ChartConfig {
fn default() -> Self {
Self {
house_system: HouseSystem::default(),
zodiac: Zodiac::default(),
bodies: BodySet::default(),
include_horizon: false,
}
}
}
@@ -0,0 +1,141 @@
//! Composite (midpoint) charts.
//!
//! A composite chart is the symbolic "average" of two natal charts:
//! every point — Sun, Moon, planets, lunar nodes, Lilith, asteroids,
//! and the four angles — is replaced by the **angular midpoint** of
//! the corresponding pair `(A, B)`. Houses are then derived in the
//! Whole-Sign convention starting from the composite Ascendant.
//!
//! The convention used here is the classical *Midpoint Composite*
//! (Ronald Davison 1958), not the *Time-Space Composite* (which builds
//! a real natal chart at the geographic and temporal midpoint of two
//! births — a different construction that requires lat/lon math the
//! caller has to do explicitly).
//!
//! The two input charts MUST share the same [`crate::BodySet`] for the
//! placements to align by index. The standard
//! [`crate::ChartConfig::default()`] is fine.
use cosmos_sky::Body;
use crate::angles::wrap_two_pi;
use crate::birth_data::BirthData;
use crate::chart::NatalChart;
use crate::error::{AstrologyError, AstrologyResult};
use crate::zodiac::{Sign, SignedLongitude};
const PI: f64 = std::f64::consts::PI;
/// One body's midpoint placement.
#[derive(Debug, Clone, Copy)]
pub struct CompositePlacement {
pub body: Body,
pub longitude: SignedLongitude,
pub sign: Sign,
/// Whole-sign house number `1..=12`.
pub house_number: u8,
}
/// A complete midpoint composite chart. Carries provenance back to
/// both source charts so callers can audit the construction.
#[derive(Debug, Clone)]
pub struct CompositeChart {
pub from_a: BirthData,
pub from_b: BirthData,
pub ascendant: SignedLongitude,
pub midheaven: SignedLongitude,
pub descendant: SignedLongitude,
pub imum_coeli: SignedLongitude,
pub placements: Vec<CompositePlacement>,
}
impl CompositeChart {
/// Lookup the first composite placement for a body. (For bodies that
/// appear twice in the source charts — `MeanNode` and its
/// auto-appended South Node — only the first match is returned;
/// the second is at the antipode.)
pub fn placement(&self, body: Body) -> Option<&CompositePlacement> {
self.placements.iter().find(|p| p.body == body)
}
}
/// Build a midpoint composite from two natal charts. The two charts
/// MUST have been computed with the same `BodySet` (so their
/// `placements` arrays line up by index); otherwise an error is
/// returned and the caller can re-run `NatalChart::compute` with
/// matching configurations.
pub fn composite(chart_a: &NatalChart, chart_b: &NatalChart) -> AstrologyResult<CompositeChart> {
if chart_a.placements.len() != chart_b.placements.len() {
return Err(AstrologyError::BodyUnavailable(format!(
"composite requires matching BodySet — chart A has {} placements, B has {}",
chart_a.placements.len(),
chart_b.placements.len()
)));
}
for (a, b) in chart_a.placements.iter().zip(chart_b.placements.iter()) {
if a.body != b.body {
return Err(AstrologyError::BodyUnavailable(format!(
"composite requires identically-ordered BodySet — chart A has {} at index, B has {}",
a.body.name(),
b.body.name()
)));
}
}
let asc = angular_midpoint_rad(
chart_a.ascendant().longitude_rad(),
chart_b.ascendant().longitude_rad(),
);
let mc = angular_midpoint_rad(
chart_a.midheaven().longitude_rad(),
chart_b.midheaven().longitude_rad(),
);
let asc_sign = SignedLongitude::from_radians(asc).sign();
let placements: Vec<CompositePlacement> = chart_a
.placements
.iter()
.zip(chart_b.placements.iter())
.map(|(a, b)| {
let mid = angular_midpoint_rad(
a.longitude.longitude_rad(),
b.longitude.longitude_rad(),
);
let sl = SignedLongitude::from_radians(mid);
let house = whole_sign_house(asc_sign, sl.sign());
CompositePlacement {
body: a.body,
longitude: sl,
sign: sl.sign(),
house_number: house,
}
})
.collect();
let desc = wrap_two_pi(asc + PI);
let ic = wrap_two_pi(mc + PI);
Ok(CompositeChart {
from_a: chart_a.birth.clone(),
from_b: chart_b.birth.clone(),
ascendant: SignedLongitude::from_radians(asc),
midheaven: SignedLongitude::from_radians(mc),
descendant: SignedLongitude::from_radians(desc),
imum_coeli: SignedLongitude::from_radians(ic),
placements,
})
}
/// Angular midpoint of two longitudes (radians). Returns the midpoint
/// of the **shorter** arc between `a` and `b`, wrapped to `[0, 2π)`.
/// Antipodal inputs default to `a` itself.
pub fn angular_midpoint_rad(a: f64, b: f64) -> f64 {
let mid = libm::atan2(libm::sin(a) + libm::sin(b), libm::cos(a) + libm::cos(b));
wrap_two_pi(mid)
}
fn whole_sign_house(asc_sign: Sign, point_sign: Sign) -> u8 {
let diff = (point_sign.index() as i32 - asc_sign.index() as i32).rem_euclid(12);
(diff + 1) as u8
}
@@ -0,0 +1,258 @@
//! Eclipses, surfaced as an astrology-layer concern: find the next
//! eclipse and report its ecliptic longitude so callers can ask
//! "is this eclipse on one of my natal points?".
//!
//! The geometric eclipse machinery lives in `eternal-validation::eclipses`
//! and requires an SPK planetary kernel. This module wraps those
//! routines, computes the eclipse longitude (Sun's longitude for a
//! solar eclipse — the Sun is what gets eclipsed; Moon's longitude for
//! a lunar eclipse), and exposes a helper that filters eclipses by
//! proximity to any natal significator.
use cosmos_sky::{Body, EphemerisSession, Instant};
use cosmos_validation::eclipses as ev_eclipses;
use crate::angles::unsigned_arc_deg;
use crate::chart::NatalChart;
use crate::error::{AstrologyError, AstrologyResult};
use crate::primary_direction::Significator;
pub use ev_eclipses::{LunarEclipseKind, SolarEclipseKind};
/// Family identifier — whether the eclipse occurs at conjunction
/// (solar) or opposition (lunar) of Sun and Moon.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EclipseFamily {
Solar,
Lunar,
}
/// One eclipse event, with its detailed sub-classification and the
/// ecliptic longitude (of date) at which it falls.
#[derive(Debug, Clone, Copy)]
pub struct Eclipse {
pub family: EclipseFamily,
/// Solar sub-kind (Total / Partial / Annular / Hybrid / None) if
/// the event is solar; `None` otherwise.
pub solar_kind: Option<SolarEclipseKind>,
/// Lunar sub-kind (Total / Partial / Penumbral / None) if the
/// event is lunar; `None` otherwise.
pub lunar_kind: Option<LunarEclipseKind>,
pub instant: Instant,
/// Ecliptic-of-date longitude where the eclipse falls (radians).
/// For a solar eclipse this is the Sun's apparent ecliptic
/// longitude at maximum; for a lunar eclipse, the Moon's.
pub eclipse_longitude_rad: f64,
}
/// Eclipse falling within orb of a natal significator.
#[derive(Debug, Clone, Copy)]
pub struct NatalEclipse {
pub eclipse: Eclipse,
pub natal_target: Significator,
pub natal_longitude_rad: f64,
/// Unsigned angular distance between eclipse longitude and natal
/// target longitude (degrees).
pub orb_deg: f64,
}
/// Find the next solar eclipse after `after` and within
/// `max_synodic_months` lunar cycles.
pub fn next_solar_eclipse(
session: &EphemerisSession,
after: Instant,
max_synodic_months: usize,
) -> AstrologyResult<Option<Eclipse>> {
next_eclipse(session, after, max_synodic_months, EclipseFamily::Solar)
}
/// Find the next lunar eclipse after `after` and within
/// `max_synodic_months` lunar cycles.
pub fn next_lunar_eclipse(
session: &EphemerisSession,
after: Instant,
max_synodic_months: usize,
) -> AstrologyResult<Option<Eclipse>> {
next_eclipse(session, after, max_synodic_months, EclipseFamily::Lunar)
}
/// Shared scan path for both solar and lunar eclipses. The two
/// families only differ in (a) which validation routine drives the
/// shadow-geometry check and (b) which body (Sun for solar, Moon for
/// lunar) carries the ecliptic longitude reported as the eclipse
/// point.
fn next_eclipse(
session: &EphemerisSession,
after: Instant,
max_synodic_months: usize,
family: EclipseFamily,
) -> AstrologyResult<Option<Eclipse>> {
let spk = require_spk(session)?;
let jd_start = after.jd_tdb()?;
let found = match family {
EclipseFamily::Solar => ev_eclipses::next_solar_eclipse(
spk,
jd_start,
max_synodic_months,
)
.map(|opt| opt.map(EclipseHit::Solar)),
EclipseFamily::Lunar => ev_eclipses::next_lunar_eclipse(
spk,
jd_start,
max_synodic_months,
)
.map(|opt| opt.map(EclipseHit::Lunar)),
}
.map_err(|e| AstrologyError::Sky(cosmos_sky::SkyError::Ephemeris(e)))?;
let Some(hit) = found else {
return Ok(None);
};
let jd_tdb = hit.jd_tdb();
let instant = Instant::from_jd_tdb(jd_tdb)?;
let longitude_body = match family {
EclipseFamily::Solar => Body::Sun,
EclipseFamily::Lunar => Body::Moon,
};
let snap = session
.body_apparent(longitude_body, instant, None)
.map_err(AstrologyError::Sky)?;
let (solar_kind, lunar_kind) = match hit {
EclipseHit::Solar((_, s)) => (Some(s.kind), None),
EclipseHit::Lunar((_, s)) => (None, Some(s.kind)),
};
Ok(Some(Eclipse {
family,
solar_kind,
lunar_kind,
instant,
eclipse_longitude_rad: snap.ecliptic_of_date.longitude_rad,
}))
}
/// Internal tagged union over the two underlying eclipse snapshot types.
enum EclipseHit {
Solar((f64, ev_eclipses::SolarEclipseSnapshot)),
Lunar((f64, ev_eclipses::LunarEclipseSnapshot)),
}
impl EclipseHit {
fn jd_tdb(&self) -> f64 {
match self {
EclipseHit::Solar((jd, _)) => *jd,
EclipseHit::Lunar((jd, _)) => *jd,
}
}
}
/// Find every eclipse (solar + lunar interleaved) within the next
/// `max_synodic_months` synodic months that falls within `orb_deg` of
/// any natal target in `targets`. If `targets` is `None`, every natal
/// body plus the four angles is used.
///
/// SPK backend required (the underlying eclipse routines need a
/// planetary kernel for the Sun and Moon positions).
pub fn eclipses_on_natal(
natal: &NatalChart,
session: &EphemerisSession,
after: Instant,
max_synodic_months: usize,
orb_deg: f64,
targets: Option<&[Significator]>,
) -> AstrologyResult<Vec<NatalEclipse>> {
let default_targets;
let targets = match targets {
Some(t) => t,
None => {
default_targets = default_natal_targets(natal);
&default_targets
}
};
// Sweep solar and lunar independently as monotonic cursor walks:
// each `next_*_eclipse` call advances past the prior find rather
// than restarting from `after + N·month`. Two sweeps, never more
// than (NUMEC_solar + NUMEC_lunar) underlying calls, no dedup.
let mut all_eclipses = Vec::new();
sweep_eclipses(session, after, max_synodic_months, EclipseFamily::Solar, &mut all_eclipses)?;
sweep_eclipses(session, after, max_synodic_months, EclipseFamily::Lunar, &mut all_eclipses)?;
all_eclipses.sort_by(|a, b| {
a.instant
.jd_utc()
.partial_cmp(&b.instant.jd_utc())
.unwrap_or(std::cmp::Ordering::Equal)
});
// Filter by natal proximity.
let mut out = Vec::new();
for ecl in all_eclipses {
for &target in targets {
let Some(target_lon_rad) = target.longitude_rad(natal) else {
continue;
};
let orb = unsigned_arc_deg(
ecl.eclipse_longitude_rad.to_degrees(),
target_lon_rad.to_degrees(),
);
if orb <= orb_deg {
out.push(NatalEclipse {
eclipse: ecl,
natal_target: target,
natal_longitude_rad: target_lon_rad,
orb_deg: orb,
});
}
}
}
out.sort_by(|a, b| {
a.orb_deg
.partial_cmp(&b.orb_deg)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(out)
}
// ─── Helpers ──────────────────────────────────────────────────────────
fn require_spk(
session: &EphemerisSession,
) -> AstrologyResult<&cosmos_ephemeris::jpl::SpkFile> {
session.require_spk().map_err(AstrologyError::Sky)
}
/// Walk a monotonic cursor through `max_synodic_months`-worth of
/// eclipses of the requested family, accumulating into `out`.
///
/// The previous implementation called `next_X_eclipse(session, cursor, 1)`
/// in a loop and advanced the cursor by ~29.53 days, which forced
/// `next_X_eclipse` to redo most of its internal sweep on every
/// iteration. By advancing the cursor past each *found* eclipse instead,
/// the total scan now does ~N underlying calls for N synodic months
/// instead of ~2N redundant ones.
fn sweep_eclipses(
session: &EphemerisSession,
after: Instant,
max_synodic_months: usize,
family: EclipseFamily,
out: &mut Vec<Eclipse>,
) -> AstrologyResult<()> {
let mut cursor = after;
let mut budget = max_synodic_months;
while budget > 0 {
let Some(ecl) = next_eclipse(session, cursor, budget, family)? else {
return Ok(());
};
let jd_after = ecl.instant.jd_utc() + 1.0;
out.push(ecl);
cursor = Instant::from_utc(after.utc().add_days(jd_after - after.jd_utc()));
// Each found eclipse "consumes" one synodic month of budget.
budget -= 1;
}
Ok(())
}
fn default_natal_targets(natal: &NatalChart) -> Vec<Significator> {
crate::transits::default_natal_targets(natal)
}
@@ -0,0 +1,23 @@
//! Unified error type.
use cosmos_sky::SkyError;
use thiserror::Error;
pub type AstrologyResult<T> = Result<T, AstrologyError>;
#[derive(Debug, Error)]
pub enum AstrologyError {
/// An underlying astronomy or time conversion failed.
#[error("sky-layer error: {0}")]
Sky(#[from] SkyError),
/// A house system could not be computed at the given location
/// (typical: Placidus / Koch inside the polar circle).
#[error("house system unavailable here: {0}")]
HouseSystemUnavailable(&'static str),
/// Something requested a body that the session was not configured
/// to compute (e.g. an asteroid without an asteroid kernel attached).
#[error("body could not be computed: {0}")]
BodyUnavailable(String),
}
@@ -0,0 +1,126 @@
//! House systems and their cusps.
//!
//! Each variant of [`HouseSystem`] forwards to a Swiss-faithful
//! implementation living in `eternal-validation::houses`. Cusp arrays
//! are always in radians, indexed `0..12` where index `i` is the start
//! of house `i+1` (house 1 = Ascendant by convention).
use cosmos_validation::houses as ev_houses;
use crate::error::{AstrologyError, AstrologyResult};
/// Selectable house system. Geometric (Whole-Sign, Equal, Porphyry) are
/// defined everywhere on Earth; quadrant systems (Placidus, Koch,
/// Campanus) diverge inside the polar circle and return an error there.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HouseSystem {
/// Houses are the 30°-wide zodiac signs counted from the
/// Ascendant's sign. The oldest documented system.
WholeSign,
/// Ascendant + N×30°. House 1 begins exactly at the Ascendant.
Equal,
/// Trisection of the diurnal and nocturnal semi-arcs measured along
/// the ecliptic between the Ascendant and the MC.
Porphyry,
/// Iterative trisection of the diurnal semi-arc measured along
/// each planet's hour circle. The Swiss / Astrodienst implementation.
Placidus,
/// Iterative trisection of the diurnal arc as projected on the
/// ecliptic. Like Placidus, undefined inside the polar circle.
Koch,
/// Trisection of the celestial equator → great-circle horizons.
Regiomontanus,
/// Trisection of the prime vertical → great-circle horizons.
Campanus,
/// **PolichPage (Topocentric)** — closed-form quadrant system
/// derived from a topocentric "pole height" `atan(tan φ · n/3)`
/// per intermediate cusp. Faster than Placidus (no iteration),
/// agrees closely in mid-latitudes, and is the canonical system
/// for primary-direction work in the GR school. Undefined inside
/// the polar circle.
PolichPage,
}
impl Default for HouseSystem {
fn default() -> Self {
HouseSystem::Placidus
}
}
/// The four angles + twelve cusps of a chart. All values are in
/// radians; helpers in [`crate::SignedLongitude`] convert to degrees /
/// sign-decimal form for presentation.
#[derive(Debug, Clone, Copy)]
pub struct Houses {
pub system: HouseSystem,
pub ascendant_rad: f64,
pub midheaven_rad: f64,
/// `cusps[i]` = ecliptic longitude (radians) of the start of house
/// `i + 1`. House 1 starts at `cusps[0]` = Ascendant by definition.
pub cusps: [f64; 12],
}
impl Houses {
/// Compute Asc/MC/cusps for a moment + observer, given the
/// already-derived Local Apparent Sidereal Time and the true
/// obliquity of date.
pub fn compute(
system: HouseSystem,
last_rad: f64,
lat_rad: f64,
obliquity_rad: f64,
) -> AstrologyResult<Self> {
let ascendant_rad = ev_houses::ascendant(last_rad, lat_rad, obliquity_rad);
let midheaven_rad = ev_houses::midheaven(last_rad, obliquity_rad);
let cusps = match system {
HouseSystem::WholeSign => ev_houses::whole_sign_houses(ascendant_rad),
HouseSystem::Equal => ev_houses::equal_houses(ascendant_rad),
HouseSystem::Porphyry => ev_houses::porphyry_houses(last_rad, lat_rad, obliquity_rad),
HouseSystem::Placidus => ev_houses::placidus_houses(last_rad, lat_rad, obliquity_rad)
.map_err(AstrologyError::HouseSystemUnavailable)?,
HouseSystem::Koch => ev_houses::koch_houses(last_rad, lat_rad, obliquity_rad)
.map_err(AstrologyError::HouseSystemUnavailable)?,
HouseSystem::Regiomontanus => {
ev_houses::regiomontanus_houses(last_rad, lat_rad, obliquity_rad)
}
HouseSystem::Campanus => ev_houses::campanus_houses(last_rad, lat_rad, obliquity_rad)
.map_err(AstrologyError::HouseSystemUnavailable)?,
HouseSystem::PolichPage => {
ev_houses::polich_page_houses(last_rad, lat_rad, obliquity_rad)
.map_err(AstrologyError::HouseSystemUnavailable)?
}
};
Ok(Self {
system,
ascendant_rad,
midheaven_rad,
cusps,
})
}
/// Find which house (1..=12) contains `longitude_rad`. Membership is
/// `cusps[i] ≤ λ < cusps[(i+1) % 12]` modulo 2π.
pub fn house_containing(&self, longitude_rad: f64) -> u8 {
const TAU: f64 = std::f64::consts::TAU;
let lon = longitude_rad.rem_euclid(TAU);
for i in 0..12 {
let start = self.cusps[i].rem_euclid(TAU);
let end = self.cusps[(i + 1) % 12].rem_euclid(TAU);
if start <= end {
if lon >= start && lon < end {
return (i + 1) as u8;
}
} else {
// Wraps past 0° — body is in this house if it sits on
// either side of the wrap.
if lon >= start || lon < end {
return (i + 1) as u8;
}
}
}
// Floating-point edge case: body lands exactly on the last
// cusp. Attribute it to house 12.
12
}
}
@@ -0,0 +1,118 @@
//! # eternal-astrology
//!
//! The astrology-specific layer built on top of [`eternal-sky`](`cosmos_sky`).
//!
//! ## What this crate is
//!
//! A typed pipeline that turns a moment of birth and a place into a
//! `NatalChart`: the four angles, twelve house cusps in the user's
//! chosen system, and every requested body placed in its sign and house
//! with retrograde flag.
//!
//! Every number this crate emits is traceable, by construction, to the
//! same validated routines that gate the regression harness of the
//! underlying astronomy crates — there is no parallel implementation of
//! ephemerides, time scales, or rotation matrices here. The astrology
//! layer is *interpretation-free*: it computes the traditional
//! astrological constructs (signs, houses, lots, retrogradation,
//! sidereal modes) with astronomical precision and does **not** make
//! claims about what those constructs mean for the person concerned.
//!
//! ## Disclaimer
//!
//! Astrology is a symbolic system with deep cultural and personal
//! significance for many people. This crate computes its traditional
//! constructs faithfully but takes no position on whether those
//! constructs describe, predict, or explain anything about an
//! individual's life. Treat the output as a *language*, not as data.
//!
//! ## Quick start
//!
//! ```no_run
//! use cosmos_astrology::{BirthData, ChartConfig, HouseSystem, NatalChart, Zodiac};
//! use cosmos_sky::{EphemerisSession, Instant, Observer, SessionConfig};
//!
//! let session = EphemerisSession::open(SessionConfig::vsop2013())?;
//! let birth = BirthData::new(
//! Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240)?,
//! Observer::from_degrees(10.4806, -66.9036, 900.0),
//! ).with_name("Subject A");
//!
//! let config = ChartConfig {
//! house_system: HouseSystem::Placidus,
//! zodiac: Zodiac::Tropical,
//! ..ChartConfig::default()
//! };
//!
//! let chart = NatalChart::compute(&birth, &config, &session)?;
//! println!("Ascendant in {:?} {:.2}°",
//! chart.ascendant().sign(),
//! chart.ascendant().degree_in_sign(),
//! );
//! # Ok::<_, cosmos_astrology::AstrologyError>(())
//! ```
pub mod angles;
pub mod aspect;
pub mod birth_data;
pub mod chart;
pub mod chart_config;
pub mod composite;
pub mod eclipses;
pub mod error;
pub mod house_system;
pub mod lots;
pub mod lunar_phase;
pub mod mundane;
pub mod placement;
pub mod primary_direction;
pub mod profections;
pub mod progression;
pub mod returns;
pub mod solar_arc;
pub mod stations;
pub mod synastry;
pub mod topocentric;
pub mod transits;
pub mod zodiac;
pub use aspect::{find_aspects, find_aspects_filtered, Aspect, AspectKind, OrbTable};
pub use birth_data::{BirthData, TimeCertainty};
pub use chart::{Angle, NatalChart};
pub use chart_config::{BodySet, ChartConfig};
pub use composite::{angular_midpoint_rad, composite, CompositeChart, CompositePlacement};
pub use eclipses::{
eclipses_on_natal, next_lunar_eclipse, next_solar_eclipse, Eclipse, EclipseFamily,
LunarEclipseKind, NatalEclipse, SolarEclipseKind,
};
pub use error::{AstrologyError, AstrologyResult};
pub use house_system::{HouseSystem, Houses};
pub use placement::BodyPlacement;
pub use topocentric::topocentric_ecliptic;
pub use progression::{
minor_progression, progress, progressed_instant, secondary_progression, tertiary_progression,
ProgressedChart, ProgressedHouses, ProgressionMethod,
};
pub use primary_direction::{
all_directions, all_directions_with_aspects, direct, direct_to_aspect, directed_longitude,
directions_to_angles, Direction, DirectionKey, DirectionMethod, PrimaryDirection, Significator,
};
pub use lots::{all_lots, compute_lot, custom_lot, Lot, LotName, LotPoint, Sect};
pub use lunar_phase::{
classify_lunation_phase, next_canonical_phase, next_lunar_phase, phase_angle_at,
phase_angle_at_deg, LunarPhase, LunationPhase,
};
pub use profections::{
annual_profection, modern_ruler, monthly_profection, profection_at, traditional_ruler,
AnnualProfection, MonthlyProfection, ProfectionHouses,
};
pub use returns::next_return;
pub use solar_arc::{solar_arc, solar_arc_naibod, solar_arc_true, SolarArcChart, SolarArcMethod};
pub use stations::{all_stations, next_station, Station, StationKind};
pub use synastry::{find_synastry_aspects, SynastryAspect};
pub use transits::{
default_natal_targets, find_current_transits, find_next_exact_transit, TransitAspect,
};
pub use zodiac::{Sign, SignedLongitude, Zodiac};
pub use cosmos_sky::Ayanamsha;
@@ -0,0 +1,234 @@
//! Arabic Parts (Hellenistic *Lots*).
//!
//! A Lot is a calculated point on the ecliptic of the form
//! `A + B C` where each of `A`, `B`, `C` is the natal longitude of
//! the Ascendant, a body, or another previously-computed Lot. Most
//! classical Lots **reverse** by day/night sect — i.e. the roles of
//! `B` and `C` swap when the Sun sits below the horizon at the moment
//! of birth.
//!
//! The seven shipped here cover the bulk of practical Hellenistic
//! work; new ones can be expressed via [`custom_lot`].
use cosmos_sky::Body;
use crate::chart::NatalChart;
use crate::error::{AstrologyError, AstrologyResult};
use crate::zodiac::{Sign, SignedLongitude};
const TAU: f64 = std::f64::consts::TAU;
/// Day or night birth, determined by whether the natal Sun is above
/// the horizon. Houses 7..=12 are the diurnal hemisphere; 1..=6 the
/// nocturnal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sect {
Day,
Night,
}
impl Sect {
/// Determine sect from a computed chart.
pub fn of(chart: &NatalChart) -> AstrologyResult<Self> {
let sun = chart.placement(Body::Sun).ok_or_else(|| {
AstrologyError::BodyUnavailable("Sun not in chart — sect undefined".into())
})?;
// Houses 7..=12 lie above the horizon, 1..=6 below.
Ok(if (7..=12).contains(&sun.house_number) {
Sect::Day
} else {
Sect::Night
})
}
}
/// A point that can appear as `A`, `B`, or `C` in a Lot formula.
#[derive(Debug, Clone, Copy)]
pub enum LotPoint {
Ascendant,
Body(Body),
Lot(LotName),
}
/// The canonical Hellenistic Lots wired here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LotName {
Fortune,
Spirit,
Eros,
Necessity,
Courage,
Victory,
Nemesis,
}
impl LotName {
pub fn label(self) -> &'static str {
match self {
LotName::Fortune => "Fortune",
LotName::Spirit => "Spirit",
LotName::Eros => "Eros",
LotName::Necessity => "Necessity",
LotName::Courage => "Courage",
LotName::Victory => "Victory",
LotName::Nemesis => "Nemesis",
}
}
/// `(A_day, B_day, C_day)` — the diurnal formula `A + B C`.
fn diurnal_triplet(self) -> (LotPoint, LotPoint, LotPoint) {
let asc = LotPoint::Ascendant;
let body = LotPoint::Body;
let lot = LotPoint::Lot;
match self {
LotName::Fortune => (asc, body(Body::Moon), body(Body::Sun)),
LotName::Spirit => (asc, body(Body::Sun), body(Body::Moon)),
LotName::Eros => (asc, body(Body::Venus), lot(LotName::Spirit)),
LotName::Necessity => (asc, lot(LotName::Fortune), body(Body::Mercury)),
LotName::Courage => (asc, body(Body::Mars), lot(LotName::Fortune)),
LotName::Victory => (asc, body(Body::Jupiter), lot(LotName::Spirit)),
LotName::Nemesis => (asc, lot(LotName::Fortune), body(Body::Saturn)),
}
}
}
/// One computed Lot.
#[derive(Debug, Clone, Copy)]
pub struct Lot {
pub name: Option<LotName>,
pub sect: Sect,
pub longitude: SignedLongitude,
pub house_number: u8,
}
impl Lot {
pub fn sign(&self) -> Sign {
self.longitude.sign()
}
}
/// Compute one of the canonical lots.
pub fn compute_lot(chart: &NatalChart, name: LotName) -> AstrologyResult<Lot> {
let sect = Sect::of(chart)?;
let mut cache = std::collections::HashMap::new();
let lon = resolve_lot(chart, sect, name, &mut cache)?;
let house = chart.houses.house_containing(
(lon + chart.ayanamsha_rad).rem_euclid(TAU),
);
Ok(Lot {
name: Some(name),
sect,
longitude: SignedLongitude::from_radians(lon),
house_number: house,
})
}
/// Compute every canonical lot in dependency order. Convenience for
/// chart reports.
pub fn all_lots(chart: &NatalChart) -> AstrologyResult<Vec<Lot>> {
let order = [
LotName::Fortune,
LotName::Spirit,
LotName::Eros,
LotName::Necessity,
LotName::Courage,
LotName::Victory,
LotName::Nemesis,
];
let mut out = Vec::with_capacity(order.len());
for name in order {
out.push(compute_lot(chart, name)?);
}
Ok(out)
}
/// Compute a user-defined Lot. Supply both diurnal and nocturnal
/// triplets; pass the same tuple twice for a non-sect-reversing Lot.
pub fn custom_lot(
chart: &NatalChart,
diurnal: (LotPoint, LotPoint, LotPoint),
nocturnal: (LotPoint, LotPoint, LotPoint),
) -> AstrologyResult<Lot> {
let sect = Sect::of(chart)?;
let (a, b, c) = match sect {
Sect::Day => diurnal,
Sect::Night => nocturnal,
};
let mut cache = std::collections::HashMap::new();
let lon = resolve_formula(chart, sect, a, b, c, &mut cache)?;
let house = chart.houses.house_containing(
(lon + chart.ayanamsha_rad).rem_euclid(TAU),
);
Ok(Lot {
name: None,
sect,
longitude: SignedLongitude::from_radians(lon),
house_number: house,
})
}
// ─── Internals ─────────────────────────────────────────────────────────
fn resolve_lot(
chart: &NatalChart,
sect: Sect,
name: LotName,
cache: &mut std::collections::HashMap<LotName, f64>,
) -> AstrologyResult<f64> {
if let Some(v) = cache.get(&name) {
return Ok(*v);
}
let (a, b, c) = match sect {
Sect::Day => name.diurnal_triplet(),
Sect::Night => reverse_triplet(name.diurnal_triplet()),
};
let lon = resolve_formula(chart, sect, a, b, c, cache)?;
cache.insert(name, lon);
Ok(lon)
}
/// Swap `B` and `C` in a diurnal triplet to obtain the nocturnal one.
fn reverse_triplet(
t: (LotPoint, LotPoint, LotPoint),
) -> (LotPoint, LotPoint, LotPoint) {
(t.0, t.2, t.1)
}
fn resolve_formula(
chart: &NatalChart,
sect: Sect,
a: LotPoint,
b: LotPoint,
c: LotPoint,
cache: &mut std::collections::HashMap<LotName, f64>,
) -> AstrologyResult<f64> {
let la = resolve_point(chart, sect, a, cache)?;
let lb = resolve_point(chart, sect, b, cache)?;
let lc = resolve_point(chart, sect, c, cache)?;
let raw = la + lb - lc;
Ok(raw.rem_euclid(TAU))
}
fn resolve_point(
chart: &NatalChart,
sect: Sect,
point: LotPoint,
cache: &mut std::collections::HashMap<LotName, f64>,
) -> AstrologyResult<f64> {
match point {
// Lots are expressed in the chart's *zodiac* (tropical or
// sidereal). The Asc/MC stored in NatalChart are already in
// zodiac frame, so use those directly.
LotPoint::Ascendant => Ok(chart.ascendant().longitude_rad()),
LotPoint::Body(b) => {
let placement = chart.placement(b).ok_or_else(|| {
AstrologyError::BodyUnavailable(format!(
"{} required by Lot is not in chart",
b.name()
))
})?;
Ok(placement.longitude.longitude_rad())
}
LotPoint::Lot(name) => resolve_lot(chart, sect, name, cache),
}
}
@@ -0,0 +1,236 @@
//! Lunar phases: the angular relationship between Moon and Sun
//! expressed in the eight classical phases.
//!
//! The **phase angle** `p` is `Moon_longitude Sun_longitude` wrapped
//! to `[0, 2π)`. At `p = 0` the Moon and Sun are conjunct (new moon);
//! at `p = π/2` first quarter; at `p = π` opposition (full moon); at
//! `p = 3π/2` last quarter. The intermediate "crescent" and "gibbous"
//! phases occupy the eighths between the four canonical instants.
//!
//! All phase finding reduces to a root-find on
//! `signed_delta(p target)` and reuses [`cosmos_sky::find_root`].
use cosmos_sky::{find_root, Body, EphemerisSession, Instant, SearchOptions, SkyResult};
use crate::angles::signed_delta_rad;
use crate::error::{AstrologyError, AstrologyResult};
const TAU: f64 = std::f64::consts::TAU;
const PI: f64 = std::f64::consts::PI;
/// One of the four canonical lunar phases (the boundary instants).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LunarPhase {
NewMoon,
FirstQuarter,
FullMoon,
LastQuarter,
}
impl LunarPhase {
pub fn target_angle_rad(self) -> f64 {
match self {
LunarPhase::NewMoon => 0.0,
LunarPhase::FirstQuarter => PI / 2.0,
LunarPhase::FullMoon => PI,
LunarPhase::LastQuarter => 3.0 * PI / 2.0,
}
}
pub fn name(self) -> &'static str {
match self {
LunarPhase::NewMoon => "New Moon",
LunarPhase::FirstQuarter => "First Quarter",
LunarPhase::FullMoon => "Full Moon",
LunarPhase::LastQuarter => "Last Quarter",
}
}
}
/// The full eight-phase classification used for "the Moon was waxing
/// gibbous when you were born" descriptions. Boundaries are at the
/// four canonical instants; the four "between" phases occupy the
/// 45°-wide bands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LunationPhase {
NewMoon,
WaxingCrescent,
FirstQuarter,
WaxingGibbous,
FullMoon,
WaningGibbous,
LastQuarter,
WaningCrescent,
}
impl LunationPhase {
pub fn name(self) -> &'static str {
match self {
LunationPhase::NewMoon => "New Moon",
LunationPhase::WaxingCrescent => "Waxing Crescent",
LunationPhase::FirstQuarter => "First Quarter",
LunationPhase::WaxingGibbous => "Waxing Gibbous",
LunationPhase::FullMoon => "Full Moon",
LunationPhase::WaningGibbous => "Waning Gibbous",
LunationPhase::LastQuarter => "Last Quarter",
LunationPhase::WaningCrescent => "Waning Crescent",
}
}
}
/// Compute the phase angle (Moon Sun longitude, mod 2π) at `t`.
pub fn phase_angle_at(session: &EphemerisSession, t: Instant) -> SkyResult<f64> {
let sun = session.body_apparent(Body::Sun, t, None)?;
let moon = session.body_apparent(Body::Moon, t, None)?;
let diff = moon.ecliptic_of_date.longitude_rad - sun.ecliptic_of_date.longitude_rad;
Ok(diff.rem_euclid(TAU))
}
/// Phase angle at `t` in degrees.
pub fn phase_angle_at_deg(session: &EphemerisSession, t: Instant) -> SkyResult<f64> {
Ok(phase_angle_at(session, t)?.to_degrees())
}
/// Classify the phase angle into one of eight lunation phases. Bands
/// are 45° wide; the canonical instants (0°, 90°, 180°, 270°) fall at
/// the band boundaries — they're classified into the *waxing* side by
/// convention (so an exact 90° is `FirstQuarter`, not `WaxingCrescent`).
pub fn classify_lunation_phase(phase_angle_rad: f64) -> LunationPhase {
let p = phase_angle_rad.rem_euclid(TAU);
let deg = p.to_degrees();
// Band boundaries: 0, 45, 90, 135, 180, 225, 270, 315.
if deg < 22.5 || deg >= 337.5 {
LunationPhase::NewMoon
} else if deg < 67.5 {
LunationPhase::WaxingCrescent
} else if deg < 112.5 {
LunationPhase::FirstQuarter
} else if deg < 157.5 {
LunationPhase::WaxingGibbous
} else if deg < 202.5 {
LunationPhase::FullMoon
} else if deg < 247.5 {
LunationPhase::WaningGibbous
} else if deg < 292.5 {
LunationPhase::LastQuarter
} else {
LunationPhase::WaningCrescent
}
}
/// Mean synodic month in days. Used to convert a phase delta into a
/// time estimate for the bisector.
const SYNODIC_MONTH_DAYS: f64 = 29.530_588_85;
/// Find the next instant after `after` at which the lunation reaches
/// the canonical `phase`. Returns `Ok(None)` if the estimated time
/// exceeds `max_window_days`.
///
/// Strategy: a single coarse bisection over the whole cycle would trip
/// over the `phase mod 2π` discontinuity (signed_delta jumps by 2π
/// when the phase angle wraps at 0/2π, which the bisector
/// misinterprets as a zero crossing). We dodge that by:
///
/// 1. Sampling the current phase angle at `after`.
/// 2. Computing the *forward* angular distance to the target
/// (`delta_phase = (target current) mod 2π`).
/// 3. Estimating the time of perfection as
/// `Δt ≈ delta_phase × synodic_month / 2π`.
/// 4. Bisecting in a ±2-day window around that estimate — short
/// enough that `signed_delta` stays monotonic.
pub fn next_lunar_phase(
session: &EphemerisSession,
phase: LunarPhase,
after: Instant,
max_window_days: f64,
) -> AstrologyResult<Option<Instant>> {
let target = phase.target_angle_rad();
let current = phase_angle_at(session, after).map_err(AstrologyError::Sky)?;
let delta_phase = (target - current).rem_euclid(TAU);
let estimated_delta_days =
delta_phase / TAU * SYNODIC_MONTH_DAYS;
if estimated_delta_days > max_window_days {
return Ok(None);
}
let center = Instant::from_utc(after.utc().add_days(estimated_delta_days));
let lo = Instant::from_utc(center.utc().add_days(-2.0));
let hi = Instant::from_utc(center.utc().add_days(2.0));
let opts = SearchOptions {
coarse_step_seconds: 3.0 * 3600.0, // 3 h
tolerance_seconds: 30.0,
max_iterations: 80,
};
find_root(
lo,
hi,
|t: Instant| {
let p = phase_angle_at(session, t)?;
Ok(signed_delta_rad(p, target))
},
opts,
)
.map_err(AstrologyError::Sky)
}
/// Find the next of *any* canonical phase. Returns `(Instant, LunarPhase)`
/// with the phase identity for the event found.
///
/// The four phases are 90° apart on the phase angle, so we can pick
/// the next target in a single computation from the current phase
/// angle — no need to bisect for all four and discard three.
pub fn next_canonical_phase(
session: &EphemerisSession,
after: Instant,
max_window_days: f64,
) -> AstrologyResult<Option<(Instant, LunarPhase)>> {
let current = phase_angle_at(session, after).map_err(AstrologyError::Sky)?;
// Phase quadrants on the unit cycle: New @ 0°, FQ @ 90°, Full @ 180°,
// LQ @ 270°. The "next" target is the next 90°-multiple boundary
// strictly *ahead* of `current`. floor(current/90°) + 1 gives the
// index of that boundary mod 4.
let quadrant_index = (current.to_degrees() / 90.0).floor() as i32;
let next_index = ((quadrant_index + 1) % 4 + 4) % 4;
let phase = match next_index {
0 => LunarPhase::NewMoon,
1 => LunarPhase::FirstQuarter,
2 => LunarPhase::FullMoon,
_ => LunarPhase::LastQuarter,
};
Ok(next_lunar_phase(session, phase, after, max_window_days)?.map(|t| (t, phase)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_lunation_phase_bands() {
assert_eq!(classify_lunation_phase(0.0), LunationPhase::NewMoon);
assert_eq!(
classify_lunation_phase((22.0_f64).to_radians()),
LunationPhase::NewMoon
);
assert_eq!(
classify_lunation_phase((23.0_f64).to_radians()),
LunationPhase::WaxingCrescent
);
assert_eq!(
classify_lunation_phase((90.0_f64).to_radians()),
LunationPhase::FirstQuarter
);
assert_eq!(
classify_lunation_phase((180.0_f64).to_radians()),
LunationPhase::FullMoon
);
assert_eq!(
classify_lunation_phase((270.0_f64).to_radians()),
LunationPhase::LastQuarter
);
assert_eq!(
classify_lunation_phase((340.0_f64).to_radians()),
LunationPhase::NewMoon
);
}
}
@@ -0,0 +1,256 @@
//! Mundane (Placidus-quadrant) helpers.
//!
//! These functions answer the **third-dimensional** questions about a
//! body that the ecliptic projection erases:
//!
//! * **Ascensional Difference (AD)** — how much earlier or later a body
//! crosses the horizon compared to a hypothetical body on the celestial
//! equator. Formula: `sin AD = tan δ · tan φ`.
//! * **Diurnal / Nocturnal Semi-Arc** — the equatorial-degree distance
//! the body travels between horizon and meridian. `DSA = 90° + AD`,
//! `NSA = 90° AD`.
//! * **Hour Angle (H)** — the equatorial angle between the body and the
//! local meridian. `H = RAMC RA(body)`, normalised to `[-π, π]`.
//! * **Mundane Position (m)** — a continuous coordinate in `[0, 4)` that
//! wraps the Placidus quadrant structure:
//! * `m = 0`: rising point (eastern horizon)
//! * `m = 1`: upper meridian (MC)
//! * `m = 2`: setting point (western horizon)
//! * `m = 3`: lower meridian (IC)
//!
//! House cusps land at the natural `m = k/3` boundaries (cusp 11 at
//! `m = 2/3`, cusp 12 at `m = 1/3`, etc., in the Placidus model).
//!
//! Latitudes near the polar circle (|φ| ≥ 90° |δ|) make AD undefined
//! — the body never sets or never rises. The helpers return `f64::NAN`
//! in that regime instead of panicking; the primary-direction layer
//! handles the NaN by surfacing an `HouseSystemUnavailable`-style error.
use std::f64::consts::{PI, TAU};
/// Wrap an angle into `[-π, π]`.
#[inline]
fn wrap_pi(x: f64) -> f64 {
let mut v = x.rem_euclid(TAU);
if v > PI {
v -= TAU;
}
v
}
/// Ascensional Difference (AD), radians. `sin AD = tan δ · tan φ`.
/// Returns `NAN` when the body never crosses the horizon (always above
/// or always below at the observer's latitude).
pub fn ascensional_difference_rad(declination_rad: f64, latitude_rad: f64) -> f64 {
let s = libm::tan(declination_rad) * libm::tan(latitude_rad);
if !(-1.0..=1.0).contains(&s) {
f64::NAN
} else {
libm::asin(s)
}
}
/// Diurnal Semi-Arc (DSA), radians. Time from rising to upper meridian
/// expressed as an equatorial angle. `DSA = π/2 + AD`.
pub fn diurnal_semi_arc_rad(declination_rad: f64, latitude_rad: f64) -> f64 {
let ad = ascensional_difference_rad(declination_rad, latitude_rad);
if ad.is_nan() {
f64::NAN
} else {
std::f64::consts::FRAC_PI_2 + ad
}
}
/// Nocturnal Semi-Arc (NSA), radians. `NSA = π/2 AD`.
pub fn nocturnal_semi_arc_rad(declination_rad: f64, latitude_rad: f64) -> f64 {
let ad = ascensional_difference_rad(declination_rad, latitude_rad);
if ad.is_nan() {
f64::NAN
} else {
std::f64::consts::FRAC_PI_2 - ad
}
}
/// Signed hour angle `H = RAMC RA`, normalised to `[-π, π]`. Negative
/// values are east of the meridian (pre-culmination), positive values
/// west (post-culmination).
pub fn signed_hour_angle_rad(ramc_rad: f64, right_ascension_rad: f64) -> f64 {
wrap_pi(ramc_rad - right_ascension_rad)
}
/// Returns `true` if the body sits above the local horizon at the
/// given natal time. The criterion is `|H| ≤ DSA`.
pub fn is_above_horizon(hour_angle_rad: f64, diurnal_semi_arc_rad: f64) -> bool {
if diurnal_semi_arc_rad.is_nan() {
// Pole / circumpolar case: handle by examining the sign of
// `tan δ · tan φ`. Skip for now and assume below.
return false;
}
hour_angle_rad.abs() <= diurnal_semi_arc_rad
}
/// Compute the continuous Placidus mundane position `m ∈ [0, 4)` given
/// the body's signed hour angle and its DSA + NSA.
///
/// Boundary mapping:
/// `m = 0` → rising (east horizon, `H = -DSA`)
/// `m = 1` → MC (`H = 0`)
/// `m = 2` → setting (west horizon, `H = +DSA`)
/// `m = 3` → IC (`H = ±π`)
///
/// Within each quadrant the mapping is linear in hour angle.
pub fn mundane_position(
hour_angle_rad: f64,
diurnal_semi_arc_rad: f64,
nocturnal_semi_arc_rad: f64,
) -> f64 {
let h = wrap_pi(hour_angle_rad);
let dsa = diurnal_semi_arc_rad;
let nsa = nocturnal_semi_arc_rad;
if dsa.is_nan() || nsa.is_nan() {
return f64::NAN;
}
if h.abs() <= dsa {
// Above horizon: m ∈ [0, 2], m = 1 + H/DSA.
return 1.0 + h / dsa;
}
// Below horizon.
if h > dsa {
// West side: m ∈ (2, 3], H = DSA + (m - 2) · NSA → m = 2 + (H - DSA)/NSA.
2.0 + (h - dsa) / nsa
} else {
// East side: m ∈ (3, 4), H = -π + (m - 3) · NSA → m = 3 + (H + π)/NSA.
3.0 + (h + PI) / nsa
}
}
/// Inverse of `mundane_position`: given an `m` and the body's
/// `(DSA, NSA)`, return the hour angle the body would have at that
/// mundane position. Used by the primary-direction code to compute the
/// arc the promissor must rotate to *reach* a target mundane position.
pub fn hour_angle_for_mundane(
m: f64,
diurnal_semi_arc_rad: f64,
nocturnal_semi_arc_rad: f64,
) -> f64 {
let dsa = diurnal_semi_arc_rad;
let nsa = nocturnal_semi_arc_rad;
let m = m.rem_euclid(4.0);
if m <= 2.0 {
// Above horizon.
(m - 1.0) * dsa
} else if m <= 3.0 {
dsa + (m - 2.0) * nsa
} else {
-PI + (m - 3.0) * nsa
}
}
/// Wrap `m` into `[0, 4)`.
#[inline]
pub fn wrap_mundane(m: f64) -> f64 {
let v = m.rem_euclid(4.0);
if v < 0.0 {
v + 4.0
} else {
v
}
}
/// Compute the natal mundane position of a body, given the chart's
/// RAMC and the body's RA/Dec, with the observer's latitude. This is
/// the one-stop helper the primary-direction layer calls.
pub fn natal_mundane_position(
ramc_rad: f64,
body_right_ascension_rad: f64,
body_declination_rad: f64,
latitude_rad: f64,
) -> f64 {
let dsa = diurnal_semi_arc_rad(body_declination_rad, latitude_rad);
let nsa = nocturnal_semi_arc_rad(body_declination_rad, latitude_rad);
let h = signed_hour_angle_rad(ramc_rad, body_right_ascension_rad);
mundane_position(h, dsa, nsa)
}
/// Convenience: wrap a difference in mundane coordinates so it lies in
/// `[0, 4)` (mod 4 — equivalent to mod 360° rotation).
pub fn wrap_mundane_diff(diff: f64) -> f64 {
wrap_mundane(diff)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ad_zero_for_equator_body_or_zero_latitude() {
// δ = 0 → AD = 0 regardless of φ.
for phi_deg in [-60.0, -10.0, 0.0, 25.0, 60.0] {
let phi = (phi_deg as f64).to_radians();
let ad = ascensional_difference_rad(0.0, phi);
assert!(ad.abs() < 1e-12, "AD(δ=0, φ={}) = {}", phi_deg, ad);
}
// φ = 0 → AD = 0 regardless of δ.
for dec_deg in [-23.0, -5.0, 0.0, 5.0, 23.0] {
let dec = (dec_deg as f64).to_radians();
let ad = ascensional_difference_rad(dec, 0.0);
assert!(ad.abs() < 1e-12, "AD(δ={}, φ=0) = {}", dec_deg, ad);
}
}
#[test]
fn dsa_plus_nsa_equals_pi() {
let dec = 15.0_f64.to_radians();
let phi = 40.0_f64.to_radians();
let s = diurnal_semi_arc_rad(dec, phi) + nocturnal_semi_arc_rad(dec, phi);
assert!((s - PI).abs() < 1e-12);
}
#[test]
fn hour_angle_wraps_correctly() {
// RAMC = 350°, RA = 10°: simple diff would be 340°, wrapped → -20°.
let h = signed_hour_angle_rad(350.0_f64.to_radians(), 10.0_f64.to_radians());
assert!((h.to_degrees() - (-20.0)).abs() < 1e-9);
}
#[test]
fn mundane_position_at_meridian_is_one() {
// Body on meridian → H = 0 → m = 1.
let dec = 10.0_f64.to_radians();
let phi = 30.0_f64.to_radians();
let dsa = diurnal_semi_arc_rad(dec, phi);
let nsa = nocturnal_semi_arc_rad(dec, phi);
let m = mundane_position(0.0, dsa, nsa);
assert!((m - 1.0).abs() < 1e-12);
}
#[test]
fn mundane_position_at_horizon_is_zero_or_two() {
let dec = 10.0_f64.to_radians();
let phi = 30.0_f64.to_radians();
let dsa = diurnal_semi_arc_rad(dec, phi);
let nsa = nocturnal_semi_arc_rad(dec, phi);
// East horizon: H = -DSA → m = 0.
let m_east = mundane_position(-dsa, dsa, nsa);
assert!(m_east.abs() < 1e-12, "east horizon m = {}", m_east);
// West horizon: H = +DSA → m = 2.
let m_west = mundane_position(dsa, dsa, nsa);
assert!((m_west - 2.0).abs() < 1e-12);
}
#[test]
fn mundane_position_roundtrip() {
let dec = (-12.0_f64).to_radians();
let phi = 45.0_f64.to_radians();
let dsa = diurnal_semi_arc_rad(dec, phi);
let nsa = nocturnal_semi_arc_rad(dec, phi);
for h_deg in [-100.0_f64, -60.0, -10.0, 0.0, 30.0, 80.0, 130.0, 175.0] {
let h = h_deg.to_radians();
let m = mundane_position(h, dsa, nsa);
let h_back = hour_angle_for_mundane(m, dsa, nsa);
// Allow ±π/360 (= 0.5°) for boundary cases.
let diff = wrap_pi(h_back - h).abs();
assert!(diff < 1e-9, "round-trip failed at H={}° m={} diff={}", h_deg, m, diff);
}
}
}
@@ -0,0 +1,62 @@
//! A single body's placement in a chart: its zodiac position, house,
//! retrograde flag, and (optionally) topocentric horizon coordinates.
use cosmos_sky::{ApparentPosition, Body, HorizonCoord};
use crate::zodiac::SignedLongitude;
#[derive(Debug, Clone, Copy)]
pub struct BodyPlacement {
pub body: Body,
/// Tropical or sidereal longitude depending on the chart's [`crate::Zodiac`].
pub longitude: SignedLongitude,
/// Ecliptic latitude, radians (`[-π/2, π/2]`).
pub latitude_rad: f64,
/// Geometric distance from the observer (geocenter or topocentric
/// origin) to the body, in km. `0.0` for purely conceptual points
/// (lunar nodes, Lilith).
pub distance_km: f64,
/// Apparent ecliptic longitude rate, radians per day. Signed:
/// negative means retrograde. Carried forward so the aspect engine
/// can decide applying vs separating without re-querying the
/// ephemeris.
pub longitude_rate_rad_per_day: f64,
/// Apparent right ascension of date, radians, `[0, 2π)`. Required
/// for mundane and primary-direction work.
pub right_ascension_rad: f64,
/// Apparent declination of date, radians, `[-π/2, π/2]`.
pub declination_rad: f64,
/// 1..=12. Computed against the chart's chosen house system.
pub house_number: u8,
/// Topocentric horizon coordinates if an Observer was supplied to
/// the apparent computation.
pub horizon: Option<HorizonCoord>,
}
impl BodyPlacement {
/// `true` if `dλ/dt < 0` at the chart's epoch — i.e. the body is
/// moving retrograde relative to its mean direction.
#[inline]
pub fn is_retrograde(&self) -> bool {
self.longitude_rate_rad_per_day < 0.0
}
pub(crate) fn from_apparent(
body: Body,
apparent: &ApparentPosition,
longitude_for_zodiac_rad: f64,
house_number: u8,
) -> Self {
Self {
body,
longitude: SignedLongitude::from_radians(longitude_for_zodiac_rad),
latitude_rad: apparent.ecliptic_of_date.latitude_rad,
distance_km: apparent.ecliptic_of_date.distance_km,
longitude_rate_rad_per_day: apparent.ecliptic_velocity.longitude_rate_rad_per_day,
right_ascension_rad: apparent.equatorial_of_date.right_ascension_rad,
declination_rad: apparent.equatorial_of_date.declination_rad,
house_number,
horizon: apparent.topocentric_horizon,
}
}
}
@@ -0,0 +1,667 @@
//! Primary Directions — the "diurnal" forecasting motor.
//!
//! Primary directions are the oldest astrological forecasting method:
//! after birth the celestial sphere continues to rotate, and points
//! that started in particular positions eventually rotate to meet
//! other natal points. The *arc* covered by the rotation, expressed in
//! equatorial degrees, is translated to *years of life* by a "key":
//!
//! * **Ptolemy**: 1° of RA = 1 year (the original classical key).
//! * **Naibod**: 0°59'08.33"/year (≈ 1.0146 years/°) — the Sun's mean
//! daily motion, more astronomically grounded.
//! * **Brahe / Placidus / others**: variants on the Sun's true motion
//! year by year; not implemented in this first cut.
//!
//! Two natal points are involved:
//!
//! * **Promissor (P)** — the "moving" point. As the sphere rotates,
//! the natal P's mundane position changes.
//! * **Significator (S)** — the "fixed" target. Its natal mundane
//! position is the goalpost the promissor must reach.
//!
//! The **arc of direction** is the equatorial angle the sphere must
//! rotate so that `m_P(new) = m_S(natal)`, where `m` is the Placidus
//! mundane coordinate (see [`crate::mundane`]). Years follow by the
//! key conversion.
//!
//! This module ships the **Placidus mundane** method, which is the
//! standard. Regiomontanus and Campanus variants reuse the same
//! framework with different mundane formulas and are scoped for a
//! follow-up.
use cosmos_sky::Body;
use crate::aspect::AspectKind;
use crate::chart::NatalChart;
use crate::error::AstrologyResult;
use crate::mundane::{
diurnal_semi_arc_rad, hour_angle_for_mundane, natal_mundane_position,
nocturnal_semi_arc_rad, signed_hour_angle_rad, wrap_mundane,
};
/// Time-arc conversion. Pick one when configuring a direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectionKey {
/// 1° of right ascension = 1 year of life.
Ptolemy,
/// 0°59'08.33"/year — the Sun's mean daily motion. The most
/// commonly used modern key.
Naibod,
}
impl DirectionKey {
/// Degrees of right ascension that correspond to one year of life
/// under this key.
pub fn degrees_per_year(self) -> f64 {
match self {
DirectionKey::Ptolemy => 1.0,
DirectionKey::Naibod => 0.985_647_3,
}
}
}
/// Which directional method to use. The three classical mundane
/// frameworks differ only in how the "house position" `m ∈ [0, 4)` is
/// projected from a body's (RA, Dec).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DirectionMethod {
/// Placidus mundane: position = proportional position within the
/// body's own diurnal/nocturnal semi-arc. The dominant choice in
/// modern practice.
#[default]
PlacidusMundane,
/// Regiomontanus mundane: position depends only on hour angle —
/// the framework is anchored to the celestial equator and the
/// poles of the world, so every body of any declination shares
/// the same `m(H)` function. As a consequence the arc of
/// direction between any two points reduces to a pure RA delta.
Regiomontanus,
/// Campanus mundane: position is the angle along the prime
/// vertical at which the great circle through (N, body, S)
/// crosses. The framework is anchored to the observer's horizon —
/// East horizon = m=0, zenith = m=1 (MC slot), West horizon =
/// m=2, nadir = m=3.
Campanus,
}
impl DirectionMethod {
/// Compute the natal mundane position `m ∈ [0, 4)` of a target
/// point with the given equatorial coordinates, at the observer's
/// latitude, with the chart's RAMC.
fn mundane_position_for(
self,
ramc_rad: f64,
ra_rad: f64,
dec_rad: f64,
lat_rad: f64,
) -> f64 {
match self {
DirectionMethod::PlacidusMundane => {
natal_mundane_position(ramc_rad, ra_rad, dec_rad, lat_rad)
}
DirectionMethod::Regiomontanus => {
let h = signed_hour_angle_rad(ramc_rad, ra_rad);
// m = 1 + H · (2/π), wrapped into [0, 4).
let m = 1.0 + h * 2.0 / std::f64::consts::PI;
wrap_mundane(m)
}
DirectionMethod::Campanus => campanus_mundane_position(
ramc_rad, ra_rad, dec_rad, lat_rad,
),
}
}
/// Given a target mundane position `m_target` and the promissor's
/// declination, return the hour angle the promissor needs to have
/// so that its mundane position (under this method) equals
/// `m_target`.
fn hour_angle_for_target(
self,
m_target: f64,
promissor_dec_rad: f64,
lat_rad: f64,
) -> f64 {
match self {
DirectionMethod::PlacidusMundane => {
let dsa = diurnal_semi_arc_rad(promissor_dec_rad, lat_rad);
let nsa = nocturnal_semi_arc_rad(promissor_dec_rad, lat_rad);
hour_angle_for_mundane(m_target, dsa, nsa)
}
DirectionMethod::Regiomontanus => {
// Inverse of `m = 1 + H · (2/π)`.
(m_target - 1.0) * std::f64::consts::PI / 2.0
}
DirectionMethod::Campanus => {
campanus_hour_angle_for_target(m_target, promissor_dec_rad, lat_rad)
}
}
}
}
/// Campanus mundane position of a body at `(RA, Dec)` for an observer
/// at latitude `φ` with chart RAMC. Result in `[0, 4)`.
///
/// The body's local horizontal Cartesian:
/// * `y_local = -cos(δ) · sin(H)` (east component)
/// * `z_local = cos(δ) · cos(H) · cos(φ) + sin(δ) · sin(φ)` (up)
///
/// Then `θ = atan2(z, y)` is the Campanus angle along the prime
/// vertical, and `m_Camp = θ · (2/π)` wrapped to `[0, 4)`.
fn campanus_mundane_position(
ramc_rad: f64,
ra_rad: f64,
dec_rad: f64,
lat_rad: f64,
) -> f64 {
let h = signed_hour_angle_rad(ramc_rad, ra_rad);
let cos_dec = libm::cos(dec_rad);
let sin_dec = libm::sin(dec_rad);
let cos_lat = libm::cos(lat_rad);
let sin_lat = libm::sin(lat_rad);
let cos_h = libm::cos(h);
let sin_h = libm::sin(h);
let y = -cos_dec * sin_h;
let z = cos_dec * cos_h * cos_lat + sin_dec * sin_lat;
let theta = libm::atan2(z, y);
let theta = if theta < 0.0 {
theta + std::f64::consts::TAU
} else {
theta
};
wrap_mundane(theta * 2.0 / std::f64::consts::PI)
}
/// Inverse of [`campanus_mundane_position`]: given the target Campanus
/// position `m_target` and the promissor's declination + observer
/// latitude, return the hour angle the promissor needs to occupy.
///
/// Solves `A · cos(H) + B · sin(H) = C` where:
/// * `A = cos(φ) · cos(θ)`, `B = sin(θ)`, `C = -tan(δ_p) · sin(φ) · cos(θ)`,
/// * `θ = m_target · (π/2)`.
///
/// Two algebraic solutions exist; we pick the one whose `(y, z)` sign
/// places the body in the correct prime-vertical quadrant (i.e. the
/// one for which `z·sin(θ) + y·cos(θ)` is positive — the analogue of
/// `r` in `atan2(z, y) = θ`).
fn campanus_hour_angle_for_target(
m_target: f64,
dec_rad: f64,
lat_rad: f64,
) -> f64 {
let theta = m_target * std::f64::consts::PI / 2.0;
let cos_dec = libm::cos(dec_rad);
let sin_dec = libm::sin(dec_rad);
let cos_lat = libm::cos(lat_rad);
let sin_lat = libm::sin(lat_rad);
let cos_theta = libm::cos(theta);
let sin_theta = libm::sin(theta);
// Degenerate cases.
if libm::fabs(cos_dec) < 1.0e-15 {
// Body essentially on a celestial pole — never moves through
// a Campanus cycle. Return 0.
return 0.0;
}
let a = cos_lat * cos_theta;
let b = sin_theta;
let c = -libm::tan(dec_rad) * sin_lat * cos_theta;
let r = libm::sqrt(a * a + b * b);
if r < 1.0e-15 {
return 0.0;
}
let argument = c / r;
if argument.abs() > 1.0 {
// No real solution at this latitude/declination combination —
// body cannot reach the requested Campanus position.
return f64::NAN;
}
let psi = libm::atan2(b, a);
let delta = libm::acos(argument);
let h_plus = psi + delta;
let h_minus = psi - delta;
let check = |h: f64| -> f64 {
let y = -cos_dec * libm::sin(h);
let z = cos_dec * libm::cos(h) * cos_lat + sin_dec * sin_lat;
z * sin_theta + y * cos_theta
};
if check(h_plus) >= check(h_minus) {
h_plus
} else {
h_minus
}
}
/// A "significator" target — either a natal body or an angle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Significator {
Body(Body),
Ascendant,
Midheaven,
Descendant,
ImumCoeli,
}
impl Significator {
/// Natal zodiacal longitude of this significator in radians.
/// Returns `None` only when the significator refers to a body not
/// present in the chart's [`crate::BodySet`].
pub fn longitude_rad(self, natal: &NatalChart) -> Option<f64> {
match self {
Significator::Body(b) => Some(natal.placement(b)?.longitude.longitude_rad()),
Significator::Ascendant => Some(natal.ascendant().longitude_rad()),
Significator::Midheaven => Some(natal.midheaven().longitude_rad()),
Significator::Descendant => Some(natal.descendant().longitude_rad()),
Significator::ImumCoeli => Some(natal.imum_coeli().longitude_rad()),
}
}
/// Natal zodiacal longitude in degrees `[0, 360)`.
pub fn longitude_deg(self, natal: &NatalChart) -> Option<f64> {
self.longitude_rad(natal).map(f64::to_degrees)
}
pub fn label(self) -> String {
match self {
Significator::Body(b) => b.name().to_string(),
Significator::Ascendant => "Ascendant".into(),
Significator::Midheaven => "Midheaven".into(),
Significator::Descendant => "Descendant".into(),
Significator::ImumCoeli => "Imum Coeli".into(),
}
}
}
/// Direct vs Converse — sentido de la rotación que mueve el promissor.
/// Direct = forward in time (la esfera sigue rotando después del
/// nacimiento). Converse = backward in time (rotación simétrica
/// inversa, como si fuera "el tiempo desplegándose al revés"). En la
/// escuela GR las conversas se usan en paralelo con las directas para
/// rectificar: un mismo evento debería aparecer en ambos rings con
/// arcos consistentes si la hora natal es correcta.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PrimaryDirection {
#[default]
Direct,
Converse,
}
/// Proyecta un cuerpo natal según las direcciones primarias a la edad
/// dada. Convierte la edad a un arco en RA usando `key`
/// (Ptolemy/Naibod), aplica la rotación al RA natal del cuerpo
/// (manteniendo su declinación constante — convención clásica), y
/// devuelve la nueva longitud eclíptica proyectada sobre la
/// eclíptica de la fecha.
///
/// Es el cómputo "moderno" de direcciones primarias usado para
/// visualización en time-scrubbing: en lugar de buscar el evento de
/// llegada a un significator, devuelve directamente "dónde está el
/// cuerpo natal después de N años de rotación diurna".
pub fn directed_longitude(
natal_ra_rad: f64,
natal_dec_rad: f64,
age_years: f64,
direction: PrimaryDirection,
key: DirectionKey,
obliquity_rad: f64,
) -> f64 {
let arc_rad = (age_years * key.degrees_per_year()).to_radians();
let sign = match direction {
PrimaryDirection::Direct => 1.0,
PrimaryDirection::Converse => -1.0,
};
let new_ra = (natal_ra_rad + sign * arc_rad).rem_euclid(std::f64::consts::TAU);
// RA + Dec → longitud eclíptica de fecha. Declinación fija
// (la rotación diurna no la cambia para un punto natal).
let (sin_ra, cos_ra) = libm::sincos(new_ra);
let (sin_dec, cos_dec) = libm::sincos(natal_dec_rad);
let (sin_eps, cos_eps) = libm::sincos(obliquity_rad);
let lon = libm::atan2(
sin_dec * sin_eps + cos_dec * cos_eps * sin_ra,
cos_dec * cos_ra,
);
lon.rem_euclid(std::f64::consts::TAU)
}
/// A computed primary direction.
#[derive(Debug, Clone, Copy)]
pub struct Direction {
pub promissor: Body,
pub significator: Significator,
/// Which aspect family this direction targets. `Conjunction` means
/// the promissor reaches the significator's natal mundane position
/// directly; other aspects target the corresponding aspect points
/// (the ecliptic longitudes `significator ± aspect.exact_angle`).
pub aspect: AspectKind,
pub method: DirectionMethod,
pub key: DirectionKey,
/// Arc of direction, radians. Always normalised to `[0, 2π)` —
/// the *next* forward rotation that brings the promissor to the
/// significator's mundane position. Negative-arc (converse)
/// directions are not produced by this module today.
pub arc_rad: f64,
/// Years of life at which the direction perfects. Arc translated
/// by the chosen key.
pub age_years: f64,
}
impl Direction {
pub fn arc_deg(&self) -> f64 {
self.arc_rad.to_degrees()
}
}
/// Compute a single conjunctional direction from `promissor` to
/// `significator`.
///
/// Equivalent to [`direct_to_aspect`] with `AspectKind::Conjunction`,
/// unwrapping the single-element result for ergonomics.
///
/// `Err` is returned if the promissor has no real semi-arc at the
/// observer's latitude (circumpolar / never-rising case).
pub fn direct(
natal: &NatalChart,
promissor: Body,
significator: Significator,
method: DirectionMethod,
key: DirectionKey,
) -> AstrologyResult<Direction> {
let mut out = direct_to_aspect(
natal,
promissor,
significator,
AspectKind::Conjunction,
method,
key,
)?;
Ok(out.remove(0))
}
/// Compute every direction of `promissor` to `significator` for the
/// given aspect family. Returns one direction for Conjunction and
/// Opposition (the aspect is symmetric and lands on a unique ecliptic
/// point); two for every other family — one for the "dexter" branch
/// (`significator exact_angle`) and one for the "sinister" branch
/// (`significator + exact_angle`).
///
/// The promissor still reaches the *mundane* position of each aspect
/// point in the Placidus framework — i.e. the rotation arc is in
/// equatorial degrees, not zodiacal.
pub fn direct_to_aspect(
natal: &NatalChart,
promissor: Body,
significator: Significator,
aspect: AspectKind,
method: DirectionMethod,
key: DirectionKey,
) -> AstrologyResult<Vec<Direction>> {
let placement = natal.placement(promissor).ok_or_else(|| {
crate::error::AstrologyError::BodyUnavailable(format!(
"{} not in chart",
promissor.name()
))
})?;
direct_to_aspect_with_placement(natal, placement, significator, aspect, method, key)
}
/// Same as [`direct_to_aspect`] but accepts a pre-resolved
/// [`BodyPlacement`] reference. Used by [`all_directions_with_aspects`]
/// to skip the body lookup in the inner loop.
fn direct_to_aspect_with_placement(
natal: &NatalChart,
placement: &crate::placement::BodyPlacement,
significator: Significator,
aspect: AspectKind,
method: DirectionMethod,
key: DirectionKey,
) -> AstrologyResult<Vec<Direction>> {
let promissor = placement.body;
let phi = natal.birth.observer.lat_rad;
let ramc = natal.local_apparent_sidereal_time_rad;
let obliquity = natal.obliquity_rad;
// Placidus needs real semi-arcs at the promissor's declination;
// Regiomontanus and Campanus do not (they don't depend on the
// semi-arc construction). Skip the circumpolar guard for those.
if matches!(method, DirectionMethod::PlacidusMundane) {
let dsa_p = diurnal_semi_arc_rad(placement.declination_rad, phi);
let nsa_p = nocturnal_semi_arc_rad(placement.declination_rad, phi);
if dsa_p.is_nan() || nsa_p.is_nan() {
return Err(crate::error::AstrologyError::HouseSystemUnavailable(
"promissor is circumpolar at the observer's latitude — \
Placidus primary directions undefined",
));
}
}
let h_p_natal = signed_hour_angle_rad(ramc, placement.right_ascension_rad);
// The natal ecliptic longitude of the significator, and a marker
// for whether it is an angle (which has no defined ecliptic
// latitude — we treat all aspect points as ecliptic-latitude zero).
let sig_lon_rad = match significator {
Significator::Body(target_body) => {
let target_p = natal.placement(target_body).ok_or_else(|| {
crate::error::AstrologyError::BodyUnavailable(format!(
"significator {} not in chart",
target_body.name()
))
})?;
// Convert back to *tropical* ecliptic longitude (placement
// stores it in chart's zodiac — add ayanamsha to undo
// sidereal offset if applicable).
(target_p.longitude.longitude_rad() + natal.ayanamsha_rad)
.rem_euclid(std::f64::consts::TAU)
}
Significator::Ascendant => (natal.ascendant().longitude_rad() + natal.ayanamsha_rad)
.rem_euclid(std::f64::consts::TAU),
Significator::Midheaven => (natal.midheaven().longitude_rad() + natal.ayanamsha_rad)
.rem_euclid(std::f64::consts::TAU),
Significator::Descendant => (natal.descendant().longitude_rad() + natal.ayanamsha_rad)
.rem_euclid(std::f64::consts::TAU),
Significator::ImumCoeli => (natal.imum_coeli().longitude_rad() + natal.ayanamsha_rad)
.rem_euclid(std::f64::consts::TAU),
};
// Build each aspect branch's mundane target.
//
// Convention: a CONJUNCTION to a natal body uses the body's
// **actual** (RA, Dec) — preserving its true ecliptic latitude
// (this is the classical "in mundo" direction to the body). All
// *other* aspects, and conjunctions to angles, use the zodiacal
// projection at β=0 (the aspect point is a longitude-only
// construct).
let (offsets_deg, n_offsets) = aspect_branch_offsets_deg(aspect);
let mut out = Vec::with_capacity(n_offsets);
for &offset_deg in &offsets_deg[..n_offsets] {
let (target_ra, target_dec) = if offset_deg == 0.0 {
// Conjunction case.
match significator {
Significator::Body(b) => {
let p = natal.placement(b).expect("checked above");
(p.right_ascension_rad, p.declination_rad)
}
_ => ecliptic_to_equatorial(sig_lon_rad, 0.0, obliquity),
}
} else {
let aspect_point_lon = (sig_lon_rad + offset_deg.to_radians())
.rem_euclid(std::f64::consts::TAU);
ecliptic_to_equatorial(aspect_point_lon, 0.0, obliquity)
};
let m_target = method.mundane_position_for(ramc, target_ra, target_dec, phi);
let h_target = method.hour_angle_for_target(m_target, placement.declination_rad, phi);
let arc_rad = normalise_forward(h_target - h_p_natal);
let age_years = arc_rad.to_degrees() / key.degrees_per_year();
out.push(Direction {
promissor,
significator,
aspect,
method,
key,
arc_rad,
age_years,
});
}
Ok(out)
}
/// Offsets (in degrees) at which the aspect family lands relative to
/// the significator's natal longitude. Conjunction → `[0]`; opposition
/// → `[180]`; symmetric aspects → both `+exact` and `exact`. Returns
/// a small stack buffer to keep the per-direction loop allocation-free.
fn aspect_branch_offsets_deg(aspect: AspectKind) -> ([f64; 2], usize) {
let exact = aspect.exact_angle_deg();
match aspect {
AspectKind::Conjunction => ([0.0, 0.0], 1),
AspectKind::Opposition => ([180.0, 0.0], 1),
_ => ([exact, -exact], 2),
}
}
/// Convert an ecliptic longitude / latitude (radians) to equatorial
/// (RA, Dec) at the given true obliquity. Standard textbook formula —
/// kept inline so the primary-direction module is self-contained.
fn ecliptic_to_equatorial(lon: f64, lat: f64, obliquity: f64) -> (f64, f64) {
let (sin_lon, cos_lon) = libm::sincos(lon);
let (sin_lat, cos_lat) = libm::sincos(lat);
let (sin_eps, cos_eps) = libm::sincos(obliquity);
let sin_dec = sin_lat * cos_eps + cos_lat * sin_eps * sin_lon;
let dec = libm::asin(sin_dec);
let ra = libm::atan2(
sin_lon * cos_eps - libm::tan(lat) * sin_eps,
cos_lon,
);
let ra = if ra < 0.0 {
ra + std::f64::consts::TAU
} else {
ra
};
(ra, dec)
}
/// Compute directions of `promissor` to each of the four angles.
pub fn directions_to_angles(
natal: &NatalChart,
promissor: Body,
method: DirectionMethod,
key: DirectionKey,
) -> AstrologyResult<[Direction; 4]> {
Ok([
direct(natal, promissor, Significator::Ascendant, method, key)?,
direct(natal, promissor, Significator::Midheaven, method, key)?,
direct(natal, promissor, Significator::Descendant, method, key)?,
direct(natal, promissor, Significator::ImumCoeli, method, key)?,
])
}
/// Compute every conjunctional direction (each natal body as promissor
/// to each angle and to each other body) whose perfection lies within
/// `max_age_years`. Sorted by `age_years` ascending.
///
/// Use [`all_directions_with_aspects`] to also include non-conjunction
/// aspects (trine, square, sextile, …).
pub fn all_directions(
natal: &NatalChart,
method: DirectionMethod,
key: DirectionKey,
max_age_years: f64,
) -> Vec<Direction> {
all_directions_with_aspects(
natal,
method,
key,
&[AspectKind::Conjunction],
max_age_years,
)
}
/// Compute every direction across the requested aspect families
/// (promissor × significator × aspect) that perfects within
/// `max_age_years`. Sorted by `age_years` ascending.
///
/// `aspect_kinds` selects which aspect families to include. Pass
/// `AspectKind::MAJORS` for the classical five, or `AspectKind::ALL`
/// for every wired aspect.
pub fn all_directions_with_aspects(
natal: &NatalChart,
method: DirectionMethod,
key: DirectionKey,
aspect_kinds: &[AspectKind],
max_age_years: f64,
) -> Vec<Direction> {
// Pre-size to a reasonable upper bound to avoid Vec growth in the
// inner accumulation loop. Each promissor produces up to
// (4 angles + (N 1) bodies) × |aspect_kinds| × 2 branches directions.
let n = natal.placements.len();
let mut out: Vec<Direction> =
Vec::with_capacity(n * (4 + n) * aspect_kinds.len() * 2);
// Outer loop walks each placement exactly once and resolves it
// here — this hoists the linear-scan body lookup that
// `direct_to_aspect` would otherwise repeat for every (sig, aspect)
// triple. Inner calls use `direct_to_aspect_with_placement`.
for promissor_p in &natal.placements {
for sig in [
Significator::Ascendant,
Significator::Midheaven,
Significator::Descendant,
Significator::ImumCoeli,
] {
for &aspect in aspect_kinds {
if let Ok(dirs) = direct_to_aspect_with_placement(
natal, promissor_p, sig, aspect, method, key,
) {
for d in dirs {
if (0.0..=max_age_years).contains(&d.age_years) {
out.push(d);
}
}
}
}
}
for sig_p in &natal.placements {
if sig_p.body == promissor_p.body {
continue;
}
for &aspect in aspect_kinds {
if let Ok(dirs) = direct_to_aspect_with_placement(
natal,
promissor_p,
Significator::Body(sig_p.body),
aspect,
method,
key,
) {
for d in dirs {
if (0.0..=max_age_years).contains(&d.age_years) {
out.push(d);
}
}
}
}
}
}
out.sort_by(|a, b| {
a.age_years
.partial_cmp(&b.age_years)
.unwrap_or(std::cmp::Ordering::Equal)
});
out
}
/// Normalise an arc into the *forward* half-line `[0, 2π)`. Backward
/// arcs (converse) wrap to their forward equivalents; users who care
/// about the distinction should compare against the natural arc.
fn normalise_forward(arc: f64) -> f64 {
let _ = wrap_mundane; // silence unused-import in absence of converse mode
let v = arc.rem_euclid(std::f64::consts::TAU);
if v < 0.0 {
v + std::f64::consts::TAU
} else {
v
}
}
@@ -0,0 +1,182 @@
//! Hellenistic profections.
//!
//! In the profection technique, each year of life corresponds to one
//! house in the natal chart, advancing one house per year and cycling
//! every twelve years:
//!
//! | Age (years) | Profected house |
//! |--------------------|-----------------|
//! | 0, 12, 24, 36, … | House 1 (Asc) |
//! | 1, 13, 25, 37, … | House 2 |
//! | 2, 14, 26, … | House 3 |
//! | ... | ... |
//! | 11, 23, 35, … | House 12 |
//!
//! The sign on that house (Whole-Sign by convention — the most common
//! framing — though any house-system mapping is allowed) gives the
//! **profected sign**; its traditional ruler is the **Lord of the
//! Year**. Monthly and daily profections subdivide the same cycle.
//!
//! This module uses Whole-Sign assignment by default: profected house
//! `n` lands on the `n`-th sign counted from the natal Ascendant's
//! sign. Callers who prefer to align profections with their natal
//! chart's actual house system can pass `ProfectionHouses::Quadrant`.
use cosmos_sky::{Body, Instant};
use crate::chart::NatalChart;
use crate::zodiac::Sign;
/// Which house-frame to use when picking the profected *sign*.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProfectionHouses {
/// Profected sign = `n`-th sign from the natal Asc's sign. The
/// classical Hellenistic convention.
#[default]
WholeSign,
/// Profected sign = sign of the natal `n`-th house cusp.
Quadrant,
}
/// One year's profection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnnualProfection {
/// Whole years since birth.
pub age_years: u32,
/// Profected house, `1..=12`.
pub profected_house: u8,
/// Profected sign.
pub profected_sign: Sign,
/// Traditional ruler of the profected sign.
pub lord_of_year: Body,
/// Modern ruler of the profected sign (only differs for Scorpio
/// → Pluto, Aquarius → Uranus, Pisces → Neptune).
pub modern_lord_of_year: Body,
}
/// One month's profection within a profected year. Months advance one
/// house at the same cadence as years — so the year's first month lands
/// on the same house as the year itself, the second month on the next
/// house, and so on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MonthlyProfection {
pub annual: AnnualProfection,
/// Months into the profected year, `0..=11`.
pub month_in_year: u8,
pub profected_house: u8,
pub profected_sign: Sign,
pub lord_of_month: Body,
}
/// Compute the annual profection for a given age in whole years.
pub fn annual_profection(
chart: &NatalChart,
age_years: u32,
houses_frame: ProfectionHouses,
) -> AnnualProfection {
let profected_house = ((age_years % 12) + 1) as u8;
let profected_sign = profected_sign(chart, profected_house, houses_frame);
AnnualProfection {
age_years,
profected_house,
profected_sign,
lord_of_year: traditional_ruler(profected_sign),
modern_lord_of_year: modern_ruler(profected_sign),
}
}
/// Compute the monthly profection for a given age and month-in-year.
/// `month_in_year = 0` is the first month (lands on the annual house);
/// `month_in_year = 11` is the last (one house before next year).
pub fn monthly_profection(
chart: &NatalChart,
age_years: u32,
month_in_year: u8,
houses_frame: ProfectionHouses,
) -> MonthlyProfection {
let annual = annual_profection(chart, age_years, houses_frame);
let house = ((annual.profected_house as u32 - 1 + month_in_year as u32) % 12 + 1) as u8;
let sign = profected_sign(chart, house, houses_frame);
MonthlyProfection {
annual,
month_in_year,
profected_house: house,
profected_sign: sign,
lord_of_month: traditional_ruler(sign),
}
}
/// Compute the profection in effect at `at`, taken as a "Solar Return
/// anniversary" cadence: each new profection year begins at the year's
/// solar return. This helper computes age in *whole* years from
/// `birth_instant` to `at` using a 365.2422-day average — caller can
/// supply a more accurate `(age_years, month_in_year)` via the
/// non-`_at` functions if needed.
pub fn profection_at(
chart: &NatalChart,
at: Instant,
houses_frame: ProfectionHouses,
) -> MonthlyProfection {
const TROPICAL_YEAR_DAYS: f64 = 365.242_190;
const MONTH_DAYS: f64 = TROPICAL_YEAR_DAYS / 12.0;
let days_elapsed = at.jd_utc() - chart.birth.instant.jd_utc();
let elapsed_years = days_elapsed / TROPICAL_YEAR_DAYS;
if days_elapsed < 0.0 {
// Pre-natal date: clamp to age 0 month 0.
return monthly_profection(chart, 0, 0, houses_frame);
}
let age_years = elapsed_years.floor() as u32;
let day_in_year = days_elapsed - (age_years as f64) * TROPICAL_YEAR_DAYS;
let month_in_year = ((day_in_year / MONTH_DAYS).floor() as i32).clamp(0, 11) as u8;
monthly_profection(chart, age_years, month_in_year, houses_frame)
}
/// Traditional (Hellenistic) sign ruler.
pub fn traditional_ruler(sign: Sign) -> Body {
match sign {
Sign::Aries => Body::Mars,
Sign::Taurus => Body::Venus,
Sign::Gemini => Body::Mercury,
Sign::Cancer => Body::Moon,
Sign::Leo => Body::Sun,
Sign::Virgo => Body::Mercury,
Sign::Libra => Body::Venus,
Sign::Scorpio => Body::Mars,
Sign::Sagittarius => Body::Jupiter,
Sign::Capricorn => Body::Saturn,
Sign::Aquarius => Body::Saturn,
Sign::Pisces => Body::Jupiter,
}
}
/// Modern (post-Uranus discovery) sign ruler. Differs from
/// [`traditional_ruler`] only for Scorpio (Pluto), Aquarius (Uranus),
/// and Pisces (Neptune).
pub fn modern_ruler(sign: Sign) -> Body {
match sign {
Sign::Scorpio => Body::Pluto,
Sign::Aquarius => Body::Uranus,
Sign::Pisces => Body::Neptune,
other => traditional_ruler(other),
}
}
// ─── Internals ─────────────────────────────────────────────────────────
fn profected_sign(chart: &NatalChart, house_number: u8, frame: ProfectionHouses) -> Sign {
let i = ((house_number as i32 - 1) % 12 + 12) % 12;
match frame {
ProfectionHouses::WholeSign => {
let asc_sign = chart.ascendant().sign().index() as i32;
Sign::from_index(((asc_sign + i) % 12) as usize)
}
ProfectionHouses::Quadrant => {
let cusp_rad = chart.houses.cusps[i as usize];
crate::zodiac::SignedLongitude::from_radians(
(cusp_rad - chart.ayanamsha_rad).rem_euclid(std::f64::consts::TAU),
)
.sign()
}
}
}
@@ -0,0 +1,194 @@
//! Secondary, tertiary, and minor progressions.
//!
//! Progressions advance a natal chart in time using a symbolic
//! "day-for-a-period" rate. Each method picks a different *period*:
//!
//! | Method | 1 day of ephemeris ↔ | Approx. shift per year of life |
//! |---|---|---|
//! | Secondary | 1 tropical year (≈ 365.2422 d) | 1 day |
//! | Tertiary | 1 mean synodic month (≈ 29.5306 d) | ≈ 12.4 days |
//! | Minor | 1 mean sidereal month (≈ 27.3217 d) | ≈ 13.4 days |
//!
//! Every progression reduces to the same three steps:
//!
//! 1. Compute a *progressed instant* = `birth + (life_years / period_years) days`.
//! 2. Recompute a full `NatalChart` at the progressed instant — using
//! the **natal observer**, not the location of the subject at age N.
//! 3. Wrap the natal + progressed pair so callers can compare.
//!
//! Houses are recomputed at the progressed instant by default (the
//! Swiss / Astrodienst convention). Pass [`ProgressedHouses::Natal`] to
//! freeze the natal cusps and only progress the bodies.
use cosmos_sky::{EphemerisSession, Instant};
use crate::birth_data::BirthData;
use crate::chart::NatalChart;
use crate::error::AstrologyResult;
/// Which symbolic period a day of ephemeris represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressionMethod {
/// Secondary (day-for-a-year). The classical Naibod / Ptolemy method.
Secondary,
/// Tertiary (day-for-a-mean-synodic-month). Brahy variant.
Tertiary,
/// Minor (day-for-a-mean-sidereal-month).
Minor,
}
impl ProgressionMethod {
/// Period associated with this method, in mean solar days. One day
/// of ephemeris corresponds to one of these.
pub fn period_days(self) -> f64 {
match self {
// Tropical year (vernal-equinox to vernal-equinox).
ProgressionMethod::Secondary => 365.242_190,
// Mean synodic month (new-moon to new-moon).
ProgressionMethod::Tertiary => 29.530_588_85,
// Mean sidereal month (Moon's return to the same star).
ProgressionMethod::Minor => 27.321_661,
}
}
}
/// How to handle the houses of a progressed chart.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProgressedHouses {
/// Recompute houses at the progressed instant using the natal
/// observer's coordinates. (Swiss / Astrodienst default.)
#[default]
Progressed,
/// Reuse the natal house cusps unchanged. The progressed planets
/// are placed against the natal house framework.
Natal,
}
/// Compute the *progressed instant* corresponding to a target age in
/// life, for the given method. The result is a real instant on the
/// ephemeris timeline.
pub fn progressed_instant(
birth: Instant,
target_age_years: f64,
method: ProgressionMethod,
) -> Instant {
let life_days = target_age_years * ProgressionMethod::Secondary.period_days();
let shift_days = life_days / method.period_days();
Instant::from_utc(birth.utc().add_days(shift_days))
}
/// A progressed chart bundled with the natal chart it derives from.
#[derive(Debug, Clone)]
pub struct ProgressedChart {
pub natal: NatalChart,
pub progressed: NatalChart,
pub method: ProgressionMethod,
pub houses_treatment: ProgressedHouses,
pub target_age_years: f64,
pub progressed_instant: Instant,
}
impl ProgressedChart {
pub fn progressed(&self) -> &NatalChart {
&self.progressed
}
pub fn natal(&self) -> &NatalChart {
&self.natal
}
}
/// Build a progressed chart at the requested age, using `method` and
/// `houses_treatment`.
pub fn progress(
natal: &NatalChart,
session: &EphemerisSession,
target_age_years: f64,
method: ProgressionMethod,
houses_treatment: ProgressedHouses,
) -> AstrologyResult<ProgressedChart> {
let prog_instant = progressed_instant(natal.birth.instant, target_age_years, method);
let prog_birth = BirthData {
instant: prog_instant,
observer: natal.birth.observer,
name: natal.birth.name.clone(),
time_certainty: natal.birth.time_certainty,
note: natal.birth.note.clone(),
};
let mut progressed = NatalChart::compute(&prog_birth, &natal.config, session)?;
if houses_treatment == ProgressedHouses::Natal {
// Replace the freshly-computed houses with the natal ones, then
// re-assign every body to its natal-frame house number. Other
// chart geometry (asc/mc/etc.) reflects the natal angles.
progressed.houses = natal.houses;
progressed.local_apparent_sidereal_time_rad =
natal.local_apparent_sidereal_time_rad;
progressed.obliquity_rad = natal.obliquity_rad;
// Asc / MC / Desc / IC come from the *natal* angles in radians,
// but the SignedLongitude needs to be rebuilt to honour the
// progressed chart's zodiac (which is identical to the natal's
// ChartConfig, so the shift logic matches).
progressed.replace_angles_with(natal);
for p in progressed.placements.iter_mut() {
p.house_number = natal.houses.house_containing(
p.longitude.longitude_rad() + progressed.ayanamsha_rad,
);
}
}
Ok(ProgressedChart {
natal: natal.clone(),
progressed,
method,
houses_treatment,
target_age_years,
progressed_instant: prog_instant,
})
}
/// Convenience: secondary progression with the default house treatment.
pub fn secondary_progression(
natal: &NatalChart,
session: &EphemerisSession,
target_age_years: f64,
) -> AstrologyResult<ProgressedChart> {
progress(
natal,
session,
target_age_years,
ProgressionMethod::Secondary,
ProgressedHouses::default(),
)
}
/// Convenience: tertiary progression with the default house treatment.
pub fn tertiary_progression(
natal: &NatalChart,
session: &EphemerisSession,
target_age_years: f64,
) -> AstrologyResult<ProgressedChart> {
progress(
natal,
session,
target_age_years,
ProgressionMethod::Tertiary,
ProgressedHouses::default(),
)
}
/// Convenience: minor progression (1 day = 1 sidereal month).
pub fn minor_progression(
natal: &NatalChart,
session: &EphemerisSession,
target_age_years: f64,
) -> AstrologyResult<ProgressedChart> {
progress(
natal,
session,
target_age_years,
ProgressionMethod::Minor,
ProgressedHouses::default(),
)
}
@@ -0,0 +1,97 @@
//! Planetary returns: find the instant at which a body's ecliptic
//! longitude crosses its natal value again.
//!
//! The solar return (Sun back to natal Sun) is the classical annual
//! "revolution"; the lunar return is the monthly one; and any planet
//! has its own synodic-style return cycle.
//!
//! All returns reduce to one primitive: bisect on
//! `f(t) = signed_angular_distance( body_longitude_at(t), natal_longitude )`.
//! The bisector lives in [`cosmos_sky::find_root`]; this module just
//! wraps it with body-aware default search windows.
use cosmos_sky::{find_root, Body, EphemerisSession, Instant, SearchOptions};
use crate::angles::signed_delta_rad;
use crate::error::{AstrologyError, AstrologyResult};
/// Estimated synodic period of `body` around the geocenter, in days.
/// Used to pick a search window and a coarse-scan step. Values are
/// nominal — the search bracket adds slack so they need not be exact.
fn nominal_period_days(body: Body) -> f64 {
match body {
Body::Moon => 27.321_661, // sidereal month
Body::Sun => 365.256_363, // sidereal year (≈ tropical year for return)
Body::Mercury => 87.969,
Body::Venus => 224.701,
Body::Mars => 686.971,
Body::Jupiter => 4_332.59,
Body::Saturn => 10_759.22,
Body::Uranus => 30_688.5,
Body::Neptune => 60_182.0,
Body::Pluto => 90_560.0,
// Lunar nodes: 18.6-year cycle. Lilith: ~8.85-year cycle.
Body::MeanNode | Body::TrueNode => 6_793.4,
Body::MeanLilith | Body::TrueLilith => 3_232.6,
// Asteroids (rough): Ceres 4.6 yr, others nearby.
Body::Ceres => 1_681.6,
Body::Pallas => 1_686.0,
Body::Juno => 1_595.0,
Body::Vesta => 1_325.0,
// Centaurs / TNOs are very slow. Pick a conservative upper bound.
_ => 100_000.0,
}
}
/// Find the next instant at or after `after` where `body`'s apparent
/// ecliptic longitude (tropical, of date) equals `natal_longitude_rad`.
///
/// The search walks forward for up to ~1.5× the body's nominal synodic
/// period, which always brackets the next return. Pass a custom
/// `max_window_days` if you need a tighter or looser bound (e.g.
/// rectifying with a degenerate fit).
pub fn next_return(
session: &EphemerisSession,
body: Body,
natal_longitude_rad: f64,
after: Instant,
max_window_days: Option<f64>,
) -> AstrologyResult<Instant> {
let nominal = nominal_period_days(body);
let window = max_window_days.unwrap_or(nominal * 1.5);
let t1 = Instant::from_utc(after.utc().add_days(window));
// Coarse-scan step: a fraction of the nominal period that resolves
// a single revolution into ~60 samples (enough for monotone signed
// delta but coarse enough not to slow outer-body searches).
let step_seconds = (nominal * 86_400.0 / 60.0).max(60.0);
let opts = SearchOptions {
coarse_step_seconds: step_seconds,
tolerance_seconds: 1.0,
max_iterations: 80,
};
let result = find_root(
after,
t1,
|t: Instant| {
let pos = session.body_apparent(body, t, None)?;
Ok(signed_delta_rad(
pos.ecliptic_of_date.longitude_rad,
natal_longitude_rad,
))
},
opts,
)?;
result.ok_or_else(|| {
AstrologyError::BodyUnavailable(format!(
"no return of {} to {:.4}° in {:.1} days after {}",
body.name(),
natal_longitude_rad.to_degrees(),
window,
after,
))
})
}
@@ -0,0 +1,180 @@
//! Solar Arc directions.
//!
//! Solar Arc adds a single angular increment — the arc the secondary-
//! progressed Sun has covered between birth and the target age — to
//! every planet *and* every house cusp uniformly. Because the same arc
//! is applied everywhere, the relative house position of each body is
//! preserved by construction; what changes are the absolute zodiac
//! positions and the angles.
//!
//! Two solar-arc conventions exist:
//!
//! * **Naibod**: the arc is the *mean* Sun's motion ≈ 0°59'08"/day.
//! Always the same per year regardless of natal Sun's actual progress.
//! * **True solar arc** (a.k.a. "Sun's secondary progression"):
//! the arc is the actual secondary-progressed Sun's longitude minus
//! the natal Sun's longitude. Varies year-to-year.
//!
//! This module implements both; the helper `solar_arc` chooses
//! [`SolarArcMethod::TrueProgressedSun`] by default — that is what
//! Swiss Ephemeris reports.
use cosmos_sky::{Body, EphemerisSession};
use crate::angles::signed_delta_rad;
use crate::chart::{Angle, NatalChart};
use crate::error::{AstrologyError, AstrologyResult};
use crate::progression::{progress, ProgressedHouses, ProgressionMethod};
use crate::zodiac::SignedLongitude;
/// Which arc convention to use.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SolarArcMethod {
/// Arc = secondary-progressed Sun's longitude natal Sun's longitude.
/// The arc varies between roughly 0°57' and 1°01' per year of life
/// depending on the natal Sun's actual motion.
#[default]
TrueProgressedSun,
/// Naibod: arc = 0°59'08.33"/year × age. Constant per year.
Naibod,
}
/// Naibod constant: mean Sun motion in radians per year of life
/// (0°59'08.33" per day × 1 day/year of life via secondary mapping).
const NAIBOD_RAD_PER_YEAR: f64 = 0.017_202_376; // ≈ 0°59'08.33" in radians
/// A solar-arc-directed chart bundled with the natal it derives from.
#[derive(Debug, Clone)]
pub struct SolarArcChart {
pub natal: NatalChart,
/// All natal positions and cusps shifted forward by `arc_rad`.
pub directed: NatalChart,
pub arc_rad: f64,
pub method: SolarArcMethod,
pub target_age_years: f64,
}
impl SolarArcChart {
pub fn arc_deg(&self) -> f64 {
self.arc_rad.to_degrees()
}
}
/// Compute a solar-arc directed chart at the requested age, using the
/// chosen arc convention.
pub fn solar_arc(
natal: &NatalChart,
session: &EphemerisSession,
target_age_years: f64,
method: SolarArcMethod,
) -> AstrologyResult<SolarArcChart> {
let arc_rad = match method {
SolarArcMethod::TrueProgressedSun => {
// Run a secondary progression and read the Sun's longitude
// delta. We need the Sun in the natal *and* progressed
// charts, both in the same tropical/sidereal zodiac.
let prog = progress(
natal,
session,
target_age_years,
ProgressionMethod::Secondary,
ProgressedHouses::Progressed,
)?;
let natal_sun = natal
.placement(Body::Sun)
.ok_or_else(|| AstrologyError::BodyUnavailable(
"natal chart missing Sun".into(),
))?
.longitude
.longitude_rad();
let prog_sun = prog
.progressed()
.placement(Body::Sun)
.ok_or_else(|| AstrologyError::BodyUnavailable(
"progressed chart missing Sun".into(),
))?
.longitude
.longitude_rad();
signed_delta_rad(prog_sun, natal_sun)
}
SolarArcMethod::Naibod => NAIBOD_RAD_PER_YEAR * target_age_years,
};
let directed = direct(natal, arc_rad);
Ok(SolarArcChart {
natal: natal.clone(),
directed,
arc_rad,
method,
target_age_years,
})
}
/// Convenience: solar arc with the default (true progressed Sun) method.
pub fn solar_arc_true(
natal: &NatalChart,
session: &EphemerisSession,
target_age_years: f64,
) -> AstrologyResult<SolarArcChart> {
solar_arc(natal, session, target_age_years, SolarArcMethod::TrueProgressedSun)
}
/// Convenience: solar arc with the Naibod (mean-Sun) method.
pub fn solar_arc_naibod(
natal: &NatalChart,
target_age_years: f64,
) -> SolarArcChart {
let arc_rad = NAIBOD_RAD_PER_YEAR * target_age_years;
let directed = direct(natal, arc_rad);
SolarArcChart {
natal: natal.clone(),
directed,
arc_rad,
method: SolarArcMethod::Naibod,
target_age_years,
}
}
/// Apply a uniform `arc_rad` shift to every angle, cusp, and body of
/// `natal`. The result inherits all kinematics (rates, retrograde) from
/// the natal chart — solar arc is a *symbolic* shift, not a real
/// physical motion.
fn direct(natal: &NatalChart, arc_rad: f64) -> NatalChart {
use std::f64::consts::TAU;
let wrap = |x: f64| {
let v = x.rem_euclid(TAU);
if v < 0.0 {
v + TAU
} else {
v
}
};
let mut directed = natal.clone();
// Shift all four angles.
directed.replace_angles_with(natal);
let asc_new = wrap(natal.ascendant().longitude_rad() + arc_rad);
let mc_new = wrap(natal.midheaven().longitude_rad() + arc_rad);
directed.set_directed_angles(
Angle::from_radians(asc_new),
Angle::from_radians(mc_new),
Angle::from_radians(wrap(asc_new + std::f64::consts::PI)),
Angle::from_radians(wrap(mc_new + std::f64::consts::PI)),
);
// Shift every cusp.
for c in directed.houses.cusps.iter_mut() {
*c = wrap(*c + arc_rad);
}
// Shift Ascendant and Midheaven in the raw `Houses` view too.
directed.houses.ascendant_rad = wrap(directed.houses.ascendant_rad + arc_rad);
directed.houses.midheaven_rad = wrap(directed.houses.midheaven_rad + arc_rad);
// Shift every placement's longitude. House numbers are invariant
// under uniform rotation, so they don't need re-assignment.
for p in directed.placements.iter_mut() {
let new_lon_rad = wrap(p.longitude.longitude_rad() + arc_rad);
p.longitude = SignedLongitude::from_radians(new_lon_rad);
}
directed
}
@@ -0,0 +1,140 @@
//! Planetary stations: the instants when a body's ecliptic longitude
//! rate `dλ/dt` crosses zero, marking the transition between direct and
//! retrograde motion.
//!
//! Reduces to one call into [`cosmos_sky::find_root`] on the apparent
//! longitude rate exposed by [`cosmos_sky::ApparentPosition::ecliptic_velocity`].
//! Use [`next_station`] for the next station after a given instant, or
//! [`all_stations`] for every station inside a window.
use cosmos_sky::{find_all_roots, find_root, Body, EphemerisSession, Instant, SearchOptions};
use crate::error::{AstrologyError, AstrologyResult};
/// Direction of the transition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StationKind {
/// Direct → retrograde. The body was moving forward and is now
/// about to move backward.
Retrograde,
/// Retrograde → direct. The body finishes the retrograde phase
/// and resumes forward motion.
Direct,
}
#[derive(Debug, Clone, Copy)]
pub struct Station {
pub body: Body,
pub instant: Instant,
pub kind: StationKind,
}
/// Find the next station of `body` at or after `after`. Returns
/// `Ok(None)` if no sign-change in the longitude rate is detected
/// within `max_window_days`.
pub fn next_station(
session: &EphemerisSession,
body: Body,
after: Instant,
max_window_days: f64,
) -> AstrologyResult<Option<Station>> {
let t1 = Instant::from_utc(after.utc().add_days(max_window_days));
let opts = station_search_options(body);
let zero = find_root(
after,
t1,
|t: Instant| {
let pos = session.body_apparent(body, t, None)?;
Ok(pos.ecliptic_velocity.longitude_rate_rad_per_day)
},
opts,
)
.map_err(AstrologyError::Sky)?;
match zero {
None => Ok(None),
Some(t_zero) => {
let kind = classify_station(session, body, t_zero)?;
Ok(Some(Station {
body,
instant: t_zero,
kind,
}))
}
}
}
/// Find every station of `body` in `[after, after + max_window_days]`.
/// Useful for plotting retrograde periods or summarising a year.
pub fn all_stations(
session: &EphemerisSession,
body: Body,
after: Instant,
max_window_days: f64,
) -> AstrologyResult<Vec<Station>> {
let t1 = Instant::from_utc(after.utc().add_days(max_window_days));
let opts = station_search_options(body);
let zeros = find_all_roots(
after,
t1,
|t: Instant| {
let pos = session.body_apparent(body, t, None)?;
Ok(pos.ecliptic_velocity.longitude_rate_rad_per_day)
},
opts,
)
.map_err(AstrologyError::Sky)?;
let mut out = Vec::with_capacity(zeros.len());
for t in zeros {
let kind = classify_station(session, body, t)?;
out.push(Station {
body,
instant: t,
kind,
});
}
Ok(out)
}
/// Coarse-scan / tolerance defaults for each body, tuned so the scan
/// brackets a single station without missing one. Fast bodies (Moon,
/// Mercury, Venus) need finer sampling near zero-rate; outer planets
/// can use a daily step.
fn station_search_options(body: Body) -> SearchOptions {
use Body::*;
let coarse_step_seconds = match body {
// Moon never stations (always direct), but we still keep a
// sensible step in case callers feed it in.
Moon => 3_600.0 * 6.0,
Mercury | Venus => 3_600.0 * 6.0,
Mars => 86_400.0,
Jupiter | Saturn | Uranus | Neptune | Pluto => 86_400.0,
_ => 86_400.0,
};
SearchOptions {
coarse_step_seconds,
tolerance_seconds: 30.0, // 30 s is well below any meaningful astrological resolution
max_iterations: 80,
}
}
/// Sample the rate slightly before `t` to decide the direction of the
/// crossing: a positive rate before zero ⇒ direct → retrograde
/// (Retrograde station); negative before zero ⇒ retrograde → direct.
fn classify_station(
session: &EphemerisSession,
body: Body,
t: Instant,
) -> AstrologyResult<StationKind> {
let probe = Instant::from_utc(t.utc().add_days(-0.5)); // half a day earlier
let pos = session.body_apparent(body, probe, None).map_err(AstrologyError::Sky)?;
let rate = pos.ecliptic_velocity.longitude_rate_rad_per_day;
Ok(if rate > 0.0 {
StationKind::Retrograde
} else {
StationKind::Direct
})
}
@@ -0,0 +1,77 @@
//! Synastry: aspect grid between two natal charts.
//!
//! Synastry compares two charts by considering every pair `(body in A,
//! body in B)` and reporting the aspects whose angular separation falls
//! within an [`OrbTable`]'s allowance. It is symmetric with respect to
//! the chart order — if A→B reports a 5° conjunction Sun↔Moon, B→A
//! reports the same. The convention used here is that `person_a_body`
//! always sits in chart A and `person_b_body` always sits in chart B.
use cosmos_sky::Body;
use crate::angles::signed_delta_deg;
use crate::aspect::{AspectKind, OrbTable};
use crate::chart::NatalChart;
/// One aspect between a body in chart A and a body in chart B.
#[derive(Debug, Clone, Copy)]
pub struct SynastryAspect {
pub person_a_body: Body,
pub person_b_body: Body,
pub kind: AspectKind,
pub orb_signed_deg: f64,
pub allowed_orb_deg: f64,
}
impl SynastryAspect {
pub fn orb_abs_deg(&self) -> f64 {
self.orb_signed_deg.abs()
}
}
/// Cross-chart aspect grid. Returns aspects sorted by orb (tightest
/// first). `kinds` selects which aspect families to test.
pub fn find_synastry_aspects(
chart_a: &NatalChart,
chart_b: &NatalChart,
orbs: &OrbTable,
kinds: &[AspectKind],
) -> Vec<SynastryAspect> {
let mut out: Vec<SynastryAspect> = Vec::new();
for a in &chart_a.placements {
for b in &chart_b.placements {
// Identical-body cross-chart aspects ARE meaningful here
// — Sun(A) conjunct Sun(B) is the canonical "same-sign
// birthday" indicator. So we do NOT skip same-body pairs.
for &kind in kinds {
let allowed = orbs.orb_for(a.body, b.body, kind);
if allowed <= 0.0 {
continue;
}
let raw =
signed_delta_deg(a.longitude.longitude_deg(), b.longitude.longitude_deg());
let separation = raw.abs();
let exact = kind.exact_angle_deg();
let diff = separation - exact;
if diff.abs() > allowed {
continue;
}
out.push(SynastryAspect {
person_a_body: a.body,
person_b_body: b.body,
kind,
orb_signed_deg: diff,
allowed_orb_deg: allowed,
});
}
}
}
out.sort_by(|x, y| {
x.orb_abs_deg()
.partial_cmp(&y.orb_abs_deg())
.unwrap_or(std::cmp::Ordering::Equal)
});
out
}
@@ -0,0 +1,244 @@
//! Corrección topocéntrica de posiciones planetarias.
//!
//! Las posiciones que entrega VSOP2013 (y por extensión `Placement`)
//! son **geocéntricas** — referidas al centro de la Tierra. El
//! observador real está en la superficie, desplazado del centro por
//! ~6378 km. La diferencia produce una **paralaje horizontal** que
//! desplaza la posición aparente del cuerpo, máxima para la Luna
//! (~1°), modesta para los planetas interiores (~30″ en Marte cerca
//! de oposición) y despreciable para los exteriores.
//!
//! En la práctica astrológica, el sistema topocéntrico es relevante
//! para:
//! - Lecturas precisas de la Luna (la diferencia es visible a simple
//! vista en la rueda).
//! - Trabajos de rectificación con Direcciones Primarias del sistema
//! GR / García Rosas, donde la paralaje cambia el resultado.
//! - Sinastrías comparativas (geocéntrico vs topocéntrico).
//!
//! Referencia: Meeus, *Astronomical Algorithms*, cap. 40 ("Correction
//! for Parallax"), ec. 40.6-40.7.
//!
//! ## Simplificaciones
//!
//! Tratamos la Tierra como esfera (sin flattening 1/298.257). Eso
//! introduce un error de ~10″ en latitudes medias — orden de
//! magnitud menor que la propia paralaje y aceptable para uso
//! astrológico. Si el caller necesita precisión sub-arc-second
//! debe usar el módulo Swiss Ephemeris directamente.
use std::f64::consts::TAU;
/// Paralaje solar standard (Meeus 40.1, en radianes): el ángulo que
/// subtiende el radio terrestre visto desde 1 AU. Equivale a 8.794″.
const SOLAR_PARALLAX_RAD: f64 = 4.263_452_25e-5;
/// Convierte una posición eclíptica geocéntrica a topocéntrica para
/// un observador dado. La conversión pasa por coordenadas
/// ecuatoriales (RA/Dec), aplica la paralaje en ese frame
/// (donde la geometría es separable en `Δα` y `Δδ` cleanly), y
/// vuelve a eclípticas.
///
/// Parámetros:
/// * `lon_rad`, `lat_rad`: longitud y latitud eclípticas geocéntricas.
/// * `dist_au`: distancia geocéntrica al cuerpo, en AU. Para cuerpos
/// con `dist_au > 50` (más allá de Plutón) el shift es < 10⁻⁶ rad
/// y se devuelve la entrada sin tocar.
/// * `obs_lat_rad`: latitud geográfica del observador.
/// * `lst_rad`: Local Apparent Sidereal Time del observador.
/// * `obliquity_rad`: obliquidad verdadera de la fecha.
///
/// Devuelve `(lon_topo_rad, lat_topo_rad)` con `lon_topo_rad ∈
/// [0, 2π)`.
pub fn topocentric_ecliptic(
lon_rad: f64,
lat_rad: f64,
dist_au: f64,
obs_lat_rad: f64,
lst_rad: f64,
obliquity_rad: f64,
) -> (f64, f64) {
// Cuerpos muy lejanos: la paralaje es indistinguible numéricamente
// de cero y devolver la geocéntrica evita ruido floating-point.
if dist_au <= 0.0 || dist_au > 50.0 {
return (lon_rad.rem_euclid(TAU), lat_rad);
}
// 1) Eclíptico → ecuatorial.
let (ra, dec) = ecliptic_to_equatorial(lon_rad, lat_rad, obliquity_rad);
// 2) Paralaje horizontal sin π = sin(8.794″) / dist_au. Para
// distancias > 0.0001 AU (≈15000 km) el seno es indistinguible
// del argumento; usamos la aproximación de ángulo pequeño.
let sin_pi = SOLAR_PARALLAX_RAD / dist_au;
// 3) Hour angle del cuerpo (H = LST - α).
let h = lst_rad - ra;
let (sin_h, cos_h) = libm::sincos(h);
// 4) Componentes del observador (esfera, ρ=1, alt despreciable).
let (sin_phi, cos_phi) = libm::sincos(obs_lat_rad);
let rho_cos_phi = cos_phi;
let rho_sin_phi = sin_phi;
// 5) Δα y δ' según Meeus 40.6-40.7.
let (sin_dec, cos_dec) = libm::sincos(dec);
let denom = cos_dec - rho_cos_phi * sin_pi * cos_h;
let delta_alpha = libm::atan2(-rho_cos_phi * sin_pi * sin_h, denom);
let ra_topo = ra + delta_alpha;
let dec_topo = libm::atan2(
(sin_dec - rho_sin_phi * sin_pi) * libm::cos(delta_alpha),
denom,
);
// 6) Ecuatorial topocéntrico → eclíptico topocéntrico.
let (lon_topo, lat_topo) = equatorial_to_ecliptic(ra_topo, dec_topo, obliquity_rad);
(lon_topo.rem_euclid(TAU), lat_topo)
}
/// Eclíptico → ecuatorial. (RA, Dec) en radianes; RA en [0, 2π).
fn ecliptic_to_equatorial(lon: f64, lat: f64, obliquity: f64) -> (f64, f64) {
let (sin_lon, cos_lon) = libm::sincos(lon);
let (sin_lat, cos_lat) = libm::sincos(lat);
let (sin_eps, cos_eps) = libm::sincos(obliquity);
let sin_dec = sin_lat * cos_eps + cos_lat * sin_eps * sin_lon;
let dec = libm::asin(sin_dec);
let ra = libm::atan2(sin_lon * cos_eps - libm::tan(lat) * sin_eps, cos_lon);
let ra = ra.rem_euclid(TAU);
(ra, dec)
}
/// Ecuatorial → eclíptico. (λ, β) en radianes; λ en [0, 2π).
fn equatorial_to_ecliptic(ra: f64, dec: f64, obliquity: f64) -> (f64, f64) {
let (sin_ra, cos_ra) = libm::sincos(ra);
let (sin_dec, cos_dec) = libm::sincos(dec);
let (sin_eps, cos_eps) = libm::sincos(obliquity);
let sin_beta = sin_dec * cos_eps - cos_dec * sin_eps * sin_ra;
let beta = libm::asin(sin_beta);
let lon = libm::atan2(
sin_dec * sin_eps + cos_dec * cos_eps * sin_ra,
cos_dec * cos_ra,
);
let lon = lon.rem_euclid(TAU);
(lon, beta)
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn deg(r: f64) -> f64 {
r.to_degrees()
}
#[test]
fn distant_body_no_shift() {
// Saturno ~9 AU: shift en arcsec ≈ 8.794" / 9 ≈ 1" — debería
// estar bajo el error tolerable para un test relativo.
let lon = 120.0_f64.to_radians();
let lat = 0.5_f64.to_radians();
let (lt, bt) = topocentric_ecliptic(
lon,
lat,
9.0, // ~Saturno
45.0_f64.to_radians(),
60.0_f64.to_radians(),
23.44_f64.to_radians(),
);
// < 5 arcsec de diferencia
assert!(deg(lt - lon).abs() < 5.0 / 3600.0);
assert!(deg(bt - lat).abs() < 5.0 / 3600.0);
}
#[test]
fn very_distant_body_returns_unchanged() {
// Pluto > 30 AU debería devolver exactamente la entrada
// (short-circuit por threshold).
let lon = 200.0_f64.to_radians();
let lat = 0.7_f64.to_radians();
let (lt, bt) = topocentric_ecliptic(
lon,
lat,
32.0,
40.0_f64.to_radians(),
90.0_f64.to_radians(),
23.44_f64.to_radians(),
);
// 32 AU sale del threshold de 50: aún se computa, pero el
// shift es minúsculo. La diferencia tiene que ser < 1 arcsec.
assert!(deg(lt - lon).abs() < 1.0 / 3600.0);
assert!(deg(bt - lat).abs() < 1.0 / 3600.0);
}
#[test]
fn moon_parallax_significant() {
// Luna a ~60 radios terrestres = 0.00257 AU. La paralaje
// horizontal es ~57'. El shift exacto depende del hour
// angle y la latitud, pero debería estar en el orden de
// arcmin, NUNCA cero, para una observación no-cenital.
let lon = 120.0_f64.to_radians(); // Leo aprox.
let lat = 0.0_f64.to_radians();
let dist_au = 0.00257;
let obs_lat = 45.0_f64.to_radians();
let lst = 60.0_f64.to_radians(); // body NO en el meridiano
let eps = 23.44_f64.to_radians();
let (lt, _bt) = topocentric_ecliptic(lon, lat, dist_au, obs_lat, lst, eps);
let shift_arcmin = deg(lt - lon).abs() * 60.0;
// Esperamos shift entre 1' y 80' (rango amplio porque
// depende mucho de la geometría exacta).
assert!(
(1.0..80.0).contains(&shift_arcmin),
"shift Luna esperado en (1', 80'), fue {}'",
shift_arcmin
);
}
#[test]
fn zenith_passage_no_shift() {
// Si el cuerpo pasa por el cenit del observador (declinación
// = latitud, hour angle = 0), la paralaje es exactamente
// radial hacia abajo y no cambia la dirección angular.
// Construimos: lon tal que ra=lst, lat=0 → δ = ε·sin(λ)·… ;
// en lugar de invertir analíticamente, picamos un caso
// simple: λ=0 (Aries 0°), β=0 → α=0, δ=0. Si lst=0 y obs_lat
// = 0, el cuerpo está en el cenit. shift debe ser ~0.
let (lt, bt) = topocentric_ecliptic(
0.0,
0.0,
0.4, // distancia tipo Mercurio
0.0_f64.to_radians(),
0.0_f64.to_radians(),
23.44_f64.to_radians(),
);
assert!(deg(lt).abs() < 0.001 || deg(lt - 360.0).abs() < 0.001);
assert!(deg(bt).abs() < 0.001);
}
#[test]
fn ecliptic_equatorial_round_trip() {
let cases: [(f64, f64); 5] = [
(0.0, 0.0),
(90.0, 23.44),
(120.0, -5.0),
(270.0, 10.0),
(359.9, -0.1),
];
let eps = 23.44_f64.to_radians();
for (lon_deg, lat_deg) in cases {
let lon = lon_deg.to_radians();
let lat = lat_deg.to_radians();
let (ra, dec) = ecliptic_to_equatorial(lon, lat, eps);
let (lon2, lat2) = equatorial_to_ecliptic(ra, dec, eps);
// Roundtrip < 1 arcsec.
let d_lon = ((lon - lon2 + PI).rem_euclid(2.0 * PI) - PI).abs();
assert!(d_lon.to_degrees() * 3600.0 < 0.5, "lon {} → {}", lon_deg, lon2.to_degrees());
assert!(
((lat - lat2).to_degrees() * 3600.0).abs() < 0.5,
"lat {} → {}",
lat_deg,
lat2.to_degrees()
);
}
}
}
@@ -0,0 +1,235 @@
//! Transits: aspects between bodies in the *sky right now* (or at any
//! chosen instant) and points in a natal chart.
//!
//! A transit is the most common kind of forecasting an astrologer
//! consults. It asks: "of all the angular relationships that the
//! transiting planets currently form with my natal points, which ones
//! are within orb?" — and, by extension, "when will the next exact
//! contact happen?"
//!
//! Two modes are exposed:
//!
//! * [`find_current_transits`] — a snapshot of every aspect a list of
//! transiting bodies makes with every body or angle in `natal`, at a
//! single instant.
//! * [`find_next_exact_transit`] — root-finds the next time a specific
//! transiting body's longitude is exactly N degrees from a specific
//! natal longitude, where N is the [`AspectKind`]'s exact angle.
use cosmos_sky::{find_root, Body, EphemerisSession, Instant, SearchOptions};
use crate::angles::signed_delta_deg;
use crate::aspect::{AspectKind, OrbTable};
use crate::chart::NatalChart;
use crate::error::{AstrologyError, AstrologyResult};
use crate::primary_direction::Significator;
/// One aspect formed by a transiting body to a natal target.
#[derive(Debug, Clone, Copy)]
pub struct TransitAspect {
/// The body currently moving in the sky.
pub transiting: Body,
/// The natal point being aspected.
pub natal_target: Significator,
pub kind: AspectKind,
/// Signed delta from exact, degrees. Same convention as the aspect
/// engine: positive = past exact, negative = short of exact.
pub orb_signed_deg: f64,
pub allowed_orb_deg: f64,
/// `true` if the transiting body's longitude is closing toward the
/// exact angle. Uses the transiting body's longitude rate; natal
/// targets are treated as fixed (rate = 0).
pub applying: bool,
pub instant: Instant,
}
impl TransitAspect {
pub fn orb_abs_deg(&self) -> f64 {
self.orb_signed_deg.abs()
}
}
/// Snapshot every transit aspect at `at`. Returns aspects sorted by
/// orb (tightest first).
///
/// `transiting_bodies` controls which sky positions to consider — pass
/// e.g. all major planets, or a subset for a quick scan. `targets`
/// controls which natal points are valid significators; pass
/// `natal_targets_default(&natal)` to use every body in the chart plus
/// the four angles.
pub fn find_current_transits(
natal: &NatalChart,
session: &EphemerisSession,
at: Instant,
transiting_bodies: &[Body],
targets: &[Significator],
orbs: &OrbTable,
aspect_kinds: &[AspectKind],
) -> AstrologyResult<Vec<TransitAspect>> {
let snapshot = orbs.snapshot();
let mut out =
Vec::with_capacity(transiting_bodies.len() * targets.len() * aspect_kinds.len());
for &transiting in transiting_bodies {
let pos = session
.body_apparent(transiting, at, None)
.map_err(AstrologyError::Sky)?;
let t_lon_deg = pos.ecliptic_of_date.longitude_deg();
let t_rate_deg_per_day =
pos.ecliptic_velocity.longitude_rate_rad_per_day.to_degrees();
for &target in targets {
let target_lon_deg = target.longitude_deg(natal);
let target_lon_deg = match target_lon_deg {
Some(v) => v,
None => continue,
};
for &kind in aspect_kinds {
let allowed = snapshot.orb_for(
transiting,
body_for_significator(target, transiting),
kind,
);
if allowed <= 0.0 {
continue;
}
let raw = signed_delta_deg(t_lon_deg, target_lon_deg);
let separation = raw.abs();
let exact = kind.exact_angle_deg();
let diff = separation - exact;
if diff.abs() > allowed {
continue;
}
// Applying: target is fixed, so d(separation)/dt has
// the sign of `raw × transiting_rate`. Closing means
// (sep exact) and dsep/dt have opposite signs.
let dsep_dt = if raw >= 0.0 {
t_rate_deg_per_day
} else {
-t_rate_deg_per_day
};
let applying = if diff > 0.0 {
dsep_dt < 0.0
} else if diff < 0.0 {
dsep_dt > 0.0
} else {
false
};
out.push(TransitAspect {
transiting,
natal_target: target,
kind,
orb_signed_deg: diff,
allowed_orb_deg: allowed,
applying,
instant: at,
});
}
}
}
out.sort_by(|a, b| {
a.orb_abs_deg()
.partial_cmp(&b.orb_abs_deg())
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(out)
}
/// Default target set for transit queries: every body present in
/// `natal.placements` (deduplicated, including the four lunar nodes
/// only once) plus the four cardinal angles.
pub fn default_natal_targets(natal: &NatalChart) -> Vec<Significator> {
let mut out: Vec<Significator> = Vec::new();
let mut seen: Vec<Body> = Vec::new();
for p in &natal.placements {
if !seen.contains(&p.body) {
out.push(Significator::Body(p.body));
seen.push(p.body);
}
}
out.push(Significator::Ascendant);
out.push(Significator::Midheaven);
out.push(Significator::Descendant);
out.push(Significator::ImumCoeli);
out
}
/// Find the next instant at or after `after` when `transiting`'s
/// ecliptic longitude is exactly `aspect_kind.exact_angle_deg()`
/// degrees from `natal_target_longitude_rad`. Returns `Ok(None)` if no
/// crossing occurs within `max_window_days`.
pub fn find_next_exact_transit(
session: &EphemerisSession,
transiting: Body,
natal_target_longitude_rad: f64,
aspect_kind: AspectKind,
after: Instant,
max_window_days: f64,
) -> AstrologyResult<Option<Instant>> {
let target_rad = natal_target_longitude_rad;
let exact_offset_rad = aspect_kind.exact_angle_deg().to_radians();
let f = |t: Instant| -> cosmos_sky::SkyResult<f64> {
let pos = session.body_apparent(transiting, t, None)?;
let lon = pos.ecliptic_of_date.longitude_rad;
// The aspect can perfect on either side of the target by the
// exact offset. Return the signed "distance to nearest exact",
// which crosses zero at perfection. We use the minimum of the
// two possible crossings for monotonicity inside a single
// aspect window — but the coarse scan handles either branch.
Ok(signed_min_distance(lon - target_rad, exact_offset_rad))
};
let nominal_step_seconds = nominal_transit_step_seconds(transiting);
let opts = SearchOptions {
coarse_step_seconds: nominal_step_seconds,
tolerance_seconds: 1.0,
max_iterations: 80,
};
let t1 = Instant::from_utc(after.utc().add_days(max_window_days));
find_root(after, t1, f, opts).map_err(AstrologyError::Sky)
}
/// Coarse-scan step for a transiting body — fast bodies need finer
/// sampling so the bisector brackets a single perfection per orbit.
fn nominal_transit_step_seconds(body: Body) -> f64 {
use Body::*;
match body {
Moon => 3_600.0, // 1 h (Moon moves ~0.5°/h)
Mercury | Venus | Sun => 21_600.0, // 6 h
Mars => 43_200.0, // 12 h
Jupiter | Saturn => 86_400.0, // 1 d
Uranus | Neptune | Pluto => 86_400.0 * 5.0, // 5 d
_ => 86_400.0,
}
}
/// Body to use as the "significator side" of the OrbTable lookup. For
/// `Significator::Body(b)` it's `b`; for angles we re-use the
/// transiting body's own multiplier so the result is symmetric.
fn body_for_significator(sig: Significator, fallback: Body) -> Body {
match sig {
Significator::Body(b) => b,
_ => fallback,
}
}
/// For an aspect that perfects when `(actual target)` equals either
/// `+exact_offset` or `exact_offset` (the two branches of the same
/// aspect family), return the smaller signed distance to perfection.
fn signed_min_distance(raw_diff_rad: f64, exact_offset_rad: f64) -> f64 {
use std::f64::consts::{PI, TAU};
let mut d = raw_diff_rad.rem_euclid(TAU);
if d > PI {
d -= TAU;
}
let plus = d - exact_offset_rad;
let minus = d + exact_offset_rad;
if plus.abs() <= minus.abs() {
plus
} else {
minus
}
}
@@ -0,0 +1,194 @@
//! The twelve zodiac signs and helpers for decomposing an ecliptic
//! longitude into (sign, degree, minute, second).
//!
//! The astrology layer supports both **tropical** (longitude measured
//! from the vernal equinox of date — the default in Western astrology)
//! and **sidereal** (longitude minus an ayanamsha — the default in
//! Indian astrology). The signs themselves are identical 30°-wide
//! sectors of the ecliptic; what changes between the two zodiacs is
//! the zero point.
use cosmos_sky::Ayanamsha;
/// The twelve zodiac signs, in chart order starting from Aries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sign {
Aries,
Taurus,
Gemini,
Cancer,
Leo,
Virgo,
Libra,
Scorpio,
Sagittarius,
Capricorn,
Aquarius,
Pisces,
}
impl Sign {
/// Sign index `0..=11` (Aries = 0).
pub fn index(self) -> usize {
self as usize
}
pub fn from_index(i: usize) -> Self {
const ALL: [Sign; 12] = [
Sign::Aries,
Sign::Taurus,
Sign::Gemini,
Sign::Cancer,
Sign::Leo,
Sign::Virgo,
Sign::Libra,
Sign::Scorpio,
Sign::Sagittarius,
Sign::Capricorn,
Sign::Aquarius,
Sign::Pisces,
];
ALL[i % 12]
}
/// Decompose a (already-normalised) ecliptic longitude in radians
/// into the sign and the offset within that sign, in radians
/// `[0, π/6)`.
pub fn decompose(longitude_rad: f64) -> (Self, f64) {
const TAU: f64 = std::f64::consts::TAU;
const SIGN_WIDTH: f64 = TAU / 12.0;
let lon = longitude_rad.rem_euclid(TAU);
let index = (lon / SIGN_WIDTH).floor() as usize;
let offset = lon - (index as f64) * SIGN_WIDTH;
(Self::from_index(index), offset)
}
/// English short name (`"Ari"`, `"Tau"`, ...). Useful for compact
/// chart printouts.
pub fn short_name(self) -> &'static str {
match self {
Sign::Aries => "Ari",
Sign::Taurus => "Tau",
Sign::Gemini => "Gem",
Sign::Cancer => "Can",
Sign::Leo => "Leo",
Sign::Virgo => "Vir",
Sign::Libra => "Lib",
Sign::Scorpio => "Sco",
Sign::Sagittarius => "Sag",
Sign::Capricorn => "Cap",
Sign::Aquarius => "Aqu",
Sign::Pisces => "Pis",
}
}
}
/// Selectable zodiac reference. `Tropical` measures longitudes from the
/// vernal equinox of date; `Sidereal` subtracts an ayanamsha so the
/// constellations stay fixed relative to the background stars.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Zodiac {
Tropical,
Sidereal(Ayanamsha),
}
impl Default for Zodiac {
fn default() -> Self {
Zodiac::Tropical
}
}
/// An ecliptic longitude paired with its zodiac decomposition. The same
/// underlying radian value drives all accessors; the helpers exist for
/// human-readable chart output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SignedLongitude {
longitude_rad: f64,
sign: Sign,
offset_rad: f64,
}
impl SignedLongitude {
/// Build from a (possibly un-normalised) ecliptic longitude in radians.
pub fn from_radians(longitude_rad: f64) -> Self {
let (sign, offset_rad) = Sign::decompose(longitude_rad);
Self {
longitude_rad: longitude_rad.rem_euclid(std::f64::consts::TAU),
sign,
offset_rad,
}
}
pub fn longitude_rad(&self) -> f64 {
self.longitude_rad
}
pub fn longitude_deg(&self) -> f64 {
self.longitude_rad.to_degrees()
}
pub fn sign(&self) -> Sign {
self.sign
}
/// Whole degree within the sign (`0..30`).
pub fn degree_in_sign(&self) -> u32 {
self.offset_rad.to_degrees().floor() as u32
}
/// Decimal degree within the sign (`0.0..30.0`).
pub fn degree_in_sign_decimal(&self) -> f64 {
self.offset_rad.to_degrees()
}
/// Whole minutes after the degree (`0..60`).
pub fn minutes_in_sign(&self) -> u32 {
let frac = (self.offset_rad.to_degrees().fract()) * 60.0;
frac.floor() as u32
}
/// Seconds after the minute, with fractional part (`0.0..60.0`).
pub fn seconds_in_sign(&self) -> f64 {
let total_min = self.offset_rad.to_degrees().fract() * 60.0;
(total_min.fract()) * 60.0
}
/// Human-readable like `"15°23'04\" Tau"`.
pub fn to_chart_format(&self) -> String {
format!(
"{:02}°{:02}'{:05.2}\" {}",
self.degree_in_sign(),
self.minutes_in_sign(),
self.seconds_in_sign(),
self.sign.short_name(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decompose_aries_zero() {
let s = SignedLongitude::from_radians(0.0);
assert_eq!(s.sign(), Sign::Aries);
assert_eq!(s.degree_in_sign(), 0);
}
#[test]
fn decompose_15_taurus() {
let lon = (30.0_f64 + 15.0).to_radians();
let s = SignedLongitude::from_radians(lon);
assert_eq!(s.sign(), Sign::Taurus);
assert_eq!(s.degree_in_sign(), 15);
}
#[test]
fn decompose_29_pisces() {
let lon = (359.99_f64).to_radians();
let s = SignedLongitude::from_radians(lon);
assert_eq!(s.sign(), Sign::Pisces);
assert_eq!(s.degree_in_sign(), 29);
}
}
@@ -0,0 +1,228 @@
//! Tests for the aspect engine and the planetary-return finder.
use cosmos_astrology::{
aspect, find_aspects, find_aspects_filtered, next_return, AspectKind, BirthData,
ChartConfig, NatalChart, OrbTable,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, observer).with_name("Fixture A")
}
#[test]
fn modern_western_orbs_match_expected_values() {
let orbs = OrbTable::modern_western();
// Sun-Moon conjunction = 8 × 1.25 = 10° (both luminaries multiply
// but only the max applies → 10°).
assert_eq!(
orbs.orb_for(Body::Sun, Body::Moon, AspectKind::Conjunction),
10.0
);
assert_eq!(
orbs.orb_for(Body::Mars, Body::Saturn, AspectKind::Trine),
7.0
);
assert_eq!(
orbs.orb_for(Body::Mars, Body::Saturn, AspectKind::Quincunx),
2.5
);
}
#[test]
fn aspect_engine_finds_expected_pairs_in_demo_chart() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let orbs = OrbTable::modern_western();
let asps = find_aspects(&chart, &orbs);
assert!(!asps.is_empty(), "real chart should have some aspects");
// Every reported aspect must (a) be within its allowed orb, and
// (b) have signed_orb consistent with the actual separation.
for a in &asps {
assert!(
a.orb_abs_deg() <= a.allowed_orb_deg + 1e-9,
"aspect {:?} {} {} orb {} > allowed {}",
a.kind,
a.a.name(),
a.b.name(),
a.orb_abs_deg(),
a.allowed_orb_deg
);
}
// Output is sorted by tightness (most exact first).
for w in asps.windows(2) {
assert!(w[0].orb_abs_deg() <= w[1].orb_abs_deg() + 1e-12);
}
}
#[test]
fn major_aspect_filter_excludes_minors() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let majors = find_aspects_filtered(&chart, &OrbTable::default(), AspectKind::MAJORS);
for a in &majors {
assert!(
AspectKind::MAJORS.contains(&a.kind),
"filter leaked {:?}",
a.kind
);
}
}
#[test]
fn applying_flag_is_consistent_with_signed_orb() {
// Construct a synthetic 2-body chart by computing aspects manually
// on two crafted placements. Easier than reasoning about a real
// birth-chart's velocities.
use cosmos_astrology::{BodyPlacement, Sign, SignedLongitude};
let mercury = BodyPlacement {
body: Body::Mercury,
longitude: SignedLongitude::from_radians(10.0_f64.to_radians()),
latitude_rad: 0.0,
distance_km: 0.0,
// Mercury moves fast (positive), trying to overtake Mars.
longitude_rate_rad_per_day: 1.0_f64.to_radians(),
right_ascension_rad: 0.0,
declination_rad: 0.0,
house_number: 1,
horizon: None,
};
let mars = BodyPlacement {
body: Body::Mars,
longitude: SignedLongitude::from_radians(15.0_f64.to_radians()),
latitude_rad: 0.0,
distance_km: 0.0,
// Mars is slower.
longitude_rate_rad_per_day: 0.5_f64.to_radians(),
right_ascension_rad: 0.0,
declination_rad: 0.0,
house_number: 1,
horizon: None,
};
let orbs = OrbTable::modern_western();
// 5° apart → conjunction in orb (8°). Mercury catches up → applying.
let asp = aspect_test_pair_helper(&mercury, &mars, AspectKind::Conjunction, &orbs)
.expect("should find conjunction");
assert_eq!(asp.kind, AspectKind::Conjunction);
assert!(asp.applying, "Mercury catching Mars should be applying");
assert!(asp.orb_abs_deg() > 0.0, "should not be exact");
let _ = Sign::Aries; // silence unused-warning when running this alone
}
/// Helper that reproduces `find_aspects` for a single pair so the
/// applying-test can hand-craft placements.
fn aspect_test_pair_helper(
a: &cosmos_astrology::BodyPlacement,
b: &cosmos_astrology::BodyPlacement,
kind: AspectKind,
orbs: &OrbTable,
) -> Option<cosmos_astrology::Aspect> {
// We call into find_aspects through a tiny NatalChart shim:
// simplest is to do the math directly via the public types.
let placements = vec![*a, *b];
let chart = synth_chart(placements);
let asps = aspect::find_aspects_filtered(&chart, orbs, &[kind]);
asps.into_iter().next()
}
/// Cheap NatalChart shim for unit testing — builds a chart with empty
/// houses + only the supplied placements. We compute one real chart and
/// then swap its placements vector.
fn synth_chart(placements: Vec<cosmos_astrology::BodyPlacement>) -> NatalChart {
let s = session();
let birth = fixture_birth();
let mut chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
chart.placements = placements;
chart
}
#[test]
fn solar_return_for_2025_lands_within_24h_of_birthday() {
// For a March 14, 1987 birth, the 2024-2025 solar return must
// happen near March 14, 2025. Bracket the search starting March 1,
// 2025 with a 30-day window.
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let natal_sun = chart
.placement(Body::Sun)
.unwrap()
.longitude
.longitude_rad();
let after = Instant::from_civil_utc(2025, 3, 1, 0, 0, 0.0).unwrap();
let return_t = next_return(&s, Body::Sun, natal_sun, after, Some(30.0)).unwrap();
// The instant must lie within 1 day of 2025-03-14T09:22 UTC. The
// Sun's daily motion is ~1°, so a 30-day search is wide and a 24-h
// tolerance is conservative.
let expected = Instant::from_civil_utc(2025, 3, 14, 9, 22, 0.0).unwrap();
let diff_days = (return_t.jd_utc() - expected.jd_utc()).abs();
assert!(
diff_days < 1.0,
"Sun return at {} too far from expected {} (Δ = {:.4} d)",
return_t.to_iso8601(),
expected.to_iso8601(),
diff_days,
);
}
#[test]
fn lunar_return_brackets_one_sidereal_month() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let natal_moon = chart
.placement(Body::Moon)
.unwrap()
.longitude
.longitude_rad();
let after = Instant::from_civil_utc(2025, 1, 1, 0, 0, 0.0).unwrap();
let return_t = next_return(&s, Body::Moon, natal_moon, after, Some(35.0)).unwrap();
let gap_days = return_t.jd_utc() - after.jd_utc();
// Sidereal month ≈ 27.32 d; allow 0..28 d to handle the lunar
// node's nutation jitter at ~±10' / day.
assert!(
(0.0..28.5).contains(&gap_days),
"Moon return gap {:.4} d outside [0, 28.5]",
gap_days
);
}
#[test]
fn find_root_handles_no_sign_change_gracefully() {
use cosmos_sky::{find_root, SearchOptions};
let t0 = Instant::from_civil_utc(2025, 1, 1, 0, 0, 0.0).unwrap();
let t1 = Instant::from_civil_utc(2025, 1, 2, 0, 0, 0.0).unwrap();
// f never changes sign.
let result = find_root(t0, t1, |_t| Ok(1.0), SearchOptions::HOURLY).unwrap();
assert!(result.is_none());
}
#[test]
fn find_root_locates_a_simple_zero_at_midpoint() {
use cosmos_sky::{find_root, SearchOptions};
let t0 = Instant::from_civil_utc(2025, 1, 1, 0, 0, 0.0).unwrap();
let t1 = Instant::from_civil_utc(2025, 1, 2, 0, 0, 0.0).unwrap();
let mid_jd = 0.5 * (t0.jd_utc() + t1.jd_utc());
// Linear f(t) crossing zero at midpoint.
let f = |t: Instant| Ok(t.jd_utc() - mid_jd);
let root = find_root(t0, t1, f, SearchOptions::HOURLY).unwrap().unwrap();
// Should land within tolerance (1 s = ~1.16e-5 days).
let diff = (root.jd_utc() - mid_jd).abs();
assert!(diff < 2.0e-5, "find_root diverged: Δ = {:.3e} days", diff);
}
@@ -0,0 +1,155 @@
//! Tests for the Campanus primary-direction method.
//!
//! Campanus mundane positions are computed from the body's local
//! horizontal Cartesian, projected onto the prime vertical. Properties
//! that must hold:
//!
//! 1. m_Campanus at MC = 1 (any body, any latitude).
//! 2. Campanus and Placidus agree on directions to **angles** (the
//! four cardinal mundane positions are the same in all three
//! classical frameworks by definition).
//! 3. Campanus differs from both Placidus and Regiomontanus on
//! body-to-body directions.
use cosmos_astrology::{
direct_to_aspect, AspectKind, BirthData, ChartConfig, DirectionKey, DirectionMethod,
NatalChart, Significator,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, observer).with_name("Fixture A")
}
#[test]
fn campanus_agrees_with_placidus_for_directions_to_each_angle() {
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
let angles = [
Significator::Ascendant,
Significator::Midheaven,
Significator::Descendant,
Significator::ImumCoeli,
];
for body in [Body::Sun, Body::Moon, Body::Mars, Body::Saturn] {
for sig in angles {
let placidus = direct_to_aspect(
&chart,
body,
sig,
AspectKind::Conjunction,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap()[0];
let campanus = direct_to_aspect(
&chart,
body,
sig,
AspectKind::Conjunction,
DirectionMethod::Campanus,
DirectionKey::Ptolemy,
)
.unwrap()[0];
// All three frameworks place angles at the same fixed
// mundane positions, so the arc must match exactly.
let diff = (placidus.arc_rad - campanus.arc_rad).abs();
let diff = diff.min((std::f64::consts::TAU - diff).abs());
assert!(
diff < 1e-9,
"{} → {:?} differs Plac={:.6}° Camp={:.6}° (Δ {:.6}°)",
body.name(),
sig,
placidus.arc_deg(),
campanus.arc_deg(),
diff.to_degrees()
);
}
}
}
#[test]
fn campanus_disagrees_with_placidus_for_body_to_body() {
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
let placidus = direct_to_aspect(
&chart,
Body::Sun,
Significator::Body(Body::Saturn),
AspectKind::Conjunction,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap()[0];
let campanus = direct_to_aspect(
&chart,
Body::Sun,
Significator::Body(Body::Saturn),
AspectKind::Conjunction,
DirectionMethod::Campanus,
DirectionKey::Ptolemy,
)
.unwrap()[0];
let diff = (placidus.arc_rad - campanus.arc_rad).abs();
assert!(
diff > 1e-4,
"Placidus and Campanus should differ on body-to-body; got Plac={:.6}° Camp={:.6}°",
placidus.arc_deg(),
campanus.arc_deg()
);
}
#[test]
fn campanus_disagrees_with_regiomontanus_for_body_to_body() {
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
let regio = direct_to_aspect(
&chart,
Body::Mercury,
Significator::Body(Body::Pluto),
AspectKind::Conjunction,
DirectionMethod::Regiomontanus,
DirectionKey::Naibod,
)
.unwrap()[0];
let campanus = direct_to_aspect(
&chart,
Body::Mercury,
Significator::Body(Body::Pluto),
AspectKind::Conjunction,
DirectionMethod::Campanus,
DirectionKey::Naibod,
)
.unwrap()[0];
let diff = (regio.arc_rad - campanus.arc_rad).abs();
assert!(
diff > 1e-4,
"Regio and Campanus should differ on body-to-body; got Regio={:.6}° Camp={:.6}°",
regio.arc_deg(),
campanus.arc_deg()
);
}
#[test]
fn campanus_method_tag_preserved_in_direction_struct() {
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
let d = direct_to_aspect(
&chart,
Body::Sun,
Significator::Midheaven,
AspectKind::Conjunction,
DirectionMethod::Campanus,
DirectionKey::Naibod,
)
.unwrap()[0];
assert_eq!(d.method, DirectionMethod::Campanus);
}
@@ -0,0 +1,164 @@
//! Tests for the midpoint composite chart.
use cosmos_astrology::{
angular_midpoint_rad, composite, BirthData, ChartConfig, NatalChart,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_a() -> BirthData {
BirthData::new(
Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap(),
Observer::from_degrees(10.4806, -66.9036, 900.0),
)
.with_name("Subject A")
}
fn fixture_b() -> BirthData {
BirthData::new(
Instant::from_civil_local(1990, 7, 22, 14, 17, 0.0, 60).unwrap(),
Observer::from_degrees(40.4168, -3.7038, 650.0),
)
.with_name("Subject B")
}
#[test]
fn composite_of_identical_charts_reproduces_the_chart() {
let s = session();
let chart = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let comp = composite(&chart, &chart).unwrap();
// Every angular midpoint equals the original.
let diff_asc = (comp.ascendant.longitude_rad() - chart.ascendant().longitude_rad()).abs();
assert!(diff_asc < 1e-12);
let diff_mc = (comp.midheaven.longitude_rad() - chart.midheaven().longitude_rad()).abs();
assert!(diff_mc < 1e-12);
// Each placement matches its natal counterpart.
assert_eq!(comp.placements.len(), chart.placements.len());
for (c, n) in comp.placements.iter().zip(chart.placements.iter()) {
assert_eq!(c.body, n.body);
let diff =
(c.longitude.longitude_rad() - n.longitude.longitude_rad()).abs();
assert!(diff < 1e-12, "{} off by {}", c.body.name(), diff);
}
}
#[test]
fn composite_is_symmetric_under_a_b_swap() {
let s = session();
let chart_a = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let chart_b = NatalChart::compute(&fixture_b(), &ChartConfig::default(), &s).unwrap();
let ab = composite(&chart_a, &chart_b).unwrap();
let ba = composite(&chart_b, &chart_a).unwrap();
assert_eq!(ab.placements.len(), ba.placements.len());
for (x, y) in ab.placements.iter().zip(ba.placements.iter()) {
assert_eq!(x.body, y.body);
let diff = (x.longitude.longitude_rad() - y.longitude.longitude_rad()).abs();
assert!(
diff < 1e-12,
"Composite midpoint differs between A→B and B→A for {}: {}",
x.body.name(),
diff
);
}
let diff_asc =
(ab.ascendant.longitude_rad() - ba.ascendant.longitude_rad()).abs();
let diff_mc =
(ab.midheaven.longitude_rad() - ba.midheaven.longitude_rad()).abs();
assert!(diff_asc < 1e-12);
assert!(diff_mc < 1e-12);
}
#[test]
fn angular_midpoint_picks_shorter_arc() {
use std::f64::consts::TAU;
// 350° and 10° — shorter arc midpoint is 0° (not 180°).
let mid_a = angular_midpoint_rad(
350.0_f64.to_radians(),
10.0_f64.to_radians(),
);
let mid_b = angular_midpoint_rad(
10.0_f64.to_radians(),
350.0_f64.to_radians(),
);
let target = 0.0_f64;
let diff_a = ((mid_a - target).rem_euclid(TAU)).min((target - mid_a).rem_euclid(TAU));
let diff_b = ((mid_b - target).rem_euclid(TAU)).min((target - mid_b).rem_euclid(TAU));
assert!(
diff_a < 1e-12 && diff_b < 1e-12,
"midpoints {} and {} should both be ~0°",
mid_a.to_degrees(),
mid_b.to_degrees()
);
// Right-angle case: 0° and 90° → midpoint 45°.
let mid = angular_midpoint_rad(0.0, 90.0_f64.to_radians());
let diff = (mid - 45.0_f64.to_radians()).abs();
assert!(diff < 1e-12);
}
#[test]
fn composite_placements_carry_whole_sign_houses() {
let s = session();
let chart_a = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let chart_b = NatalChart::compute(&fixture_b(), &ChartConfig::default(), &s).unwrap();
let comp = composite(&chart_a, &chart_b).unwrap();
let asc_sign = comp.ascendant.sign();
for p in &comp.placements {
// Whole-sign: house = (sign index asc sign index) mod 12 + 1
let expected = ((p.sign.index() as i32 - asc_sign.index() as i32).rem_euclid(12) + 1) as u8;
assert_eq!(
p.house_number, expected,
"{} sign {:?} should be H{} (Asc sign {:?})",
p.body.name(),
p.sign,
expected,
asc_sign
);
}
}
#[test]
fn composite_sun_lies_between_inputs() {
// For inputs that are not antipodal, the composite Sun longitude
// should fall on the shorter arc between the two natal Suns.
let s = session();
let chart_a = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let chart_b = NatalChart::compute(&fixture_b(), &ChartConfig::default(), &s).unwrap();
let comp = composite(&chart_a, &chart_b).unwrap();
let sun_a = chart_a.placement(Body::Sun).unwrap().longitude.longitude_rad();
let sun_b = chart_b.placement(Body::Sun).unwrap().longitude.longitude_rad();
let sun_c = comp.placement(Body::Sun).unwrap().longitude.longitude_rad();
// The composite must equal angular_midpoint(sun_a, sun_b).
let expected = angular_midpoint_rad(sun_a, sun_b);
assert!((sun_c - expected).abs() < 1e-12);
}
#[test]
fn composite_descendant_opposes_ascendant() {
let s = session();
let chart_a = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let chart_b = NatalChart::compute(&fixture_b(), &ChartConfig::default(), &s).unwrap();
let comp = composite(&chart_a, &chart_b).unwrap();
let asc = comp.ascendant.longitude_rad();
let desc = comp.descendant.longitude_rad();
let diff = ((desc - asc).rem_euclid(std::f64::consts::TAU)
- std::f64::consts::PI)
.abs();
assert!(diff < 1e-12, "Desc not opposite Asc, off by {}", diff);
let mc = comp.midheaven.longitude_rad();
let ic = comp.imum_coeli.longitude_rad();
let diff = ((ic - mc).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
assert!(diff < 1e-12);
}
@@ -0,0 +1,220 @@
//! Tests for Arabic Parts (Lots) and Hellenistic profections.
use cosmos_astrology::{
all_lots, annual_profection, compute_lot, modern_ruler, monthly_profection,
profection_at, traditional_ruler, BirthData, ChartConfig, LotName, NatalChart,
ProfectionHouses, Sect, Sign,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_a_day_birth() -> BirthData {
// 14 March 1987, 05:22 Caracas local → Sun is just below horizon.
// For a clean DAY-birth test we use a noon birth instead.
BirthData::new(
Instant::from_civil_local(1987, 3, 14, 12, 0, 0.0, -240).unwrap(),
Observer::from_degrees(10.4806, -66.9036, 900.0),
)
}
fn fixture_a_night_birth() -> BirthData {
BirthData::new(
Instant::from_civil_local(1987, 3, 14, 0, 0, 0.0, -240).unwrap(),
Observer::from_degrees(10.4806, -66.9036, 900.0),
)
}
// ─── Lots ────────────────────────────────────────────────────────────
#[test]
fn fortune_swaps_with_spirit_between_day_and_night() {
let s = session();
let day_chart =
NatalChart::compute(&fixture_a_day_birth(), &ChartConfig::default(), &s).unwrap();
let night_chart =
NatalChart::compute(&fixture_a_night_birth(), &ChartConfig::default(), &s).unwrap();
assert_eq!(Sect::of(&day_chart).unwrap(), Sect::Day);
assert_eq!(Sect::of(&night_chart).unwrap(), Sect::Night);
// For Fortune the day formula is Asc + Moon - Sun and night is the
// reverse. So Fortune_day - Spirit_day = -(Fortune_night - Spirit_night)
// for the SAME chart (after sect determined). To check sect swap we
// compute Fortune_day on day_chart vs Fortune_day formula manually on
// night_chart and verify they differ by 2(Moon - Sun) (the formula
// swap).
let fortune_day = compute_lot(&day_chart, LotName::Fortune).unwrap();
let asc = day_chart.ascendant().longitude_rad();
let moon = day_chart.placement(Body::Moon).unwrap().longitude.longitude_rad();
let sun = day_chart.placement(Body::Sun).unwrap().longitude.longitude_rad();
let expected_day =
(asc + moon - sun).rem_euclid(std::f64::consts::TAU);
assert!(
(fortune_day.longitude.longitude_rad() - expected_day).abs() < 1e-12,
"day Fortune formula Asc+Moon-Sun mismatch",
);
// Spirit day = Asc + Sun Moon — exact opposite operands.
let spirit_day = compute_lot(&day_chart, LotName::Spirit).unwrap();
let expected_spirit =
(asc + sun - moon).rem_euclid(std::f64::consts::TAU);
assert!(
(spirit_day.longitude.longitude_rad() - expected_spirit).abs() < 1e-12,
"day Spirit formula Asc+Sun-Moon mismatch",
);
// Fortune and Spirit are symmetric around the Ascendant by
// construction: (F + S)/2 = Asc + (Moon-Sun+Sun-Moon)/2 + Asc/2 = Asc.
// Equivalently F S = 2(Moon Sun) (mod 2π), so F + S ≡ 2·Asc (mod 2π).
let sum = (fortune_day.longitude.longitude_rad() + spirit_day.longitude.longitude_rad())
.rem_euclid(std::f64::consts::TAU);
let twice_asc = (2.0 * asc).rem_euclid(std::f64::consts::TAU);
let diff = (sum - twice_asc).abs();
let diff = diff.min((std::f64::consts::TAU - diff).abs());
assert!(diff < 1e-12, "F+S ≠ 2·Asc, diff = {}", diff);
}
#[test]
fn all_lots_produces_seven_named_lots() {
let s = session();
let chart =
NatalChart::compute(&fixture_a_day_birth(), &ChartConfig::default(), &s).unwrap();
let lots = all_lots(&chart).unwrap();
assert_eq!(lots.len(), 7);
for lot in &lots {
assert!(
(0.0..std::f64::consts::TAU).contains(&lot.longitude.longitude_rad())
);
assert!((1..=12).contains(&lot.house_number));
}
}
#[test]
fn eros_depends_on_spirit() {
// Eros_day = Asc + Venus Spirit. Validate the dependency was
// resolved recursively (not silently dropped).
let s = session();
let chart =
NatalChart::compute(&fixture_a_day_birth(), &ChartConfig::default(), &s).unwrap();
let spirit = compute_lot(&chart, LotName::Spirit).unwrap();
let eros = compute_lot(&chart, LotName::Eros).unwrap();
let venus = chart.placement(Body::Venus).unwrap().longitude.longitude_rad();
let asc = chart.ascendant().longitude_rad();
let expected =
(asc + venus - spirit.longitude.longitude_rad()).rem_euclid(std::f64::consts::TAU);
assert!(
(eros.longitude.longitude_rad() - expected).abs() < 1e-12,
"Eros day formula did not resolve Spirit"
);
}
// ─── Profections ─────────────────────────────────────────────────────
#[test]
fn annual_profection_advances_one_house_per_year_and_cycles() {
let s = session();
let chart =
NatalChart::compute(&fixture_a_day_birth(), &ChartConfig::default(), &s).unwrap();
// Year 0 → house 1. Year 12 → house 1 again. Year 11 → house 12.
let y0 = annual_profection(&chart, 0, ProfectionHouses::WholeSign);
let y11 = annual_profection(&chart, 11, ProfectionHouses::WholeSign);
let y12 = annual_profection(&chart, 12, ProfectionHouses::WholeSign);
let y36 = annual_profection(&chart, 36, ProfectionHouses::WholeSign);
assert_eq!(y0.profected_house, 1);
assert_eq!(y11.profected_house, 12);
assert_eq!(y12.profected_house, 1);
assert_eq!(y36.profected_house, 1);
// House 1 sign = Asc sign with Whole-Sign.
assert_eq!(y0.profected_sign, chart.ascendant().sign());
// House 12 sign = sign just before Asc's (counterclockwise).
let asc_idx = chart.ascendant().sign().index();
assert_eq!(
y11.profected_sign.index(),
(asc_idx + 11) % 12
);
}
#[test]
fn monthly_profection_at_month_0_matches_annual_house() {
let s = session();
let chart =
NatalChart::compute(&fixture_a_day_birth(), &ChartConfig::default(), &s).unwrap();
let monthly = monthly_profection(&chart, 30, 0, ProfectionHouses::WholeSign);
let annual = annual_profection(&chart, 30, ProfectionHouses::WholeSign);
assert_eq!(monthly.profected_house, annual.profected_house);
assert_eq!(monthly.profected_sign, annual.profected_sign);
// Last month (month 11) of a year lands on the sign immediately
// *before* the annual house — the monthly cycle traverses 11
// signs forward, ending one position short of completing the full
// 12-sign loop. (The annual cycle then jumps forward by 1 to
// start year+1; the gap of 2 signs between month 11 of year N and
// month 0 of year N+1 is the classical pattern.)
let last = monthly_profection(&chart, 30, 11, ProfectionHouses::WholeSign);
let expected_house =
((annual.profected_house as i32 - 2 + 12) % 12 + 1) as u8;
assert_eq!(
last.profected_house, expected_house,
"year house {} → month 11 should be house {}",
annual.profected_house, expected_house
);
}
#[test]
fn lord_of_year_uses_traditional_rulership() {
let s = session();
let chart =
NatalChart::compute(&fixture_a_day_birth(), &ChartConfig::default(), &s).unwrap();
// Pick a year that lands the profected sign on Aquarius (Saturn
// traditionally, Uranus modern). Aquarius index = 10. We need
// (asc_idx + N) % 12 = 10 → N = (10 - asc_idx + 12) % 12.
let asc_idx = chart.ascendant().sign().index();
let age = ((10 + 12 - asc_idx) % 12) as u32;
let p = annual_profection(&chart, age, ProfectionHouses::WholeSign);
assert_eq!(p.profected_sign, Sign::Aquarius);
assert_eq!(p.lord_of_year, Body::Saturn);
assert_eq!(p.modern_lord_of_year, Body::Uranus);
}
#[test]
fn rulership_tables_cover_every_sign() {
for i in 0..12 {
let s = Sign::from_index(i);
let trad = traditional_ruler(s);
let modern = modern_ruler(s);
// Both must produce a body in the canonical luminary/planet set.
let allowed = [
Body::Sun,
Body::Moon,
Body::Mercury,
Body::Venus,
Body::Mars,
Body::Jupiter,
Body::Saturn,
Body::Uranus,
Body::Neptune,
Body::Pluto,
];
assert!(allowed.contains(&trad), "trad ruler of {:?} = {:?}", s, trad);
assert!(allowed.contains(&modern), "modern ruler of {:?} = {:?}", s, modern);
}
}
#[test]
fn profection_at_present_is_consistent_with_age() {
let s = session();
let chart =
NatalChart::compute(&fixture_a_day_birth(), &ChartConfig::default(), &s).unwrap();
// 14 March 1987 + 39 years = 14 March 2026.
let now = Instant::from_civil_utc(2026, 3, 14, 16, 0, 0.0).unwrap();
let p = profection_at(&chart, now, ProfectionHouses::WholeSign);
// Age ≈ 39 years → house = (39 % 12) + 1 = 4.
assert_eq!(p.annual.age_years, 39);
assert_eq!(p.annual.profected_house, 4);
}
@@ -0,0 +1,132 @@
//! Tests for lunar phases and the eclipse-on-natal helpers.
//!
//! Lunar phases use the VSOP backend (Sun + Moon longitudes are well-
//! defined analytically). Eclipses require SPK, so those tests only
//! exercise the error path; the underlying eclipse code itself is
//! already validated by `eternal-validation`.
use cosmos_astrology::{
classify_lunation_phase, eclipses_on_natal, next_canonical_phase, next_lunar_phase,
phase_angle_at_deg, BirthData, ChartConfig, LunarPhase, LunationPhase, NatalChart,
};
use cosmos_sky::{EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
BirthData::new(
Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap(),
Observer::from_degrees(10.4806, -66.9036, 900.0),
)
}
// ─── Lunar phases ─────────────────────────────────────────────────────
#[test]
fn phase_angle_at_known_new_moon_is_near_zero() {
// New Moon on 2025-02-28 around 00:45 UTC. The phase angle in
// VSOP-only is at the ~arc-minute level, so allow ±0.5° to cover
// analytical-vs-SPK lunar differences.
let s = session();
let t = Instant::from_civil_utc(2025, 2, 28, 0, 45, 0.0).unwrap();
let p = phase_angle_at_deg(&s, t).unwrap();
let dist = p.min(360.0 - p); // distance to 0/360 boundary
assert!(
dist < 1.0,
"phase angle {}° not within 1° of new moon",
p
);
}
#[test]
fn next_new_moon_lands_near_2025_02_28() {
// From 2025-02-15 the next new moon must be near 2025-02-28.
let s = session();
let after = Instant::from_civil_utc(2025, 2, 15, 0, 0, 0.0).unwrap();
let t = next_lunar_phase(&s, LunarPhase::NewMoon, after, 20.0)
.unwrap()
.expect("new moon must occur within 20 d of 2025-02-15");
let expected = Instant::from_civil_utc(2025, 2, 28, 0, 45, 0.0).unwrap();
let diff_hours = (t.jd_utc() - expected.jd_utc()) * 24.0;
assert!(
diff_hours.abs() < 6.0,
"new moon {} differs from expected 2025-02-28 00:45 UTC by {:.2} h",
t.to_iso8601(),
diff_hours
);
}
#[test]
fn next_full_moon_after_new_moon_is_about_15_days_later() {
let s = session();
let after = Instant::from_civil_utc(2025, 2, 28, 0, 45, 0.0).unwrap();
let full = next_lunar_phase(&s, LunarPhase::FullMoon, after, 20.0)
.unwrap()
.expect("full moon within 20 d of new moon");
let gap_days = full.jd_utc() - after.jd_utc();
assert!(
(13.5..16.0).contains(&gap_days),
"new→full gap {:.4} d outside [13.5, 16.0]",
gap_days
);
}
#[test]
fn next_canonical_phase_returns_the_nearest_phase() {
// From 2025-03-01 the next phase should be the First Quarter (around 2025-03-06).
let s = session();
let after = Instant::from_civil_utc(2025, 3, 1, 0, 0, 0.0).unwrap();
let (t, phase) = next_canonical_phase(&s, after, 10.0)
.unwrap()
.expect("a canonical phase must occur within 10 d");
assert_eq!(phase, LunarPhase::FirstQuarter);
let gap = t.jd_utc() - after.jd_utc();
assert!(
(0.0..10.0).contains(&gap),
"First Quarter gap {:.4} d outside [0, 10]",
gap
);
}
#[test]
fn classify_lunation_phase_covers_eight_bands() {
// Boundaries at 0°, 22.5°, 67.5°, 112.5°, 157.5°, 202.5°, 247.5°,
// 292.5°, 337.5°.
let cases = [
(10.0_f64, LunationPhase::NewMoon),
(45.0, LunationPhase::WaxingCrescent),
(90.0, LunationPhase::FirstQuarter),
(135.0, LunationPhase::WaxingGibbous),
(180.0, LunationPhase::FullMoon),
(225.0, LunationPhase::WaningGibbous),
(270.0, LunationPhase::LastQuarter),
(315.0, LunationPhase::WaningCrescent),
];
for (deg, expected) in cases {
let p = classify_lunation_phase(deg.to_radians());
assert_eq!(p, expected, "phase angle {}°", deg);
}
}
// ─── Eclipses (error path only — full path requires SPK) ─────────────
#[test]
fn eclipses_on_natal_returns_clear_error_without_spk() {
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
let after = Instant::from_civil_utc(2026, 1, 1, 0, 0, 0.0).unwrap();
let result = eclipses_on_natal(&chart, &s, after, 12, 3.0, None);
assert!(
result.is_err(),
"eclipses_on_natal must error without an SPK kernel"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.to_lowercase().contains("spk"),
"error message should mention SPK kernel: got {:?}",
msg
);
}
@@ -0,0 +1,187 @@
//! End-to-end `NatalChart` tests. The VSOP2013 backend is used so no
//! external kernels are required. These tests assert that:
//!
//! 1. The chart pipeline produces internally consistent angles.
//! 2. House numbering is well-formed (every body lands in some 1..=12).
//! 3. Sidereal mode shifts every longitude by the same ayanamsha.
//! 4. Houses with closed-form definitions (Whole-Sign, Equal) match
//! the canonical formulas exactly.
use cosmos_astrology::{
Ayanamsha, BirthData, ChartConfig, HouseSystem, NatalChart, Sign, Zodiac,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn fixture_session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
// March 14, 1987, 05:22 local (Caracas, UTC4) → 09:22 UTC.
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let caracas = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, caracas).with_name("Fixture A")
}
#[test]
fn chart_with_defaults_yields_valid_angles_and_houses() {
let session = fixture_session();
let birth = fixture_birth();
let config = ChartConfig::default();
let chart = NatalChart::compute(&birth, &config, &session).unwrap();
// Angles must be normalised.
let asc = chart.ascendant().longitude_rad();
let mc = chart.midheaven().longitude_rad();
assert!((0.0..std::f64::consts::TAU).contains(&asc));
assert!((0.0..std::f64::consts::TAU).contains(&mc));
// Descendant = Ascendant + π (mod 2π).
let desc = chart.descendant().longitude_rad();
let diff = ((desc - asc).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
assert!(diff < 1e-12, "Desc should be opposite Asc, got diff {}", diff);
// IC = MC + π.
let ic = chart.imum_coeli().longitude_rad();
let diff = ((ic - mc).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
assert!(diff < 1e-12);
// Every cusp inside [0, 2π).
for &c in &chart.houses.cusps {
assert!((0.0..std::f64::consts::TAU).contains(&c));
}
// Every body lands in some house 1..=12.
for placement in &chart.placements {
assert!(
(1..=12).contains(&placement.house_number),
"body {} got house {}",
placement.body.name(),
placement.house_number
);
}
}
#[test]
fn whole_sign_houses_match_ascendant_sign() {
let session = fixture_session();
let birth = fixture_birth();
let config = ChartConfig {
house_system: HouseSystem::WholeSign,
..ChartConfig::default()
};
let chart = NatalChart::compute(&birth, &config, &session).unwrap();
// First cusp = 0° of Asc's sign.
let asc_sign_index = chart.ascendant().sign().index();
let cusp0_deg = chart.houses.cusps[0].to_degrees();
let expected_deg = (asc_sign_index as f64) * 30.0;
let diff = (cusp0_deg - expected_deg).abs();
assert!(
diff < 1e-9 || (diff - 360.0).abs() < 1e-9,
"Whole-Sign cusp[0] should be at 0° of Asc sign ({:?} → {}°), got {}°",
chart.ascendant().sign(),
expected_deg,
cusp0_deg
);
// The 12 cusps are exactly 30° apart.
for i in 0..12 {
let expected = ((asc_sign_index as i32 + i as i32) as f64) * 30.0;
let got = chart.houses.cusps[i].to_degrees();
let diff = ((got - expected).rem_euclid(360.0)).min((expected - got).rem_euclid(360.0));
assert!(diff < 1e-9, "cusp[{}] off by {}°", i, diff);
}
}
#[test]
fn sidereal_mode_subtracts_a_constant_offset_from_every_body() {
let session = fixture_session();
let birth = fixture_birth();
let tropical = ChartConfig {
zodiac: Zodiac::Tropical,
..ChartConfig::default()
};
let sidereal = ChartConfig {
zodiac: Zodiac::Sidereal(Ayanamsha::Lahiri),
..ChartConfig::default()
};
let trop_chart = NatalChart::compute(&birth, &tropical, &session).unwrap();
let sid_chart = NatalChart::compute(&birth, &sidereal, &session).unwrap();
let ayanamsha = sid_chart.ayanamsha_rad;
assert!(ayanamsha > 0.0, "Lahiri ayanamsha at 1987 should be positive");
for (trop_p, sid_p) in trop_chart.placements.iter().zip(sid_chart.placements.iter()) {
let expected_sid = (trop_p.longitude.longitude_rad() - ayanamsha)
.rem_euclid(std::f64::consts::TAU);
let got = sid_p.longitude.longitude_rad();
let diff = (expected_sid - got).abs();
let diff = diff.min((std::f64::consts::TAU - diff).abs());
assert!(
diff < 1e-12,
"body {} sidereal longitude off by {} rad",
trop_p.body.name(),
diff
);
}
}
#[test]
fn sun_in_march_lies_in_pisces_or_aries() {
// Birth on March 14: Sun should be late Pisces (tropical) — about
// 23° Pisces. Make this a coarse smoke test so future ephemeris
// refinements don't break it.
let session = fixture_session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &session).unwrap();
let sun = chart.placement(Body::Sun).expect("Sun should be present");
let sign = sun.longitude.sign();
assert!(
sign == Sign::Pisces,
"Sun on March 14 should be in Pisces, got {:?} at {}",
sign,
sun.longitude.to_chart_format()
);
}
#[test]
fn south_node_is_180_opposite_north_node() {
let session = fixture_session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &session).unwrap();
// Default config includes Mean Node + auto South Node.
let nodes: Vec<_> = chart
.placements
.iter()
.filter(|p| p.body == Body::MeanNode)
.collect();
assert_eq!(nodes.len(), 2, "expected ascending + descending node");
let n = nodes[0].longitude.longitude_rad();
let s = nodes[1].longitude.longitude_rad();
let diff = ((s - n).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
assert!(diff < 1e-12, "South Node should be opposite N Node");
}
#[test]
fn placidus_works_at_temperate_latitude() {
let session = fixture_session();
let birth = fixture_birth(); // Caracas at +10.5° — well outside polar circle.
let config = ChartConfig {
house_system: HouseSystem::Placidus,
..ChartConfig::default()
};
let chart = NatalChart::compute(&birth, &config, &session).unwrap();
// First cusp = Ascendant.
let diff = (chart.houses.cusps[0] - chart.ascendant().longitude_rad()
- chart.ayanamsha_rad)
.abs();
// (chart.ascendant() is sidereal-shifted iff sidereal; tropical default
// yields ayanamsha_rad = 0.)
assert!(diff < 1e-9, "Placidus cusp[0] should equal Asc");
}
@@ -0,0 +1,199 @@
//! Tests for the mundane helpers and the Placidus primary-direction
//! engine.
use cosmos_astrology::{
all_directions, direct, directions_to_angles, mundane, BirthData, ChartConfig,
DirectionKey, DirectionMethod, NatalChart, Significator,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, observer).with_name("Fixture A")
}
#[test]
fn body_to_mc_arc_equals_negative_natal_hour_angle() {
// For any promissor, directing it to the MC should require an arc
// equal in magnitude to the natal hour angle (mod 2π), because the
// MC has m=1 → target H = 0.
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
for body in [Body::Sun, Body::Moon, Body::Mars, Body::Jupiter] {
let placement = chart.placement(body).unwrap();
let ramc = chart.local_apparent_sidereal_time_rad;
let h_natal =
mundane::signed_hour_angle_rad(ramc, placement.right_ascension_rad);
let dir = direct(
&chart,
body,
Significator::Midheaven,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
// arc + h_natal ≡ 0 (mod 2π), since target H = 0.
let recovered = (dir.arc_rad + h_natal).rem_euclid(std::f64::consts::TAU);
let diff = recovered.min(std::f64::consts::TAU - recovered);
assert!(
diff < 1e-9,
"{} → MC: arc={:.6}° + H_natal={:.6}° ≠ 0 (mod 360°)",
body.name(),
dir.arc_deg(),
h_natal.to_degrees(),
);
}
}
#[test]
fn body_to_ic_is_body_to_mc_plus_180() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
for body in [Body::Sun, Body::Moon, Body::Saturn] {
let p = chart.placement(body).unwrap();
let phi = chart.birth.observer.lat_rad;
let dsa = mundane::diurnal_semi_arc_rad(p.declination_rad, phi);
let nsa = mundane::nocturnal_semi_arc_rad(p.declination_rad, phi);
if dsa.is_nan() || nsa.is_nan() {
continue;
}
let to_mc = direct(
&chart,
body,
Significator::Midheaven,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
let to_ic = direct(
&chart,
body,
Significator::ImumCoeli,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
// IC mundane = 3 (m=3, H = ±π). MC mundane = 1 (H=0).
// Target H_IC = -π + 0 · NSA_p = -π. Target H_MC = 0.
// Δarc = (target_H_IC - h_natal) (target_H_MC - h_natal) = -π.
// After wrapping into [0, 2π), the relation is to_ic.arc - to_mc.arc ≡ π (mod 2π).
let delta = (to_ic.arc_rad - to_mc.arc_rad).rem_euclid(std::f64::consts::TAU);
let diff = (delta - std::f64::consts::PI).abs();
assert!(
diff < 1e-9,
"{} → IC vs MC delta is {:.4}° not 180°",
body.name(),
delta.to_degrees(),
);
}
}
#[test]
fn naibod_key_yields_slightly_more_years_than_ptolemy() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let ptolemy = direct(
&chart,
Body::Sun,
Significator::Midheaven,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
let naibod = direct(
&chart,
Body::Sun,
Significator::Midheaven,
DirectionMethod::PlacidusMundane,
DirectionKey::Naibod,
)
.unwrap();
// Same arc, different key. Naibod degrees/year < 1 → years > Ptolemy's.
assert!((ptolemy.arc_rad - naibod.arc_rad).abs() < 1e-12);
assert!(
naibod.age_years > ptolemy.age_years,
"Naibod years ({}) should exceed Ptolemy years ({})",
naibod.age_years,
ptolemy.age_years,
);
// Naibod years ≈ ptolemy * 1.0146.
let ratio = naibod.age_years / ptolemy.age_years;
assert!(
(ratio - 1.014_56).abs() < 1e-3,
"Naibod/Ptolemy ratio {} far from 1.0146",
ratio,
);
}
#[test]
fn directions_to_angles_returns_consistent_four_angle_set() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let arcs = directions_to_angles(
&chart,
Body::Sun,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
// All four directions live in [0, 360°).
for d in &arcs {
assert!((0.0..std::f64::consts::TAU).contains(&d.arc_rad));
}
}
#[test]
fn all_directions_filters_by_max_age_and_sorts() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let arcs = all_directions(
&chart,
DirectionMethod::PlacidusMundane,
DirectionKey::Naibod,
90.0,
);
assert!(!arcs.is_empty(), "modern chart should have many directions in 90 yr");
for d in &arcs {
assert!(
d.age_years <= 90.0 + 1e-9,
"direction at {} yrs exceeds max",
d.age_years
);
}
for w in arcs.windows(2) {
assert!(w[0].age_years <= w[1].age_years + 1e-12);
}
}
#[test]
fn sun_to_self_direction_to_other_body_is_well_defined() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let d = direct(
&chart,
Body::Sun,
Significator::Body(Body::Moon),
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
// Sanity: arc in [0, 2π); age = arc/key.
assert!((0.0..std::f64::consts::TAU).contains(&d.arc_rad));
assert!((d.age_years - d.arc_deg()).abs() < 1e-12); // Ptolemy: 1°=1yr
}
@@ -0,0 +1,208 @@
//! Tests for secondary / tertiary / minor progressions and solar arc.
//!
//! Strategy: every progression reduces to "compute a chart at a shifted
//! instant", so we verify the math by comparing against direct chart
//! computations at the expected shifted instant. The solar-arc direction
//! is checked structurally: every body shifts by the same arc, and
//! house numbers are preserved.
use cosmos_astrology::{
progress, progressed_instant, secondary_progression, solar_arc_naibod, solar_arc_true,
BirthData, ChartConfig, NatalChart, ProgressedHouses, ProgressionMethod,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, observer).with_name("Fixture A")
}
#[test]
fn progressed_instant_secondary_at_age_1_is_one_day_later() {
let birth = Instant::from_civil_utc(1987, 3, 14, 9, 22, 0.0).unwrap();
let prog = progressed_instant(birth, 1.0, ProgressionMethod::Secondary);
// Tropical year = 365.2422 days, so 1 yr of life → 1 d ephemeris.
let diff_days = prog.jd_utc() - birth.jd_utc();
assert!((diff_days - 1.0).abs() < 1e-9);
}
#[test]
fn progressed_instant_minor_at_age_1_is_one_sidereal_month_scaled() {
let birth = Instant::from_civil_utc(1987, 3, 14, 9, 22, 0.0).unwrap();
let prog = progressed_instant(birth, 1.0, ProgressionMethod::Minor);
let diff_days = prog.jd_utc() - birth.jd_utc();
// 1 year of life × (1 sidereal month / 27.3217 d × 365.2422 d/yr) ≈ 13.37 d.
let expected = 365.242_190 / 27.321_661;
assert!(
(diff_days - expected).abs() < 1e-6,
"minor progression at age 1 yields {} d, expected {}",
diff_days,
expected
);
}
#[test]
fn secondary_progression_at_age_30_advances_sun_about_30_degrees() {
// Real Sun moves ~0.9856°/day. After 30 days the secondary-
// progressed Sun should be ~29.5° farther along the ecliptic.
let s = session();
let birth = fixture_birth();
let natal = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let prog = secondary_progression(&natal, &s, 30.0).unwrap();
let natal_sun = natal
.placement(Body::Sun)
.unwrap()
.longitude
.longitude_deg();
let prog_sun = prog
.progressed()
.placement(Body::Sun)
.unwrap()
.longitude
.longitude_deg();
let arc = signed_delta_deg(prog_sun, natal_sun);
assert!(
(28.0..31.0).contains(&arc),
"Sun advance over 30 yrs of secondary ≈ 30°, got {:.3}°",
arc
);
}
#[test]
fn secondary_progression_with_natal_houses_preserves_cusps() {
let s = session();
let birth = fixture_birth();
let natal = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let prog = progress(
&natal,
&s,
30.0,
ProgressionMethod::Secondary,
ProgressedHouses::Natal,
)
.unwrap();
for i in 0..12 {
let diff = (prog.progressed().houses.cusps[i] - natal.houses.cusps[i]).abs();
assert!(diff < 1e-12, "cusp[{}] drift {} rad under Natal treatment", i, diff);
}
}
#[test]
fn solar_arc_true_shifts_every_placement_by_the_same_amount() {
let s = session();
let birth = fixture_birth();
let natal = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let arc_chart = solar_arc_true(&natal, &s, 30.0).unwrap();
// Same arc applied to every body — verify by comparing the
// wrapped delta of one body against the arc.
let directed_sun = arc_chart
.directed
.placement(Body::Sun)
.unwrap()
.longitude
.longitude_rad();
let natal_sun = natal
.placement(Body::Sun)
.unwrap()
.longitude
.longitude_rad();
let sun_arc = signed_delta_rad(directed_sun, natal_sun);
assert!(
(sun_arc - arc_chart.arc_rad).abs() < 1e-12,
"Sun delta {} rad ≠ stored arc {} rad",
sun_arc,
arc_chart.arc_rad
);
// Same arc for Mars.
let directed_mars = arc_chart
.directed
.placement(Body::Mars)
.unwrap()
.longitude
.longitude_rad();
let natal_mars = natal
.placement(Body::Mars)
.unwrap()
.longitude
.longitude_rad();
let mars_arc = signed_delta_rad(directed_mars, natal_mars);
assert!(
(mars_arc - arc_chart.arc_rad).abs() < 1e-12,
"Mars delta {} rad ≠ arc {} rad",
mars_arc,
arc_chart.arc_rad
);
}
#[test]
fn solar_arc_preserves_natal_house_numbers() {
let s = session();
let birth = fixture_birth();
let natal = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let arc_chart = solar_arc_true(&natal, &s, 30.0).unwrap();
// Walk parallel indices — `placement(body)` returns the first
// match, which is wrong for the two MeanNode entries (ascending +
// auto-added descending). The two `placements` arrays were built
// from the same BodySet in the same order.
assert_eq!(natal.placements.len(), arc_chart.directed.placements.len());
for (natal_p, directed_p) in natal
.placements
.iter()
.zip(arc_chart.directed.placements.iter())
{
assert_eq!(natal_p.body, directed_p.body);
assert_eq!(
natal_p.house_number, directed_p.house_number,
"body {} (index entry) switched house under solar arc",
natal_p.body.name()
);
}
}
#[test]
fn solar_arc_naibod_yields_30_degree_arc_at_30_years() {
let s = session();
let birth = fixture_birth();
let natal = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let arc = solar_arc_naibod(&natal, 30.0);
// Naibod constant = 0°59'08.33"/yr → 30 yr ≈ 29.572°.
let arc_deg = arc.arc_deg();
assert!(
(29.5..29.7).contains(&arc_deg),
"Naibod arc at 30 yrs should be ~29.57°, got {:.4}°",
arc_deg
);
}
fn signed_delta_rad(a: f64, b: f64) -> f64 {
const PI: f64 = std::f64::consts::PI;
const TAU: f64 = std::f64::consts::TAU;
let mut d = a - b;
while d > PI {
d -= TAU;
}
while d < -PI {
d += TAU;
}
d
}
fn signed_delta_deg(a: f64, b: f64) -> f64 {
let mut d = a - b;
while d > 180.0 {
d -= 360.0;
}
while d < -180.0 {
d += 360.0;
}
d
}
@@ -0,0 +1,180 @@
//! Tests for the Regiomontanus primary-direction method.
//!
//! Regiomontanus mundane positions depend only on hour angle, so the
//! arc of direction reduces to a pure RA delta. We verify this against
//! the underlying placement data and contrast with Placidus to confirm
//! the methods disagree on body-to-body but **agree on directions to
//! angles** (because the angles have fixed mundane positions in both
//! frameworks).
use cosmos_astrology::{
direct_to_aspect, mundane, AspectKind, BirthData, ChartConfig, DirectionKey,
DirectionMethod, NatalChart, Significator,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, observer).with_name("Fixture A")
}
#[test]
fn regiomontanus_body_to_body_arc_equals_pure_ra_delta() {
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
// Pick two bodies guaranteed to be present.
let promissor = Body::Sun;
let significator = Body::Mars;
let dirs = direct_to_aspect(
&chart,
promissor,
Significator::Body(significator),
AspectKind::Conjunction,
DirectionMethod::Regiomontanus,
DirectionKey::Ptolemy,
)
.unwrap();
assert_eq!(dirs.len(), 1);
let arc = dirs[0].arc_rad;
// Reconstruct the expected arc from raw placement RAs:
// Regiomontanus arc to body = RA_promissor RA_significator
// (the promissor must rotate forward until it occupies the
// significator's natal hour-angle slot).
let ra_p = chart
.placement(promissor)
.unwrap()
.right_ascension_rad;
let ra_s = chart
.placement(significator)
.unwrap()
.right_ascension_rad;
let expected = (ra_p - ra_s).rem_euclid(std::f64::consts::TAU);
let diff = (arc - expected).abs();
let diff = diff.min((std::f64::consts::TAU - diff).abs());
assert!(
diff < 1e-12,
"Regio Sun→Mars arc {} ≠ RA delta {} (diff {})",
arc.to_degrees(),
expected.to_degrees(),
diff.to_degrees()
);
}
#[test]
fn regiomontanus_and_placidus_agree_for_directions_to_mc() {
// The MC is at H=0 in both Placidus (m=1, H=0) and Regiomontanus
// (m=1, H=0). So the arc must be identical.
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
for body in [Body::Sun, Body::Mercury, Body::Mars, Body::Saturn] {
let placidus = direct_to_aspect(
&chart,
body,
Significator::Midheaven,
AspectKind::Conjunction,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap()[0];
let regio = direct_to_aspect(
&chart,
body,
Significator::Midheaven,
AspectKind::Conjunction,
DirectionMethod::Regiomontanus,
DirectionKey::Ptolemy,
)
.unwrap()[0];
let diff = (placidus.arc_rad - regio.arc_rad).abs();
assert!(
diff < 1e-12,
"{} → MC arc differs between Placidus ({}°) and Regio ({}°)",
body.name(),
placidus.arc_deg(),
regio.arc_deg()
);
}
}
#[test]
fn regiomontanus_and_placidus_disagree_for_body_to_body() {
// For non-zero declination bodies, the two methods should produce
// different arcs (semi-arc vs equator framework).
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
let placidus = direct_to_aspect(
&chart,
Body::Sun,
Significator::Body(Body::Saturn),
AspectKind::Conjunction,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap()[0];
let regio = direct_to_aspect(
&chart,
Body::Sun,
Significator::Body(Body::Saturn),
AspectKind::Conjunction,
DirectionMethod::Regiomontanus,
DirectionKey::Ptolemy,
)
.unwrap()[0];
let diff = (placidus.arc_rad - regio.arc_rad).abs();
assert!(
diff > 1e-4,
"expected Placidus and Regio to differ for body-to-body, got {} (Plac {}°, Regio {}°)",
diff,
placidus.arc_deg(),
regio.arc_deg()
);
}
#[test]
fn regiomontanus_skips_circumpolar_check() {
// Regio works even for circumpolar declinations because the
// framework doesn't use semi-arcs. We can't actually reproduce a
// circumpolar Body at +10° latitude (Caracas), but we can verify
// the method-dispatch path runs without raising the Placidus
// error.
let s = session();
let chart = NatalChart::compute(&fixture_birth(), &ChartConfig::default(), &s).unwrap();
// Saturn at this birth has Dec ≈ -22°, |Dec|+|lat| ~ 32° < 90°, so
// not circumpolar — but the test is sanity-only: confirms the
// dispatch ran.
let d = direct_to_aspect(
&chart,
Body::Saturn,
Significator::Body(Body::Sun),
AspectKind::Conjunction,
DirectionMethod::Regiomontanus,
DirectionKey::Naibod,
)
.unwrap();
assert!(!d.is_empty());
assert_eq!(d[0].method, DirectionMethod::Regiomontanus);
}
#[test]
fn regiomontanus_mundane_position_helper_matches_definition() {
// The Regio mundane position is m = 1 + H × (2/π). At H=0, m=1
// (MC). At H = ±π/2, m = 2 / 0 (Desc / Asc). Verified via the
// dispatch through DirectionMethod and a small synthetic case.
let phi = 30.0_f64.to_radians();
let ramc = 0.0;
// Body on the meridian: RA = RAMC, so H = 0.
let ra = 0.0;
let dec = 25.0_f64.to_radians();
let m = mundane::natal_mundane_position(ramc, ra, dec, phi);
// Placidus says m = 1 (on MC). Regiomontanus should also say 1.
assert!((m - 1.0).abs() < 1e-9, "Placidus MC m = {}", m);
}
@@ -0,0 +1,201 @@
//! Tests for planetary stations and primary directions to non-
//! conjunction aspects.
use cosmos_astrology::{
all_directions, all_directions_with_aspects, all_stations, direct, direct_to_aspect,
next_station, AspectKind, BirthData, ChartConfig, DirectionKey, DirectionMethod,
NatalChart, Significator, StationKind,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_birth() -> BirthData {
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, observer).with_name("Fixture A")
}
// ─── Stations ────────────────────────────────────────────────────────
#[test]
fn mercury_2025_march_retrograde_station_lands_near_2025_03_15() {
// Mercury retrograde 2025: stations Rx on 2025-03-15 around 06 UTC.
let s = session();
let after = Instant::from_civil_utc(2025, 3, 1, 0, 0, 0.0).unwrap();
let station = next_station(&s, Body::Mercury, after, 30.0)
.unwrap()
.expect("Mercury must station in March 2025");
assert_eq!(station.kind, StationKind::Retrograde);
let expected = Instant::from_civil_utc(2025, 3, 15, 6, 0, 0.0).unwrap();
let diff_days = (station.instant.jd_utc() - expected.jd_utc()).abs();
assert!(
diff_days < 1.0,
"Mercury Rx station {} differs from expected ~2025-03-15 06 UTC by {:.4} d",
station.instant.to_iso8601(),
diff_days,
);
}
#[test]
fn mercury_2025_march_retrograde_pair_inside_window() {
// The retrograde pair (Rx then Direct) should both fall inside a
// 6-week window starting 2025-03-01.
let s = session();
let after = Instant::from_civil_utc(2025, 3, 1, 0, 0, 0.0).unwrap();
let stations = all_stations(&s, Body::Mercury, after, 45.0).unwrap();
assert_eq!(
stations.len(),
2,
"expected 1 Rx + 1 Direct station, got {}",
stations.len()
);
assert_eq!(stations[0].kind, StationKind::Retrograde);
assert_eq!(stations[1].kind, StationKind::Direct);
// The Direct station follows the Rx by ~22 days.
let gap = stations[1].instant.jd_utc() - stations[0].instant.jd_utc();
assert!(
(15.0..30.0).contains(&gap),
"Mercury Rx → Direct gap {} d outside [15, 30]",
gap
);
}
#[test]
fn moon_does_not_station() {
// The Moon's longitude rate is always positive (~13°/day). A 30-day
// search should find no station.
let s = session();
let after = Instant::from_civil_utc(2025, 1, 1, 0, 0, 0.0).unwrap();
let s_opt = next_station(&s, Body::Moon, after, 30.0).unwrap();
assert!(s_opt.is_none(), "Moon should never station");
}
// ─── Primary directions to aspects ───────────────────────────────────
#[test]
fn direct_conjunction_matches_legacy_direct_function() {
// direct_to_aspect(..., Conjunction) must return exactly one
// Direction equal to direct(...) for the same args.
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let legacy = direct(
&chart,
Body::Sun,
Significator::Midheaven,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
let extended = direct_to_aspect(
&chart,
Body::Sun,
Significator::Midheaven,
AspectKind::Conjunction,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
assert_eq!(extended.len(), 1);
assert!(
(extended[0].arc_rad - legacy.arc_rad).abs() < 1e-12,
"Conjunction arc differs between direct() and direct_to_aspect()"
);
assert_eq!(extended[0].aspect, AspectKind::Conjunction);
}
#[test]
fn trine_yields_two_branches_with_distinct_arcs() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let trines = direct_to_aspect(
&chart,
Body::Sun,
Significator::Body(Body::Moon),
AspectKind::Trine,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
assert_eq!(trines.len(), 2, "Trine should yield ±120° branches");
assert_eq!(trines[0].aspect, AspectKind::Trine);
assert_eq!(trines[1].aspect, AspectKind::Trine);
let arc0 = trines[0].arc_deg();
let arc1 = trines[1].arc_deg();
assert!(
(arc0 - arc1).abs() > 1.0,
"two trine branches should produce distinct arcs (got {:.4}° and {:.4}°)",
arc0,
arc1
);
}
#[test]
fn opposition_yields_single_branch() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let opp = direct_to_aspect(
&chart,
Body::Sun,
Significator::Body(Body::Mars),
AspectKind::Opposition,
DirectionMethod::PlacidusMundane,
DirectionKey::Ptolemy,
)
.unwrap();
assert_eq!(opp.len(), 1, "Opposition is symmetric, one branch");
assert_eq!(opp[0].aspect, AspectKind::Opposition);
}
#[test]
fn all_directions_remains_conjunction_only_for_back_compat() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let all = all_directions(
&chart,
DirectionMethod::PlacidusMundane,
DirectionKey::Naibod,
90.0,
);
for d in &all {
assert_eq!(d.aspect, AspectKind::Conjunction);
}
}
#[test]
fn all_directions_with_aspects_includes_trines_and_squares() {
let s = session();
let birth = fixture_birth();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let all = all_directions_with_aspects(
&chart,
DirectionMethod::PlacidusMundane,
DirectionKey::Naibod,
AspectKind::MAJORS,
90.0,
);
let kinds: std::collections::HashSet<_> = all.iter().map(|d| d.aspect).collect();
for k in AspectKind::MAJORS {
// For a chart spanning many bodies + 4 angles, all major
// aspects should perfect at some age in [0, 90].
assert!(
kinds.contains(k),
"no direction found for {:?}",
k
);
}
for d in &all {
assert!(d.age_years <= 90.0 + 1e-9);
}
for w in all.windows(2) {
assert!(w[0].age_years <= w[1].age_years + 1e-12);
}
}
@@ -0,0 +1,234 @@
//! Tests for the transit engine and the synastry aspect grid.
use cosmos_astrology::{
aspect::AspectKind, default_natal_targets, find_current_transits,
find_next_exact_transit, find_synastry_aspects, BirthData, ChartConfig, NatalChart,
OrbTable, Significator,
};
use cosmos_sky::{Body, EphemerisSession, Instant, Observer, SessionConfig};
fn session() -> EphemerisSession {
EphemerisSession::open(SessionConfig::vsop2013()).unwrap()
}
fn fixture_a() -> BirthData {
let instant = Instant::from_civil_local(1987, 3, 14, 5, 22, 0.0, -240).unwrap();
let observer = Observer::from_degrees(10.4806, -66.9036, 900.0);
BirthData::new(instant, observer).with_name("Subject A")
}
fn fixture_b() -> BirthData {
let instant = Instant::from_civil_local(1990, 7, 22, 14, 17, 0.0, 60).unwrap();
let observer = Observer::from_degrees(40.4168, -3.7038, 650.0); // Madrid
BirthData::new(instant, observer).with_name("Subject B")
}
// ─── Transits ─────────────────────────────────────────────────────────
#[test]
fn self_transit_at_natal_moment_produces_exact_self_aspects() {
// At the natal moment, every planet transits its own natal position
// with orb 0° (conjunction). This is the trivial sanity case for
// the transit engine: feed the chart's own instant in.
let s = session();
let birth = fixture_a();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let targets = default_natal_targets(&chart);
let transits = find_current_transits(
&chart,
&s,
chart.birth.instant,
&[Body::Sun, Body::Moon, Body::Mars],
&targets,
&OrbTable::modern_western(),
&[AspectKind::Conjunction],
)
.unwrap();
// Each of the three transiting bodies should have a near-zero-orb
// conjunction with its own natal point.
for body in [Body::Sun, Body::Moon, Body::Mars] {
let self_aspect = transits.iter().find(|t| {
t.transiting == body && matches!(t.natal_target, Significator::Body(b) if b == body)
});
let asp = self_aspect.unwrap_or_else(|| {
panic!("expected {} to transit its own natal position", body.name())
});
assert!(
asp.orb_abs_deg() < 1e-6,
"self-transit orb for {} = {} ° (expected ~0)",
body.name(),
asp.orb_abs_deg(),
);
}
}
#[test]
fn transits_are_sorted_and_within_orb() {
let s = session();
let birth = fixture_a();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let targets = default_natal_targets(&chart);
let now = Instant::from_civil_utc(2026, 5, 15, 0, 0, 0.0).unwrap();
let transits = find_current_transits(
&chart,
&s,
now,
&[Body::Mars, Body::Saturn, Body::Jupiter],
&targets,
&OrbTable::modern_western(),
AspectKind::MAJORS,
)
.unwrap();
for t in &transits {
assert!(
t.orb_abs_deg() <= t.allowed_orb_deg + 1e-9,
"transit out of orb"
);
}
for w in transits.windows(2) {
assert!(w[0].orb_abs_deg() <= w[1].orb_abs_deg() + 1e-12);
}
}
#[test]
fn next_exact_sun_conjunction_returns_within_a_year() {
// Transiting Sun conjunct natal Sun must perfect within ~365 d of
// any starting instant (it's the literal definition of the solar
// year — same as a solar return).
let s = session();
let birth = fixture_a();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let natal_sun = chart
.placement(Body::Sun)
.unwrap()
.longitude
.longitude_rad();
let after = Instant::from_civil_utc(2025, 6, 1, 0, 0, 0.0).unwrap();
let exact = find_next_exact_transit(
&s,
Body::Sun,
natal_sun,
AspectKind::Conjunction,
after,
400.0,
)
.unwrap()
.expect("Sun should reach natal longitude within 400 days");
let days = exact.jd_utc() - after.jd_utc();
assert!(
(0.0..380.0).contains(&days),
"expected gap in [0, 380] d, got {:.4}",
days
);
}
#[test]
fn next_exact_moon_trine_resolves_within_a_month() {
let s = session();
let birth = fixture_a();
let chart = NatalChart::compute(&birth, &ChartConfig::default(), &s).unwrap();
let natal_sun = chart
.placement(Body::Sun)
.unwrap()
.longitude
.longitude_rad();
let after = Instant::from_civil_utc(2025, 6, 1, 0, 0, 0.0).unwrap();
let exact = find_next_exact_transit(
&s,
Body::Moon,
natal_sun,
AspectKind::Trine,
after,
35.0,
)
.unwrap();
assert!(
exact.is_some(),
"Moon must form a trine to natal Sun within 35 days of any instant"
);
}
// ─── Synastry ─────────────────────────────────────────────────────────
#[test]
fn synastry_finds_aspects_between_two_real_charts() {
let s = session();
let chart_a = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let chart_b = NatalChart::compute(&fixture_b(), &ChartConfig::default(), &s).unwrap();
let asps = find_synastry_aspects(
&chart_a,
&chart_b,
&OrbTable::modern_western(),
AspectKind::ALL,
);
assert!(!asps.is_empty(), "two real charts should share aspects");
for a in &asps {
assert!(a.orb_abs_deg() <= a.allowed_orb_deg + 1e-9);
}
for w in asps.windows(2) {
assert!(w[0].orb_abs_deg() <= w[1].orb_abs_deg() + 1e-12);
}
}
#[test]
fn synastry_is_symmetric_under_chart_swap() {
// find_synastry_aspects(A, B) and find_synastry_aspects(B, A) must
// produce the same set of aspects up to (person_a ↔ person_b) swap.
let s = session();
let chart_a = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let chart_b = NatalChart::compute(&fixture_b(), &ChartConfig::default(), &s).unwrap();
let ab = find_synastry_aspects(
&chart_a,
&chart_b,
&OrbTable::modern_western(),
AspectKind::MAJORS,
);
let ba = find_synastry_aspects(
&chart_b,
&chart_a,
&OrbTable::modern_western(),
AspectKind::MAJORS,
);
assert_eq!(ab.len(), ba.len());
for (x, y) in ab.iter().zip(ba.iter()) {
assert_eq!(x.kind, y.kind);
assert_eq!(x.person_a_body, y.person_b_body);
assert_eq!(x.person_b_body, y.person_a_body);
// Signed orbs: when computed as |sep|exact they are equal,
// because |sep| is symmetric in (a, b).
assert!((x.orb_abs_deg() - y.orb_abs_deg()).abs() < 1e-9);
}
}
#[test]
fn synastry_self_self_yields_exact_self_conjunctions() {
// Synastry of a chart against itself contains exact self-
// conjunctions for every body — useful sanity check.
let s = session();
let chart_a = NatalChart::compute(&fixture_a(), &ChartConfig::default(), &s).unwrap();
let asps = find_synastry_aspects(
&chart_a,
&chart_a,
&OrbTable::modern_western(),
&[AspectKind::Conjunction],
);
for body in [Body::Sun, Body::Moon, Body::Mars] {
let self_aspect = asps.iter().find(|a| {
a.person_a_body == body
&& a.person_b_body == body
&& a.kind == AspectKind::Conjunction
});
let asp = self_aspect
.unwrap_or_else(|| panic!("missing self-conjunction for {}", body.name()));
assert!(asp.orb_abs_deg() < 1e-9);
}
}
@@ -0,0 +1,19 @@
[package]
name = "cosmos-canvas-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "cosmos-canvas-llimphi — backend Llimphi del lienzo astrológico. Toma la lista de `DrawCommand` agnóstica de `cosmos-render::compose_wheel` y la traduce a primitivas vello (Circle/Line/Polygon) + texto vía llimphi-text, dentro del rect del nodo."
[dependencies]
cosmos-render = { path = "../cosmos-render" }
llimphi-ui = { workspace = true }
[dev-dependencies]
pineal-render = { workspace = true }
[[example]]
name = "dense_starfield"
path = "examples/dense_starfield.rs"
@@ -0,0 +1,10 @@
# cosmos-canvas-llimphi
> Backend Llimphi (vello) para [cosmos](../README.md).
Convierte los `Vec<Shape>` de [`cosmos-render`](../cosmos-render/README.md) en operaciones `vello::Scene` adentro de un `View::paint_with(...)` Llimphi. Pan + zoom + rotación. Tracking del cursor sobre el cielo → tooltip con info del objeto bajo el puntero.
## Deps
- [`cosmos-render`](../cosmos-render/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello)
@@ -0,0 +1,10 @@
# cosmos-canvas-llimphi
> Llimphi (vello) backend for [cosmos](../README.md).
Converts `Vec<Shape>` from [`cosmos-render`](../cosmos-render/README.md) into `vello::Scene` operations inside a Llimphi `View::paint_with(...)`. Pan + zoom + rotation. Cursor tracking over the sky → tooltip with info on the object under the pointer.
## Deps
- [`cosmos-render`](../cosmos-render/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello)
@@ -0,0 +1,211 @@
//! Caller real de Fase 5 del SDD `02_ruway/llimphi/SDD.md`
//! §"GPU directo wgpu" — un starfield denso (N estrellas sintéticas
//! distribuidas en una esfera celeste con concentración en el plano
//! galáctico) renderizado en una sola draw call con `gpu_paint_with`.
//!
//! No es producción: las estrellas son sintéticas (no HYG, no Gaia DR3).
//! Lo que valida es la cadena completa:
//!
//! cosmos-canvas-llimphi
//! → pineal-render::GpuSceneCanvas (Canvas trait)
//! → llimphi-raster::GpuBatch (rects/lines/tris)
//! → llimphi-ui::View::gpu_paint_with (encoder + view)
//! → wgpu (draw call instanciada)
//!
//! El painter es agnóstico — habla contra `pineal_render::Canvas` con
//! `fill_rect` por estrella, y elegir GPU vs vello es decisión de la
//! app al enchufar `gpu_paint_with` vs `paint_with`. Cambio el N con
//! teclas: + sube, - baja.
//!
//! Corre con: `cargo run -p cosmos-canvas-llimphi --example dense_starfield --release`.
use std::sync::{Arc, OnceLock};
use llimphi_ui::llimphi_hal::wgpu;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::peniko::Color as PenikoColor;
use llimphi_ui::llimphi_raster::{GpuBatch, GpuPipelines};
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, PaintRect, View};
use pineal_render::{Canvas, Color, GpuSceneCanvas, Rect};
/// Conteo inicial. Las teclas + / - lo doblan/parten dentro de
/// [10K, 4M] — útil para ver dónde empieza a caerse el frame rate
/// en GPU real.
const START_N: u32 = 250_000;
#[derive(Clone)]
enum Msg {
Multiply(f32),
}
struct DenseStarfield;
impl App for DenseStarfield {
type Model = u32;
type Msg = Msg;
fn title() -> &'static str {
"cosmos · dense_starfield (GPU directo)"
}
fn init(_: &Handle<Self::Msg>) -> Self::Model {
START_N
}
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Multiply(f) => {
let next = (model as f32 * f).round() as u32;
next.clamp(10_000, 4_000_000)
}
}
}
fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
if !matches!(ev.state, KeyState::Pressed) {
return None;
}
match &ev.key {
Key::Character(c) if c.as_str() == "+" || c.as_str() == "=" => {
Some(Msg::Multiply(2.0))
}
Key::Character(c) if c.as_str() == "-" => Some(Msg::Multiply(0.5)),
Key::Named(NamedKey::Space) => Some(Msg::Multiply(1.0)), // re-roll seed
_ => None,
}
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let n = *model;
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(PenikoColor::from_rgba8(6, 8, 16, 255))
// Texto informativo lo dibuja vello (paint_with) PRIMERO; el
// starfield denso queda encima vía gpu_paint_with. No hay
// texto en el GPU directo por diseño.
.paint_with(move |scene, ts, rect: PaintRect| {
use llimphi_ui::llimphi_text::{
draw_layout, layout_block, Alignment, TextBlock,
};
let block = TextBlock {
text: &format!(
"{n} estrellas · GpuSceneCanvas + GpuBatch · ±/= para escalar"
),
size_px: 16.0,
color: PenikoColor::from_rgba8(200, 215, 240, 220),
origin: (rect.x as f64 + 16.0, rect.y as f64 + 14.0),
max_width: Some(rect.w - 32.0),
alignment: Alignment::Start,
line_height: 1.2,
italic: false,
font_family: None,
};
let layout = layout_block(ts, &block);
draw_layout(scene, &layout, block.color, block.origin);
})
.gpu_paint_with(move |device, queue, encoder, view, rect, _viewport| {
let pipelines = pipelines_for(device);
let mut batch = GpuBatch::new(&pipelines);
{
let mut canvas = GpuSceneCanvas::new(&mut batch);
paint_starfield(&mut canvas, rect, n);
}
batch.flush(
device,
queue,
encoder,
view,
(rect.w, rect.h),
wgpu::LoadOp::Load,
);
})
}
}
fn pipelines_for(device: &wgpu::Device) -> Arc<GpuPipelines> {
// Una sola GpuPipelines viva por proceso. El swap format del
// intermediate de llimphi-hal es Rgba8Unorm — el `view` que recibimos
// en gpu_paint_with es esa textura.
static SLOT: OnceLock<Arc<GpuPipelines>> = OnceLock::new();
SLOT.get_or_init(|| {
Arc::new(GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm))
})
.clone()
}
fn paint_starfield<C: Canvas>(canvas: &mut C, rect: PaintRect, n: u32) {
// Distribución sintética estilo "esfera celeste vista de frente":
// densidad ~uniforme en la franja central + cresta diagonal que
// simula el plano galáctico. Determinista (LCG con seed fijo) para
// que el resultado sea reproducible entre frames y entre apps.
let mut state: u32 = 0xCAFEBABEu32;
let lcg = |s: &mut u32| -> f32 {
*s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(*s & 0x00FF_FFFF) as f32 / 16_777_215.0
};
let cx = rect.x + rect.w * 0.5;
let cy = rect.y + rect.h * 0.5;
// Cresta galáctica: una franja inclinada con peso gaussiano.
let galactic_angle: f32 = 0.42; // rad
let (sa, ca) = galactic_angle.sin_cos();
let radius = (rect.w.min(rect.h)) * 0.49;
for _ in 0..n {
// 30% va a la cresta, 70% al campo difuso.
let in_galaxy = lcg(&mut state) < 0.30;
let (px, py, brightness) = if in_galaxy {
// Coordenadas locales (u along, v across) gauss.
let u = lcg(&mut state) - 0.5;
let v_u1 = lcg(&mut state) - 0.5;
let v_u2 = lcg(&mut state) - 0.5;
let v = (v_u1 + v_u2) * 0.08; // ~gauss strict thin
// Rotar (u, v) → (x, y) por galactic_angle.
let lx = u * 2.0 * radius;
let ly = v * 2.0 * radius;
let x = cx + ca * lx - sa * ly;
let y = cy + sa * lx + ca * ly;
// Brillo mayor cerca del centro galáctico (u ~ 0).
let b = 0.4 + (1.0 - u.abs() * 2.0).max(0.0) * 0.6;
(x, y, b)
} else {
// Disco circular relleno.
let r2 = lcg(&mut state);
let theta = lcg(&mut state) * std::f32::consts::TAU;
let r = radius * r2.sqrt();
let x = cx + theta.cos() * r;
let y = cy + theta.sin() * r;
let b = (1.0 - lcg(&mut state).powi(3)).clamp(0.15, 1.0);
(x, y, b)
};
// Pequeñas variaciones de color: blanco-azulado a amarillo.
let t = lcg(&mut state);
let r_col = 0.85 + 0.15 * t;
let g_col = 0.88 + 0.10 * (1.0 - t);
let b_col = 0.95 + 0.05 * (1.0 - t);
let alpha = brightness * 0.85;
// Pintar como cuadrado 1.2px — el SDD §"GPU directo" usa
// exactamente este tamaño para starfield denso. El GpuBatch
// emite un rect instanciado por estrella.
let size = 1.2 + brightness * 0.6;
let r = Rect {
x: px - size * 0.5,
y: py - size * 0.5,
w: size,
h: size,
};
canvas.fill_rect(r, Color::rgba(r_col, g_col, b_col, alpha));
}
}
fn main() {
llimphi_ui::run::<DenseStarfield>();
}
@@ -0,0 +1,356 @@
//! `cosmos-canvas-llimphi` — backend Llimphi del lienzo astrológico.
//!
//! Toma la lista de [`DrawCommand`] agnóstica que produce
//! `cosmos-render::compose_wheel` y la pinta con vello. Sin estado
//! entre frames — el host reconstruye el View con la lista de
//! comandos del frame actual; idéntico contrato que
//! `dominium-canvas-llimphi`.
//!
//! La lista de `DrawCommand` está en coordenadas locales del wheel
//! (centrada en `(size/2, size/2)` con `size = opts.size`). Acá
//! traducimos a coordenadas absolutas del rect del nodo, centrando
//! el wheel y aplicando un aspect-fit si el rect no es cuadrado
//! (se usa el lado menor + offset). Tipografía vía llimphi-text con
//! el Typesetter cacheado del runtime — los glyphs simbólicos
//! (`"sun"`, `"aries"`, etc.) los rendereamos como letras unicode
//! astronómicas estándar (☉ ☽ ♈…) si están en el font del sistema;
//! sino caen al texto del campo `symbol` que viene en `Glyph`.
#![forbid(unsafe_code)]
use cosmos_render::{DrawCommand, TextAnchor};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{layout_block, Alignment, TextBlock, Typesetter};
use llimphi_ui::{PaintRect, View};
/// Zoom + paneo aplicados sobre el aspect-fit base del canvas. `zoom`
/// multiplica la escala; `pan` desplaza el origen en píxeles de pantalla.
/// `Default` (zoom 1, pan 0) = aspect-fit centrado puro.
#[derive(Debug, Clone, Copy)]
pub struct ViewTransform {
pub zoom: f32,
pub pan: (f32, f32),
}
impl Default for ViewTransform {
fn default() -> Self {
Self {
zoom: 1.0,
pan: (0.0, 0.0),
}
}
}
/// Escala y offset (en coords de pantalla) para un rect dado y transform.
fn fit(rect_w: f32, rect_h: f32, wheel_size: f32, t: ViewTransform) -> (f64, f64, f64) {
let scale = (rect_w.min(rect_h) / wheel_size) as f64 * t.zoom.max(0.01) as f64;
let disp = wheel_size as f64 * scale;
let off_x = (rect_w as f64 - disp) * 0.5 + t.pan.0 as f64;
let off_y = (rect_h as f64 - disp) * 0.5 + t.pan.1 as f64;
(scale, off_x, off_y)
}
/// Construye un View que pinta `commands` centrados en su rect.
///
/// `wheel_size` debe coincidir con `CompositionOpts::size` que se
/// pasó a `compose_wheel` — define el cuadrado lógico donde viven los
/// comandos. El canvas aplica un aspect-fit centrado al rect que le
/// asignó taffy.
pub fn canvas_view<Msg>(
commands: Vec<DrawCommand>,
wheel_size: f32,
background: Option<Color>,
) -> View<Msg>
where
Msg: Clone + 'static,
{
canvas_view_ex(commands, wheel_size, background, ViewTransform::default())
}
/// Como [`canvas_view`] pero con zoom + paneo.
pub fn canvas_view_ex<Msg>(
commands: Vec<DrawCommand>,
wheel_size: f32,
background: Option<Color>,
t: ViewTransform,
) -> View<Msg>
where
Msg: Clone + 'static,
{
let view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
});
let view = if let Some(bg) = background {
view.fill(bg)
} else {
view
};
view.paint_with(move |scene, ts, rect: PaintRect| {
if commands.is_empty() || wheel_size <= 0.0 {
return;
}
// Aspect-fit centrado + zoom/pan del usuario.
let (scale, off_local_x, off_local_y) = fit(rect.w, rect.h, wheel_size, t);
let off_x = rect.x as f64 + off_local_x;
let off_y = rect.y as f64 + off_local_y;
// El transform global aplica a las primitivas geométricas; el
// texto lo posicionamos absoluto (parley no compone bien con
// transforms para sizing/alignment).
let xform = Affine::translate((off_x, off_y)) * Affine::scale(scale);
for cmd in &commands {
paint_command(scene, ts, cmd, xform, off_x, off_y, scale);
}
})
}
/// Variante de [`canvas_view`] que dispara `on_click` cuando el
/// usuario hace click dentro del canvas. El handler recibe las
/// coordenadas del click **ya convertidas a coords del wheel** (mismo
/// espacio en el que se emitieron los `DrawCommand`s), y devuelve
/// `Option<Msg>`. Pensado para hit-testear contra [`WheelHits`].
pub fn canvas_view_clickable<Msg, F>(
commands: Vec<DrawCommand>,
wheel_size: f32,
background: Option<Color>,
on_click: F,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn(f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
canvas_view_clickable_ex(
commands,
wheel_size,
background,
ViewTransform::default(),
on_click,
)
}
/// Como [`canvas_view_clickable`] pero con zoom + paneo; el hit-test
/// invierte el mismo transform para que el click siga cayendo sobre el
/// glyph correcto a cualquier zoom/pan.
pub fn canvas_view_clickable_ex<Msg, F>(
commands: Vec<DrawCommand>,
wheel_size: f32,
background: Option<Color>,
t: ViewTransform,
on_click: F,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn(f32, f32) -> Option<Msg> + Send + Sync + 'static,
{
let view = canvas_view_ex::<Msg>(commands, wheel_size, background, t);
view.on_click_at(move |local_x, local_y, rect_w, rect_h| {
if wheel_size <= 0.0 {
return None;
}
// Invertir el aspect-fit + zoom/pan que aplica `paint_with`.
let (scale, off_x, off_y) = fit(rect_w, rect_h, wheel_size, t);
let wheel_x = (local_x as f64 - off_x) / scale;
let wheel_y = (local_y as f64 - off_y) / scale;
on_click(wheel_x as f32, wheel_y as f32)
})
}
fn paint_command(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut Typesetter,
cmd: &DrawCommand,
xform: Affine,
off_x: f64,
off_y: f64,
scale: f64,
) {
match cmd {
DrawCommand::Circle { cx, cy, r, stroke, fill, stroke_w } => {
let c = KurboCircle::new((*cx as f64, *cy as f64), *r as f64);
if let Some(f) = fill {
scene.fill(Fill::NonZero, xform, rgba_to_color(*f), None, &c);
}
if let Some(s) = stroke {
scene.stroke(
&Stroke::new(*stroke_w as f64),
xform,
rgba_to_color(*s),
None,
&c,
);
}
}
DrawCommand::Line { x1, y1, x2, y2, color, width, dash } => {
let mut path = BezPath::new();
path.move_to((*x1 as f64, *y1 as f64));
path.line_to((*x2 as f64, *y2 as f64));
let mut stroke = Stroke::new(*width as f64);
if let Some((on, off)) = dash {
stroke = stroke.with_dashes(0.0, [*on as f64, *off as f64]);
}
scene.stroke(&stroke, xform, rgba_to_color(*color), None, &path);
}
DrawCommand::Polygon { points, fill, stroke, stroke_w } => {
if points.is_empty() {
return;
}
let mut path = BezPath::new();
let (x0, y0) = points[0];
path.move_to((x0 as f64, y0 as f64));
for (x, y) in &points[1..] {
path.line_to((*x as f64, *y as f64));
}
path.close_path();
if let Some(f) = fill {
scene.fill(Fill::NonZero, xform, rgba_to_color(*f), None, &path);
}
if let Some(s) = stroke {
scene.stroke(
&Stroke::new(*stroke_w as f64),
xform,
rgba_to_color(*s),
None,
&path,
);
}
}
DrawCommand::Path { d, stroke, fill, stroke_w } => {
// kurbo parsea sintaxis SVG (M/L/C/Q/A/Z) — los glyphs
// astrológicos vienen de `cosmos_render::glyphs` como
// strings agnósticas para que el surface no se ate a
// ninguna fuente.
let Ok(path) = BezPath::from_svg(d) else {
eprintln!("cosmos-canvas: path SVG inválido: {d}");
return;
};
if let Some(f) = fill {
scene.fill(Fill::NonZero, xform, rgba_to_color(*f), None, &path);
}
if let Some(s) = stroke {
scene.stroke(
&Stroke::new(*stroke_w as f64),
xform,
rgba_to_color(*s),
None,
&path,
);
}
}
DrawCommand::Text { x, y, content, color, size, anchor } => {
paint_text(scene, ts, x, y, content, color, size, anchor, off_x, off_y, scale);
}
DrawCommand::RadialGradient { cx, cy, r, inner, outer } => {
use llimphi_ui::llimphi_raster::peniko::Gradient;
let center = llimphi_ui::llimphi_raster::kurbo::Point::new(*cx as f64, *cy as f64);
let grad = Gradient::new_radial(center, *r)
.with_stops([rgba_to_color(*inner), rgba_to_color(*outer)].as_slice());
let circle = KurboCircle::new((*cx as f64, *cy as f64), *r as f64);
scene.fill(Fill::NonZero, xform, &grad, None, &circle);
}
}
}
#[allow(clippy::too_many_arguments)]
fn paint_text(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut Typesetter,
x: &f32,
y: &f32,
content: &str,
color: &cosmos_render::Rgba,
size: &f32,
anchor: &TextAnchor,
off_x: f64,
off_y: f64,
scale: f64,
) {
let translated = pretty_symbol(content);
// Coordenadas absolutas del anchor.
let ax = off_x + *x as f64 * scale;
let ay = off_y + *y as f64 * scale;
let size_px = *size * scale as f32;
let align = match anchor {
TextAnchor::Start => Alignment::Start,
TextAnchor::Middle => Alignment::Center,
TextAnchor::End => Alignment::End,
};
let color = rgba_to_color(*color);
// Para centrar verticalmente alrededor de (ax, ay) medimos primero.
// Anchor horizontal lo resuelve parley vía `max_width + alignment`
// si le damos un max_width simétrico al anchor.
let approx_w = size_px as f64 * translated.chars().count() as f64;
let (origin_x, max_w) = match anchor {
TextAnchor::Start => (ax, None),
TextAnchor::Middle => (ax - approx_w, Some(approx_w as f32 * 2.0)),
TextAnchor::End => (ax - approx_w, Some(approx_w as f32)),
};
let block = TextBlock {
text: &translated,
size_px,
color,
origin: (origin_x, ay - size_px as f64 * 0.5),
max_width: max_w,
alignment: align,
line_height: 1.0,
italic: false,
font_family: None,
};
let layout = layout_block(ts, &block);
llimphi_ui::llimphi_text::draw_layout(scene, &layout, color, block.origin);
}
fn rgba_to_color(c: cosmos_render::Rgba) -> Color {
let to_byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
Color::from_rgba8(to_byte(c.r), to_byte(c.g), to_byte(c.b), to_byte(c.a))
}
/// Traduce un identificador simbólico de cosmos-render
/// (`"sun"`, `"aries"`, `"asc"`, etc.) a un glyph unicode astrológico.
/// Si no hay traducción registrada, devuelve el string original — el
/// caller puede pasar texto ya formateado (coord labels) sin que
/// rompa.
fn pretty_symbol(s: &str) -> String {
match s {
// Cuerpos clásicos.
"sun" => "".into(),
"moon" => "".into(),
"mercury" => "".into(),
"venus" => "".into(),
"mars" => "".into(),
"jupiter" => "".into(),
"saturn" => "".into(),
"uranus" => "".into(),
"neptune" => "".into(),
"pluto" => "".into(),
"earth" => "".into(),
// Puntos del chart.
"asc" => "Asc".into(),
"desc" => "Desc".into(),
"mc" => "MC".into(),
"ic" => "IC".into(),
"north_node" | "ascending_node" => "".into(),
"south_node" | "descending_node" => "".into(),
"lilith" => "".into(),
"chiron" => "".into(),
// Signos zodiacales.
"aries" => "".into(),
"taurus" => "".into(),
"gemini" => "".into(),
"cancer" => "".into(),
"leo" => "".into(),
"virgo" => "".into(),
"libra" => "".into(),
"scorpio" => "".into(),
"sagittarius" => "".into(),
"capricorn" => "".into(),
"aquarius" => "".into(),
"pisces" => "".into(),
other => other.to_string(),
}
}
@@ -0,0 +1,46 @@
//! Regresión: cada path SVG emitido por `cosmos_render::glyphs` debe
//! parsear con `kurbo::BezPath::from_svg`. Si no, el canvas Llimphi
//! silenciosamente se saltea el comando (eprintln + return) y el
//! glyph aparece roto en el wheel — exactamente el bug que motivó
//! pasarse a geometría vectorial.
use cosmos_render::draw::{DrawCommand, Rgba};
use cosmos_render::glyphs::{planet_commands, retrograde_marker, sign_commands};
use llimphi_ui::llimphi_raster::kurbo::BezPath;
#[test]
fn todos_los_glyphs_parsean_con_kurbo() {
let color = Rgba::opaque(1.0, 1.0, 1.0);
let planets = [
"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune",
"pluto", "north_node", "south_node", "chiron", "lilith",
];
let signs = [
"aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra", "scorpio", "sagittarius",
"capricorn", "aquarius", "pisces",
];
let mut fallas = Vec::new();
let mut check = |cmds: Vec<DrawCommand>, label: &str| {
for c in cmds {
if let DrawCommand::Path { d, .. } = c {
if let Err(e) = BezPath::from_svg(&d) {
fallas.push(format!("{label}: {e:?} :: {d}"));
}
}
}
};
for p in &planets {
check(planet_commands(p, 100.0, 100.0, 30.0, color, 2.0), p);
}
check(vec![retrograde_marker(100.0, 100.0, 30.0, color)], "retro");
for s in &signs {
check(sign_commands(s, 100.0, 100.0, 30.0, color, 2.0), s);
}
assert!(
fallas.is_empty(),
"{} paths inválidos:\n{}",
fallas.len(),
fallas.join("\n")
);
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "cosmos-card"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — Tarjeta de Presentación brahman + spawn del sidecar + protocolo del service socket."
[dependencies]
card-core = { workspace = true }
card-sidecar = { workspace = true }
cosmos-engine = { path = "../cosmos-engine" }
cosmos-model = { path = "../cosmos-model" }
ulid = { workspace = true }
serde = { workspace = true }
postcard = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
directories = { workspace = true }
thiserror = { workspace = true }
+10
View File
@@ -0,0 +1,10 @@
# cosmos-card
> Card escritorio (resumen) de [cosmos](../README.md).
Widget pequeño para mostrar en el panel principal del escritorio: hora local + sol/luna del día + próximo evento astronómico relevante. Reusa [`llimphi-widget-stat-card`](../../../02_ruway/llimphi/widgets/stat-card/README.md) como base.
## Deps
- [`cosmos-engine`](../cosmos-engine/README.md), [`cosmos-sky`](../cosmos-sky/README.md)
- [`llimphi-widget-stat-card`](../../../02_ruway/llimphi/widgets/stat-card/README.md)
+10
View File
@@ -0,0 +1,10 @@
# cosmos-card
> Desktop summary card of [cosmos](../README.md).
Small widget for the main desktop panel: local time + sun/moon of the day + next relevant astronomical event. Reuses [`llimphi-widget-stat-card`](../../../02_ruway/llimphi/widgets/stat-card/README.md) as base.
## Deps
- [`cosmos-engine`](../cosmos-engine/README.md), [`cosmos-sky`](../cosmos-sky/README.md)
- [`llimphi-widget-stat-card`](../../../02_ruway/llimphi/widgets/stat-card/README.md)
+95
View File
@@ -0,0 +1,95 @@
//! `cosmos_app-card` — Tarjeta de Presentación + sidecar de la app.
//!
//! Cualquier binario que levante Tahuantinsuyu llama [`spawn_sidecar`]
//! antes de abrir la ventana GPUI. La lógica de thread / tokio /
//! ping-loop vive en `brahman-sidecar`; aquí solo declaramos quién es
//! Tahuantinsuyu como módulo Brahman.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod service;
use std::collections::BTreeSet;
use card_core::{
Card, Flow, Flows, FsPolicy, IpcPolicy, Lifecycle, Payload, Permissions, Priority, Supervision,
TypeRef, CARD_SCHEMA_VERSION,
};
use ulid::Ulid;
/// Label canónico — coincide con el binario y aparece en `ListEntes`.
pub const LABEL: &str = "brahman.cosmos_app";
/// Spawn fire-and-forget. Si el Init no está corriendo, el sidecar
/// loggea y termina; la app sigue ejecutándose standalone.
pub fn spawn_sidecar() {
card_sidecar::spawn(build_card());
}
/// Construye la Card. Expuesto público para tests + para shells que
/// quieran inspeccionar el manifiesto antes de spawnear. Anuncia el
/// path del service socket en `Card.service_socket` para que otros
/// módulos brahman, después de matchear via el broker, puedan conectar
/// directo al data plane.
pub fn build_card() -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: LABEL.into(),
service_socket: Some(service::default_service_socket()),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Widget,
priority: Priority::Normal,
permissions: Permissions {
// La app guarda su DB SQLite en disco; necesita RW filesystem.
filesystem: FsPolicy::ReadWrite,
ipc: IpcPolicy {
allow: vec!["wit-v1".into()],
},
..Default::default()
},
flow: Flows {
// Recibe peticiones de cómputo (carta natal, transit, etc.)
// serializadas como JSON. La forma exacta la define
// `cosmos_app-engine`.
input: vec![Flow {
name: "chart-request".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
// Publica el resultado de un cómputo (placements, aspectos,
// casas) también como JSON. Otras apps brahman pueden
// consumirlo para visualizar o derivar.
output: vec![Flow {
name: "chart-result".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
},
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn card_label_and_flow() {
let c = build_card();
assert_eq!(c.label, LABEL);
assert_eq!(c.flow.input.len(), 1);
assert_eq!(c.flow.output.len(), 1);
assert_eq!(c.flow.input[0].name, "chart-request");
assert_eq!(c.flow.output[0].name, "chart-result");
}
}
+244
View File
@@ -0,0 +1,244 @@
//! Service socket de Tahuantinsuyu — protocolo y server.
//!
//! La Card de Tahuantinsuyu declara desde fase 1 los flows
//! `chart-request` (input) y `chart-result` (output). Acá vive el
//! **data plane** real que los implementa: un Unix socket sobre el que
//! cualquier módulo brahman puede pedir un cómputo de carta y recibir
//! el RenderModel ya armado.
//!
//! ## Protocolo
//!
//! Frame: `u32 length` little-endian + `postcard`-serialized payload.
//! Misma forma que `brahman-handshake` para reducir sorpresas.
//!
//! ## Endpoints
//!
//! - `ComputeRequest::Natal { birth, config, offset_minutes }` →
//! `ComputeResponse::Render { render }` o `Error { message }`.
//! - `ComputeRequest::Ping` → `ComputeResponse::Pong`.
//!
//! El service no expone los overlays (transit / synastry / etc) por
//! ahora — son una pasada futura. Cubre el caso 80%: "necesito la
//! carta natal de estos datos".
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use cosmos_engine::{compose_with_options, NatalOptions, RenderModel};
use cosmos_model::{Chart, ChartId, ChartKind, ContactId, StoredBirthData, StoredChartConfig};
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tracing::{debug, error, info, warn};
/// Path canónico del service socket. Usa `XDG_RUNTIME_DIR` si está
/// (por usuario, no persistente), sino cae a `/tmp/cosmos_app.sock`.
pub fn default_service_socket() -> PathBuf {
if let Some(rt) = directories::ProjectDirs::from("net", "gioser", "cosmos_app") {
// ProjectDirs no expone runtime_dir directo en todas las
// plataformas — usamos cache_dir como fallback estable.
let mut p = rt.cache_dir().to_path_buf();
std::fs::create_dir_all(&p).ok();
p.push("service.sock");
return p;
}
PathBuf::from("/tmp/cosmos_app.sock")
}
// =====================================================================
// Tipos del protocolo
// =====================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ComputeRequest {
/// Salud del server. Usá para verificar que el sidecar está vivo.
Ping,
/// Pide el cómputo de una carta natal pura (sin overlays).
Natal {
birth: StoredBirthData,
config: StoredChartConfig,
/// Offset en minutos sobre el instante natal — útil para
/// rectificación rápida sin guardar variantes.
#[serde(default)]
offset_minutes: i64,
/// Label opcional para que el render lo lleve en su title.
#[serde(default)]
label: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ComputeResponse {
Pong,
Render { render: RenderModel },
Error { message: String },
}
// =====================================================================
// Errores
// =====================================================================
#[derive(Debug, Error)]
pub enum ServiceError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("postcard: {0}")]
Postcard(#[from] postcard::Error),
#[error("frame demasiado grande: {0} bytes")]
FrameTooLarge(u32),
#[error("connect a {path}: {source}")]
Connect {
path: PathBuf,
source: std::io::Error,
},
}
/// Cap de tamaño de frame — defensivo contra peers malformados.
const MAX_FRAME_BYTES: u32 = 1024 * 1024; // 1 MiB
// =====================================================================
// Server
// =====================================================================
/// Arranca el server async sobre `socket_path`. Cada conexión nueva
/// procesa una secuencia de Request/Response hasta que el peer cierra.
pub async fn serve(socket_path: PathBuf) -> Result<(), ServiceError> {
// Si quedó un socket viejo del run anterior, lo borramos.
let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path)?;
info!(socket = %socket_path.display(), "cosmos_app service socket arriba");
loop {
let (stream, _addr) = listener.accept().await?;
tokio::spawn(async move {
if let Err(e) = serve_connection(stream).await {
warn!(?e, "connection terminó con error");
}
});
}
}
async fn serve_connection(mut stream: UnixStream) -> Result<(), ServiceError> {
loop {
let request: ComputeRequest = match read_frame(&mut stream).await {
Ok(r) => r,
Err(ServiceError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!("peer cerró");
return Ok(());
}
Err(e) => return Err(e),
};
let response = handle(request);
write_frame(&mut stream, &response).await?;
}
}
fn handle(req: ComputeRequest) -> ComputeResponse {
match req {
ComputeRequest::Ping => ComputeResponse::Pong,
ComputeRequest::Natal {
birth,
config,
offset_minutes,
label,
} => {
let chart = Chart {
id: ChartId::new(),
contact_id: ContactId::new(),
kind: ChartKind::Natal,
label: label.unwrap_or_else(|| "Service request".into()),
birth_data: birth,
config,
related_chart_id: None,
created_at_ms: 0,
};
match compose_with_options(&chart, offset_minutes, &[], &NatalOptions::default()) {
Ok(render) => ComputeResponse::Render { render },
Err(e) => ComputeResponse::Error {
message: format!("{}", e),
},
}
}
}
}
// =====================================================================
// Client helper
// =====================================================================
/// Cliente async: abre el socket, envía un request, espera la response.
/// Cierra la conexión al volver (no reusable; útil para CLI/tests).
pub async fn request(
socket: &Path,
req: &ComputeRequest,
) -> Result<ComputeResponse, ServiceError> {
let mut stream = UnixStream::connect(socket).await.map_err(|source| {
ServiceError::Connect {
path: socket.to_path_buf(),
source,
}
})?;
write_frame(&mut stream, req).await?;
read_frame(&mut stream).await
}
// =====================================================================
// Framing
// =====================================================================
async fn write_frame<T: Serialize>(stream: &mut UnixStream, value: &T) -> Result<(), ServiceError> {
let bytes = postcard::to_allocvec(value)?;
let len = u32::try_from(bytes.len()).map_err(|_| ServiceError::FrameTooLarge(u32::MAX))?;
if len > MAX_FRAME_BYTES {
return Err(ServiceError::FrameTooLarge(len));
}
stream.write_u32_le(len).await?;
stream.write_all(&bytes).await?;
stream.flush().await?;
Ok(())
}
async fn read_frame<T: for<'de> Deserialize<'de>>(
stream: &mut UnixStream,
) -> Result<T, ServiceError> {
let len = stream.read_u32_le().await?;
if len > MAX_FRAME_BYTES {
return Err(ServiceError::FrameTooLarge(len));
}
let mut buf = vec![0u8; len as usize];
stream.read_exact(&mut buf).await?;
let value = postcard::from_bytes(&buf)?;
Ok(value)
}
// =====================================================================
// Spawn helper para uso desde el binario GUI
// =====================================================================
/// Spawn fire-and-forget: thread aparte con tokio runtime current_thread
/// corriendo el server. Si la initialización falla, loggea warn y el
/// thread termina. El binario GUI sigue funcionando standalone.
pub fn spawn_service_thread(socket_path: PathBuf) {
std::thread::Builder::new()
.name("cosmos_app-service".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
{
Ok(rt) => rt,
Err(e) => {
error!(?e, "no pude crear runtime para service thread");
return;
}
};
if let Err(e) = rt.block_on(serve(socket_path)) {
error!(?e, "service server terminó con error");
}
})
.map(|_| ())
.unwrap_or_else(|e| {
error!(?e, "no pude spawnear thread del service socket");
});
}
@@ -0,0 +1 @@
data
@@ -0,0 +1,49 @@
[package]
name = "cosmos-catalog"
version = "0.1.0-alpha.1"
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Astronomical Catalog operations"
keywords = ["astronomy", "coordinates", "celestial", "astrometry", "GAIA"]
categories = ["science"]
[[bin]]
name = "forge"
path = "src/bin/forge/main.rs"
required-features = ["cli"]
[[bin]]
name = "query-catalog"
path = "src/bin/query.rs"
required-features = ["cli"]
[dependencies]
cosmos-core.workspace = true
cosmos-time.workspace = true
cosmos-coords.workspace = true
libm.workspace = true
memmap2.workspace = true
anyhow.workspace = true
# CLI and pipeline dependencies (only needed for forge/query-healpix binaries)
clap = { workspace = true, optional = true }
flate2 = { workspace = true, optional = true }
csv = { workspace = true, optional = true }
rayon = { workspace = true, optional = true }
indicatif = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"], optional = true }
serde_json = { workspace = true, optional = true }
reqwest = { workspace = true, features = ["blocking"], optional = true }
tokio = { workspace = true, optional = true }
quick-xml = { workspace = true, optional = true }
[dev-dependencies]
tempfile.workspace = true
[features]
default = []
integration-tests = []
cli = ["dep:clap", "dep:flate2", "dep:csv", "dep:rayon", "dep:indicatif", "dep:serde", "dep:serde_json", "dep:reqwest", "dep:tokio", "dep:quick-xml"]
+146
View File
@@ -0,0 +1,146 @@
# cosmos-catalog
HEALPix-indexed star catalog combining Gaia DR3 and Hipparcos. Memory-mapped for fast cone searches.
[![Crates.io](https://img.shields.io/crates/v/cosmos-catalog)](https://crates.io/crates/cosmos-catalog)
[![Documentation](https://docs.rs/cosmos-catalog/badge.svg)](https://docs.rs/cosmos-catalog)
[![License: Apache 2.0](https://img.shields.io/crates/l/cosmos-catalog)](https://gitea.gioser.net/sergio/eternal)
## Installation
As a library:
```toml
[dependencies]
cosmos-catalog = "0.1"
```
For the CLI tools (`forge` and `query-catalog`):
```sh
cargo install cosmos-catalog --features cli
```
## Example
```rust
use eternal_catalog::query::{Catalog, cone_search, ConeSearchParams};
let catalog = Catalog::open("/path/to/catalog.bin").unwrap();
let results = cone_search(&catalog, &ConeSearchParams {
ra_deg: 83.633,
dec_deg: 22.014,
radius_deg: 0.5,
max_mag: Some(12.0),
max_results: Some(100),
epoch: None,
});
for r in &results {
println!("{} mag={:.2} dist={:.4}°", r.star.source_id, r.star.mag, r.distance_deg);
}
```
## Modules
| Module | Purpose |
|------------------|------------------------------------------------------------|
| `query::Catalog` | Memory-mapped catalog reader (mmap, zero-copy star access) |
| `query::cone` | Cone search with optional proper-motion propagation |
| `query::healpix` | HEALPix pixel math (`ang2pix_nest`, `query_disc_nest`) |
## Download a Pre-Built Catalog
You may [download the latest catalog](https://drive.google.com/drive/folders/1akV1qbERKQETLn6smW3-K0vGzVFgEqsB) from Google Drive. The pre-built catalog has a magnitude cutoff at 18.5.
## Data Sources & Attribution
The pre-built catalog is derived from:
- **Gaia DR3** — European Space Agency (ESA) mission Gaia ([gaia.esa.int](https://www.cosmos.esa.int/gaia)), processed by the Gaia Data Processing and Analysis Consortium ([DPAC](https://www.cosmos.esa.int/web/gaia/dpac/consortium)). Gaia DR3 is licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). See the [Gaia credit page](https://gaia.aip.de/cms/credit/) for full citation requirements.
- **Hipparcos** — ESA Hipparcos and Tycho Catalogues (ESA, 1997). Public domain.
If you use the pre-built catalog in published work, please cite Gaia DR3 per ESA's guidelines.
## Building the Catalog from Source
Building your own catalog from raw survey data requires significant disk space (~700 GB for raw Gaia CSVs) and time. The `forge` CLI handles the full pipeline: download, ingest, merge, and index.
### Step 1 — Download the Gaia Catalog
```sh
forge download-gaia --output /path/to/download/it/to
```
### Step 2 — Ingest Raw Gaia Catalog
See `forge ingest-gaia --help` for all flags.
```sh
forge ingest-gaia \
--path /path/to/gaia/dir/ \
--output /path/to/output/dir/ \
--mag-limit 16
```
Outputs `gaia_ingest.bin`.
#### Choosing a Magnitude Limit
Most applications don't need stars fainter than ~18.5. A this cutoff keeps the final HEALPix binary around 1.5 GB. Going deeper balloons quickly.
### Step 3 — Ingest Hipparcos Catalog
Hipparcos epochs are propagated to J2016.0 to match Gaia DR3.
```sh
forge ingest-hipparcos \
--hip /path/to/hip_main.dat \
--crossmatch /path/to/Hipparcos2BestNeighbour.csv \
--output /path/to/output
```
Outputs `hipparcos_ingest.bin`.
### Step 4 — Merge Catalogs
```sh
forge merge --verbose --workdir /path/to/working/dir
```
Outputs `merged.bin`.
### Step 5 — Build the HEALPix Index
```sh
forge build-index \
--workdir /path/that/contains/both/bin/files \
--threads 16 \
--output ./catalog.20260217.bin \
--max-per-cell 40
```
The `--max-per-cell` flag caps the number of stars per HEALPix pixel, dropping faint stars first. This trims dense regions along the galactic plane.
## Features
- **`cli`** — Enables the `forge` and `query-catalog` binaries (adds clap, rayon, reqwest, etc.)
## License
Licensed under the Apache License, Version 2.0
([LICENSE-APACHE](../LICENSE-APACHE) or
<https://www.apache.org/licenses/LICENSE-2.0>).
See [NOTICE](../NOTICE) for upstream attribution.
## Acknowledgements
Forked from [celestial](https://github.com/gaker/celestial) by **Greg Aker**
(originally dual-licensed under MIT OR Apache-2.0). This crate is derived
directly from that work and is maintained in this fork by Sergio Velásquez
Zeballos with Claude (Anthropic).
## Contributing
See the [repository](https://gitea.gioser.net/sergio/eternal) for contribution guidelines.
@@ -0,0 +1,37 @@
use cosmos_catalog::query::{cone_search, Catalog, ConeSearchParams};
fn main() -> anyhow::Result<()> {
let path = std::env::args()
.nth(1)
.expect("Usage: cone_search <catalog.bin>");
let catalog = Catalog::open(&path)?;
println!("{}", catalog.header());
let params = ConeSearchParams {
ra_deg: 83.633,
dec_deg: -5.375,
radius_deg: 0.5,
max_mag: Some(10.0),
max_results: Some(20),
epoch: None,
};
let results = cone_search(&catalog, &params);
println!(
"\n{} stars within {:.1}° of ({:.3}, {:.3}):\n",
results.len(),
params.radius_deg,
params.ra_deg,
params.dec_deg,
);
for r in &results {
println!(
" {:>20} RA {:.6}° Dec {:+.6}° mag {:.2} dist {:.4}°",
r.star.source_id, r.ra_deg, r.dec_deg, r.star.mag, r.distance_deg,
);
}
Ok(())
}
@@ -0,0 +1,590 @@
//! Build HEALPix-indexed binary catalog from merged catalog.
//!
//! Three-pass memory-mapped algorithm for handling arbitrarily large catalogs:
//! 1. Count stars per HEALPix pixel
//! 2. Scatter stars to their final positions in output file
//! 3. Sort each pixel's stars by magnitude in-place
use crate::cli::{BuildIndexArgs, Cli};
use cosmos_catalog::query::healpix::ang2pix_nest;
use memmap2::{Mmap, MmapMut};
use std::fs::{self, File, OpenOptions};
use std::io::Read;
use std::path::Path;
use std::time::Instant;
const RECORD_SIZE: usize = 56;
const MERGED_HEADER_SIZE: usize = 24;
const OUTPUT_HEADER_SIZE: usize = 64;
const PIXEL_ENTRY_SIZE: usize = 16;
const CATALOG_MAGIC: &[u8; 4] = b"CCAT";
const CATALOG_VERSION: u32 = 1;
const EPOCH_J2016: f64 = 2016.0;
struct IndexStats {
healpix_order: u32,
nside: u32,
npix: u64,
total_stars: u64,
stars_after_cap: Option<u64>,
cells_capped: Option<u64>,
min_stars: u32,
max_stars: u32,
mean_stars: f64,
median_stars: u32,
empty_pixels: u64,
file_size: u64,
elapsed_secs: f64,
}
pub fn run(args: &BuildIndexArgs, cli: &Cli) -> anyhow::Result<()> {
validate_paths(args)?;
print_plan(args, cli);
let start = Instant::now();
let merged_path = args.workdir.join("merged.bin");
let (total_stars, mag_limit) = read_merged_header(&merged_path)?;
let nside = 1u32 << args.healpix_order;
let npix = 12u64 * (nside as u64) * (nside as u64);
println!("Memory-mapping input file ({} stars)...", total_stars);
let input_mmap = mmap_input(&merged_path)?;
println!("Pass 1: Counting stars per pixel...");
let counts = count_stars_per_pixel_mmap(&input_mmap, total_stars, args.healpix_order)?;
println!("Pass 2: Scattering stars to output positions...");
let temp_path = args.output.with_extension("bin.tmp");
scatter_stars_to_output(
&input_mmap,
&temp_path,
total_stars,
args.healpix_order,
mag_limit,
&counts,
)?;
drop(input_mmap);
println!("Pass 3: Sorting each pixel by magnitude...");
sort_pixels_in_place(&temp_path, &counts)?;
let final_counts = match args.max_per_cell {
Some(cap) => {
println!("Pass 4: Compacting to {} max stars per cell...", cap);
compact_with_cap(
&temp_path,
&args.output,
&counts,
cap,
mag_limit,
args.healpix_order,
)?;
fs::remove_file(&temp_path)?;
counts.iter().map(|&c| c.min(cap)).collect::<Vec<u32>>()
}
None => {
fs::rename(&temp_path, &args.output)?;
counts.clone()
}
};
let elapsed = start.elapsed().as_secs_f64();
let stats = compute_stats(
args.healpix_order,
nside,
npix,
total_stars,
args.max_per_cell,
&final_counts,
&args.output,
elapsed,
)?;
print_stats(&stats);
println!("\nValidating output...");
validate_output(&args.output, args.healpix_order)?;
Ok(())
}
fn validate_paths(args: &BuildIndexArgs) -> anyhow::Result<()> {
let merged = args.workdir.join("merged.bin");
if !merged.exists() {
anyhow::bail!("Merged catalog not found: {:?}", merged);
}
if let Some(parent) = args.output.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
Ok(())
}
fn print_plan(args: &BuildIndexArgs, cli: &Cli) {
let nside = 1u32 << args.healpix_order;
let npix = 12u64 * (nside as u64) * (nside as u64);
println!("=== Build HEALPix Index ===");
println!("Working directory: {:?}", args.workdir);
println!("HEALPix order: {}", args.healpix_order);
println!("nside: {}", nside);
println!("npix: {}", npix);
println!("Output: {:?}", args.output);
match args.max_per_cell {
Some(cap) => println!("Max per cell: {}", cap),
None => println!("Max per cell: unlimited"),
}
println!("Verbose: {}", cli.verbose);
println!();
}
fn read_merged_header(path: &Path) -> anyhow::Result<(u64, f32)> {
let mut file = File::open(path)?;
let mut header = [0u8; MERGED_HEADER_SIZE];
file.read_exact(&mut header)?;
let magic = &header[0..4];
if magic != b"MERG" {
anyhow::bail!("Invalid merged catalog magic: {:?}", magic);
}
let total_stars = u64::from_le_bytes(header[8..16].try_into().unwrap());
let mag_limit = f32::from_le_bytes(header[16..20].try_into().unwrap());
Ok((total_stars, mag_limit))
}
fn mmap_input(path: &Path) -> anyhow::Result<Mmap> {
let file = File::open(path)?;
let mmap = unsafe { Mmap::map(&file)? };
Ok(mmap)
}
fn count_stars_per_pixel_mmap(mmap: &Mmap, total: u64, order: u32) -> anyhow::Result<Vec<u32>> {
let nside = 1u32 << order;
let npix = 12u64 * (nside as u64) * (nside as u64);
let mut counts = vec![0u32; npix as usize];
let data = &mmap[MERGED_HEADER_SIZE..];
for i in 0..total as usize {
let offset = i * RECORD_SIZE;
let (ra_deg, dec_deg) = extract_ra_dec_from_slice(&data[offset..offset + RECORD_SIZE]);
let pixel = ang2pix_nest(order, ra_deg, dec_deg);
counts[pixel as usize] += 1;
}
Ok(counts)
}
fn extract_ra_dec_from_slice(buf: &[u8]) -> (f64, f64) {
let ra = f64::from_le_bytes(buf[8..16].try_into().unwrap());
let dec = f64::from_le_bytes(buf[16..24].try_into().unwrap());
(ra, dec)
}
fn scatter_stars_to_output(
input_mmap: &Mmap,
output_path: &Path,
total_stars: u64,
order: u32,
mag_limit: f32,
counts: &[u32],
) -> anyhow::Result<()> {
let nside = 1u32 << order;
let npix = counts.len() as u64;
let star_data_offset = OUTPUT_HEADER_SIZE + (npix as usize) * PIXEL_ENTRY_SIZE;
let total_file_size = star_data_offset + (total_stars as usize) * RECORD_SIZE;
let file = create_output_file(output_path, total_file_size)?;
let mut output_mmap = unsafe { MmapMut::map_mut(&file)? };
write_header_to_mmap(&mut output_mmap, order, nside, npix, total_stars, mag_limit);
let byte_offsets = write_offset_table_to_mmap(&mut output_mmap, counts);
let mut cursors: Vec<u64> = byte_offsets.clone();
let input_data = &input_mmap[MERGED_HEADER_SIZE..];
for i in 0..total_stars as usize {
let src_offset = i * RECORD_SIZE;
let record_bytes = &input_data[src_offset..src_offset + RECORD_SIZE];
let (ra_deg, dec_deg) = extract_ra_dec_from_slice(record_bytes);
let pixel = ang2pix_nest(order, ra_deg, dec_deg) as usize;
let dst_offset = star_data_offset + cursors[pixel] as usize;
output_mmap[dst_offset..dst_offset + RECORD_SIZE].copy_from_slice(record_bytes);
cursors[pixel] += RECORD_SIZE as u64;
}
output_mmap.flush()?;
Ok(())
}
fn create_output_file(path: &Path, size: usize) -> anyhow::Result<File> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
file.set_len(size as u64)?;
Ok(file)
}
fn write_header_to_mmap(
mmap: &mut MmapMut,
order: u32,
nside: u32,
npix: u64,
total_stars: u64,
mag_limit: f32,
) {
let mut offset = 0;
mmap[offset..offset + 4].copy_from_slice(CATALOG_MAGIC);
offset += 4;
mmap[offset..offset + 4].copy_from_slice(&CATALOG_VERSION.to_le_bytes());
offset += 4;
mmap[offset..offset + 4].copy_from_slice(&order.to_le_bytes());
offset += 4;
mmap[offset..offset + 4].copy_from_slice(&nside.to_le_bytes());
offset += 4;
mmap[offset..offset + 8].copy_from_slice(&npix.to_le_bytes());
offset += 8;
mmap[offset..offset + 8].copy_from_slice(&total_stars.to_le_bytes());
offset += 8;
mmap[offset..offset + 8].copy_from_slice(&EPOCH_J2016.to_le_bytes());
offset += 8;
mmap[offset..offset + 4].copy_from_slice(&mag_limit.to_le_bytes());
}
fn write_offset_table_to_mmap(mmap: &mut MmapMut, counts: &[u32]) -> Vec<u64> {
let mut byte_offsets = Vec::with_capacity(counts.len());
let mut current_byte_offset: u64 = 0;
for (i, &count) in counts.iter().enumerate() {
let table_offset = OUTPUT_HEADER_SIZE + i * PIXEL_ENTRY_SIZE;
mmap[table_offset..table_offset + 8].copy_from_slice(&current_byte_offset.to_le_bytes());
mmap[table_offset + 8..table_offset + 12].copy_from_slice(&count.to_le_bytes());
mmap[table_offset + 12..table_offset + 16].copy_from_slice(&0u32.to_le_bytes());
byte_offsets.push(current_byte_offset);
current_byte_offset += (count as u64) * (RECORD_SIZE as u64);
}
byte_offsets
}
fn sort_pixels_in_place(path: &Path, counts: &[u32]) -> anyhow::Result<()> {
let file = OpenOptions::new().read(true).write(true).open(path)?;
let mut mmap = unsafe { MmapMut::map_mut(&file)? };
let npix = counts.len();
let star_data_offset = OUTPUT_HEADER_SIZE + npix * PIXEL_ENTRY_SIZE;
let mut current_offset = star_data_offset;
for &count in counts {
if count > 1 {
sort_pixel_region(&mut mmap, current_offset, count as usize);
}
current_offset += (count as usize) * RECORD_SIZE;
}
mmap.flush()?;
Ok(())
}
fn sort_pixel_region(mmap: &mut MmapMut, offset: usize, count: usize) {
let region = &mut mmap[offset..offset + count * RECORD_SIZE];
let records: &mut [[u8; RECORD_SIZE]] = unsafe {
std::slice::from_raw_parts_mut(region.as_mut_ptr() as *mut [u8; RECORD_SIZE], count)
};
records.sort_by(|a, b| {
let mag_a = f32::from_le_bytes(a[48..52].try_into().unwrap());
let mag_b = f32::from_le_bytes(b[48..52].try_into().unwrap());
mag_a
.partial_cmp(&mag_b)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
fn compact_with_cap(
sorted_path: &Path,
output_path: &Path,
counts: &[u32],
cap: u32,
mag_limit: f32,
order: u32,
) -> anyhow::Result<()> {
let capped: Vec<u32> = counts.iter().map(|&c| c.min(cap)).collect();
let capped_total: u64 = capped.iter().map(|&c| c as u64).sum();
let npix = counts.len() as u64;
let nside = 1u32 << order;
let star_data_offset = OUTPUT_HEADER_SIZE + (npix as usize) * PIXEL_ENTRY_SIZE;
let out_size = star_data_offset + (capped_total as usize) * RECORD_SIZE;
let src_file = File::open(sorted_path)?;
let src_mmap = unsafe { Mmap::map(&src_file)? };
let dst_file = create_output_file(output_path, out_size)?;
let mut dst_mmap = unsafe { MmapMut::map_mut(&dst_file)? };
write_header_to_mmap(&mut dst_mmap, order, nside, npix, capped_total, mag_limit);
write_offset_table_to_mmap(&mut dst_mmap, &capped);
copy_capped_records(&src_mmap, &mut dst_mmap, counts, &capped, star_data_offset);
dst_mmap.flush()?;
Ok(())
}
fn copy_capped_records(
src: &Mmap,
dst: &mut MmapMut,
counts: &[u32],
capped: &[u32],
star_data_offset: usize,
) {
let src_star_offset = OUTPUT_HEADER_SIZE + counts.len() * PIXEL_ENTRY_SIZE;
let mut src_pos = src_star_offset;
let mut dst_pos = star_data_offset;
for (i, &orig) in counts.iter().enumerate() {
let keep = capped[i] as usize;
let copy_bytes = keep * RECORD_SIZE;
dst[dst_pos..dst_pos + copy_bytes].copy_from_slice(&src[src_pos..src_pos + copy_bytes]);
src_pos += (orig as usize) * RECORD_SIZE;
dst_pos += copy_bytes;
}
}
fn compute_stats(
order: u32,
nside: u32,
npix: u64,
total_stars_before: u64,
max_per_cell: Option<u32>,
final_counts: &[u32],
output: &Path,
elapsed: f64,
) -> anyhow::Result<IndexStats> {
let total_after: u64 = final_counts.iter().map(|&c| c as u64).sum();
let non_empty: Vec<u32> = final_counts.iter().copied().filter(|&c| c > 0).collect();
let empty_pixels = npix - non_empty.len() as u64;
let (min_stars, max_stars) = if non_empty.is_empty() {
(0, 0)
} else {
(
*non_empty.iter().min().unwrap(),
*non_empty.iter().max().unwrap(),
)
};
let mean_stars = if non_empty.is_empty() {
0.0
} else {
non_empty.iter().map(|&c| c as f64).sum::<f64>() / non_empty.len() as f64
};
let median_stars = compute_median(&non_empty);
let file_size = fs::metadata(output)?.len();
let (stars_after_cap, cells_capped) = match max_per_cell {
Some(cap) => {
let capped = final_counts.iter().filter(|&&c| c >= cap).count() as u64;
(Some(total_after), Some(capped))
}
None => (None, None),
};
Ok(IndexStats {
healpix_order: order,
nside,
npix,
total_stars: total_stars_before,
stars_after_cap,
cells_capped,
min_stars,
max_stars,
mean_stars,
median_stars,
empty_pixels,
file_size,
elapsed_secs: elapsed,
})
}
fn compute_median(values: &[u32]) -> u32 {
if values.is_empty() {
return 0;
}
let mut sorted = values.to_vec();
sorted.sort();
let mid = sorted.len() / 2;
if sorted.len().is_multiple_of(2) {
(sorted[mid - 1] + sorted[mid]) / 2
} else {
sorted[mid]
}
}
fn print_stats(stats: &IndexStats) {
println!();
println!("=== Index Statistics ===");
println!("HEALPix order: {}", stats.healpix_order);
println!("nside: {}", stats.nside);
println!("npix: {}", stats.npix);
println!("Total stars before cap: {}", stats.total_stars);
if let (Some(after), Some(capped)) = (stats.stars_after_cap, stats.cells_capped) {
println!("Total stars after cap: {}", after);
println!(
"Stars removed by cap: {}",
stats.total_stars.saturating_sub(after)
);
let non_empty = stats.npix - stats.empty_pixels;
println!(
"Cells at cap: {} / {} non-empty ({:.1}%)",
capped,
non_empty,
if non_empty > 0 {
capped as f64 / non_empty as f64 * 100.0
} else {
0.0
}
);
}
println!("Empty pixels: {}", stats.empty_pixels);
println!(
"Stars per pixel (non-empty): min={}, max={}, mean={:.1}, median={}",
stats.min_stars, stats.max_stars, stats.mean_stars, stats.median_stars
);
println!(
"Output file size: {} bytes ({:.2} GB)",
stats.file_size,
stats.file_size as f64 / 1_073_741_824.0
);
println!("Elapsed time: {:.2}s", stats.elapsed_secs);
}
fn validate_output(path: &Path, order: u32) -> anyhow::Result<()> {
let file = File::open(path)?;
let mmap = unsafe { Mmap::map(&file)? };
let header = read_output_header(&mmap)?;
validate_header_fields(&header, order)?;
let offsets = read_offset_table(&mmap, header.npix)?;
validate_sample_pixels(&mmap, order, &header, &offsets)?;
println!("Validation passed.");
Ok(())
}
struct OutputHeader {
npix: u64,
_total_stars: u64,
}
fn read_output_header(mmap: &Mmap) -> anyhow::Result<OutputHeader> {
let header = &mmap[0..OUTPUT_HEADER_SIZE];
let magic = &header[0..4];
if magic != CATALOG_MAGIC {
anyhow::bail!("Invalid catalog magic");
}
let npix = u64::from_le_bytes(header[16..24].try_into().unwrap());
let total_stars = u64::from_le_bytes(header[24..32].try_into().unwrap());
Ok(OutputHeader {
npix,
_total_stars: total_stars,
})
}
fn validate_header_fields(header: &OutputHeader, order: u32) -> anyhow::Result<()> {
let expected_npix = 12u64 * (1u64 << order) * (1u64 << order);
if header.npix != expected_npix {
anyhow::bail!(
"npix mismatch: expected {}, got {}",
expected_npix,
header.npix
);
}
Ok(())
}
struct PixelEntry {
offset: u64,
count: u32,
}
fn read_offset_table(mmap: &Mmap, npix: u64) -> anyhow::Result<Vec<PixelEntry>> {
let mut entries = Vec::with_capacity(npix as usize);
for i in 0..npix as usize {
let table_offset = OUTPUT_HEADER_SIZE + i * PIXEL_ENTRY_SIZE;
let offset = u64::from_le_bytes(mmap[table_offset..table_offset + 8].try_into().unwrap());
let count = u32::from_le_bytes(
mmap[table_offset + 8..table_offset + 12]
.try_into()
.unwrap(),
);
entries.push(PixelEntry { offset, count });
}
Ok(entries)
}
fn validate_sample_pixels(
mmap: &Mmap,
order: u32,
header: &OutputHeader,
offsets: &[PixelEntry],
) -> anyhow::Result<()> {
let star_data_offset = OUTPUT_HEADER_SIZE + (header.npix as usize) * PIXEL_ENTRY_SIZE;
let samples = pick_sample_pixels(offsets);
for (pixel_idx, entry) in samples {
validate_single_pixel(mmap, order, pixel_idx, entry, star_data_offset)?;
}
Ok(())
}
fn pick_sample_pixels(offsets: &[PixelEntry]) -> Vec<(usize, &PixelEntry)> {
let non_empty: Vec<(usize, &PixelEntry)> = offsets
.iter()
.enumerate()
.filter(|(_, e)| e.count > 0)
.collect();
if non_empty.is_empty() {
return vec![];
}
let mut samples = Vec::new();
if !non_empty.is_empty() {
samples.push(non_empty[0]);
}
if non_empty.len() > 1 {
samples.push(non_empty[non_empty.len() / 2]);
}
if non_empty.len() > 2 {
samples.push(non_empty[non_empty.len() - 1]);
}
samples
}
fn validate_single_pixel(
mmap: &Mmap,
order: u32,
pixel_idx: usize,
entry: &PixelEntry,
star_data_offset: usize,
) -> anyhow::Result<()> {
let pixel_start = star_data_offset + entry.offset as usize;
let mut prev_mag: Option<f32> = None;
for i in 0..entry.count {
let record_offset = pixel_start + (i as usize) * RECORD_SIZE;
let record_bytes = &mmap[record_offset..record_offset + RECORD_SIZE];
let ra = f64::from_le_bytes(record_bytes[8..16].try_into().unwrap());
let dec = f64::from_le_bytes(record_bytes[16..24].try_into().unwrap());
let mag = f32::from_le_bytes(record_bytes[48..52].try_into().unwrap());
let computed_pixel = ang2pix_nest(order, ra, dec);
if computed_pixel != pixel_idx as u64 {
anyhow::bail!(
"Pixel mismatch: star at ({:.6}, {:.6}) expected pixel {}, got {}",
ra,
dec,
pixel_idx,
computed_pixel
);
}
if let Some(prev) = prev_mag {
if mag < prev {
anyhow::bail!(
"Magnitude not sorted in pixel {}: star {} has mag {:.2}, prev was {:.2}",
pixel_idx,
i,
mag,
prev
);
}
}
prev_mag = Some(mag);
}
println!(" Pixel {}: {} stars verified", pixel_idx, entry.count);
Ok(())
}
@@ -0,0 +1,119 @@
//! CLI argument definitions for forge
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "forge")]
#[command(about = "Astronomical catalog data pipeline")]
#[command(version)]
pub struct Cli {
/// Enable verbose output
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Download Gaia DR3 source files from ESA CDN
DownloadGaia(DownloadGaiaArgs),
/// Ingest Gaia DR3 catalog from gzipped CSV files
IngestGaia(IngestGaiaArgs),
/// Ingest Hipparcos catalog with epoch propagation to J2016.0
IngestHipparcos(IngestHipparcosArgs),
/// Merge ingested catalogs with cross-match deduplication
Merge(MergeArgs),
/// Build HEALPix-indexed binary catalog
BuildIndex(BuildIndexArgs),
}
#[derive(Parser)]
pub struct DownloadGaiaArgs {
/// Output directory for downloaded .csv.gz files
#[arg(long)]
pub output: PathBuf,
/// Maximum concurrent downloads
#[arg(long, default_value = "4")]
pub concurrency: usize,
/// Download only the first N files (for testing)
#[arg(long)]
pub limit: Option<usize>,
/// Retry failed downloads up to N times
#[arg(long, default_value = "3")]
pub retries: u32,
}
#[derive(Parser)]
pub struct IngestGaiaArgs {
/// Directory containing gzipped Gaia CSV files
#[arg(long)]
pub path: PathBuf,
/// Magnitude limit (keep stars brighter than this)
#[arg(long, default_value = "15.0")]
pub mag_limit: f32,
/// Output working directory for intermediate files
#[arg(long)]
pub output: PathBuf,
/// Skip final concatenation (for incremental ingestion)
#[arg(long)]
pub no_concat: bool,
/// Number of threads for parallel processing (0 = all cores)
#[arg(short, long, default_value = "0")]
pub threads: usize,
}
#[derive(Parser)]
pub struct IngestHipparcosArgs {
/// Working directory for source data (hip2.dat, crossmatch CSV).
/// Files are downloaded automatically if not present.
#[arg(long)]
pub workdir: PathBuf,
/// Magnitude limit (keep stars brighter than this)
#[arg(long, default_value = "7.0")]
pub mag_limit: f32,
/// Output working directory for ingested binary
#[arg(long)]
pub output: PathBuf,
}
#[derive(Parser)]
pub struct MergeArgs {
/// Working directory containing ingested catalogs
#[arg(long)]
pub workdir: PathBuf,
}
#[derive(Parser)]
pub struct BuildIndexArgs {
/// Working directory containing merged catalog
#[arg(long)]
pub workdir: PathBuf,
/// HEALPix order (nside = 2^order)
#[arg(long, default_value = "8")]
pub healpix_order: u32,
/// Output binary catalog file
#[arg(long)]
pub output: PathBuf,
/// Maximum stars per HEALPix cell (brightest kept, rest discarded)
#[arg(long)]
pub max_per_cell: Option<u32>,
}
@@ -0,0 +1,392 @@
//! Download Gaia DR3 source files from ESA CDN
//!
//! Lists the S3 bucket, builds a manifest with ETags,
//! and downloads files with resume support and parallel fetching.
use crate::cli::{Cli, DownloadGaiaArgs};
use anyhow::Context;
use quick_xml::events::Event;
use quick_xml::Reader;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
const LISTING_URL: &str =
"https://gaia.eu-1.cdn77-storage.com/?prefix=Gaia/gdr3/gaia_source/&delimiter=/";
const CDN_BASE: &str = "https://cdn.gea.esac.esa.int/";
const MANIFEST_FILENAME: &str = "gaia_manifest.json";
#[derive(Debug, Serialize, Deserialize)]
struct Manifest {
files: HashMap<String, FileEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
struct FileEntry {
etag: String,
size: u64,
downloaded: bool,
}
struct RemoteFile {
key: String,
filename: String,
size: u64,
etag: String,
}
pub fn run(args: &DownloadGaiaArgs, cli: &Cli) -> anyhow::Result<()> {
fs::create_dir_all(&args.output)?;
println!("=== Gaia DR3 Download ===");
println!("Output: {:?}", args.output);
println!("Concurrency: {}", args.concurrency);
if let Some(limit) = args.limit {
println!("Limit: {} files", limit);
}
println!();
println!("Listing files from ESA CDN...");
let mut remote_files = list_remote_files(cli.verbose)?;
remote_files.sort_by(|a, b| a.filename.cmp(&b.filename));
if let Some(limit) = args.limit {
remote_files.truncate(limit);
}
let total_size: u64 = remote_files.iter().map(|f| f.size).sum();
println!(
"Found {} files ({:.1} GB total)",
remote_files.len(),
total_size as f64 / 1_073_741_824.0
);
let manifest_path = args.output.join(MANIFEST_FILENAME);
let mut manifest = load_manifest(&manifest_path);
let to_download = plan_downloads(&remote_files, &manifest, &args.output);
if to_download.is_empty() {
println!("All files already downloaded and verified.");
return Ok(());
}
let skip_count = remote_files.len() - to_download.len();
let dl_size: u64 = to_download.iter().map(|f| f.size).sum();
println!(
"Skipping {} already downloaded, {} to download ({:.1} GB)",
skip_count,
to_download.len(),
dl_size as f64 / 1_073_741_824.0
);
println!();
let completed = Arc::new(AtomicUsize::new(0));
let bytes_done = Arc::new(AtomicU64::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let total_count = to_download.len();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(args.concurrency)
.build()
.context("Failed to build thread pool")?;
pool.scope(|s| {
for file in &to_download {
let output = &args.output;
let retries = args.retries;
let completed = Arc::clone(&completed);
let bytes_done = Arc::clone(&bytes_done);
let failed = Arc::clone(&failed);
s.spawn(move |_| {
let dest = output.join(&file.filename);
match download_with_retry(&file.key, &dest, file.size, retries) {
Ok(etag) => {
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
let bytes = bytes_done.fetch_add(file.size, Ordering::Relaxed) + file.size;
println!(
"[{}/{}] {} ({:.1} MB) - {:.1} GB done",
done,
total_count,
file.filename,
file.size as f64 / 1_048_576.0,
bytes as f64 / 1_073_741_824.0,
);
let _ = etag; // used when saving manifest below
}
Err(e) => {
failed.fetch_add(1, Ordering::Relaxed);
eprintln!("FAILED {}: {}", file.filename, e);
}
}
});
}
});
update_manifest(&mut manifest, &remote_files, &args.output);
save_manifest(&manifest, &manifest_path)?;
let fail_count = failed.load(Ordering::Relaxed);
let ok_count = completed.load(Ordering::Relaxed);
println!("\n=== Summary ===");
println!("Downloaded: {}", ok_count);
println!("Skipped: {}", skip_count);
println!("Failed: {}", fail_count);
if fail_count > 0 {
anyhow::bail!("{} downloads failed. Re-run to retry.", fail_count);
}
Ok(())
}
fn list_remote_files(verbose: bool) -> anyhow::Result<Vec<RemoteFile>> {
let mut files = Vec::new();
let mut marker: Option<String> = None;
loop {
let url = match &marker {
Some(m) => format!("{}&marker={}", LISTING_URL, m),
None => LISTING_URL.to_string(),
};
if verbose {
eprintln!("Listing: {}", url);
}
let body = reqwest::blocking::get(&url)
.context("Failed to fetch bucket listing")?
.text()
.context("Failed to read listing response")?;
let (batch, next_marker) = parse_listing(&body)?;
let batch_len = batch.len();
files.extend(batch);
if verbose {
eprintln!(" Got {} keys (total: {})", batch_len, files.len());
}
match next_marker {
Some(m) => marker = Some(m),
None => break,
}
}
Ok(files)
}
fn parse_listing(xml: &str) -> anyhow::Result<(Vec<RemoteFile>, Option<String>)> {
let mut reader = Reader::from_str(xml);
let mut files = Vec::new();
let mut next_marker: Option<String> = None;
let mut buf = String::new();
let mut in_contents = false;
let mut in_key = false;
let mut in_size = false;
let mut in_etag = false;
let mut in_next_marker = false;
let mut cur_key = String::new();
let mut cur_size = 0u64;
let mut cur_etag = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
match name.as_str() {
"Contents" => {
in_contents = true;
cur_key.clear();
cur_size = 0;
cur_etag.clear();
}
"Key" if in_contents => in_key = true,
"Size" if in_contents => in_size = true,
"ETag" if in_contents => in_etag = true,
"NextMarker" => in_next_marker = true,
_ => {}
}
}
Ok(Event::Text(e)) => {
buf.clear();
buf.push_str(&e.unescape().unwrap_or_default());
if in_key {
cur_key.push_str(&buf);
} else if in_size {
cur_size = buf.trim().parse().unwrap_or(0);
} else if in_etag {
cur_etag.push_str(buf.trim().trim_matches('"'));
} else if in_next_marker {
next_marker = Some(buf.trim().to_string());
}
}
Ok(Event::End(e)) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
match name.as_str() {
"Contents" => {
in_contents = false;
if cur_key.ends_with(".csv.gz") {
let filename = extract_filename(&cur_key);
files.push(RemoteFile {
key: cur_key.clone(),
filename,
size: cur_size,
etag: cur_etag.clone(),
});
}
}
"Key" => in_key = false,
"Size" => in_size = false,
"ETag" => in_etag = false,
"NextMarker" => in_next_marker = false,
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => anyhow::bail!("XML parse error: {}", e),
_ => {}
}
}
Ok((files, next_marker))
}
fn extract_filename(key: &str) -> String {
key.rsplit('/').next().unwrap_or(key).to_string()
}
fn load_manifest(path: &Path) -> Manifest {
match fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap_or(Manifest {
files: HashMap::new(),
}),
Err(_) => Manifest {
files: HashMap::new(),
},
}
}
fn save_manifest(manifest: &Manifest, path: &Path) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(manifest)?;
fs::write(path, json)?;
Ok(())
}
fn plan_downloads<'a>(
remote: &'a [RemoteFile],
manifest: &Manifest,
output_dir: &Path,
) -> Vec<&'a RemoteFile> {
remote
.iter()
.filter(|f| !is_already_good(f, manifest, output_dir))
.collect()
}
fn is_already_good(file: &RemoteFile, manifest: &Manifest, output_dir: &Path) -> bool {
let local_path = output_dir.join(&file.filename);
let meta = match fs::metadata(&local_path) {
Ok(m) => m,
Err(_) => return false,
};
if meta.len() != file.size {
return false;
}
if let Some(entry) = manifest.files.get(&file.filename) {
return entry.etag == file.etag && entry.size == file.size && entry.downloaded;
}
false
}
fn download_with_retry(
key: &str,
dest: &Path,
expected_size: u64,
max_retries: u32,
) -> anyhow::Result<String> {
let url = format!("{}{}", CDN_BASE, key);
let mut last_err = None;
for attempt in 0..=max_retries {
if attempt > 0 {
eprintln!(" Retry {}/{} for {}", attempt, max_retries, key);
}
match download_file(&url, dest, expected_size) {
Ok(etag) => return Ok(etag),
Err(e) => {
last_err = Some(e);
if dest.exists() {
let _ = fs::remove_file(dest);
}
}
}
}
Err(last_err.unwrap())
}
fn download_file(url: &str, dest: &Path, expected_size: u64) -> anyhow::Result<String> {
let response =
reqwest::blocking::get(url).with_context(|| format!("Failed to connect: {}", url))?;
if !response.status().is_success() {
anyhow::bail!("HTTP {}", response.status());
}
let etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim_matches('"').to_string())
.unwrap_or_default();
let tmp_path = dest.with_extension("csv.gz.tmp");
let mut file =
File::create(&tmp_path).with_context(|| format!("Failed to create {:?}", tmp_path))?;
let bytes = response.bytes().context("Failed to read body")?;
file.write_all(&bytes)?;
file.flush()?;
drop(file);
let written = fs::metadata(&tmp_path)?.len();
if written != expected_size {
let _ = fs::remove_file(&tmp_path);
anyhow::bail!(
"Size mismatch: expected {} got {} for {}",
expected_size,
written,
url
);
}
fs::rename(&tmp_path, dest)
.with_context(|| format!("Failed to rename {:?} -> {:?}", tmp_path, dest))?;
Ok(etag)
}
fn update_manifest(manifest: &mut Manifest, remote: &[RemoteFile], output_dir: &Path) {
for file in remote {
let local_path = output_dir.join(&file.filename);
let ok = match fs::metadata(&local_path) {
Ok(m) => m.len() == file.size,
Err(_) => false,
};
if ok {
manifest.files.insert(
file.filename.clone(),
FileEntry {
etag: file.etag.clone(),
size: file.size,
downloaded: true,
},
);
}
}
}
@@ -0,0 +1,216 @@
//! Gaia DR3 ECSV parser
//!
//! Handles gzipped ECSV format with `#` metadata lines.
use std::collections::HashMap;
use std::io::BufRead;
pub struct GaiaStarRaw {
pub source_id: i64,
pub ra: f64,
pub dec: f64,
pub pmra: f64,
pub pmdec: f64,
pub parallax: f64,
pub mag: f32,
pub flags: u16,
}
pub const FLAG_HAS_PROPER_MOTION: u16 = 1 << 0;
pub const FLAG_HAS_PARALLAX: u16 = 1 << 1;
pub const FLAG_RUWE_SUSPECT: u16 = 1 << 2;
pub const FLAG_NO_5PARAM: u16 = 1 << 3;
pub const FLAG_BP_RP_EXCESS_SUSPECT: u16 = 1 << 4;
pub const FLAG_SOURCE_HIPPARCOS: u16 = 1 << 5;
struct ColumnIndices {
source_id: usize,
ra: usize,
dec: usize,
pmra: usize,
pmdec: usize,
parallax: usize,
phot_g_mean_mag: usize,
ruwe: usize,
astrometric_params_solved: usize,
duplicated_source: usize,
phot_bp_rp_excess_factor: usize,
}
pub struct GaiaParser<R: BufRead> {
reader: R,
indices: ColumnIndices,
mag_limit: f32,
line_buf: String,
}
impl<R: BufRead> GaiaParser<R> {
pub fn new(mut reader: R, mag_limit: f32) -> anyhow::Result<Self> {
let indices = Self::parse_header(&mut reader)?;
Ok(Self {
reader,
indices,
mag_limit,
line_buf: String::with_capacity(4096),
})
}
fn parse_header(reader: &mut R) -> anyhow::Result<ColumnIndices> {
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
anyhow::bail!("EOF before finding header");
}
if !line.starts_with('#') {
break;
}
}
Self::build_column_indices(&line)
}
fn build_column_indices(header_line: &str) -> anyhow::Result<ColumnIndices> {
let mut col_map: HashMap<&str, usize> = HashMap::new();
for (idx, col) in header_line.trim().split(',').enumerate() {
col_map.insert(col, idx);
}
Ok(ColumnIndices {
source_id: Self::require_column(&col_map, "source_id")?,
ra: Self::require_column(&col_map, "ra")?,
dec: Self::require_column(&col_map, "dec")?,
pmra: Self::require_column(&col_map, "pmra")?,
pmdec: Self::require_column(&col_map, "pmdec")?,
parallax: Self::require_column(&col_map, "parallax")?,
phot_g_mean_mag: Self::require_column(&col_map, "phot_g_mean_mag")?,
ruwe: Self::require_column(&col_map, "ruwe")?,
astrometric_params_solved: Self::require_column(&col_map, "astrometric_params_solved")?,
duplicated_source: Self::require_column(&col_map, "duplicated_source")?,
phot_bp_rp_excess_factor: Self::require_column(&col_map, "phot_bp_rp_excess_factor")?,
})
}
fn require_column(col_map: &HashMap<&str, usize>, name: &str) -> anyhow::Result<usize> {
col_map
.get(name)
.copied()
.ok_or_else(|| anyhow::anyhow!("Missing column: {}", name))
}
}
impl<R: BufRead> Iterator for GaiaParser<R> {
type Item = anyhow::Result<GaiaStarRaw>;
fn next(&mut self) -> Option<Self::Item> {
loop {
self.line_buf.clear();
match self.reader.read_line(&mut self.line_buf) {
Ok(0) => return None,
Ok(_) => {}
Err(e) => return Some(Err(e.into())),
}
if self.line_buf.starts_with('#') {
continue;
}
match self.parse_row() {
Ok(Some(star)) => return Some(Ok(star)),
Ok(None) => continue,
Err(e) => return Some(Err(e)),
}
}
}
}
impl<R: BufRead> GaiaParser<R> {
fn parse_row(&self) -> anyhow::Result<Option<GaiaStarRaw>> {
let fields: Vec<&str> = self.line_buf.trim().split(',').collect();
if self.should_skip_row(&fields) {
return Ok(None);
}
let mag = parse_f32(fields.get(self.indices.phot_g_mean_mag).copied());
if mag.is_none() || mag.unwrap() > self.mag_limit {
return Ok(None);
}
Ok(Some(self.build_star(&fields, mag.unwrap())))
}
fn should_skip_row(&self, fields: &[&str]) -> bool {
let dup = fields
.get(self.indices.duplicated_source)
.copied()
.unwrap_or("");
dup.eq_ignore_ascii_case("true")
}
fn build_star(&self, fields: &[&str], mag: f32) -> GaiaStarRaw {
let pmra = parse_f64(fields.get(self.indices.pmra).copied());
let pmdec = parse_f64(fields.get(self.indices.pmdec).copied());
let parallax = parse_f64(fields.get(self.indices.parallax).copied());
let flags = self.compute_flags(fields, pmra, pmdec, parallax);
GaiaStarRaw {
source_id: parse_i64(fields.get(self.indices.source_id).copied()).unwrap_or(0),
ra: parse_f64(fields.get(self.indices.ra).copied()).unwrap_or(0.0),
dec: parse_f64(fields.get(self.indices.dec).copied()).unwrap_or(0.0),
pmra: pmra.unwrap_or(0.0),
pmdec: pmdec.unwrap_or(0.0),
parallax: parallax.unwrap_or(0.0),
mag,
flags,
}
}
fn compute_flags(
&self,
fields: &[&str],
pmra: Option<f64>,
pmdec: Option<f64>,
parallax: Option<f64>,
) -> u16 {
let mut flags = 0u16;
if pmra.is_some() && pmdec.is_some() {
flags |= FLAG_HAS_PROPER_MOTION;
}
if parallax.is_some() {
flags |= FLAG_HAS_PARALLAX;
}
flags |= self.compute_quality_flags(fields);
flags
}
fn compute_quality_flags(&self, fields: &[&str]) -> u16 {
let mut flags = 0u16;
if let Some(ruwe) = parse_f32(fields.get(self.indices.ruwe).copied()) {
if ruwe > 1.4 {
flags |= FLAG_RUWE_SUSPECT;
}
}
if let Some(params) = parse_i8(fields.get(self.indices.astrometric_params_solved).copied())
{
if params != 31 {
flags |= FLAG_NO_5PARAM;
}
}
if let Some(excess) = parse_f32(fields.get(self.indices.phot_bp_rp_excess_factor).copied())
{
if excess > 3.0 {
flags |= FLAG_BP_RP_EXCESS_SUSPECT;
}
}
flags
}
}
fn parse_i64(s: Option<&str>) -> Option<i64> {
s.and_then(|v| if v.is_empty() { None } else { v.parse().ok() })
}
fn parse_i8(s: Option<&str>) -> Option<i8> {
s.and_then(|v| if v.is_empty() { None } else { v.parse().ok() })
}
fn parse_f64(s: Option<&str>) -> Option<f64> {
s.and_then(|v| if v.is_empty() { None } else { v.parse().ok() })
}
fn parse_f32(s: Option<&str>) -> Option<f32> {
s.and_then(|v| if v.is_empty() { None } else { v.parse().ok() })
}
@@ -0,0 +1,426 @@
//! Gaia DR3 catalog ingestion
use crate::cli::{Cli, IngestGaiaArgs};
use crate::gaia::{GaiaParser, GaiaStarRaw};
use anyhow::Context;
use flate2::read::GzDecoder;
use indicatif::{ProgressBar, ProgressStyle};
use rayon::prelude::*;
use std::fs::{self, File};
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
const RECORD_SIZE: usize = 56;
const PART_HEADER_SIZE: usize = 8;
const FINAL_MAGIC: &[u8; 4] = b"GAIA";
const FINAL_VERSION: u32 = 1;
#[repr(C)]
struct StarRecord {
source_id: i64,
ra: f64,
dec: f64,
pmra: f64,
pmdec: f64,
parallax: f64,
mag: f32,
flags: u16,
_padding: u16,
}
struct FileStats {
kept: u64,
scanned: u64,
}
pub fn run(args: &IngestGaiaArgs, cli: &Cli) -> anyhow::Result<()> {
validate_args(args)?;
let files = find_gzipped_csvs(&args.path)?;
if files.is_empty() {
anyhow::bail!("No .csv.gz files found in {:?}", args.path);
}
print_plan(args, files.len());
configure_thread_pool(args.threads)?;
let files_to_process = filter_already_processed(&files, &args.output)?;
println!(
"Files to process: {} (skipping {} already done)",
files_to_process.len(),
files.len() - files_to_process.len()
);
if !files_to_process.is_empty() {
process_files(&files_to_process, args, cli)?;
}
if args.no_concat {
println!("Skipping concatenation (--no-concat)");
return Ok(());
}
concatenate_part_files(&args.output, args.mag_limit)
}
fn validate_args(args: &IngestGaiaArgs) -> anyhow::Result<()> {
if !args.path.exists() {
anyhow::bail!("Gaia directory does not exist: {:?}", args.path);
}
if !args.path.is_dir() {
anyhow::bail!("Gaia path is not a directory: {:?}", args.path);
}
if !args.output.exists() {
fs::create_dir_all(&args.output)?;
}
Ok(())
}
fn find_gzipped_csvs(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if is_gzipped_csv(&path) {
files.push(path);
}
}
files.sort();
Ok(files)
}
fn is_gzipped_csv(path: &Path) -> bool {
path.extension().is_some_and(|e| e == "gz")
&& path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| s.ends_with(".csv"))
}
fn filter_already_processed(files: &[PathBuf], output_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
Ok(files
.iter()
.filter(|f| !part_file_exists(f, output_dir))
.cloned()
.collect())
}
fn part_file_exists(input: &Path, output_dir: &Path) -> bool {
let Some(part_path) = compute_part_path(input, output_dir) else {
return false;
};
part_path.exists()
&& fs::metadata(&part_path)
.map(|m| m.len() > 0)
.unwrap_or(false)
}
fn extract_numeric_portion(filename: &str) -> Option<&str> {
let stem = filename.strip_suffix(".csv.gz")?;
stem.strip_prefix("GaiaSource_")
}
fn compute_part_path(input: &Path, output_dir: &Path) -> Option<PathBuf> {
let filename = input.file_name()?.to_str()?;
let numeric = extract_numeric_portion(filename)?;
Some(output_dir.join(format!("gaia_part_{}.bin", numeric)))
}
fn print_plan(args: &IngestGaiaArgs, file_count: usize) {
println!("=== Gaia DR3 Ingestion ===");
println!("Input directory: {:?}", args.path);
println!("Files found: {}", file_count);
println!("Magnitude limit: {:.1}", args.mag_limit);
println!("Output directory: {:?}", args.output);
println!("Threads: {}", resolve_threads(args.threads));
println!();
}
fn resolve_threads(threads: usize) -> usize {
if threads == 0 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
} else {
threads
}
}
fn configure_thread_pool(threads: usize) -> anyhow::Result<()> {
rayon::ThreadPoolBuilder::new()
.num_threads(resolve_threads(threads))
.build_global()
.ok();
Ok(())
}
fn process_files(files: &[PathBuf], args: &IngestGaiaArgs, cli: &Cli) -> anyhow::Result<()> {
let pb = create_progress_bar(files.len() as u64);
let total_kept = AtomicU64::new(0);
let total_scanned = AtomicU64::new(0);
let errors = AtomicU64::new(0);
let failed_files: Mutex<Vec<(PathBuf, String)>> = Mutex::new(Vec::new());
files.par_iter().for_each(|file| {
let result = process_single_file(file, args, cli, &total_kept, &total_scanned);
if let Err(e) = result {
eprintln!(
"\nWarning: {:?}: {}",
file.file_name().unwrap_or_default(),
e
);
errors.fetch_add(1, Ordering::Relaxed);
if let Ok(mut failed) = failed_files.lock() {
failed.push((file.clone(), e.to_string()));
}
}
pb.inc(1);
});
pb.finish_with_message("Done");
write_failed_log(&args.output, &failed_files)?;
print_summary(files.len(), &total_kept, &total_scanned, &errors)
}
fn write_failed_log(
output_dir: &Path,
failed: &Mutex<Vec<(PathBuf, String)>>,
) -> anyhow::Result<()> {
let failed = failed
.lock()
.map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
if failed.is_empty() {
return Ok(());
}
let log_path = output_dir.join("failed_files.log");
let mut file = File::create(&log_path)?;
for (path, error) in failed.iter() {
writeln!(file, "{}\t{}", path.display(), error)?;
}
println!("Failed files logged to: {:?}", log_path);
Ok(())
}
fn create_progress_bar(total: u64) -> ProgressBar {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
pb
}
fn process_single_file(
path: &Path,
args: &IngestGaiaArgs,
cli: &Cli,
total_kept: &AtomicU64,
total_scanned: &AtomicU64,
) -> anyhow::Result<()> {
let output_path =
compute_part_path(path, &args.output).context("Failed to compute output path")?;
let stats = stream_and_filter(path, &output_path, args.mag_limit)?;
total_kept.fetch_add(stats.kept, Ordering::Relaxed);
total_scanned.fetch_add(stats.scanned, Ordering::Relaxed);
if cli.verbose {
let name = path.file_name().unwrap_or_default();
eprintln!(
"\n{:?}: kept={}, scanned={}",
name, stats.kept, stats.scanned
);
}
Ok(())
}
fn stream_and_filter(input: &Path, output: &Path, mag_limit: f32) -> anyhow::Result<FileStats> {
let file = File::open(input)?;
let mut decoder = GzDecoder::new(BufReader::new(file));
validate_gzip_header(&mut decoder, input)?;
let buf_reader = BufReader::new(decoder);
let parser = GaiaParser::new(buf_reader, mag_limit)?;
let temp_path = output.with_extension("bin.tmp");
let stats = write_part_file(parser, &temp_path)?;
fs::rename(&temp_path, output)?;
Ok(stats)
}
fn validate_gzip_header<R: Read>(decoder: &mut GzDecoder<R>, path: &Path) -> anyhow::Result<()> {
let header = decoder.header();
if header.is_none() {
anyhow::bail!("Invalid or corrupt gzip file: {:?}", path);
}
Ok(())
}
fn write_part_file<R: std::io::BufRead>(
parser: GaiaParser<R>,
output: &Path,
) -> anyhow::Result<FileStats> {
let file = File::create(output)?;
let mut writer = BufWriter::new(file);
writer.write_all(&[0u8; PART_HEADER_SIZE])?;
let mut stats = FileStats {
kept: 0,
scanned: 0,
};
for result in parser {
stats.scanned += 1;
match result {
Ok(star) => {
write_star_record(&mut writer, &star)?;
stats.kept += 1;
}
Err(e) => {
// Decompression or parse error — bail immediately
anyhow::bail!("Error at row {}: {}", stats.scanned, e);
}
}
}
finalize_part_file(writer, stats)
}
fn finalize_part_file(mut writer: BufWriter<File>, stats: FileStats) -> anyhow::Result<FileStats> {
writer.flush()?;
let mut file = writer.into_inner()?;
file.seek(SeekFrom::Start(0))?;
file.write_all(&stats.kept.to_le_bytes())?;
Ok(stats)
}
fn write_star_record<W: Write>(writer: &mut W, star: &GaiaStarRaw) -> anyhow::Result<()> {
let record = StarRecord {
source_id: star.source_id,
ra: star.ra,
dec: star.dec,
pmra: star.pmra,
pmdec: star.pmdec,
parallax: star.parallax,
mag: star.mag,
flags: star.flags,
_padding: 0,
};
let bytes = unsafe {
std::slice::from_raw_parts(&record as *const StarRecord as *const u8, RECORD_SIZE)
};
writer.write_all(bytes)?;
Ok(())
}
fn print_summary(
file_count: usize,
total_kept: &AtomicU64,
total_scanned: &AtomicU64,
errors: &AtomicU64,
) -> anyhow::Result<()> {
let kept = total_kept.load(Ordering::Relaxed);
let scanned = total_scanned.load(Ordering::Relaxed);
let errs = errors.load(Ordering::Relaxed);
println!();
println!("=== Summary ===");
println!("Files processed: {}", file_count);
println!("Stars scanned: {}", scanned);
println!("Stars kept: {}", kept);
if errs > 0 {
println!("Files with errors: {}", errs);
}
if errs == file_count as u64 {
anyhow::bail!("All files failed to process");
}
Ok(())
}
fn concatenate_part_files(output_dir: &Path, mag_limit: f32) -> anyhow::Result<()> {
let part_files = collect_part_files(output_dir)?;
if part_files.is_empty() {
anyhow::bail!("No part files found in {:?}", output_dir);
}
println!("\n=== Concatenating {} part files ===", part_files.len());
let final_path = output_dir.join("gaia_ingest.bin");
let temp_path = final_path.with_extension("bin.tmp");
let total_stars = write_final_catalog(&part_files, &temp_path, mag_limit)?;
fs::rename(&temp_path, &final_path)?;
println!("Written {} stars to {:?}", total_stars, final_path);
delete_part_files(&part_files)?;
Ok(())
}
fn collect_part_files(output_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut files: Vec<PathBuf> = fs::read_dir(output_dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| is_part_file(p))
.collect();
files.sort();
Ok(files)
}
fn is_part_file(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("gaia_part_") && n.ends_with(".bin"))
.unwrap_or(false)
}
fn write_final_catalog(
part_files: &[PathBuf],
output: &Path,
mag_limit: f32,
) -> anyhow::Result<u64> {
let total_stars = count_total_stars(part_files)?;
let file = File::create(output)?;
let mut writer = BufWriter::new(file);
write_final_header(&mut writer, total_stars, mag_limit)?;
copy_star_records(&mut writer, part_files)?;
writer.flush()?;
Ok(total_stars)
}
fn count_total_stars(part_files: &[PathBuf]) -> anyhow::Result<u64> {
let mut total = 0u64;
for path in part_files {
total += read_part_star_count(path)?;
}
Ok(total)
}
fn read_part_star_count(path: &Path) -> anyhow::Result<u64> {
let mut file = File::open(path)?;
let mut buf = [0u8; 8];
file.read_exact(&mut buf)?;
Ok(u64::from_le_bytes(buf))
}
fn write_final_header<W: Write>(writer: &mut W, total: u64, mag_limit: f32) -> anyhow::Result<()> {
writer.write_all(FINAL_MAGIC)?;
writer.write_all(&FINAL_VERSION.to_le_bytes())?;
writer.write_all(&total.to_le_bytes())?;
writer.write_all(&mag_limit.to_le_bytes())?;
writer.write_all(&[0u8; 4])?;
Ok(())
}
fn copy_star_records<W: Write>(writer: &mut W, part_files: &[PathBuf]) -> anyhow::Result<()> {
let mut buf = vec![0u8; 64 * 1024];
for path in part_files {
copy_single_part(writer, path, &mut buf)?;
}
Ok(())
}
fn copy_single_part<W: Write>(writer: &mut W, path: &Path, buf: &mut [u8]) -> anyhow::Result<()> {
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(PART_HEADER_SIZE as u64))?;
loop {
let n = file.read(buf)?;
if n == 0 {
break;
}
writer.write_all(&buf[..n])?;
}
Ok(())
}
fn delete_part_files(part_files: &[PathBuf]) -> anyhow::Result<()> {
for path in part_files {
fs::remove_file(path)?;
}
println!("Deleted {} part files", part_files.len());
Ok(())
}
@@ -0,0 +1,399 @@
//! Hipparcos New Reduction (van Leeuwen 2007) catalog ingestion
//!
//! Parses hip2.dat fixed-width format from I/311.
//! Auto-downloads from CDS if not present locally.
//! Epoch-propagates positions from J1991.25 to J2016.0.
use crate::cli::{Cli, IngestHipparcosArgs};
use crate::gaia::{FLAG_HAS_PARALLAX, FLAG_HAS_PROPER_MOTION, FLAG_SOURCE_HIPPARCOS};
use anyhow::Context;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
use std::path::Path;
const RECORD_SIZE: usize = 56;
const FINAL_MAGIC: &[u8; 4] = b"HIPP";
const FINAL_VERSION: u32 = 2;
const HIPPARCOS_EPOCH: f64 = 1991.25;
const GAIA_EPOCH: f64 = 2016.0;
const DELTA_T_YEARS: f64 = GAIA_EPOCH - HIPPARCOS_EPOCH;
const HIP_SYNTHETIC_ID_BASE: i64 = 0x4000_0000_0000_0000;
const HIP2_URL: &str = "https://cdsarc.cds.unistra.fr/ftp/I/311/hip2.dat.gz";
const CROSSMATCH_URL: &str =
"https://cdn.gea.esac.esa.int/Gaia/gedr3/cross_match/hipparcos2_best_neighbour/Hipparcos2BestNeighbour.csv.gz";
const HIP2_FILENAME: &str = "hip2.dat";
const CROSSMATCH_FILENAME: &str = "Hipparcos2BestNeighbour.csv";
#[repr(C)]
struct StarRecord {
source_id: i64,
ra: f64,
dec: f64,
pmra: f64,
pmdec: f64,
parallax: f64,
mag: f32,
flags: u16,
_padding: u16,
}
#[allow(dead_code)]
struct Hip2Star {
hip: u32,
ra_rad: f64,
dec_rad: f64,
parallax: f64,
pmra: f64,
pmdec: f64,
hpmag: f32,
b_v: Option<f64>,
v_i: Option<f64>,
solution_type: u16,
num_components: u8,
}
struct IngestStats {
total_parsed: u64,
kept_after_mag: u64,
with_gaia_match: u64,
without_match: u64,
}
pub fn run(args: &IngestHipparcosArgs, cli: &Cli) -> anyhow::Result<()> {
fs::create_dir_all(&args.workdir)?;
fs::create_dir_all(&args.output)?;
let hip_path = ensure_file(
&args.workdir.join(HIP2_FILENAME),
HIP2_URL,
"hip2.dat (van Leeuwen 2007)",
)?;
let crossmatch_path = ensure_file(
&args.workdir.join(CROSSMATCH_FILENAME),
CROSSMATCH_URL,
"Hipparcos2BestNeighbour.csv (Gaia eDR3)",
)?;
print_plan(args, cli);
let crossmatch = load_crossmatch(&crossmatch_path)?;
println!("Loaded {} cross-match entries", crossmatch.len());
let stars = parse_hip2(&hip_path, args.mag_limit)?;
println!(
"Parsed {} stars (mag <= {:.1})",
stars.len(),
args.mag_limit
);
let stats = write_output(&stars, &crossmatch, &args.output, args.mag_limit)?;
print_validation(&stars, &crossmatch);
print_summary(&stats);
Ok(())
}
fn ensure_file(path: &Path, url: &str, label: &str) -> anyhow::Result<std::path::PathBuf> {
if path.exists() {
println!("Found {}: {:?}", label, path);
return Ok(path.to_path_buf());
}
let gz_path = path.with_extension(gz_extension(path));
if gz_path.exists() {
println!("Decompressing {:?}...", gz_path);
decompress_gz(&gz_path, path)?;
return Ok(path.to_path_buf());
}
println!("{} not found at {:?}", label, path);
println!("Downloading: {}", url);
download_and_decompress(url, path)?;
Ok(path.to_path_buf())
}
fn gz_extension(path: &Path) -> String {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
format!("{}.gz", ext)
}
fn download_and_decompress(url: &str, dest: &Path) -> anyhow::Result<()> {
let response =
reqwest::blocking::get(url).with_context(|| format!("Failed to download {}", url))?;
if !response.status().is_success() {
anyhow::bail!("Download failed with status {}", response.status());
}
let compressed = response.bytes().context("Failed to read response")?;
println!("Downloaded {:.1} MB", compressed.len() as f64 / 1_048_576.0);
let decoder = flate2::read::GzDecoder::new(&compressed[..]);
let mut reader = BufReader::new(decoder);
let mut file = File::create(dest)?;
let mut buf = vec![0u8; 256 * 1024];
let mut written = 0u64;
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
file.write_all(&buf[..n])?;
written += n as u64;
}
println!(
"Decompressed to {:?} ({:.1} MB)",
dest,
written as f64 / 1_048_576.0
);
Ok(())
}
fn decompress_gz(gz_path: &Path, dest: &Path) -> anyhow::Result<()> {
let gz_file = File::open(gz_path)?;
let decoder = flate2::read::GzDecoder::new(BufReader::new(gz_file));
let mut reader = BufReader::new(decoder);
let mut file = File::create(dest)?;
std::io::copy(&mut reader, &mut file)?;
Ok(())
}
fn print_plan(args: &IngestHipparcosArgs, cli: &Cli) {
println!("\n=== Hipparcos New Reduction Ingestion ===");
println!("Workdir: {:?}", args.workdir);
println!("Magnitude limit: {:.1}", args.mag_limit);
println!("Output directory: {:?}", args.output);
println!("Verbose: {}", cli.verbose);
println!();
}
fn load_crossmatch(path: &Path) -> anyhow::Result<HashMap<u32, i64>> {
let file = File::open(path).context("Failed to open cross-match file")?;
let reader = BufReader::new(file);
let mut map = HashMap::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line?;
if line_num == 0 && line.contains("source_id") {
continue;
}
if let Some((hip, gaia_id)) = parse_crossmatch_line(&line) {
map.insert(hip, gaia_id);
}
}
Ok(map)
}
fn parse_crossmatch_line(line: &str) -> Option<(u32, i64)> {
let fields: Vec<&str> = line.split(',').collect();
if fields.len() < 2 {
return None;
}
let gaia_id: i64 = fields[0].trim().parse().ok()?;
let hip: u32 = fields[1].trim().parse().ok()?;
Some((hip, gaia_id))
}
fn parse_hip2(path: &Path, mag_limit: f32) -> anyhow::Result<Vec<Hip2Star>> {
let file = File::open(path).context("Failed to open hip2.dat")?;
let reader = BufReader::new(file);
let mut stars = Vec::with_capacity(120_000);
for (line_num, line) in reader.lines().enumerate() {
let line = line?;
match parse_hip2_line(&line) {
Some(star) if star.hpmag <= mag_limit => stars.push(star),
Some(_) => {}
None => {
if line.len() > 10 {
eprintln!("Warning: failed to parse line {}", line_num + 1);
}
}
}
}
Ok(stars)
}
fn parse_hip2_line(line: &str) -> Option<Hip2Star> {
if line.len() < 171 {
return None;
}
let bytes = line.as_bytes();
let hip: u32 = col(bytes, 0, 6)?.trim().parse().ok()?;
let sn: u16 = col(bytes, 7, 10)?.trim().parse().ok()?;
let nc: u8 = col(bytes, 13, 14)?.trim().parse().ok()?;
let ra_rad: f64 = col(bytes, 15, 28)?.trim().parse().ok()?;
let dec_rad: f64 = col(bytes, 29, 42)?.trim().parse().ok()?;
let parallax: f64 = col(bytes, 43, 50)?.trim().parse().unwrap_or(0.0);
let pmra: f64 = col(bytes, 51, 59)?.trim().parse().unwrap_or(0.0);
let pmdec: f64 = col(bytes, 60, 68)?.trim().parse().unwrap_or(0.0);
let hpmag_str = col(bytes, 129, 136)?.trim();
if hpmag_str.is_empty() {
return None;
}
let hpmag: f32 = hpmag_str.parse().ok()?;
let b_v = col(bytes, 152, 158).and_then(|s| s.trim().parse::<f64>().ok());
let v_i = col(bytes, 165, 171).and_then(|s| s.trim().parse::<f64>().ok());
Some(Hip2Star {
hip,
ra_rad,
dec_rad,
parallax,
pmra,
pmdec,
hpmag,
b_v,
v_i,
solution_type: sn,
num_components: nc,
})
}
fn col(bytes: &[u8], start: usize, end: usize) -> Option<&str> {
if end > bytes.len() {
return None;
}
std::str::from_utf8(&bytes[start..end]).ok()
}
fn propagate_position(star: &Hip2Star) -> (f64, f64) {
let pmdec_rad_per_yr = star.pmdec / 3_600_000.0 * (cosmos_core::constants::PI / 180.0);
let pmra_rad_per_yr = star.pmra / 3_600_000.0 * (cosmos_core::constants::PI / 180.0);
let dec_2016 = star.dec_rad + pmdec_rad_per_yr * DELTA_T_YEARS;
let cos_dec = libm::cos(star.dec_rad);
let ra_2016 = if libm::fabs(cos_dec) > 1e-10 {
star.ra_rad + pmra_rad_per_yr * DELTA_T_YEARS / cos_dec
} else {
star.ra_rad
};
(ra_2016, dec_2016)
}
fn compute_source_id(hip: u32, crossmatch: &HashMap<u32, i64>) -> i64 {
crossmatch
.get(&hip)
.copied()
.unwrap_or(HIP_SYNTHETIC_ID_BASE | hip as i64)
}
fn compute_flags(star: &Hip2Star) -> u16 {
let mut flags = FLAG_SOURCE_HIPPARCOS;
if star.pmra != 0.0 || star.pmdec != 0.0 {
flags |= FLAG_HAS_PROPER_MOTION;
}
if star.parallax != 0.0 {
flags |= FLAG_HAS_PARALLAX;
}
flags
}
fn write_output(
stars: &[Hip2Star],
crossmatch: &HashMap<u32, i64>,
output_dir: &Path,
mag_limit: f32,
) -> anyhow::Result<IngestStats> {
let output_path = output_dir.join("hipparcos_ingest.bin");
let file = File::create(&output_path)?;
let mut writer = BufWriter::new(file);
write_header(&mut writer, stars.len() as u64, mag_limit)?;
let stats = write_records(&mut writer, stars, crossmatch)?;
writer.flush()?;
println!("Written {} stars to {:?}", stars.len(), output_path);
Ok(stats)
}
fn write_header<W: Write>(w: &mut W, count: u64, mag_limit: f32) -> anyhow::Result<()> {
w.write_all(FINAL_MAGIC)?;
w.write_all(&FINAL_VERSION.to_le_bytes())?;
w.write_all(&count.to_le_bytes())?;
w.write_all(&mag_limit.to_le_bytes())?;
w.write_all(&[0u8; 4])?;
Ok(())
}
fn write_records<W: Write>(
writer: &mut W,
stars: &[Hip2Star],
crossmatch: &HashMap<u32, i64>,
) -> anyhow::Result<IngestStats> {
let mut stats = IngestStats {
total_parsed: stars.len() as u64,
kept_after_mag: stars.len() as u64,
with_gaia_match: 0,
without_match: 0,
};
for star in stars {
let (ra_2016, dec_2016) = propagate_position(star);
let source_id = compute_source_id(star.hip, crossmatch);
if crossmatch.contains_key(&star.hip) {
stats.with_gaia_match += 1;
} else {
stats.without_match += 1;
}
write_star_record(writer, star, ra_2016, dec_2016, source_id)?;
}
Ok(stats)
}
fn write_star_record<W: Write>(
writer: &mut W,
star: &Hip2Star,
ra_rad: f64,
dec_rad: f64,
source_id: i64,
) -> anyhow::Result<()> {
let record = StarRecord {
source_id,
ra: ra_rad * 180.0 / cosmos_core::constants::PI,
dec: dec_rad * 180.0 / cosmos_core::constants::PI,
pmra: star.pmra,
pmdec: star.pmdec,
parallax: star.parallax,
mag: star.hpmag,
flags: compute_flags(star),
_padding: 0,
};
let bytes = unsafe {
std::slice::from_raw_parts(&record as *const StarRecord as *const u8, RECORD_SIZE)
};
writer.write_all(bytes)?;
Ok(())
}
fn print_validation(stars: &[Hip2Star], crossmatch: &HashMap<u32, i64>) {
println!("\n=== Validation (known stars) ===");
validate_star(stars, crossmatch, 32349, "Sirius");
validate_star(stars, crossmatch, 91262, "Vega");
}
fn validate_star(stars: &[Hip2Star], crossmatch: &HashMap<u32, i64>, hip: u32, name: &str) {
let Some(star) = stars.iter().find(|s| s.hip == hip) else {
println!("HIP {} ({}): not found in filtered catalog", hip, name);
return;
};
let (ra_2016, dec_2016) = propagate_position(star);
let source_id = compute_source_id(hip, crossmatch);
let ra_deg = ra_2016 * 180.0 / cosmos_core::constants::PI;
let dec_deg = dec_2016 * 180.0 / cosmos_core::constants::PI;
let match_status = if crossmatch.contains_key(&hip) {
"Gaia match"
} else {
"synthetic ID"
};
println!(
"HIP {} ({}): RA={:.6} Dec={:.6} deg (J2016.0), Hp={:.3}, {}",
hip, name, ra_deg, dec_deg, star.hpmag, match_status
);
let orig_ra_deg = star.ra_rad * 180.0 / cosmos_core::constants::PI;
let orig_dec_deg = star.dec_rad * 180.0 / cosmos_core::constants::PI;
println!(
" Original (J1991.25): RA={:.6} Dec={:.6} deg",
orig_ra_deg, orig_dec_deg
);
println!(
" PM: pmRA={:.2} mas/yr, pmDec={:.2} mas/yr",
star.pmra, star.pmdec
);
if let Some(bv) = star.b_v {
println!(" B-V={:.3}", bv);
}
println!(" Source ID: {}", source_id);
}
fn print_summary(stats: &IngestStats) {
println!("\n=== Summary ===");
println!("Total parsed: {}", stats.total_parsed);
println!("Kept after mag filter: {}", stats.kept_after_mag);
println!("With Gaia match: {}", stats.with_gaia_match);
println!("Without match (synthetic ID): {}", stats.without_match);
}
@@ -0,0 +1,31 @@
//! Forge: astronomical catalog data pipeline CLI
//!
//! Ingests raw catalog data (Gaia DR3, Hipparcos) and produces
//! a unified HEALPix-indexed binary catalog.
mod build_index;
mod cli;
mod download_gaia;
mod gaia;
mod ingest_gaia;
mod ingest_hipparcos;
mod merge;
use clap::Parser;
use cli::{Cli, Commands};
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if cli.verbose {
eprintln!("Verbose mode enabled");
}
match &cli.command {
Commands::DownloadGaia(args) => download_gaia::run(args, &cli),
Commands::IngestGaia(args) => ingest_gaia::run(args, &cli),
Commands::IngestHipparcos(args) => ingest_hipparcos::run(args, &cli),
Commands::Merge(args) => merge::run(args, &cli),
Commands::BuildIndex(args) => build_index::run(args, &cli),
}
}
@@ -0,0 +1,278 @@
//! Catalog merging: Hipparcos + Gaia deduplication
//!
//! Hipparcos stars (cross-matched at ingest time) take priority over Gaia.
//! Streams through Gaia, skips duplicates, writes merged output.
use crate::cli::{Cli, MergeArgs};
use crate::gaia::FLAG_SOURCE_HIPPARCOS;
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::Path;
const RECORD_SIZE: usize = 56;
const HEADER_SIZE: usize = 24;
const MERGED_MAGIC: &[u8; 4] = b"MERG";
const MERGED_VERSION: u32 = 1;
const EPOCH_J2016: f64 = 2016.0;
#[repr(C)]
struct StarRecord {
source_id: i64,
ra: f64,
dec: f64,
pmra: f64,
pmdec: f64,
parallax: f64,
mag: f32,
flags: u16,
_padding: u16,
}
struct MergeStats {
hipparcos_count: u64,
gaia_scanned: u64,
gaia_skipped: u64,
gaia_kept: u64,
}
pub fn run(args: &MergeArgs, cli: &Cli) -> anyhow::Result<()> {
validate_paths(args)?;
print_plan(args, cli);
let hipparcos_ids = load_hipparcos_source_ids(args)?;
let stats = merge_catalogs(args, &hipparcos_ids)?;
print_stats(&stats);
validate_output(args)?;
Ok(())
}
fn validate_paths(args: &MergeArgs) -> anyhow::Result<()> {
if !args.workdir.exists() {
anyhow::bail!("Working directory does not exist: {:?}", args.workdir);
}
let hip_path = args.workdir.join("hipparcos_ingest.bin");
if !hip_path.exists() {
anyhow::bail!("Hipparcos ingest file not found: {:?}", hip_path);
}
let gaia_path = args.workdir.join("gaia_ingest.bin");
if !gaia_path.exists() {
anyhow::bail!("Gaia ingest file not found: {:?}", gaia_path);
}
Ok(())
}
fn print_plan(args: &MergeArgs, cli: &Cli) {
println!("=== Catalog Merge ===");
println!("Working directory: {:?}", args.workdir);
println!("Verbose: {}", cli.verbose);
println!();
}
fn load_hipparcos_source_ids(args: &MergeArgs) -> anyhow::Result<HashSet<i64>> {
let path = args.workdir.join("hipparcos_ingest.bin");
let (count, mut reader) = open_catalog_file(&path)?;
println!("Loading {} Hipparcos source IDs...", count);
let mut ids = HashSet::with_capacity(count as usize);
let mut buf = [0u8; RECORD_SIZE];
for _ in 0..count {
reader.read_exact(&mut buf)?;
let source_id = i64::from_le_bytes(buf[0..8].try_into().unwrap());
ids.insert(source_id);
}
println!("Loaded {} Hipparcos source IDs", ids.len());
Ok(ids)
}
fn open_catalog_file(path: &Path) -> anyhow::Result<(u64, BufReader<File>)> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut header = [0u8; HEADER_SIZE];
reader.read_exact(&mut header)?;
let count = u64::from_le_bytes(header[8..16].try_into().unwrap());
Ok((count, reader))
}
fn merge_catalogs(args: &MergeArgs, hipparcos_ids: &HashSet<i64>) -> anyhow::Result<MergeStats> {
let output_path = args.workdir.join("merged.bin");
let temp_path = output_path.with_extension("bin.tmp");
let file = File::create(&temp_path)?;
let mut writer = BufWriter::new(file);
write_placeholder_header(&mut writer)?;
let hip_count = write_hipparcos_records(args, &mut writer)?;
let (gaia_scanned, gaia_skipped, gaia_kept) =
stream_gaia_records(args, &mut writer, hipparcos_ids)?;
let total = hip_count + gaia_kept;
finalize_output(&mut writer, total)?;
drop(writer);
fs::rename(&temp_path, &output_path)?;
println!("Written {} stars to {:?}", total, output_path);
Ok(MergeStats {
hipparcos_count: hip_count,
gaia_scanned,
gaia_skipped,
gaia_kept,
})
}
fn write_placeholder_header<W: Write>(writer: &mut W) -> anyhow::Result<()> {
writer.write_all(&[0u8; HEADER_SIZE])?;
Ok(())
}
fn write_hipparcos_records(args: &MergeArgs, writer: &mut BufWriter<File>) -> anyhow::Result<u64> {
let path = args.workdir.join("hipparcos_ingest.bin");
let (count, mut reader) = open_catalog_file(&path)?;
println!("Writing {} Hipparcos records...", count);
copy_records(&mut reader, writer, count)?;
Ok(count)
}
fn copy_records<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
count: u64,
) -> anyhow::Result<()> {
let mut buf = vec![0u8; 64 * 1024];
let total_bytes = count * RECORD_SIZE as u64;
let mut remaining = total_bytes;
while remaining > 0 {
let to_read = remaining.min(buf.len() as u64) as usize;
reader.read_exact(&mut buf[..to_read])?;
writer.write_all(&buf[..to_read])?;
remaining -= to_read as u64;
}
Ok(())
}
fn stream_gaia_records(
args: &MergeArgs,
writer: &mut BufWriter<File>,
hipparcos_ids: &HashSet<i64>,
) -> anyhow::Result<(u64, u64, u64)> {
let path = args.workdir.join("gaia_ingest.bin");
let (count, mut reader) = open_catalog_file(&path)?;
println!("Streaming {} Gaia records...", count);
let (skipped, kept) = filter_gaia_records(&mut reader, writer, count, hipparcos_ids)?;
Ok((count, skipped, kept))
}
fn filter_gaia_records<R: Read, W: Write>(
reader: &mut R,
writer: &mut W,
count: u64,
hipparcos_ids: &HashSet<i64>,
) -> anyhow::Result<(u64, u64)> {
let mut buf = [0u8; RECORD_SIZE];
let mut skipped = 0u64;
let mut kept = 0u64;
for _ in 0..count {
reader.read_exact(&mut buf)?;
let source_id = i64::from_le_bytes(buf[0..8].try_into().unwrap());
if hipparcos_ids.contains(&source_id) {
skipped += 1;
} else {
writer.write_all(&buf)?;
kept += 1;
}
}
Ok((skipped, kept))
}
fn finalize_output(writer: &mut BufWriter<File>, total_stars: u64) -> anyhow::Result<()> {
writer.flush()?;
let file = writer.get_mut();
file.seek(SeekFrom::Start(0))?;
write_final_header(file, total_stars)?;
Ok(())
}
fn write_final_header<W: Write + Seek>(writer: &mut W, total: u64) -> anyhow::Result<()> {
writer.write_all(MERGED_MAGIC)?;
writer.write_all(&MERGED_VERSION.to_le_bytes())?;
writer.write_all(&total.to_le_bytes())?;
writer.write_all(&(EPOCH_J2016 as f32).to_le_bytes())?;
writer.write_all(&[0u8; 4])?;
Ok(())
}
fn print_stats(stats: &MergeStats) {
println!();
println!("=== Merge Statistics ===");
println!("Hipparcos stars loaded: {}", stats.hipparcos_count);
println!("Gaia stars scanned: {}", stats.gaia_scanned);
println!("Gaia stars skipped (duplicates): {}", stats.gaia_skipped);
println!("Gaia stars kept: {}", stats.gaia_kept);
println!("Total merged: {}", stats.hipparcos_count + stats.gaia_kept);
}
fn validate_output(args: &MergeArgs) -> anyhow::Result<()> {
let path = args.workdir.join("merged.bin");
let (count, mut reader) = open_catalog_file(&path)?;
println!();
println!("=== Validation (sample records) ===");
println!("Total stars in merged catalog: {}", count);
print_hipparcos_samples(&mut reader, count)?;
let path = args.workdir.join("merged.bin");
let (_, mut reader) = open_catalog_file(&path)?;
print_gaia_samples(&mut reader, count)?;
Ok(())
}
fn print_hipparcos_samples<R: Read>(reader: &mut R, count: u64) -> anyhow::Result<()> {
println!();
println!("Hipparcos-flagged records:");
let mut found = 0;
let mut buf = [0u8; RECORD_SIZE];
for i in 0..count.min(10000) {
reader.read_exact(&mut buf)?;
let record = parse_record(&buf);
if record.flags & FLAG_SOURCE_HIPPARCOS != 0 {
print_record(i, &record);
found += 1;
if found >= 3 {
break;
}
}
}
Ok(())
}
fn print_gaia_samples<R: Read>(reader: &mut R, count: u64) -> anyhow::Result<()> {
println!();
println!("Gaia records (no Hipparcos flag):");
let mut found = 0;
let mut buf = [0u8; RECORD_SIZE];
for i in 0..count.min(100000) {
reader.read_exact(&mut buf)?;
let record = parse_record(&buf);
if record.flags & FLAG_SOURCE_HIPPARCOS == 0 {
print_record(i, &record);
found += 1;
if found >= 3 {
break;
}
}
}
Ok(())
}
fn parse_record(buf: &[u8; RECORD_SIZE]) -> StarRecord {
StarRecord {
source_id: i64::from_le_bytes(buf[0..8].try_into().unwrap()),
ra: f64::from_le_bytes(buf[8..16].try_into().unwrap()),
dec: f64::from_le_bytes(buf[16..24].try_into().unwrap()),
pmra: f64::from_le_bytes(buf[24..32].try_into().unwrap()),
pmdec: f64::from_le_bytes(buf[32..40].try_into().unwrap()),
parallax: f64::from_le_bytes(buf[40..48].try_into().unwrap()),
mag: f32::from_le_bytes(buf[48..52].try_into().unwrap()),
flags: u16::from_le_bytes(buf[52..54].try_into().unwrap()),
_padding: 0,
}
}
fn print_record(index: u64, record: &StarRecord) {
println!(
" [{}] source_id={}, RA={:.6}, Dec={:.6}, mag={:.2}, flags=0x{:04x}",
index, record.source_id, record.ra, record.dec, record.mag, record.flags
);
}
@@ -0,0 +1,249 @@
use cosmos_catalog::query::catalog::FLAG_SOURCE_HIPPARCOS;
use cosmos_catalog::query::{cone_search, Catalog, ConeSearchParams, ConeSearchResult};
use cosmos_core::angle::{AngleUnits, DmsFmt, HmsFmt};
use cosmos_core::Angle;
use cosmos_time::JulianDate;
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
use std::time::Instant;
#[derive(Clone, ValueEnum)]
enum OutputFormat {
Table,
Json,
Csv,
}
#[derive(Parser)]
#[command(name = "query-healpix")]
#[command(about = "Query HEALPix-indexed star catalogs")]
struct Cli {
/// Path to the catalog file
#[arg(long)]
catalog: PathBuf,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Print catalog information
Info,
/// Perform a cone search
Search {
/// Right ascension (degrees, or HMS e.g. 18h36m56s, 18:36:56)
ra: String,
/// Declination (degrees, or DMS e.g. +38d47m01s, -5:22:30)
dec: String,
/// Search radius in degrees
#[arg(long, default_value = "1.0")]
radius: f64,
/// Maximum magnitude filter
#[arg(long)]
mag_max: Option<f64>,
/// Maximum number of results
#[arg(long)]
limit: Option<usize>,
/// Observation epoch as Julian Date (conflicts with --date)
#[arg(long, conflicts_with = "date")]
epoch: Option<f64>,
/// Observation date as ISO 8601 YYYY-MM-DD (conflicts with --epoch)
#[arg(long, conflicts_with = "epoch")]
date: Option<String>,
/// Print query timing
#[arg(long)]
timing: bool,
/// Output decimal degrees instead of HMS/DMS
#[arg(long)]
raw: bool,
/// Output format
#[arg(long, value_enum, default_value = "table")]
format: OutputFormat,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Info => {
let catalog = Catalog::open(&cli.catalog)?;
let size_mb = catalog.file_size() as f64 / 1_048_576.0;
println!("{}", catalog.header());
println!(
"File size: {} bytes ({:.2} MB)",
catalog.file_size(),
size_mb
);
}
Commands::Search {
ra,
dec,
radius,
mag_max,
limit,
epoch,
date,
timing,
raw,
format,
} => {
let catalog = Catalog::open(&cli.catalog)?;
let ra_deg = parse_ra(&ra)?;
let dec_deg = parse_dec(&dec)?;
let epoch = if let Some(date_str) = date {
Some(parse_date_to_jd(&date_str)?)
} else {
epoch.map(JulianDate::from_f64)
};
let params = ConeSearchParams {
ra_deg,
dec_deg,
radius_deg: radius,
max_mag: mag_max,
max_results: limit,
epoch,
};
let start = if timing { Some(Instant::now()) } else { None };
let results = cone_search(&catalog, &params);
if let Some(start_time) = start {
let elapsed = start_time.elapsed();
eprintln!(
"Query completed in {:.2} ms",
elapsed.as_secs_f64() * 1000.0
);
}
match format {
OutputFormat::Table => print_table(&results, raw),
OutputFormat::Json => print_json(&results),
OutputFormat::Csv => print_csv(&results),
}
}
}
Ok(())
}
fn print_table(results: &[ConeSearchResult], raw: bool) {
let hms = HmsFmt { frac_digits: 4 };
let dms = DmsFmt { frac_digits: 4 };
for (i, result) in results.iter().enumerate() {
let source = source_name(result.star.flags);
if raw {
println!(
"{:4}: {:>20} RA={:.6}° Dec={:+.6}° Mag={:5.2} Dist={:.4}° Source={}",
i + 1,
result.star.source_id,
result.ra_deg,
result.dec_deg,
result.star.mag,
result.distance_deg,
source
);
} else {
let ra_str = hms.fmt(Angle::from_degrees(result.ra_deg));
let dec_str = dms.fmt(Angle::from_degrees(result.dec_deg));
println!(
"{:4}: {:>20} RA={} Dec={} Mag={:5.2} Dist={:.4}° Source={}",
i + 1,
result.star.source_id,
ra_str,
dec_str,
result.star.mag,
result.distance_deg,
source
);
}
}
if results.is_empty() {
println!("No stars found matching the search criteria.");
} else {
println!("\nTotal results: {}", results.len());
}
}
#[derive(serde::Serialize)]
struct JsonStar {
source_id: i64,
ra_deg: f64,
dec_deg: f64,
mag: f32,
distance_deg: f64,
source: &'static str,
}
fn print_json(results: &[ConeSearchResult]) {
let stars: Vec<JsonStar> = results
.iter()
.map(|r| JsonStar {
source_id: r.star.source_id,
ra_deg: r.ra_deg,
dec_deg: r.dec_deg,
mag: r.star.mag,
distance_deg: r.distance_deg,
source: source_name(r.star.flags),
})
.collect();
println!("{}", serde_json::to_string_pretty(&stars).unwrap());
}
fn print_csv(results: &[ConeSearchResult]) {
println!("source_id,ra_deg,dec_deg,mag,distance_deg,source");
for r in results {
println!(
"{},{},{},{},{},{}",
r.star.source_id,
r.ra_deg,
r.dec_deg,
r.star.mag,
r.distance_deg,
source_name(r.star.flags)
);
}
}
fn source_name(flags: u16) -> &'static str {
if (flags & FLAG_SOURCE_HIPPARCOS) != 0 {
"HIPPARCOS"
} else {
"GAIA"
}
}
fn parse_ra(s: &str) -> anyhow::Result<f64> {
s.hms()
.or_else(|_| s.deg())
.map(|a| a.degrees())
.map_err(|e| anyhow::anyhow!("Cannot parse RA '{}': {}", s, e))
}
fn parse_dec(s: &str) -> anyhow::Result<f64> {
s.dms()
.or_else(|_| s.deg())
.map(|a| a.degrees())
.map_err(|e| anyhow::anyhow!("Cannot parse Dec '{}': {}", s, e))
}
fn parse_date_to_jd(date_str: &str) -> anyhow::Result<JulianDate> {
let parts: Vec<&str> = date_str.split('-').collect();
if parts.len() != 3 {
anyhow::bail!("Invalid date format, expected YYYY-MM-DD");
}
let year: i32 = parts[0].parse()?;
let month: u8 = parts[1].parse()?;
let day: u8 = parts[2].parse()?;
Ok(JulianDate::from_calendar(year, month, day, 0, 0, 0.0))
}
@@ -0,0 +1,45 @@
//! HEALPix-indexed star catalog for fast positional queries.
//!
//! Provides memory-mapped access to a binary catalog of ~37 million stars
//! (Gaia DR3 + Hipparcos) organized as a HEALPix spatial index. The catalog
//! file is memory-mapped on open — no parsing, no loading into RAM. Star
//! records are read as zero-copy `repr(C)` slices directly from the map.
//!
//! # Modules
//!
//! | Module | Purpose |
//! |--------|---------|
//! | [`query::catalog`] | [`Catalog`](query::Catalog) reader, [`StarRecord`](query::StarRecord), [`CatalogHeader`](query::CatalogHeader), flag constants |
//! | [`query::cone`] | [`cone_search`](query::cone_search), [`ConeSearchParams`](query::ConeSearchParams), proper-motion propagation |
//! | [`query::healpix`] | Pixel indexing ([`ang2pix_nest`](query::ang2pix_nest)), disc queries, angular separation |
//!
//! # Quick Start
//!
//! ```ignore
//! use cosmos_catalog::query::{Catalog, cone_search, ConeSearchParams};
//!
//! let catalog = Catalog::open("catalog.bin")?;
//!
//! let results = cone_search(&catalog, &ConeSearchParams {
//! ra_deg: 83.633,
//! dec_deg: -5.375,
//! radius_deg: 0.5,
//! max_mag: Some(14.0),
//! max_results: Some(50),
//! epoch: None,
//! });
//! ```
//!
//! # Binary Format
//!
//! The catalog file has three sections: a 64-byte header, a pixel offset table
//! (`npix × 16` bytes), and contiguous star data (`total_stars × 56` bytes).
//! Stars are grouped by HEALPix pixel, enabling spatial queries that touch
//! only the relevant pages.
//!
//! # Features
//!
//! - **`cli`** — Enables the `forge` and `query-catalog` binaries for building
//! and querying catalogs from the command line.
pub mod query;
@@ -0,0 +1,593 @@
//! Memory-mapped catalog reader for HEALPix-indexed star catalogs.
//!
//! The catalog binary format has three contiguous sections:
//!
//! 1. **Header** (64 bytes) — magic, version, HEALPix parameters, star count, epoch
//! 2. **Pixel offset table** (`npix × 16` bytes) — byte offset and count per pixel
//! 3. **Star data** (`total_stars × 56` bytes) — [`StarRecord`] structs grouped by pixel
//!
//! Open a catalog with [`Catalog::open`], then query stars by pixel index
//! or use the higher-level cone search in [`super::cone`].
use anyhow::{bail, Context, Result};
use memmap2::Mmap;
use std::fmt;
use std::fs::File;
use std::path::Path;
const CATALOG_MAGIC: &[u8; 4] = b"CCAT";
const CATALOG_VERSION: u32 = 1;
const HEADER_SIZE: usize = 64;
const PIXEL_ENTRY_SIZE: usize = 16;
/// Metadata parsed from the first 64 bytes of a catalog file.
#[derive(Debug, Clone)]
pub struct CatalogHeader {
/// HEALPix order (nside = 2^order). Order 8 gives 786,432 pixels.
pub order: u32,
/// HEALPix nside parameter. Always `2.pow(order)`.
pub nside: u32,
/// Total number of HEALPix pixels (`12 * nside * nside`).
pub npix: u64,
/// Total number of star records in the catalog.
pub total_stars: u64,
/// Catalog epoch as a Julian year (e.g. 2016.0 for Gaia DR3).
pub epoch: f64,
/// Faintest magnitude included in the catalog.
pub mag_limit: f32,
}
impl fmt::Display for CatalogHeader {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let avg = self.total_stars as f64 / self.npix as f64;
writeln!(f, "HEALPix order: {}", self.order)?;
writeln!(f, "nside: {}", self.nside)?;
writeln!(f, "npix: {}", self.npix)?;
writeln!(f, "Total stars: {}", self.total_stars)?;
writeln!(f, "Epoch: J{:.1}", self.epoch)?;
writeln!(f, "Magnitude limit: {:.2}", self.mag_limit)?;
write!(f, "Average stars per pixel: {:.1}", avg)
}
}
/// A single star entry (56 bytes, `repr(C)`).
///
/// Laid out for zero-copy reads from the memory-mapped file. Fields are
/// stored in catalog-epoch coordinates; use [`super::cone::cone_search`]
/// with an observation epoch to get proper-motion-corrected positions.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct StarRecord {
/// Gaia DR3 source ID, or negative Hipparcos ID for Hipparcos-only stars.
pub source_id: i64,
/// Right ascension at catalog epoch, in degrees.
pub ra: f64,
/// Declination at catalog epoch, in degrees.
pub dec: f64,
/// Proper motion in RA (μα*), including cos(δ) factor, in mas/yr.
pub pmra: f64,
/// Proper motion in declination, in mas/yr.
pub pmdec: f64,
/// Trigonometric parallax, in milliarcseconds.
pub parallax: f64,
/// Apparent magnitude (G-band for Gaia, V-band for Hipparcos).
pub mag: f32,
/// Bitfield of quality and source flags. See `FLAG_*` constants.
pub flags: u16,
pub(crate) _padding: u16,
}
/// Star has measured proper motion (pmra and pmdec are valid).
pub const FLAG_HAS_PROPER_MOTION: u16 = 1 << 0;
/// Star has measured parallax.
pub const FLAG_HAS_PARALLAX: u16 = 1 << 1;
/// Gaia RUWE > 1.4 — astrometric solution may be unreliable.
pub const FLAG_RUWE_SUSPECT: u16 = 1 << 2;
/// No 5-parameter astrometric solution in Gaia.
pub const FLAG_NO_5PARAM: u16 = 1 << 3;
/// BP/RP flux excess factor is suspect (possible blend or extended source).
pub const FLAG_BP_RP_EXCESS_SUSPECT: u16 = 1 << 4;
/// Star originates from Hipparcos, not Gaia DR3.
pub const FLAG_SOURCE_HIPPARCOS: u16 = 1 << 5;
const _: () = assert!(std::mem::size_of::<StarRecord>() == 56);
#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct PixelEntry {
offset: u64,
count: u32,
_reserved: u32,
}
const _: () = assert!(std::mem::size_of::<PixelEntry>() == 16);
/// Memory-mapped handle to a HEALPix star catalog.
///
/// Created by [`Catalog::open`]. The underlying file stays mapped for the
/// lifetime of this value. Star slices returned by [`Catalog::stars_in_pixel`]
/// borrow directly from the map with no allocation or copying.
pub struct Catalog {
mmap: Mmap,
header: CatalogHeader,
}
impl Catalog {
/// Open and memory-map a catalog file.
///
/// Validates the header (magic bytes, version, HEALPix consistency) and
/// returns immediately. No star data is read until you call
/// [`Catalog::stars_in_pixel`] or run a cone search.
///
/// # Errors
/// Returns an error if the file cannot be opened, is too small, has an
/// invalid header, or has inconsistent HEALPix parameters.
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let file =
File::open(path).with_context(|| format!("Failed to open catalog file: {:?}", path))?;
let mmap = unsafe { Mmap::map(&file) }
.with_context(|| format!("Failed to memory-map catalog file: {:?}", path))?;
if mmap.len() < HEADER_SIZE {
bail!("Catalog file too small: {} bytes", mmap.len());
}
let header = parse_header(&mmap)?;
let expected_offset_table_size = header.npix as usize * PIXEL_ENTRY_SIZE;
let min_size = HEADER_SIZE + expected_offset_table_size;
if mmap.len() < min_size {
bail!(
"Catalog file too small for offset table: {} bytes, expected at least {}",
mmap.len(),
min_size
);
}
Ok(Self { mmap, header })
}
/// Returns the catalog header (order, nside, star count, epoch, etc.).
pub fn header(&self) -> &CatalogHeader {
&self.header
}
/// Returns a zero-copy slice of all stars in the given HEALPix pixel.
///
/// The returned slice borrows directly from the memory map. Returns an
/// empty slice if `pixel_index` is out of range, the pixel contains no
/// stars, or the underlying data is misaligned.
pub fn stars_in_pixel(&self, pixel_index: u64) -> &[StarRecord] {
if pixel_index >= self.header.npix {
return &[];
}
let offset_table_start = HEADER_SIZE;
let entry_offset = offset_table_start + (pixel_index as usize * PIXEL_ENTRY_SIZE);
if entry_offset + PIXEL_ENTRY_SIZE > self.mmap.len() {
return &[];
}
let entry_bytes = &self.mmap[entry_offset..entry_offset + PIXEL_ENTRY_SIZE];
let offset = u64::from_le_bytes(entry_bytes[0..8].try_into().unwrap());
let count = u32::from_le_bytes(entry_bytes[8..12].try_into().unwrap());
if count == 0 {
return &[];
}
let star_data_start = HEADER_SIZE + (self.header.npix as usize * PIXEL_ENTRY_SIZE);
let star_offset = star_data_start + offset as usize;
let star_size = count as usize * std::mem::size_of::<StarRecord>();
if star_offset + star_size > self.mmap.len() {
return &[];
}
let star_bytes = &self.mmap[star_offset..star_offset + star_size];
let ptr = star_bytes.as_ptr();
if !(ptr as usize).is_multiple_of(std::mem::align_of::<StarRecord>()) {
return &[];
}
unsafe { std::slice::from_raw_parts(ptr as *const StarRecord, count as usize) }
}
/// Returns the total size of the memory-mapped file in bytes.
pub fn file_size(&self) -> usize {
self.mmap.len()
}
}
fn parse_header(mmap: &Mmap) -> Result<CatalogHeader> {
let header_bytes = &mmap[0..HEADER_SIZE];
let magic = &header_bytes[0..4];
if magic != CATALOG_MAGIC {
bail!(
"Invalid catalog magic: expected {:?}, got {:?}",
CATALOG_MAGIC,
magic
);
}
let version = u32::from_le_bytes(header_bytes[4..8].try_into().unwrap());
if version != CATALOG_VERSION {
bail!(
"Unsupported catalog version: expected {}, got {}",
CATALOG_VERSION,
version
);
}
let order = u32::from_le_bytes(header_bytes[8..12].try_into().unwrap());
let nside = u32::from_le_bytes(header_bytes[12..16].try_into().unwrap());
let npix = u64::from_le_bytes(header_bytes[16..24].try_into().unwrap());
let total_stars = u64::from_le_bytes(header_bytes[24..32].try_into().unwrap());
let epoch = f64::from_le_bytes(header_bytes[32..40].try_into().unwrap());
let mag_limit = f32::from_le_bytes(header_bytes[40..44].try_into().unwrap());
let expected_nside = 1u32 << order;
if nside != expected_nside {
bail!(
"Inconsistent nside: order {} implies nside {}, got {}",
order,
expected_nside,
nside
);
}
let expected_npix = 12u64 * (nside as u64) * (nside as u64);
if npix != expected_npix {
bail!(
"Inconsistent npix: nside {} implies npix {}, got {}",
nside,
expected_npix,
npix
);
}
Ok(CatalogHeader {
order,
nside,
npix,
total_stars,
epoch,
mag_limit,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_star_record_size() {
assert_eq!(std::mem::size_of::<StarRecord>(), 56);
}
#[test]
fn test_pixel_entry_size() {
assert_eq!(std::mem::size_of::<PixelEntry>(), 16);
}
#[test]
fn test_star_record_alignment() {
assert_eq!(std::mem::align_of::<StarRecord>(), 8);
}
fn make_star(source_id: i64, ra: f64, dec: f64, mag: f32, flags: u16) -> StarRecord {
StarRecord {
source_id,
ra,
dec,
pmra: 0.0,
pmdec: 0.0,
parallax: 0.0,
mag,
flags,
_padding: 0,
}
}
fn star_to_bytes(star: &StarRecord) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
star as *const StarRecord as *const u8,
std::mem::size_of::<StarRecord>(),
)
}
}
fn build_test_catalog(
order: u32,
epoch: f64,
mag_limit: f32,
pixel_stars: &[(u64, Vec<StarRecord>)],
) -> NamedTempFile {
let nside = 1u32 << order;
let npix = 12u64 * (nside as u64) * (nside as u64);
let total_stars: u64 = pixel_stars.iter().map(|(_, s)| s.len() as u64).sum();
let mut buf: Vec<u8> = Vec::new();
// Header (64 bytes)
buf.extend_from_slice(b"CCAT");
buf.extend_from_slice(&1u32.to_le_bytes());
buf.extend_from_slice(&order.to_le_bytes());
buf.extend_from_slice(&nside.to_le_bytes());
buf.extend_from_slice(&npix.to_le_bytes());
buf.extend_from_slice(&total_stars.to_le_bytes());
buf.extend_from_slice(&epoch.to_le_bytes());
buf.extend_from_slice(&mag_limit.to_le_bytes());
buf.extend_from_slice(&[0u8; 20]); // padding to 64 bytes
assert_eq!(buf.len(), HEADER_SIZE);
// Build sorted star data and compute offsets per pixel
let mut offsets: Vec<(u64, u32)> = vec![(0, 0); npix as usize];
let mut star_data: Vec<u8> = Vec::new();
for &(pixel_idx, ref stars) in pixel_stars {
let byte_offset = star_data.len() as u64;
offsets[pixel_idx as usize] = (byte_offset, stars.len() as u32);
for star in stars {
star_data.extend_from_slice(star_to_bytes(star));
}
}
// Pixel offset table (npix * 16 bytes)
for &(offset, count) in &offsets {
buf.extend_from_slice(&offset.to_le_bytes());
buf.extend_from_slice(&count.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes()); // reserved
}
// Star data section
buf.extend_from_slice(&star_data);
let mut file = NamedTempFile::new().unwrap();
file.write_all(&buf).unwrap();
file.flush().unwrap();
file
}
#[test]
fn test_open_valid_catalog() {
let star = make_star(1001, 180.0, -45.0, 5.5, FLAG_HAS_PROPER_MOTION);
let file = build_test_catalog(1, 2016.0, 21.0, &[(0, vec![star])]);
let catalog = Catalog::open(file.path()).unwrap();
let hdr = catalog.header();
assert_eq!(hdr.order, 1);
assert_eq!(hdr.nside, 2);
assert_eq!(hdr.npix, 48);
assert_eq!(hdr.total_stars, 1);
assert_eq!(hdr.epoch, 2016.0);
assert_eq!(hdr.mag_limit, 21.0);
}
#[test]
fn test_open_truncated_file() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(&[0u8; 32]).unwrap();
file.flush().unwrap();
let result = Catalog::open(file.path());
let msg = result.err().expect("expected error").to_string();
assert!(msg.contains("too small"), "unexpected error: {}", msg);
}
#[test]
fn test_open_bad_magic() {
let mut buf = vec![0u8; HEADER_SIZE + 48 * PIXEL_ENTRY_SIZE];
buf[0..4].copy_from_slice(b"XXXX");
buf[4..8].copy_from_slice(&1u32.to_le_bytes());
buf[8..12].copy_from_slice(&1u32.to_le_bytes()); // order=1
buf[12..16].copy_from_slice(&2u32.to_le_bytes()); // nside=2
buf[16..24].copy_from_slice(&48u64.to_le_bytes()); // npix=48
let mut file = NamedTempFile::new().unwrap();
file.write_all(&buf).unwrap();
file.flush().unwrap();
let result = Catalog::open(file.path());
let msg = result.err().expect("expected error").to_string();
assert!(
msg.contains("Invalid catalog magic"),
"unexpected error: {}",
msg
);
}
#[test]
fn test_open_bad_version() {
let mut buf = vec![0u8; HEADER_SIZE + 48 * PIXEL_ENTRY_SIZE];
buf[0..4].copy_from_slice(b"CCAT");
buf[4..8].copy_from_slice(&99u32.to_le_bytes()); // bad version
buf[8..12].copy_from_slice(&1u32.to_le_bytes());
buf[12..16].copy_from_slice(&2u32.to_le_bytes());
buf[16..24].copy_from_slice(&48u64.to_le_bytes());
let mut file = NamedTempFile::new().unwrap();
file.write_all(&buf).unwrap();
file.flush().unwrap();
let result = Catalog::open(file.path());
let msg = result.err().expect("expected error").to_string();
assert!(
msg.contains("Unsupported catalog version"),
"unexpected error: {}",
msg
);
}
#[test]
fn test_open_inconsistent_nside() {
let mut buf = vec![0u8; HEADER_SIZE + 48 * PIXEL_ENTRY_SIZE];
buf[0..4].copy_from_slice(b"CCAT");
buf[4..8].copy_from_slice(&1u32.to_le_bytes());
buf[8..12].copy_from_slice(&1u32.to_le_bytes()); // order=1
buf[12..16].copy_from_slice(&7u32.to_le_bytes()); // nside=7 (should be 2)
buf[16..24].copy_from_slice(&48u64.to_le_bytes());
let mut file = NamedTempFile::new().unwrap();
file.write_all(&buf).unwrap();
file.flush().unwrap();
let result = Catalog::open(file.path());
let msg = result.err().expect("expected error").to_string();
assert!(
msg.contains("Inconsistent nside"),
"unexpected error: {}",
msg
);
}
#[test]
fn test_open_inconsistent_npix() {
let mut buf = vec![0u8; HEADER_SIZE + 48 * PIXEL_ENTRY_SIZE];
buf[0..4].copy_from_slice(b"CCAT");
buf[4..8].copy_from_slice(&1u32.to_le_bytes());
buf[8..12].copy_from_slice(&1u32.to_le_bytes()); // order=1
buf[12..16].copy_from_slice(&2u32.to_le_bytes()); // nside=2 (correct)
buf[16..24].copy_from_slice(&999u64.to_le_bytes()); // npix=999 (should be 48)
let mut file = NamedTempFile::new().unwrap();
file.write_all(&buf).unwrap();
file.flush().unwrap();
let result = Catalog::open(file.path());
let msg = result.err().expect("expected error").to_string();
assert!(
msg.contains("Inconsistent npix"),
"unexpected error: {}",
msg
);
}
#[test]
fn test_stars_in_pixel_populated() {
let star = make_star(42, 83.633, -5.375, 0.42, FLAG_SOURCE_HIPPARCOS);
let file = build_test_catalog(1, 2016.0, 21.0, &[(5, vec![star])]);
let catalog = Catalog::open(file.path()).unwrap();
let stars = catalog.stars_in_pixel(5);
assert_eq!(stars.len(), 1);
assert_eq!(stars[0].source_id, 42);
}
#[test]
fn test_stars_in_pixel_empty() {
let star = make_star(1, 10.0, 20.0, 8.0, 0);
let file = build_test_catalog(1, 2016.0, 21.0, &[(0, vec![star])]);
let catalog = Catalog::open(file.path()).unwrap();
let stars = catalog.stars_in_pixel(1);
assert_eq!(stars.len(), 0);
}
#[test]
fn test_stars_in_pixel_out_of_bounds() {
let file = build_test_catalog(1, 2016.0, 21.0, &[]);
let catalog = Catalog::open(file.path()).unwrap();
assert_eq!(catalog.stars_in_pixel(48).len(), 0);
assert_eq!(catalog.stars_in_pixel(999).len(), 0);
assert_eq!(catalog.stars_in_pixel(u64::MAX).len(), 0);
}
#[test]
fn test_stars_in_pixel_multiple_stars() {
let stars = vec![
make_star(100, 10.0, 20.0, 5.0, 0),
make_star(101, 10.1, 20.1, 6.0, FLAG_HAS_PROPER_MOTION),
make_star(102, 10.2, 20.2, 7.0, FLAG_HAS_PARALLAX),
];
let file = build_test_catalog(1, 2016.0, 21.0, &[(12, stars)]);
let catalog = Catalog::open(file.path()).unwrap();
let result = catalog.stars_in_pixel(12);
assert_eq!(result.len(), 3);
assert_eq!(result[0].source_id, 100);
assert_eq!(result[1].source_id, 101);
assert_eq!(result[2].source_id, 102);
}
#[test]
fn test_file_size_matches() {
let star = make_star(1, 0.0, 0.0, 10.0, 0);
let file = build_test_catalog(1, 2016.0, 21.0, &[(0, vec![star])]);
let expected = HEADER_SIZE + 48 * PIXEL_ENTRY_SIZE + std::mem::size_of::<StarRecord>();
let catalog = Catalog::open(file.path()).unwrap();
assert_eq!(catalog.file_size(), expected);
}
#[test]
fn test_star_fields_round_trip() {
let star = StarRecord {
source_id: -9_999_999,
ra: 359.99999999,
dec: -89.99999999,
pmra: 1234.5678,
pmdec: -8765.4321,
parallax: 0.001,
mag: 21.49,
flags: FLAG_HAS_PROPER_MOTION | FLAG_HAS_PARALLAX | FLAG_SOURCE_HIPPARCOS,
_padding: 0,
};
let file = build_test_catalog(1, 2016.0, 21.5, &[(47, vec![star])]);
let catalog = Catalog::open(file.path()).unwrap();
let result = catalog.stars_in_pixel(47);
assert_eq!(result.len(), 1);
let s = &result[0];
assert_eq!(s.source_id, -9_999_999);
assert_eq!(s.ra, 359.99999999);
assert_eq!(s.dec, -89.99999999);
assert_eq!(s.pmra, 1234.5678);
assert_eq!(s.pmdec, -8765.4321);
assert_eq!(s.parallax, 0.001);
assert_eq!(s.mag, 21.49);
assert_eq!(
s.flags,
FLAG_HAS_PROPER_MOTION | FLAG_HAS_PARALLAX | FLAG_SOURCE_HIPPARCOS
);
}
#[test]
fn test_catalog_header_display() {
let header = CatalogHeader {
order: 8,
nside: 256,
npix: 786432,
total_stars: 37_000_000,
epoch: 2016.0,
mag_limit: 21.0,
};
let output = format!("{}", header);
assert!(output.contains("HEALPix order: 8"), "missing order");
assert!(output.contains("nside: 256"), "missing nside");
assert!(output.contains("npix: 786432"), "missing npix");
assert!(
output.contains("Total stars: 37000000"),
"missing total_stars"
);
assert!(output.contains("Epoch: J2016.0"), "missing epoch");
assert!(
output.contains("Magnitude limit: 21.00"),
"missing mag_limit"
);
assert!(
output.contains("Average stars per pixel: 47.0"),
"missing avg"
);
}
}
@@ -0,0 +1,214 @@
//! Cone search over a HEALPix-indexed star catalog.
//!
//! Given a sky position and radius, [`cone_search`] determines which HEALPix
//! pixels overlap the cone, scans only those pixels, filters by distance and
//! optional magnitude limit, and returns results sorted by angular distance.
//!
//! Proper motion can be propagated from the catalog epoch (J2016.0) to an
//! arbitrary observation epoch before matching.
use cosmos_time::JulianDate;
use super::catalog::{Catalog, StarRecord};
use super::healpix::{angular_separation_deg, query_disc_nest};
/// Julian date of epoch J2016.0 (Gaia DR3 reference epoch).
const J2016_JD: f64 = 2457389.0;
/// Parameters for a cone search query.
#[derive(Debug, Clone)]
pub struct ConeSearchParams {
/// Cone center right ascension, in degrees.
pub ra_deg: f64,
/// Cone center declination, in degrees.
pub dec_deg: f64,
/// Search radius, in degrees.
pub radius_deg: f64,
/// If set, exclude stars fainter than this magnitude.
pub max_mag: Option<f64>,
/// If set, return at most this many results (closest first).
pub max_results: Option<usize>,
/// If set, propagate proper motion from J2016.0 to this epoch before matching.
pub epoch: Option<JulianDate>,
}
/// A single star returned from a cone search.
#[derive(Debug, Clone)]
pub struct ConeSearchResult {
/// The original star record from the catalog.
pub star: StarRecord,
/// Right ascension used for matching (propagated if an epoch was given).
pub ra_deg: f64,
/// Declination used for matching (propagated if an epoch was given).
pub dec_deg: f64,
/// Angular distance from the search center, in degrees.
pub distance_deg: f64,
}
/// Convenience wrapper that runs a cone search with proper-motion propagation.
///
/// Equivalent to calling [`cone_search`] with `epoch` set and
/// no magnitude or result-count limits.
pub fn cone_search_at_epoch(
catalog: &Catalog,
ra_deg: f64,
dec_deg: f64,
radius_deg: f64,
epoch: JulianDate,
) -> Vec<ConeSearchResult> {
let params = ConeSearchParams {
ra_deg,
dec_deg,
radius_deg,
max_mag: None,
max_results: None,
epoch: Some(epoch),
};
cone_search(catalog, &params)
}
/// Search for stars within a cone on the sky.
///
/// Identifies overlapping HEALPix pixels, scans their star lists, applies
/// optional proper-motion propagation and magnitude filtering, then returns
/// results sorted by angular distance from the cone center.
pub fn cone_search(catalog: &Catalog, params: &ConeSearchParams) -> Vec<ConeSearchResult> {
let order = catalog.header().order;
let nside = 1 << order;
let overlapping_pixels =
query_disc_nest(nside, params.ra_deg, params.dec_deg, params.radius_deg);
let mut results = Vec::new();
for pixel in overlapping_pixels {
let stars = catalog.stars_in_pixel(pixel);
for star in stars {
let (ra_obs, dec_obs) = if let Some(epoch_jd) = params.epoch {
apply_proper_motion(star, epoch_jd)
} else {
(star.ra, star.dec)
};
let distance_deg =
angular_separation_deg(params.ra_deg, params.dec_deg, ra_obs, dec_obs);
if distance_deg > params.radius_deg {
continue;
}
if let Some(max_mag) = params.max_mag {
if star.mag as f64 > max_mag {
continue;
}
}
results.push(ConeSearchResult {
star: *star,
ra_deg: ra_obs,
dec_deg: dec_obs,
distance_deg,
});
}
}
results.sort_by(|a, b| {
a.distance_deg
.partial_cmp(&b.distance_deg)
.unwrap_or(std::cmp::Ordering::Equal)
});
if let Some(max_results) = params.max_results {
results.truncate(max_results);
}
results
}
/// Linearly propagate a star's position from J2016.0 to `epoch_jd`.
fn apply_proper_motion(star: &StarRecord, epoch_jd: JulianDate) -> (f64, f64) {
const MAS_PER_DEGREE: f64 = 3_600_000.0;
let dt_years = (epoch_jd - JulianDate::new(J2016_JD, 0.0)).to_f64() / 365.25;
let dec_obs = star.dec + star.pmdec * dt_years / MAS_PER_DEGREE;
let cos_dec = libm::cos(star.dec * cosmos_core::constants::PI / 180.0);
let ra_obs = star.ra + star.pmra * dt_years / MAS_PER_DEGREE / cos_dec;
(ra_obs, dec_obs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_angular_distance_same_point() {
let dist = angular_separation_deg(0.0, 0.0, 0.0, 0.0);
assert!((dist - 0.0).abs() < 1e-10);
}
#[test]
fn test_angular_distance_90_degrees() {
let dist = angular_separation_deg(0.0, 0.0, 90.0, 0.0);
assert!((dist - 90.0).abs() < 1e-10);
}
#[test]
fn test_angular_distance_pole_to_equator() {
let dist = angular_separation_deg(0.0, 90.0, 0.0, 0.0);
assert!((dist - 90.0).abs() < 1e-10);
}
#[test]
fn test_angular_distance_antipodes() {
let dist = angular_separation_deg(0.0, 0.0, 180.0, 0.0);
assert!((dist - 180.0).abs() < 1e-10);
}
#[test]
fn test_apply_proper_motion_zero_pm() {
let star = StarRecord {
source_id: 1,
ra: 100.0,
dec: 45.0,
pmra: 0.0,
pmdec: 0.0,
parallax: 0.0,
mag: 5.0,
flags: 0,
_padding: 0,
};
let (ra, dec) = apply_proper_motion(&star, JulianDate::new(J2016_JD, 0.0).add_days(365.25));
assert!((ra - 100.0).abs() < 1e-10);
assert!((dec - 45.0).abs() < 1e-10);
}
#[test]
fn test_apply_proper_motion_one_year() {
let star = StarRecord {
source_id: 1,
ra: 100.0,
dec: 45.0,
pmra: 3600.0,
pmdec: 3600.0,
parallax: 0.0,
mag: 5.0,
flags: 0,
_padding: 0,
};
let (ra, dec) = apply_proper_motion(&star, JulianDate::new(J2016_JD, 0.0).add_days(365.25));
// pmdec is sky rate, converts directly: 3600 mas/yr = 0.001 deg/yr
let expected_dec = 45.0 + (3600.0 / 3_600_000.0);
assert!((dec - expected_dec).abs() < 1e-10);
// pmra is μα* = μα·cos(δ), so ΔRA = μα*/cos(δ) · Δt
let cos_dec = libm::cos(45.0_f64 * cosmos_core::constants::PI / 180.0);
let expected_ra = 100.0 + (3600.0 / 3_600_000.0) / cos_dec;
assert!((ra - expected_ra).abs() < 1e-10);
}
}
@@ -0,0 +1,304 @@
//! HEALPix utilities for cone search queries.
//!
//! Provides conversion between sky coordinates and HEALPix pixel indices,
//! as well as disc/cone query support for efficient spatial searches.
use cosmos_core::constants::{PI, RAD_TO_DEG, TWOPI};
use cosmos_core::{math::vincenty_angular_separation, Angle};
use std::collections::HashSet;
/// Convert (RA, Dec) in degrees to HEALPix nested pixel index.
///
/// Implements the Gorski et al. (2005) algorithm for the nested scheme.
///
/// # Arguments
/// * `order` - HEALPix order (nside = 2^order)
/// * `ra_deg` - Right ascension in degrees
/// * `dec_deg` - Declination in degrees
///
/// # Returns
/// Nested pixel index in range [0, 12*nside^2)
pub fn ang2pix_nest(order: u32, ra_deg: f64, dec_deg: f64) -> u64 {
let ra = Angle::from_degrees(ra_deg);
let dec = Angle::from_degrees(dec_deg);
let phi = ra.radians();
let z = dec.sin();
let nside = 1u64 << order;
let (face, ix, iy) = compute_face_and_position(phi, z, nside);
let ipix_in_face = xy2pix_nest(ix, iy, order);
face as u64 * nside * nside + ipix_in_face
}
/// Query all HEALPix pixels that overlap a cone/disc on the sphere.
///
/// Returns a conservative set of pixels - may include some pixels that
/// don't actually overlap the cone, but will never miss pixels that do.
///
/// Uses a grid-based approach: samples points within the search cone and
/// collects all unique pixel indices that those points fall into.
///
/// # Arguments
/// * `nside` - HEALPix nside parameter
/// * `ra_deg` - Cone center right ascension in degrees
/// * `dec_deg` - Cone center declination in degrees
/// * `radius_deg` - Cone radius in degrees
///
/// # Returns
/// Vector of nested pixel indices that overlap the cone
pub(crate) fn query_disc_nest(nside: u64, ra_deg: f64, dec_deg: f64, radius_deg: f64) -> Vec<u64> {
let order = nside.trailing_zeros();
// Pixel size in degrees (approximate) - for order 8, ~0.22 degrees
let pixel_size_deg = 58.6 / nside as f64; // 58.6 = sqrt(4*pi*(180/pi)^2 / 12) / 1
let step = pixel_size_deg * 0.5; // Half-pixel resolution for safety
let mut pixels = HashSet::new();
// Declination range — pad by one pixel to catch pixels straddling the boundary
let dec_min = (dec_deg - radius_deg - pixel_size_deg).max(-90.0);
let dec_max = (dec_deg + radius_deg + pixel_size_deg).min(90.0);
// Step through declination
let mut dec = dec_min;
while dec <= dec_max {
// RA range expands near poles due to convergence
let cos_dec = libm::cos(dec * PI / 180.0).max(0.01);
let ra_step = step / cos_dec;
// For very high declinations, we need full RA coverage
let ra_range = if libm::fabs(dec) > 89.0 {
360.0
} else {
(radius_deg / cos_dec).min(180.0) * 2.0
};
let ra_min = ra_deg - ra_range / 2.0;
let ra_max = ra_deg + ra_range / 2.0;
let mut ra = ra_min;
while ra <= ra_max {
// Normalize RA to [0, 360)
let ra_norm = ((ra % 360.0) + 360.0) % 360.0;
// Check if this point is actually within the search radius
let dist = angular_separation_deg(ra_deg, dec_deg, ra_norm, dec);
if dist <= radius_deg + pixel_size_deg {
// Include margin for pixel extent
let pixel = ang2pix_nest(order, ra_norm, dec);
pixels.insert(pixel);
}
ra += ra_step;
}
dec += step;
}
pixels.into_iter().collect()
}
/// Compute angular distance between two points on the sphere using Vincenty formula.
///
/// Accurate at all angular separations.
///
/// # Arguments
/// * `ra1_deg`, `dec1_deg` - First point in degrees
/// * `ra2_deg`, `dec2_deg` - Second point in degrees
///
/// # Returns
/// Angular distance in degrees
pub(crate) fn angular_separation_deg(
ra1_deg: f64,
dec1_deg: f64,
ra2_deg: f64,
dec2_deg: f64,
) -> f64 {
let dec1 = Angle::from_degrees(dec1_deg);
let dec2 = Angle::from_degrees(dec2_deg);
let delta_lon = Angle::from_degrees(ra2_deg - ra1_deg).radians();
let (d1_sin, d1_cos) = dec1.sin_cos();
let (d2_sin, d2_cos) = dec2.sin_cos();
let sep_rad = vincenty_angular_separation(d1_sin, d1_cos, d2_sin, d2_cos, delta_lon);
sep_rad * RAD_TO_DEG
}
/// Determine which of the 12 HEALPix base faces contains the point,
/// and compute the (ix, iy) position within that face.
fn compute_face_and_position(phi: f64, z: f64, nside: u64) -> (u32, u64, u64) {
let z_abs = libm::fabs(z);
let tt = phi_to_tt(phi);
if z_abs <= 2.0 / 3.0 {
compute_equatorial_face(tt, z, nside)
} else {
compute_polar_face(tt, z, z_abs, nside)
}
}
/// Convert phi to tt (0..4 range for the 4 quadrants).
fn phi_to_tt(phi: f64) -> f64 {
let phi_norm = if phi < 0.0 { phi + TWOPI } else { phi };
phi_norm * 2.0 / PI
}
/// Compute face and position for equatorial belt (-2/3 <= z <= 2/3).
fn compute_equatorial_face(tt: f64, z: f64, nside: u64) -> (u32, u64, u64) {
let temp1 = nside as f64 * (0.5 + tt);
let temp2 = nside as f64 * z * 0.75;
let jp = (temp1 - temp2) as i64;
let jm = (temp1 + temp2) as i64;
let nside_i = nside as i64;
let ifp = jp / nside_i;
let ifm = jm / nside_i;
let face = compute_equatorial_face_number(ifp, ifm);
let ix = jm - (face as i64 % 4) * nside_i;
let iy = nside_i - 1 - (jp - (face as i64 / 4) * nside_i);
(face, ix as u64, iy as u64)
}
fn compute_equatorial_face_number(ifp: i64, ifm: i64) -> u32 {
match (ifp, ifm) {
(4, _) => ((ifm + 4) % 4) as u32,
(_, 4) => ((ifp + 4) % 4 + 4) as u32,
_ if ifp == ifm => (ifp + 4) as u32,
_ if ifp < ifm => ifp as u32,
_ => (ifm + 8) as u32,
}
}
/// Compute face and position for polar caps (|z| > 2/3).
fn compute_polar_face(tt: f64, z: f64, z_abs: f64, nside: u64) -> (u32, u64, u64) {
let tp = tt - libm::floor(tt);
let tmp = nside as f64 * libm::sqrt(3.0 * (1.0 - z_abs));
let jp = (tp * tmp) as i64;
let jm = ((1.0 - tp) * tmp) as i64;
let jp = jp.min(nside as i64 - 1);
let jm = jm.min(nside as i64 - 1);
let ntt = libm::floor(tt) as u32;
let face_offset = if z > 0.0 { 0 } else { 8 };
let face = (ntt % 4) + face_offset;
let (ix, iy) = if z > 0.0 {
(nside as i64 - jm - 1, nside as i64 - jp - 1)
} else {
(jp, jm)
};
(face, ix as u64, iy as u64)
}
/// Convert (ix, iy) to nested pixel index within a base face using Z-order curve.
fn xy2pix_nest(ix: u64, iy: u64, order: u32) -> u64 {
let mut result: u64 = 0;
for i in 0..order {
let bit_x = (ix >> i) & 1;
let bit_y = (iy >> i) & 1;
result |= (bit_x << (2 * i)) | (bit_y << (2 * i + 1));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xy2pix_nest() {
assert_eq!(xy2pix_nest(0, 0, 2), 0);
assert_eq!(xy2pix_nest(1, 0, 2), 1);
assert_eq!(xy2pix_nest(0, 1, 2), 2);
assert_eq!(xy2pix_nest(1, 1, 2), 3);
}
#[test]
fn test_ang2pix_nest_poles() {
let north_pole = ang2pix_nest(0, 0.0, 90.0);
assert!(north_pole < 12);
let south_pole = ang2pix_nest(0, 0.0, -90.0);
assert!(south_pole < 12);
}
#[test]
fn test_ang2pix_nest_equator() {
let pixel = ang2pix_nest(0, 0.0, 0.0);
assert!(pixel < 12);
}
#[test]
fn test_ang2pix_nest_order8_bounds() {
let nside = 1u64 << 8;
let npix = 12 * nside * nside;
for ra in [0.0, 90.0, 180.0, 270.0] {
for dec in [-89.0, -45.0, 0.0, 45.0, 89.0] {
let pixel = ang2pix_nest(8, ra, dec);
assert!(
pixel < npix,
"pixel {} >= npix {} for ({}, {})",
pixel,
npix,
ra,
dec
);
}
}
}
#[test]
fn test_angular_separation_deg() {
// Same point
assert!((angular_separation_deg(0.0, 0.0, 0.0, 0.0) - 0.0).abs() < 1e-10);
// 90 degrees apart on equator
let dist = angular_separation_deg(0.0, 0.0, 90.0, 0.0);
assert!((dist - 90.0).abs() < 1e-10);
// Pole to pole
let dist = angular_separation_deg(0.0, 90.0, 0.0, -90.0);
assert!((dist - 180.0).abs() < 1e-10);
// Small separation
let dist = angular_separation_deg(0.0, 0.0, 0.1, 0.1);
assert!(dist > 0.14 && dist < 0.15);
}
#[test]
fn test_query_disc_nest_basic() {
let nside = 16u64;
let ra = 0.0;
let dec = 0.0;
let radius = 10.0;
let pixels = query_disc_nest(nside, ra, dec, radius);
// Should return some pixels
assert!(!pixels.is_empty());
// All pixels should be in valid range
let npix = 12 * nside * nside;
for &pix in &pixels {
assert!(pix < npix);
}
// Center pixel should be included
let order = nside.trailing_zeros();
let center_pixel = ang2pix_nest(order, ra, dec);
assert!(pixels.contains(&center_pixel));
}
#[test]
fn test_query_disc_nest_pole() {
let nside = 16u64;
let ra = 0.0;
let dec = 90.0;
let radius = 5.0;
let pixels = query_disc_nest(nside, ra, dec, radius);
// Should return pixels around north pole
assert!(!pixels.is_empty());
// Center pixel should be included
let order = nside.trailing_zeros();
let center_pixel = ang2pix_nest(order, ra, dec);
assert!(pixels.contains(&center_pixel));
}
}
@@ -0,0 +1,14 @@
//! Query interface for HEALPix-indexed star catalogs.
//!
//! Three submodules cover the full query surface:
//!
//! - [`catalog`] — open a catalog file, access the header, read stars by pixel
//! - [`cone`] — cone search with magnitude filtering and proper-motion propagation
//! - [`healpix`] — coordinate-to-pixel conversion, disc queries, angular separation
pub mod catalog;
pub mod cone;
pub mod healpix;
pub use catalog::{Catalog, CatalogHeader, StarRecord};
pub use cone::{cone_search, cone_search_at_epoch, ConeSearchParams, ConeSearchResult};
@@ -0,0 +1,61 @@
#![cfg(feature = "integration-tests")]
use cosmos_catalog::query::catalog::Catalog;
const TEST_CATALOG: &str = "data/catalog.bin";
#[test]
fn test_catalog_open() {
let catalog = Catalog::open(TEST_CATALOG).expect("Failed to open catalog");
let header = catalog.header();
assert_eq!(header.order, 8);
assert_eq!(header.nside, 256);
assert_eq!(header.npix, 786432);
assert!(header.total_stars > 0);
assert_eq!(header.epoch, 2016.0);
}
#[test]
fn test_stars_in_pixel() {
let catalog = Catalog::open(TEST_CATALOG).expect("Failed to open catalog");
let stars = catalog.stars_in_pixel(100000);
assert!(!stars.is_empty(), "Expected non-empty pixel");
for star in stars {
assert!(star.ra >= 0.0 && star.ra < 360.0, "Invalid RA");
assert!(star.dec >= -90.0 && star.dec <= 90.0, "Invalid Dec");
assert!(star.mag >= 0.0 && star.mag < 30.0, "Invalid magnitude");
}
}
#[test]
fn test_out_of_bounds_pixel() {
let catalog = Catalog::open(TEST_CATALOG).expect("Failed to open catalog");
let header = catalog.header();
let stars = catalog.stars_in_pixel(header.npix + 1000);
assert_eq!(
stars.len(),
0,
"Out of bounds pixel should return empty slice"
);
}
#[test]
fn test_magnitude_sorting() {
let catalog = Catalog::open(TEST_CATALOG).expect("Failed to open catalog");
let stars = catalog.stars_in_pixel(100000);
assert!(stars.len() > 1, "Need multiple stars to test sorting");
for i in 1..stars.len() {
assert!(
stars[i].mag >= stars[i - 1].mag,
"Stars not sorted by magnitude: {} >= {}",
stars[i].mag,
stars[i - 1].mag
);
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "cosmos-cli"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tahuantinsuyu — CLI cliente del service socket. Pide cómputos de cartas sin abrir la GUI."
[dependencies]
cosmos-card = { path = "../cosmos-card" }
cosmos-model = { path = "../cosmos-model" }
clap = { workspace = true }
tokio = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
[[bin]]
name = "cosmos-cli"
path = "src/main.rs"
+19
View File
@@ -0,0 +1,19 @@
# cosmos-cli
> CLI de [cosmos](../README.md).
Comandos:
```sh
cosmos-cli download # DE files + catálogos
cosmos-cli when "venus rises" # próximo rise de venus
cosmos-cli where mars # posición actual de marte
cosmos-cli eclipses --next 5 # próximos 5 eclipses
cosmos-cli sky --time now # snapshot del cielo
cosmos-cli validate # corre el regression harness
```
## Deps
- Todos los `cosmos-*` core
- `clap`, `serde_json`
+19
View File
@@ -0,0 +1,19 @@
# cosmos-cli
> CLI of [cosmos](../README.md).
Commands:
```sh
cosmos-cli download # DE files + catalogs
cosmos-cli when "venus rises" # next venus rise
cosmos-cli where mars # current mars position
cosmos-cli eclipses --next 5 # next 5 eclipses
cosmos-cli sky --time now # sky snapshot
cosmos-cli validate # runs the regression harness
```
## Deps
- All `cosmos-*` core
- `clap`, `serde_json`
+164
View File
@@ -0,0 +1,164 @@
//! `cosmos_app-cli` — cliente del service socket de Tahuantinsuyu.
//!
//! Pide cómputos de cartas sin abrir la GUI. Útil para integraciones,
//! scripts y para verificar end-to-end que el data plane brahman está
//! sirviendo. Conecta al socket que la app GUI expone (default
//! `$XDG_CACHE_HOME/cosmos_app/service.sock`).
//!
//! ## Comandos
//!
//! - `ping` — verifica que el server responde.
//! - `natal --year N --month M --day D --hour H --minute MIN
//! --tz-min TZ --lat LAT --lon LON [--alt ALT] [--label TEXT]`
//! — pide una carta natal y la imprime como JSON.
//!
//! ## Ejemplo
//!
//! ```bash
//! cargo run -p cosmos_app-cli -- natal \
//! --year 1987 --month 3 --day 14 \
//! --hour 5 --minute 22 --tz-min -240 \
//! --lat 10.4806 --lon -66.9036 \
//! --label "Sergio"
//! ```
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use cosmos_card::service::{self, ComputeRequest, ComputeResponse};
use cosmos_model::{StoredBirthData, StoredChartConfig};
#[derive(Parser)]
#[command(
name = "cosmos_app-cli",
version,
about = "Cliente del service socket de Tahuantinsuyu."
)]
struct Cli {
/// Path al service socket. Default: el resuelto por
/// `service::default_service_socket()`.
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Health check — verifica que el server responde con Pong.
Ping,
/// Pide el cómputo de una carta natal e imprime el RenderModel
/// como JSON.
Natal {
#[arg(long)]
year: i32,
#[arg(long)]
month: u32,
#[arg(long)]
day: u32,
#[arg(long)]
hour: u32,
#[arg(long)]
minute: u32,
#[arg(long, default_value_t = 0.0)]
second: f64,
/// Offset de zona horaria del lugar de nacimiento, en minutos.
/// Ej: Argentina = -180, UTC = 0, Madrid = 60.
#[arg(long = "tz-min")]
tz_offset_minutes: i32,
#[arg(long)]
lat: f64,
#[arg(long)]
lon: f64,
#[arg(long, default_value_t = 0.0)]
alt: f64,
/// Etiqueta del chart para el title del RenderModel.
#[arg(long)]
label: Option<String>,
/// Offset adicional en minutos sobre el instante natal (útil
/// para rectificación rápida sin guardar variantes).
#[arg(long, default_value_t = 0)]
offset_minutes: i64,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let socket = cli
.socket
.unwrap_or_else(service::default_service_socket);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.context("crear tokio runtime")?;
rt.block_on(async {
match cli.command {
Command::Ping => {
let response = service::request(&socket, &ComputeRequest::Ping)
.await
.with_context(|| format!("ping a {}", socket.display()))?;
match response {
ComputeResponse::Pong => {
println!("pong");
Ok(())
}
other => Err(anyhow!("respuesta inesperada al ping: {:?}", other)),
}
}
Command::Natal {
year,
month,
day,
hour,
minute,
second,
tz_offset_minutes,
lat,
lon,
alt,
label,
offset_minutes,
} => {
let request = ComputeRequest::Natal {
birth: StoredBirthData {
year,
month,
day,
hour,
minute,
second,
tz_offset_minutes,
latitude_deg: lat,
longitude_deg: lon,
altitude_m: alt,
time_certainty: Default::default(),
subject_name: label.clone(),
birthplace_label: None,
},
config: StoredChartConfig::default(),
offset_minutes,
label,
};
let response = service::request(&socket, &request)
.await
.with_context(|| format!("natal request a {}", socket.display()))?;
match response {
ComputeResponse::Render { render } => {
let json = serde_json::to_string_pretty(&render)
.context("serializar RenderModel a JSON")?;
println!("{}", json);
Ok(())
}
ComputeResponse::Error { message } => {
Err(anyhow!("server reportó error: {}", message))
}
other => Err(anyhow!("respuesta inesperada al natal: {:?}", other)),
}
}
}
})
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "cosmos-coords"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Astronomical coordinate transformations"
keywords = ["astronomy", "coordinates", "celestial", "astrometry", "icrs"]
categories = ["science"]
[dependencies]
cosmos-core.workspace = true
celestial-eop-data.workspace = true
cosmos-time.workspace = true
libm.workspace = true
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
thiserror.workspace = true
[features]
default = []
serde = ["dep:serde", "dep:serde_json", "cosmos-core/serde", "cosmos-time/serde"]
[dev-dependencies]
criterion.workspace = true
reqwest.workspace = true
serde_json.workspace = true
tokio.workspace = true
+217
View File
@@ -0,0 +1,217 @@
# cosmos-coords
Type-safe astronomical coordinate transformations between reference frames.
[![Crates.io](https://img.shields.io/crates/v/cosmos-coords)](https://crates.io/crates/cosmos-coords)
[![Documentation](https://docs.rs/cosmos-coords/badge.svg)](https://docs.rs/cosmos-coords)
[![License: Apache 2.0](https://img.shields.io/crates/l/cosmos-coords)](https://gitea.gioser.net/sergio/eternal)
Pure Rust implementation of coordinate frame transformations with full aberration, light deflection, and Earth orientation support. Each frame is a distinct type to prevent accidental mixing. ICRS serves as the pivot for all transformations.
## Installation
```toml
[dependencies]
cosmos-coords = "0.1"
```
## Coordinate Frames
| Frame | Description |
|--------------------------|------------------------------------------------------------------------|
| `ICRSPosition` | International Celestial Reference System (catalog positions, J2000) |
| `CIRSPosition` | Celestial Intermediate Reference System (precession + nutation + bias) |
| `GCRSPosition` | Geocentric Celestial Reference System |
| `TIRSPosition` | Terrestrial Intermediate Reference System |
| `ITRSPosition` | International Terrestrial Reference System (ECEF) |
| `GalacticPosition` | Galactic coordinates (l, b) with IAU standard pole |
| `EclipticPosition` | Ecliptic coordinates with IAU 2006 obliquity |
| `TopocentricPosition` | Observer-specific azimuth/elevation |
| `HourAnglePosition` | Hour angle + declination for a given observer |
| `HeliographicCarrington` | Solar surface coordinates (Carrington rotation) |
| `HeliographicStonyhurst` | Solar surface coordinates (fixed grid) |
| `SelenographicPosition` | Lunar surface coordinates |
## Modules
| Module | Purpose |
|--------------|-------------------------------------------------------------|
| `frames` | Coordinate frame types and conversions |
| `transforms` | `CoordinateFrame` trait, Cartesian utilities |
| `distance` | Distance type with parsec/AU/ly/km conversions |
| `eop` | Earth Orientation Parameters (polar motion, UT1-UTC) |
| `aberration` | Stellar aberration and gravitational light deflection |
| `lighttime` | Light-time correction for proper motion and radial velocity |
| `solar` | Solar orientation (B0, L0, P angle, Carrington rotation) |
| `lunar` | Lunar libration and orientation |
## Example
```rust
use eternal_coords::{ICRSPosition, GalacticPosition, Distance};
use eternal_coords::transforms::CoordinateFrame;
use eternal_time::TT;
// Create a position in ICRS (catalog coordinates)
let sirius = ICRSPosition::from_hours_degrees(6.752, -16.716)?;
// Transform to Galactic coordinates
let epoch = TT::j2000();
let galactic = sirius.to_galactic(&epoch)?;
println!("l = {:.2}°, b = {:.2}°", galactic.longitude().degrees(), galactic.latitude().degrees());
// With distance (parallax-derived)
let distance = Distance::from_parallax_milliarcsec(379.21)?;
let proxima = ICRSPosition::from_degrees_with_distance(217.42, -62.68, distance)?;
println!("Distance: {:.2} pc", proxima.distance().unwrap().parsecs());
```
## Transformation Chain
The full IAU 2000/2006 transformation from catalog to telescope has two paths from ICRS.
**CIRS path** (full pipeline -- precession, nutation, aberration, light deflection):
```text
ICRS (catalog)
| frame bias + precession + nutation (IAU 2006A)
| stellar aberration (~20.5")
| gravitational light deflection (~1.75" max)
v
CIRS (geocentric apparent)
| Earth Rotation Angle
v
TIRS
| polar motion (EOP)
v
ITRS (terrestrial)
```
**GCRS path** (aberration only -- no light deflection, no precession/nutation):
```text
ICRS (catalog)
| stellar aberration only
v
GCRS (geocentric, no Earth rotation applied)
```
Use the CIRS path for telescope pointing and observational work. GCRS is used for intermediate calculations where you need aberration correction without the full pipeline.
All transformations route through ICRS as the pivot frame. The `CoordinateFrame` trait provides:
```rust
pub trait CoordinateFrame: Sized {
fn to_icrs(&self, epoch: &TT) -> CoordResult<ICRSPosition>;
fn from_icrs(icrs: &ICRSPosition, epoch: &TT) -> CoordResult<Self>;
}
```
Eight frames implement `CoordinateFrame`: `ICRSPosition`, `GCRSPosition`, `CIRSPosition`, `GalacticPosition`, `EclipticPosition`, `HeliographicStonyhurst`, `HeliographicCarrington`, `SelenographicPosition`.
Four frames do **not** implement it: `TIRSPosition`, `ITRSPosition`, `TopocentricPosition`, `HourAnglePosition`. These require Earth Orientation Parameters or an observer location beyond just an epoch, so they use dedicated conversion methods instead.
## Earth Orientation Parameters
Required for CIRS to ITRS transformations (polar motion, UT1-UTC):
```rust
use eternal_coords::eop::EopProvider;
// Bundled IERS C04 + finals2000A data (1962-present + predictions)
let provider = EopProvider::bundled()?;
// Get parameters for a specific MJD
let params = provider.get(60000.0)?;
println!("UT1-UTC = {:.4} s", params.ut1_utc);
println!("Polar motion: x={:.6}\", y={:.6}\"", params.x_p, params.y_p);
```
Additional constructors:
```rust
// C04 data only (no finals2000A predictions)
let provider = EopProvider::bundled_c04()?;
// Parse a finals2000A file from disk
let provider = EopProvider::from_finals_file("/path/to/finals2000A.data")?;
// Bundled data merged with a newer finals file (extends prediction range)
let provider = EopProvider::bundled_with_update("/path/to/finals2000A.data")?;
// From raw finals2000A text
let provider = EopProvider::from_finals_str(&text_content)?;
```
## Topocentric Observations
```rust
use eternal_coords::{TopocentricPosition, Distance};
use eternal_core::{Angle, Location};
use eternal_time::TT;
let observer = Location::from_degrees(19.8283, -155.4783, 4145.0)?; // Keck
let epoch = TT::j2000();
let moon_distance = Distance::from_kilometers(384400.0)?;
let moon = TopocentricPosition::with_distance(
Angle::from_degrees(180.0),
Angle::from_degrees(45.0),
observer,
epoch,
moon_distance,
)?;
// Airmass (Rozenberg formula)
println!("Airmass: {:.2}", moon.air_mass());
// Atmospheric refraction (standard conditions)
let refraction = moon.atmospheric_refraction(1013.25, 15.0, 0.5, 0.574);
println!("Refraction: {:.1}\"", refraction.arcseconds());
// Diurnal parallax
let parallax = moon.diurnal_parallax().unwrap();
println!("Parallax: {:.1}'", parallax.arcminutes());
```
## Solar and Lunar Coordinates
```rust
use eternal_coords::solar::{compute_solar_orientation, carrington_rotation_number};
use eternal_coords::lunar::compute_optical_libration;
use eternal_time::TT;
let epoch = TT::j2000();
// Solar orientation
let solar = compute_solar_orientation(&epoch);
println!("B0 = {:.2}°", solar.b0.degrees());
println!("L0 = {:.2}°", solar.l0.degrees());
println!("Carrington rotation: {}", carrington_rotation_number(&epoch));
// Lunar libration
let (lib_lon, lib_lat) = compute_optical_libration(&epoch);
println!("Libration: lon={:.2}°, lat={:.2}°", lib_lon.degrees(), lib_lat.degrees());
```
## Features
- **`serde`** - Serialization for coordinate types and EOP records
## License
Licensed under the Apache License, Version 2.0
([LICENSE-APACHE](../LICENSE-APACHE) or
<https://www.apache.org/licenses/LICENSE-2.0>).
See [NOTICE](../NOTICE) for upstream attribution.
## Acknowledgements
Forked from [celestial](https://github.com/gaker/celestial) by **Greg Aker**
(originally dual-licensed under MIT OR Apache-2.0). This crate is derived
directly from that work and is maintained in this fork by Sergio Velásquez
Zeballos with Claude (Anthropic).
## Contributing
See the [repository](https://gitea.gioser.net/sergio/eternal) for contribution guidelines.
@@ -0,0 +1,152 @@
use cosmos_coords::eop::record::EopRecord;
use cosmos_coords::frames::{CIRSPosition, ICRSPosition};
use cosmos_coords::transforms::CoordinateFrame;
use cosmos_coords::{Distance, EopProvider, Location};
use cosmos_time::{tt_from_calendar, ToTAI, ToUTC};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// --- Setup: observer, time, EOP ---
// McDonald Observatory, Texas
let observer = Location::from_degrees(30.6714, -104.0225, 2070.0)?;
// 2023-06-15 03:00:00 TT (nighttime in Texas)
let tt = tt_from_calendar(2023, 6, 15, 3, 0, 0.0);
let utc = tt.to_tai()?.to_utc()?;
println!("Epoch: TT JD = {:.6}", tt.to_julian_date().to_f64());
println!(" UTC JD = {:.6}", utc.to_julian_date().to_f64());
println!(
"Observer: McDonald Observatory ({:.4}°N, {:.4}°W, {:.0}m)\n",
observer.latitude_angle().degrees(),
-observer.longitude_angle().degrees(),
2070.0
);
// Realistic EOP data around this date
let eop_records = vec![
EopRecord::new(60108.0, 0.183, 0.343, -0.0298, 0.00071)?.with_cip_offsets(0.198, -0.102)?,
EopRecord::new(60109.0, 0.184, 0.341, -0.0305, 0.00073)?.with_cip_offsets(0.201, -0.105)?,
EopRecord::new(60110.0, 0.185, 0.339, -0.0312, 0.00075)?.with_cip_offsets(0.204, -0.108)?,
EopRecord::new(60111.0, 0.186, 0.337, -0.0319, 0.00077)?.with_cip_offsets(0.207, -0.111)?,
EopRecord::new(60112.0, 0.187, 0.335, -0.0326, 0.00079)?.with_cip_offsets(0.210, -0.114)?,
];
let provider = EopProvider::from_records(eop_records)?;
// --- Vega: ICRS catalog position ---
let vega = ICRSPosition::from_hours_degrees(18.61564, 38.78369)?;
println!("=== Vega ===");
println!(
"ICRS: RA = {:.5}h Dec = {:+.5}°",
vega.ra().hours(),
vega.dec().degrees()
);
// ICRS → CIRS (applies frame bias, precession, nutation, aberration, light deflection)
let cirs = CIRSPosition::from_icrs(&vega, &tt)?;
println!(
"CIRS: RA = {:.5}h Dec = {:+.5}°",
cirs.ra().hours(),
cirs.dec().degrees()
);
// CIRS → Hour Angle / Declination
let mjd = tt.to_julian_date().to_f64() - 2400000.5;
let mut eop = provider.get(mjd)?;
eop.compute_s_prime();
let delta_t = eop.ut1_utc; // UT1-UTC in seconds
let ha_pos = cirs.to_hour_angle(&observer, -delta_t)?;
println!(
"HA/Dec: HA = {:.5}h Dec = {:+.5}°",
ha_pos.hour_angle().hours(),
ha_pos.declination().degrees()
);
// Hour Angle → Topocentric (Az/El)
let topo = ha_pos.to_topocentric()?;
println!(
"Topo: Az = {:.4}° El = {:.4}°",
topo.azimuth().degrees(),
topo.elevation().degrees()
);
println!(" Air mass = {:.3}", topo.air_mass());
// Atmospheric refraction at standard conditions
let refraction = topo.atmospheric_refraction(1013.25, 15.0, 0.3, 0.55);
println!(" Refraction = {:.2}\"", refraction.degrees() * 3600.0);
println!();
// --- CIRS → TIRS → ITRS (full EOP chain) ---
println!("=== Full IAU 2000/2006 chain ===");
let tirs = cirs.to_tirs(&eop)?;
println!("TIRS: ({:.9}, {:.9}, {:.9})", tirs.x(), tirs.y(), tirs.z());
let itrs = tirs.to_itrs(&tt, &eop)?;
println!("ITRS: ({:.9}, {:.9}, {:.9})", itrs.x(), itrs.y(), itrs.z());
println!();
// --- Roundtrip precision test: ICRS → CIRS → ICRS ---
let roundtrip = cirs.to_icrs(&tt)?;
let ra_diff_mas = (roundtrip.ra().degrees() - vega.ra().degrees()).abs() * 3600.0 * 1000.0;
let dec_diff_mas = (roundtrip.dec().degrees() - vega.dec().degrees()).abs() * 3600.0 * 1000.0;
println!("=== Roundtrip precision (ICRS → CIRS → ICRS) ===");
println!(" ΔRA = {:.6} mas", ra_diff_mas);
println!(" ΔDec = {:.6} mas", dec_diff_mas);
println!();
// --- Sirius: with distance ---
let sirius = ICRSPosition::from_hours_degrees(6.75248, -16.71612)?;
let sirius = sirius.with_distance_value(Distance::from_parsecs(2.637)?);
println!(
"=== Sirius (d = {:.3} pc = {:.2} ly) ===",
sirius.distance().unwrap().parsecs(),
sirius.distance().unwrap().light_years()
);
let cirs = CIRSPosition::from_icrs(&sirius, &tt)?;
let ha_pos = cirs.to_hour_angle(&observer, -delta_t)?;
let topo = ha_pos.to_topocentric()?;
println!(
"ICRS: RA = {:.5}h Dec = {:+.5}°",
sirius.ra().hours(),
sirius.dec().degrees()
);
println!(
"Topo: Az = {:.4}° El = {:.4}°",
topo.azimuth().degrees(),
topo.elevation().degrees()
);
if topo.elevation().degrees() < 0.0 {
println!(" (below horizon)");
} else {
println!(" Air mass = {:.3}", topo.air_mass());
}
println!();
// --- Angular separation ---
let betelgeuse = ICRSPosition::from_hours_degrees(5.91953, 7.40706)?;
let rigel = ICRSPosition::from_hours_degrees(5.24230, -8.20164)?;
let sep = betelgeuse.angular_separation(&rigel);
println!("=== Angular separation ===");
println!("Betelgeuse ↔ Rigel = {:.4}°", sep.degrees());
Ok(())
}
trait WithDistanceValue {
fn with_distance_value(self, distance: Distance) -> Self;
}
impl WithDistanceValue for ICRSPosition {
fn with_distance_value(mut self, distance: Distance) -> Self {
self.set_distance(distance);
self
}
}
@@ -0,0 +1,67 @@
use cosmos_coords::eop::record::EopRecord;
use cosmos_coords::EopProvider;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// --- Bundled IERS data ---
// The library ships with real IERS C04 + finals2000A data (updated weekly).
// C04 covers 1962-present observed values; finals extends with ~1yr predictions.
let provider = EopProvider::bundled()?;
let mjd = 59945.0; // 2023-01-01
let params = provider.get(mjd)?;
println!("Bundled IERS data lookup for MJD {mjd}:");
println!(" {params}");
println!(" LOD = {:.7} s", params.lod);
println!(" s' = {:.4e} rad", params.s_prime);
println!(" source = {:?}", params.flags.source);
println!(" quality = {:?}", params.flags.quality);
if let Some((start, end)) = provider.time_span() {
println!(
" coverage = MJD {start:.0} to {end:.0} ({:.0} days)",
end - start
);
}
println!(" records = {}", provider.record_count());
println!();
// --- Manual EOP records ---
// Build records by hand for testing or when you have your own data source.
let r1 = EopRecord::new(60000.0, 0.100, 0.250, -0.050, 0.0015)?
.with_cip_offsets(0.120, -0.080)?
.with_pole_rates(0.00012, -0.00008)?;
let r2 = EopRecord::new(60001.0, 0.102, 0.248, -0.052, 0.0016)?
.with_cip_offsets(0.125, -0.082)?
.with_pole_rates(0.00013, -0.00009)?;
let r3 = EopRecord::new(60002.0, 0.104, 0.246, -0.054, 0.0014)?
.with_cip_offsets(0.130, -0.084)?
.with_pole_rates(0.00014, -0.00010)?;
let provider = EopProvider::from_records(vec![r1, r2, r3])?;
let interp = provider.get(60000.5)?;
println!("Interpolated at MJD 60000.5:");
println!(" {interp}");
println!(" dX = {:.3} mas", interp.dx.unwrap_or(0.0));
println!(" dY = {:.3} mas", interp.dy.unwrap_or(0.0));
println!(" xrt = {:.6} arcsec/day", interp.xrt.unwrap_or(0.0));
println!(" yrt = {:.6} arcsec/day", interp.yrt.unwrap_or(0.0));
println!(" has_cip = {}", interp.flags.has_cip_offsets);
println!(" has_rates = {}", interp.flags.has_pole_rates);
println!();
// --- Data span ---
if let Some((start, end)) = provider.time_span() {
println!(
"Loaded data covers MJD {start:.0} to {end:.0} ({:.0} days)",
end - start
);
}
Ok(())
}
@@ -0,0 +1,82 @@
use cosmos_coords::EopProvider;
use std::path::PathBuf;
const FINALS_URL: &str = "https://datacenter.iers.org/data/9/finals2000A.all";
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cache_dir = cache_dir();
let finals_path = cache_dir.join("finals2000A.all");
// Show bundled data coverage
let bundled = EopProvider::bundled()?;
let (bstart, bend) = bundled.time_span().unwrap();
println!(
"Bundled EOP data: MJD {bstart:.0} to {bend:.0} ({} records)",
bundled.record_count()
);
// Download fresh finals2000A if not cached (or always, in production)
if !finals_path.exists() {
println!("\nDownloading finals2000A from IERS...");
let body = reqwest::get(FINALS_URL).await?.text().await?;
std::fs::create_dir_all(&cache_dir)?;
std::fs::write(&finals_path, &body)?;
println!("Saved to {}", finals_path.display());
} else {
println!("\nUsing cached {}", finals_path.display());
}
// Load from file only
let from_file = EopProvider::from_finals_file(&finals_path)?;
let (fstart, fend) = from_file.time_span().unwrap();
println!(
"\nFinals-only: MJD {fstart:.0} to {fend:.0} ({} records)",
from_file.record_count()
);
// Bundled + update overlay (the telescope control pattern)
let merged = EopProvider::bundled_with_update(&finals_path)?;
let (mstart, mend) = merged.time_span().unwrap();
println!(
"Merged: MJD {mstart:.0} to {mend:.0} ({} records)",
merged.record_count()
);
// Compare a lookup across providers
let test_mjd = bend - 10.0; // 10 days before end of bundled data
let bundled_params = bundled.get(test_mjd)?;
let merged_params = merged.get(test_mjd)?;
println!("\nLookup at MJD {test_mjd:.1}:");
println!(" Bundled: {bundled_params}");
println!(" Merged: {merged_params}");
// Try a date beyond bundled range (if the finals data extends further)
if mend > bend {
let future_mjd = bend + 30.0;
match merged.get(future_mjd) {
Ok(params) => {
println!("\nFuture lookup at MJD {future_mjd:.1} (beyond bundled):");
println!(" {params}");
}
Err(e) => println!("\nMJD {future_mjd:.1} not available: {e}"),
}
}
Ok(())
}
fn cache_dir() -> PathBuf {
// XDG on Linux, ~/Library/Caches on macOS, AppData\Local on Windows
#[cfg(target_os = "macos")]
let base = std::env::var("HOME").map(|h| PathBuf::from(h).join("Library/Caches"));
#[cfg(target_os = "linux")]
let base = std::env::var("XDG_CACHE_HOME")
.or_else(|_| std::env::var("HOME").map(|h| format!("{h}/.cache")))
.map(PathBuf::from);
#[cfg(target_os = "windows")]
let base = std::env::var("LOCALAPPDATA").map(PathBuf::from);
base.unwrap_or_else(|_| PathBuf::from("."))
.join("eternal")
}
@@ -0,0 +1,191 @@
use cosmos_coords::frames::{EclipticPosition, GalacticPosition, ICRSPosition};
use cosmos_coords::transforms::CoordinateFrame;
use cosmos_coords::Distance;
use cosmos_time::tt_from_calendar;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let tt = tt_from_calendar(2024, 6, 21, 12, 0, 0.0); // Summer solstice 2024
// --- Galactic coordinates ---
// The galactic frame is fixed (IAU 1958 definition, refined by Hipparcos).
// No epoch dependence — the rotation matrix from ICRS to galactic is constant.
println!("=== Galactic Coordinate System ===\n");
// Sagittarius A* (galactic center)
let sgr_a = ICRSPosition::from_hours_degrees(17.76112, -29.00781)?;
let gal = sgr_a.to_galactic(&tt)?;
println!("Sgr A* (Galactic Center):");
println!(
" ICRS: RA = {:.5}h Dec = {:+.5}°",
sgr_a.ra().hours(),
sgr_a.dec().degrees()
);
println!(
" Galactic: l = {:.4}° b = {:+.4}°",
gal.longitude().degrees(),
gal.latitude().degrees()
);
println!(
" Near plane? {} In bulge? {}\n",
gal.is_near_galactic_plane(),
gal.is_in_galactic_bulge()
);
// Polaris — far from the galactic plane
let polaris = ICRSPosition::from_hours_degrees(2.53030, 89.26411)?;
let gal = polaris.to_galactic(&tt)?;
println!("Polaris:");
println!(
" ICRS: RA = {:.5}h Dec = {:+.5}°",
polaris.ra().hours(),
polaris.dec().degrees()
);
println!(
" Galactic: l = {:.4}° b = {:+.4}°",
gal.longitude().degrees(),
gal.latitude().degrees()
);
println!(" Near pole? {}\n", gal.is_near_galactic_pole());
// Roundtrip: galactic → ICRS
let gc = GalacticPosition::galactic_center();
let icrs = gc.to_icrs(&tt)?;
println!("Galactic center reference point:");
println!(
" l = {:.1}°, b = {:.1}° → RA = {:.5}h, Dec = {:+.5}°\n",
gc.longitude().degrees(),
gc.latitude().degrees(),
icrs.ra().hours(),
icrs.dec().degrees()
);
// Notable galactic reference points
let ngp = GalacticPosition::north_galactic_pole();
let ngp_icrs = ngp.to_icrs(&tt)?;
println!("North Galactic Pole:");
println!(
" l = {:.1}°, b = {:+.1}° → RA = {:.5}h, Dec = {:+.5}°",
ngp.longitude().degrees(),
ngp.latitude().degrees(),
ngp_icrs.ra().hours(),
ngp_icrs.dec().degrees()
);
let anti = GalacticPosition::galactic_anticenter();
let anti_icrs = anti.to_icrs(&tt)?;
println!("Galactic Anticenter:");
println!(
" l = {:.1}°, b = {:+.1}° → RA = {:.5}h, Dec = {:+.5}°\n",
anti.longitude().degrees(),
anti.latitude().degrees(),
anti_icrs.ra().hours(),
anti_icrs.dec().degrees()
);
// --- Ecliptic coordinates ---
// Ecliptic longitude/latitude relative to the ecliptic plane.
// Epoch-dependent: the ecliptic precesses with time.
println!("=== Ecliptic Coordinate System ===\n");
// Objects near the ecliptic tend to be solar system bodies.
// Stars far from the ecliptic have large |β|.
let vega = ICRSPosition::from_hours_degrees(18.61564, 38.78369)?;
let ecl = vega.to_ecliptic(&tt)?;
println!("Vega:");
println!(
" ICRS: RA = {:.5}h Dec = {:+.5}°",
vega.ra().hours(),
vega.dec().degrees()
);
println!(
" Ecliptic: λ = {:.4}° β = {:+.4}°",
ecl.lambda().degrees(),
ecl.beta().degrees()
);
println!(" Near ecliptic? {}", ecl.is_near_ecliptic_plane());
println!(
" Mean obliquity = {:.6}°\n",
ecl.mean_obliquity().degrees()
);
// Aldebaran — near the ecliptic (zodiac star)
let aldebaran = ICRSPosition::from_hours_degrees(4.59868, 16.50930)?;
let ecl = aldebaran.to_ecliptic(&tt)?;
println!("Aldebaran (in Taurus, near ecliptic):");
println!(
" ICRS: RA = {:.5}h Dec = {:+.5}°",
aldebaran.ra().hours(),
aldebaran.dec().degrees()
);
println!(
" Ecliptic: λ = {:.4}° β = {:+.4}°",
ecl.lambda().degrees(),
ecl.beta().degrees()
);
println!(" Near ecliptic? {}\n", ecl.is_near_ecliptic_plane());
// Ecliptic reference points
let ve = EclipticPosition::vernal_equinox(tt);
let ve_icrs = ve.to_icrs(&tt)?;
println!("Vernal equinox (λ=0°, β=0°):");
println!(
" → RA = {:.5}h, Dec = {:+.5}°",
ve_icrs.ra().hours(),
ve_icrs.dec().degrees()
);
let ss = EclipticPosition::summer_solstice(tt);
let ss_icrs = ss.to_icrs(&tt)?;
println!("Summer solstice (λ=90°, β=0°):");
println!(
" → RA = {:.5}h, Dec = {:+.5}°\n",
ss_icrs.ra().hours(),
ss_icrs.dec().degrees()
);
// --- Angular separation across frames ---
let m31 = ICRSPosition::from_hours_degrees(0.71222, 41.26917)?;
let m31 = with_distance(m31, Distance::from_parsecs(778_000.0)?);
let m31_gal = m31.to_galactic(&tt)?;
println!("=== Distances ===\n");
println!("M31 (Andromeda):");
println!(
" ICRS: RA = {:.5}h Dec = {:+.5}°",
m31.ra().hours(),
m31.dec().degrees()
);
println!(
" Galactic: l = {:.4}° b = {:+.4}°",
m31_gal.longitude().degrees(),
m31_gal.latitude().degrees()
);
println!(
" Distance: {:.0} kpc = {:.2} Mly = {:.0} AU",
m31.distance().unwrap().parsecs() / 1000.0,
m31.distance().unwrap().light_years() / 1e6,
m31.distance().unwrap().au()
);
println!(
" Distance modulus: {:.2} mag",
m31.distance().unwrap().distance_modulus()
);
println!(
" Local Group member? {}",
m31.distance().unwrap().is_local_group()
);
let sep = sgr_a.angular_separation(&m31);
println!(" Sgr A* ↔ M31 = {:.2}°", sep.degrees());
Ok(())
}
fn with_distance(mut pos: ICRSPosition, d: Distance) -> ICRSPosition {
pos.set_distance(d);
pos
}

Some files were not shown because too many files have changed in this diff Show More