astro_core/
lib.rs

1use libc::{c_char, c_int};
2use std::{
3    ffi::CString,
4    sync::{Mutex, OnceLock},
5};
6use thiserror::Error;
7
8mod ffi {
9    use libc::{c_char, c_double, c_int};
10
11    pub const SE_SUN: c_int = 0;
12    pub const SE_MOON: c_int = 1;
13    pub const SE_ASC: usize = 0;
14    pub const SE_GREG_CAL: c_int = 1;
15    pub const SEFLG_SWIEPH: c_int = 2;
16    pub const AS_MAXCH: usize = 256;
17
18    extern "C" {
19        pub fn swe_set_ephe_path(path: *const c_char);
20
21        pub fn swe_utc_to_jd(
22            year: c_int,
23            month: c_int,
24            day: c_int,
25            hour: c_int,
26            minute: c_int,
27            second: c_double,
28            gregflag: c_int,
29            dret: *mut c_double,
30            serr: *mut c_char,
31        ) -> c_int;
32
33        pub fn swe_calc_ut(
34            tjd_ut: c_double,
35            ipl: c_int,
36            iflag: c_int,
37            xx: *mut c_double,
38            serr: *mut c_char,
39        ) -> c_int;
40
41        pub fn swe_houses_ex(
42            tjd_ut: c_double,
43            iflag: c_int,
44            geolat: c_double,
45            geolon: c_double,
46            hsys: c_int,
47            cusps: *mut c_double,
48            ascmc: *mut c_double,
49        ) -> c_int;
50    }
51}
52
53/// Basic data for birth info in UTC.
54#[derive(Debug, Clone)]
55pub struct BirthData {
56    pub year: i32,
57    pub month: i32,
58    pub day: i32,
59    pub hour: i32,   // 0-23, UTC
60    pub minute: i32, // 0-59
61    pub second: f64, // 0.0-59.999
62    pub lat: f64,    // latitude in degrees (+N, -S)
63    pub lon: f64,    // longitude in degrees (+E, -W)
64}
65
66/// Date-only birth info in UTC.
67#[derive(Debug, Clone)]
68pub struct BirthDate {
69    pub year: i32,
70    pub month: i32,
71    pub day: i32,
72}
73
74/// Time-of-day info in UTC.
75#[derive(Debug, Clone)]
76pub struct BirthTime {
77    pub hour: i32,   // 0-23, UTC
78    pub minute: i32, // 0-59
79    pub second: f64, // 0.0-59.999
80}
81
82/// Geographic location for chart calculations.
83#[derive(Debug, Clone)]
84pub struct BirthLocation {
85    pub lat: f64, // latitude in degrees (+N, -S)
86    pub lon: f64, // longitude in degrees (+E, -W)
87}
88
89/// Flexible input for partial chart calculations.
90#[derive(Debug, Clone)]
91pub struct BirthInput {
92    pub date: BirthDate,
93    pub time: Option<BirthTime>,
94    pub location: Option<BirthLocation>,
95}
96
97/// Core chart with three main indicators.
98#[derive(Debug, Clone)]
99pub struct CoreChart {
100    pub sun_sign: String, // "aries", "taurus", ...
101    pub moon_sign: String,
102    pub asc_sign: String,
103}
104
105/// Partial chart where missing inputs yield missing outputs.
106#[derive(Debug, Clone)]
107pub struct PartialChart {
108    pub sun_sign: Option<String>,
109    pub moon_sign: Option<String>,
110    pub asc_sign: Option<String>,
111}
112
113#[derive(Debug, Error)]
114pub enum AstroError {
115    #[error("Swiss Ephemeris error: {0}")]
116    EphemerisError(String),
117
118    #[error("Invalid input: {0}")]
119    InvalidInput(String),
120}
121
122static EPHE_PATH: OnceLock<Mutex<String>> = OnceLock::new();
123
124/// Override the Swiss Ephemeris data path. Defaults to the current directory when unset.
125pub fn set_ephe_path(path: &str) {
126    let mut guard = ephe_path_store()
127        .lock()
128        .expect("ephemeris path mutex poisoned");
129    *guard = path.to_string();
130    let c_path = CString::new(path).expect("ephemeris path must not contain interior null bytes");
131    unsafe {
132        ffi::swe_set_ephe_path(c_path.as_ptr());
133    }
134}
135
136/// Calculate the Sun, Moon, and Ascendant signs for the given birth data.
137pub fn calculate_core_chart(birth: &BirthData) -> Result<CoreChart, AstroError> {
138    apply_ephe_path()?;
139    let tjd_ut = julian_day_ut(birth)?;
140
141    let sun_long = body_longitude(tjd_ut, ffi::SE_SUN)?;
142    let moon_long = body_longitude(tjd_ut, ffi::SE_MOON)?;
143    let asc_long = ascendant_longitude(tjd_ut, birth.lat, birth.lon)?;
144
145    Ok(CoreChart {
146        sun_sign: sign_name_from_longitude(sun_long),
147        moon_sign: sign_name_from_longitude(moon_long),
148        asc_sign: sign_name_from_longitude(asc_long),
149    })
150}
151
152/// Calculate a partial chart when the birth time is unknown.
153pub fn calculate_core_chart_date_only(
154    date: &BirthDate,
155    location: &BirthLocation,
156) -> Result<PartialChart, AstroError> {
157    let input = BirthInput {
158        date: date.clone(),
159        time: None,
160        location: Some(location.clone()),
161    };
162    calculate_core_chart_partial(&input)
163}
164
165/// Calculate the most reliable chart possible from partial input.
166pub fn calculate_core_chart_partial(input: &BirthInput) -> Result<PartialChart, AstroError> {
167    apply_ephe_path()?;
168    let (sun_sign, moon_sign, asc_sign) = match &input.time {
169        Some(time) => {
170            let tjd_ut = julian_day_ut_from_datetime(&input.date, time)?;
171            let sun_sign = Some(sign_name_from_longitude(body_longitude(
172                tjd_ut,
173                ffi::SE_SUN,
174            )?));
175            let moon_sign = Some(sign_name_from_longitude(body_longitude(
176                tjd_ut,
177                ffi::SE_MOON,
178            )?));
179            let asc_sign = if let Some(location) = &input.location {
180                Some(sign_name_from_longitude(ascendant_longitude(
181                    tjd_ut,
182                    location.lat,
183                    location.lon,
184                )?))
185            } else {
186                None
187            };
188            (sun_sign, moon_sign, asc_sign)
189        }
190        None => {
191            let sun_sign = Some(sign_name_from_longitude(body_longitude(
192                julian_day_ut_from_datetime(
193                    &input.date,
194                    &BirthTime {
195                        hour: 12,
196                        minute: 0,
197                        second: 0.0,
198                    },
199                )?,
200                ffi::SE_SUN,
201            )?));
202            (sun_sign, None, None)
203        }
204    };
205
206    Ok(PartialChart {
207        sun_sign,
208        moon_sign,
209        asc_sign,
210    })
211}
212
213fn ephe_path_store() -> &'static Mutex<String> {
214    EPHE_PATH.get_or_init(|| Mutex::new(String::new()))
215}
216
217fn apply_ephe_path() -> Result<(), AstroError> {
218    let guard = ephe_path_store()
219        .lock()
220        .map_err(|_| AstroError::InvalidInput("ephemeris path lock poisoned".to_string()))?;
221    let c_path = CString::new(guard.as_str())
222        .map_err(|_| AstroError::InvalidInput("ephemeris path contains null byte".into()))?;
223    unsafe {
224        ffi::swe_set_ephe_path(c_path.as_ptr());
225    }
226    Ok(())
227}
228
229fn julian_day_ut(birth: &BirthData) -> Result<f64, AstroError> {
230    let date = BirthDate {
231        year: birth.year,
232        month: birth.month,
233        day: birth.day,
234    };
235    let time = BirthTime {
236        hour: birth.hour,
237        minute: birth.minute,
238        second: birth.second,
239    };
240    julian_day_ut_from_datetime(&date, &time)
241}
242
243fn julian_day_ut_from_datetime(
244    date: &BirthDate,
245    time: &BirthTime,
246) -> Result<f64, AstroError> {
247    let mut dret = [0f64; 2];
248    let mut serr = [0 as c_char; ffi::AS_MAXCH];
249    let rc = unsafe {
250        ffi::swe_utc_to_jd(
251            date.year as c_int,
252            date.month as c_int,
253            date.day as c_int,
254            time.hour as c_int,
255            time.minute as c_int,
256            time.second,
257            ffi::SE_GREG_CAL,
258            dret.as_mut_ptr(),
259            serr.as_mut_ptr(),
260        )
261    };
262    if rc < 0 {
263        return Err(AstroError::EphemerisError(error_string(&serr)));
264    }
265    // dret[1] = UT
266    Ok(dret[1])
267}
268
269fn body_longitude(tjd_ut: f64, ipl: c_int) -> Result<f64, AstroError> {
270    let mut xx = [0f64; 6];
271    let mut serr = [0 as c_char; ffi::AS_MAXCH];
272    let rc = unsafe {
273        ffi::swe_calc_ut(
274            tjd_ut,
275            ipl,
276            ffi::SEFLG_SWIEPH,
277            xx.as_mut_ptr(),
278            serr.as_mut_ptr(),
279        )
280    };
281    if rc < 0 {
282        return Err(AstroError::EphemerisError(error_string(&serr)));
283    }
284    Ok(xx[0])
285}
286
287fn ascendant_longitude(tjd_ut: f64, lat: f64, lon: f64) -> Result<f64, AstroError> {
288    let mut cusps = [0f64; 13];
289    let mut ascmc = [0f64; 10];
290    let rc = unsafe {
291        ffi::swe_houses_ex(
292            tjd_ut,
293            ffi::SEFLG_SWIEPH,
294            lat,
295            lon,
296            'P' as c_int,
297            cusps.as_mut_ptr(),
298            ascmc.as_mut_ptr(),
299        )
300    };
301    if rc < 0 {
302        return Err(AstroError::EphemerisError(
303            "failed to compute ascendant".to_string(),
304        ));
305    }
306    Ok(ascmc[ffi::SE_ASC])
307}
308
309fn error_string(buf: &[c_char]) -> String {
310    let nul = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
311    let bytes: Vec<u8> = buf[..nul].iter().map(|&c| c as u8).collect();
312    if bytes.is_empty() {
313        "unknown Swiss Ephemeris error".to_string()
314    } else {
315        String::from_utf8_lossy(&bytes).into_owned()
316    }
317}
318
319const ZODIAC_SIGNS: [&str; 12] = [
320    "aries",
321    "taurus",
322    "gemini",
323    "cancer",
324    "leo",
325    "virgo",
326    "libra",
327    "scorpio",
328    "sagittarius",
329    "capricorn",
330    "aquarius",
331    "pisces",
332];
333
334pub fn sign_name_from_longitude(lon: f64) -> String {
335    let mut norm = lon % 360.0;
336    if norm < 0.0 {
337        norm += 360.0;
338    }
339    let index = (norm / 30.0).floor() as usize % ZODIAC_SIGNS.len();
340    ZODIAC_SIGNS[index].to_string()
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::path::Path;
347
348    #[test]
349    fn calculates_core_chart() {
350        let ephe_path = "src/swisseph/ephe";
351        if !Path::new(ephe_path).exists() {
352            eprintln!(
353                "skipping calculates_core_chart: missing ephemeris data at {}",
354                ephe_path
355            );
356            return;
357        }
358        set_ephe_path(ephe_path);
359        let birth = BirthData {
360            year: 1990,
361            month: 1,
362            day: 1,
363            hour: 0,
364            minute: 0,
365            second: 0.0,
366            lat: 0.0,
367            lon: 0.0,
368        };
369
370        let chart = calculate_core_chart(&birth).expect("chart should compute");
371
372        assert!(ZODIAC_SIGNS.contains(&chart.sun_sign.as_str()));
373        assert!(ZODIAC_SIGNS.contains(&chart.moon_sign.as_str()));
374        assert!(ZODIAC_SIGNS.contains(&chart.asc_sign.as_str()));
375    }
376
377    #[test]
378    fn calculates_core_chart_date_only() {
379        let ephe_path = "src/swisseph/ephe";
380        if !Path::new(ephe_path).exists() {
381            eprintln!(
382                "skipping calculates_core_chart_date_only: missing ephemeris data at {}",
383                ephe_path
384            );
385            return;
386        }
387        set_ephe_path(ephe_path);
388        let date = BirthDate {
389            year: 1990,
390            month: 1,
391            day: 1,
392        };
393        let location = BirthLocation { lat: 0.0, lon: 0.0 };
394
395        let chart =
396            calculate_core_chart_date_only(&date, &location).expect("chart should compute");
397
398        assert!(chart.sun_sign.is_some());
399        if let Some(sun) = chart.sun_sign.as_deref() {
400            assert!(ZODIAC_SIGNS.contains(&sun));
401        }
402        assert!(chart.asc_sign.is_none());
403    }
404}