⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/tahuantinsuyu/tahuantinsuyu-model/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): "Cielo ahora" + "General" como rows fijos al top del tree
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}