tawasuyu

tawasuyu/brahman

Rust 405TOML 156Markdown 40JSON 175449 Sutichaykuna

⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs Rust · 369 siq'ikuna · 11.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): "Cielo ahora" + "General" como rows fijos al top del tree 72758e75c · sergio ·

1//! `tahuantinsuyu-model` — tipos agnósticos del estudio astrológico.
2//!
3//! Esta es la capa de **datos puros**: no conoce GPUI, ni rusqlite, ni
4//! `eternal-astrology`. Solo tipos `serde`-able que viajan entre la
5//! store, la engine, los widgets, y eventualmente la Card de Brahman.
6//!
7//! ## Jerarquía
8//!
9//! ```text
10//!   Group  (puede anidar otros Groups vía parent_id)
11//!     ├── Group  (sub-agrupación)
12//!     └── Contact  (persona / evento / lugar)
13//!           └── Chart  (carta astrológica)
14//! ```
15//!
16//! Las `Chart` son las hojas — cada una guarda su `StoredBirthData` y su
17//! `StoredChartConfig`. La engine las traduce a tipos de `eternal-astrology`
18//! cuando hay que computar.
19//!
20//! ## Por qué tipos "Stored" propios y no reusar `eternal-astrology`
21//!
22//! Forward-compat: si mañana cambia el shape de `BirthData` upstream, o
23//! queremos persistir en otro backend astronómico, el modelo + la base
24//! sobreviven. La engine es el único puente que conoce ambas formas.
25
26#![forbid(unsafe_code)]
27#![warn(rust_2018_idioms)]
28
29use serde::{Deserialize, Serialize};
30use thiserror::Error;
31use ulid::Ulid;
32
33pub use ::ulid;
34
35// =====================================================================
36// Identidades
37// =====================================================================
38
39macro_rules! ulid_newtype {
40    ($name:ident, $doc:expr) => {
41        #[doc = $doc]
42        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43        pub struct $name(pub Ulid);
44
45        impl $name {
46            pub fn new() -> Self {
47                Self(Ulid::new())
48            }
49        }
50
51        impl Default for $name {
52            fn default() -> Self {
53                Self::new()
54            }
55        }
56
57        impl std::fmt::Display for $name {
58            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59                self.0.fmt(f)
60            }
61        }
62
63        impl std::str::FromStr for $name {
64            type Err = ulid::DecodeError;
65            fn from_str(s: &str) -> Result<Self, Self::Err> {
66                Ulid::from_string(s).map(Self)
67            }
68        }
69    };
70}
71
72ulid_newtype!(GroupId, "Identificador estable de un Group.");
73ulid_newtype!(ContactId, "Identificador estable de un Contact.");
74ulid_newtype!(ChartId, "Identificador estable de un Chart.");
75
76// =====================================================================
77// Group / Contact
78// =====================================================================
79
80/// Agrupación jerárquica de contactos. Puede anidar otros groups vía
81/// `parent_id` (un Group raíz tiene `parent_id = None`).
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Group {
84    pub id: GroupId,
85    pub parent_id: Option<GroupId>,
86    pub name: String,
87    #[serde(default)]
88    pub description: Option<String>,
89    /// Epoch millis. Decisión: `i64` para tolerar valores pre-1970 en
90    /// imports históricos sin overflow.
91    pub created_at_ms: i64,
92    /// Orden manual dentro del padre. Más bajo = primero. Empate → por nombre.
93    #[serde(default)]
94    pub sort_order: i32,
95}
96
97/// Persona o evento del que se calcula una o más cartas. Puede vivir
98/// directamente en la raíz (`group_id = None`).
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Contact {
101    pub id: ContactId,
102    pub group_id: Option<GroupId>,
103    pub name: String,
104    #[serde(default)]
105    pub notes: Option<String>,
106    pub created_at_ms: i64,
107}
108
109// =====================================================================
110// Datos de nacimiento (espejo agnóstico de eternal_astrology::BirthData)
111// =====================================================================
112
113/// Datos crudos de nacimiento. La engine los traduce a
114/// `eternal_astrology::BirthData` cuando hay que computar.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct StoredBirthData {
117    /// Calendario civil local.
118    pub year: i32,
119    pub month: u32,
120    pub day: u32,
121    pub hour: u32,
122    pub minute: u32,
123    /// Segundos fraccionarios (0.0..60.0).
124    pub second: f64,
125    /// Offset desde UTC, en minutos. Ej: -240 = UTC-04:00.
126    pub tz_offset_minutes: i32,
127
128    /// Coordenadas geográficas en grados decimales.
129    pub latitude_deg: f64,
130    pub longitude_deg: f64,
131    /// Altura en metros sobre el geoide WGS-84.
132    #[serde(default)]
133    pub altitude_m: f64,
134
135    #[serde(default)]
136    pub time_certainty: TimeCertainty,
137    #[serde(default)]
138    pub subject_name: Option<String>,
139    #[serde(default)]
140    pub birthplace_label: Option<String>,
141}
142
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum TimeCertainty {
146    #[default]
147    Exact,
148    RoundedHour,
149    RoundedDay,
150    Estimated,
151}
152
153// =====================================================================
154// Configuración de carta (espejo agnóstico de eternal_astrology::ChartConfig)
155// =====================================================================
156
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum Zodiac {
160    #[default]
161    Tropical,
162    Sidereal,
163    Draconic,
164}
165
166#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "kebab-case")]
168pub enum HouseSystem {
169    #[default]
170    Placidus,
171    Koch,
172    Regiomontanus,
173    Campanus,
174    Porphyry,
175    Equal,
176    WholeSign,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct StoredChartConfig {
181    #[serde(default)]
182    pub zodiac: Zodiac,
183    #[serde(default)]
184    pub house_system: HouseSystem,
185    /// Nombre del ayanamsha cuando `zodiac == Sidereal`. Ej: "lahiri",
186    /// "fagan_bradley". Ignorado para Tropical/Draconic.
187    #[serde(default)]
188    pub ayanamsha: Option<String>,
189    /// Cuerpos a incluir. Strings opacos para que el modelo no se ate
190    /// al enum `Body` de eternal. Ej: ["sun","moon","mercury",…].
191    #[serde(default = "default_bodies")]
192    pub bodies: Vec<String>,
193    #[serde(default = "default_true")]
194    pub include_south_node: bool,
195    #[serde(default)]
196    pub include_lilith: bool,
197    #[serde(default)]
198    pub include_main_belt_asteroids: bool,
199    #[serde(default)]
200    pub include_fixed_stars: bool,
201    /// Tabla de orbes a usar (nombre simbólico). `None` → orbes defaults
202    /// de la engine.
203    #[serde(default)]
204    pub orb_table: Option<String>,
205}
206
207impl Default for StoredChartConfig {
208    fn default() -> Self {
209        Self {
210            zodiac: Zodiac::default(),
211            house_system: HouseSystem::default(),
212            ayanamsha: None,
213            bodies: default_bodies(),
214            include_south_node: true,
215            include_lilith: false,
216            include_main_belt_asteroids: false,
217            include_fixed_stars: false,
218            orb_table: None,
219        }
220    }
221}
222
223fn default_bodies() -> Vec<String> {
224    vec![
225        "sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune",
226        "pluto", "mean_node",
227    ]
228    .into_iter()
229    .map(String::from)
230    .collect()
231}
232
233fn default_true() -> bool {
234    true
235}
236
237// =====================================================================
238// Chart
239// =====================================================================
240
241/// Tipo de carta astrológica. Determina qué rutina de la engine corre
242/// y qué `Layer`s aporta al canvas.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum ChartKind {
246    Natal,
247    Transit,
248    SecondaryProgression,
249    TertiaryProgression,
250    MinorProgression,
251    SolarArc,
252    SolarReturn,
253    LunarReturn,
254    Synastry,
255    Composite,
256    Davison,
257    Profection,
258    PrimaryDirection,
259    /// Carta "mundial" para un instante + lugar sin sujeto natal.
260    Mundane,
261}
262
263impl ChartKind {
264    /// `true` si la carta necesita una segunda carta natal como referencia
265    /// (synastry/composite/davison). Útil para validar al persistir.
266    pub fn requires_related_chart(self) -> bool {
267        matches!(
268            self,
269            ChartKind::Synastry | ChartKind::Composite | ChartKind::Davison
270        )
271    }
272}
273
274/// Una carta concreta dentro de un contacto. Las cartas de tipo
275/// derivado (transit, progression, synastry, …) referencian la carta
276/// natal de la que parten vía `related_chart_id`.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Chart {
279    pub id: ChartId,
280    pub contact_id: ContactId,
281    pub kind: ChartKind,
282    pub label: String,
283    pub birth_data: StoredBirthData,
284    pub config: StoredChartConfig,
285    /// Para cartas derivadas: la carta de referencia. Para transit/
286    /// progression apunta a la natal del mismo contacto. Para synastry
287    /// apunta a la carta del otro sujeto.
288    #[serde(default)]
289    pub related_chart_id: Option<ChartId>,
290    pub created_at_ms: i64,
291}
292
293// =====================================================================
294// Estado de módulos por carta (qué capas están activas + su config)
295// =====================================================================
296
297/// Cada `ChartKind` puede activar uno o más `module_id` (ej. una carta
298/// natal puede tener `natal`, `dignities`, `fixed_stars`, `uranian`).
299/// El estado por-carta se persiste en la store; el canvas lo consulta
300/// para decidir qué capas pintar y qué controles mostrar en el panel.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct ModuleState {
303    pub chart_id: ChartId,
304    pub module_id: String,
305    pub enabled: bool,
306    /// JSON libre — cada módulo define su schema.
307    #[serde(default)]
308    pub config: serde_json::Value,
309}
310
311// =====================================================================
312// Selección activa (qué muestra el canvas)
313// =====================================================================
314
315/// Item activo del tree. El canvas reacciona a este tipo:
316/// - `Chart` → abre la carta puntual.
317/// - `Contact` / `Group` → muestra thumbnails de las cartas descendientes.
318/// - `PresentSky` → carta efímera del instante presente (cielo ahora);
319///   no vive en la store, se computa al vuelo cuando el host la pide.
320/// - `GeneralRoot` → nodo branch virtual que agrupa los contactos
321///   sin grupo padre (contacts con parent=None).
322#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
323pub enum TreeSelection {
324    Group(GroupId),
325    Contact(ContactId),
326    Chart(ChartId),
327    PresentSky,
328    GeneralRoot,
329}
330
331// =====================================================================
332// Errores
333// =====================================================================
334
335#[derive(Debug, Error)]
336pub enum ModelError {
337    #[error("chart {kind:?} requiere related_chart_id pero recibió None")]
338    MissingRelatedChart { kind: ChartKind },
339    #[error("group {0} no puede ser su propio ancestro")]
340    GroupCycle(GroupId),
341    #[error("invalid field {field}: {reason}")]
342    InvalidField {
343        field: &'static str,
344        reason: String,
345    },
346}
347
348impl Chart {
349    /// Validación liviana: ataja errores que la base no captura
350    /// (ej. synastry sin `related_chart_id`).
351    pub fn validate(&self) -> Result<(), ModelError> {
352        if self.kind.requires_related_chart() && self.related_chart_id.is_none() {
353            return Err(ModelError::MissingRelatedChart { kind: self.kind });
354        }
355        if !(-90.0..=90.0).contains(&self.birth_data.latitude_deg) {
356            return Err(ModelError::InvalidField {
357                field: "latitude_deg",
358                reason: format!("{} fuera de [-90, 90]", self.birth_data.latitude_deg),
359            });
360        }
361        if !(-180.0..=180.0).contains(&self.birth_data.longitude_deg) {
362            return Err(ModelError::InvalidField {
363                field: "longitude_deg",
364                reason: format!("{} fuera de [-180, 180]", self.birth_data.longitude_deg),
365            });
366        }
367        Ok(())
368    }
369}