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:
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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á 3–5.
|
||||||
|
|
||||||
|
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°59′08.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 {
|
||||||
|
"0°".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 TDB−UTC ~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
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# cosmos-astrology
|
||||||
|
|
||||||
|
The astrology-specific layer of the `eternal` workspace, built on the [`cosmos-sky`](../cosmos-sky/) façade.
|
||||||
|
|
||||||
|
[](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 (UTC−4).
|
||||||
|
// 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,
|
||||||
|
/// **Polich–Page (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, UTC−4) → 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# cosmos-catalog
|
||||||
|
|
||||||
|
HEALPix-indexed star catalog combining Gaia DR3 and Hipparcos. Memory-mapped for fast cone searches.
|
||||||
|
|
||||||
|
[](https://crates.io/crates/cosmos-catalog)
|
||||||
|
[](https://docs.rs/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, ¶ms);
|
||||||
|
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(¤t_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, ¶ms);
|
||||||
|
|
||||||
|
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, ¶ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(¢er_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(¢er_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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# cosmos-coords
|
||||||
|
|
||||||
|
Type-safe astronomical coordinate transformations between reference frames.
|
||||||
|
|
||||||
|
[](https://crates.io/crates/cosmos-coords)
|
||||||
|
[](https://docs.rs/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
Reference in New Issue
Block a user