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#[derive(Debug, Clone)]
55pub struct BirthData {
56 pub year: i32,
57 pub month: i32,
58 pub day: i32,
59 pub hour: i32, pub minute: i32, pub second: f64, pub lat: f64, pub lon: f64, }
65
66#[derive(Debug, Clone)]
68pub struct BirthDate {
69 pub year: i32,
70 pub month: i32,
71 pub day: i32,
72}
73
74#[derive(Debug, Clone)]
76pub struct BirthTime {
77 pub hour: i32, pub minute: i32, pub second: f64, }
81
82#[derive(Debug, Clone)]
84pub struct BirthLocation {
85 pub lat: f64, pub lon: f64, }
88
89#[derive(Debug, Clone)]
91pub struct BirthInput {
92 pub date: BirthDate,
93 pub time: Option<BirthTime>,
94 pub location: Option<BirthLocation>,
95}
96
97#[derive(Debug, Clone)]
99pub struct CoreChart {
100 pub sun_sign: String, pub moon_sign: String,
102 pub asc_sign: String,
103}
104
105#[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
124pub 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
136pub 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
152pub 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
165pub 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 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}