From bc31f432e35d6a2da900e038fd8ca27ac8beeac8 Mon Sep 17 00:00:00 2001 From: Jack Steam Date: Sun, 8 Nov 2026 16:07:49 -0600 Subject: [PATCH] feat: add current PAC Lahiri chart reference --- docs/chart-conventions.md | 43 ++++++++++++++++++ examples/configurable-chart/index.html | 2 +- src/chart.rs | 12 ++++-- src/conventions.rs | 1 + src/sidereal.rs | 60 +++++++++++++++++++++++++- tests/lahiri.rs | 18 ++++++++ 6 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/lahiri.rs diff --git a/docs/chart-conventions.md b/docs/chart-conventions.md index e6793d6..0d40cab 100644 --- a/docs/chart-conventions.md +++ b/docs/chart-conventions.md @@ -127,3 +127,46 @@ Chart comparisons use a 1e-12 absolute tolerance for native/Wasm floating-point roundoff in degrees or degrees/day. The recorded Wasm run's largest residual was 1.14e-13 degrees; speeds matched exactly. This release also retains the existing frozen source-Wasm parity checks for tropical charts and Sun searches. + +## Lahiri + +Select `reference: "lahiri"` with equal sign divisions in Rust or the Wasm +adapter. The example selector exposes the same option. Existing defaults and +Fagan–Bradley calculations are unchanged. + +The mean offset follows the PAC Indian Astronomical Ephemeris 2027, printed +page 382, formula introduced in 2021: at J2000 it is 23°51′25.53″, with +precession in arcseconds +5028.796195T + 1.1054348T² + 0.00007964T³ − 0.00023857T⁴ − 0.0000000383T⁵, +where T is TT centuries since J2000. The engine adds its nutation in longitude, +consistent with its tropical coordinates (the publication describes true +ayanamsa on page 423). Source: +https://mausam.imd.gov.in/responsive/indianAstronomicalEphemeris.php + +Evaluation supports 1800-01-01 through 2200-01-01 TT inclusive, additionally +limited by dataset coverage. This range is not a claim of independently +verified astronomical accuracy. Planetary speeds subtract the centered daily +offset change; angle speeds remain zero. Whole Sign houses use the projected +Ascendant, while Equal and Placidus follow the existing projection and fallback. + +### Initial benchmark evidence + +The approved external benchmark was tested at UTC dates in 1900, 1956, 2000, +2026, and 2100 using independently computed Rust values and the existing local +dataset. All five true ayanamsas passed the one-arcsecond threshold (maximum +0.110 arcseconds). Sun/Moon longitudes passed in 1956, 2000, and 2026, but +failed in 1900 and 2100. Tropical control calculations reproduced those +failures, with maximum Moon error 20.622 arcseconds. The underlying time and +dataset conventions require investigation before claiming chart alignment. + +No benchmark-derived reference data is committed, and no new Rust-generated +regression fixtures have been frozen: their prerequisite is benchmark agreement. +Broader body, speed, house, and date sweeps remain outstanding. + +An expanded Whole Sign batch compared all 19 body/angle longitudes, 15 body +speeds and declinations, 12 cusps, and true ayanamsa at each of those five dates. +One complete case (1956) passed. All speeds and Whole Sign cusps passed; +maximum speed residual was 0.393 arcseconds/day. Additional failures included +angles (up to 11.799 arcseconds at J2000) and Moon declination (up to 6.543 +arcseconds in 2100). These remain unresolved; Equal/Placidus and polar sweeps +are not yet validated. diff --git a/examples/configurable-chart/index.html b/examples/configurable-chart/index.html index 9608126..992d76b 100644 --- a/examples/configurable-chart/index.html +++ b/examples/configurable-chart/index.html @@ -7,7 +7,7 @@ - + diff --git a/src/chart.rs b/src/chart.rs index 34fd1df..45a548c 100644 --- a/src/chart.rs +++ b/src/chart.rs @@ -35,7 +35,7 @@ pub struct ChartValues { pub configuration: ZodiacConfiguration, pub convention_revision: &'static str, pub reference_offset_degrees: f64, - /// True Sky retains tropical speeds; Fagan–Bradley corrects them. + /// True Sky retains tropical speeds; Fagan–Bradley and Lahiri correct them. /// Angle speeds remain zero (not calculated) for every reference. pub speed_reference: SpeedReference, pub bodies: Vec, @@ -57,6 +57,10 @@ pub fn calculate_chart( let (offset, rate, speed_reference) = match configuration.reference { ZodiacReference::Tropical => (0.0, 0.0, SpeedReference::Tropical), ZodiacReference::TrueSky => (true_sky_offset(input.epoch)?, 0.0, SpeedReference::Tropical), + ZodiacReference::Lahiri => { + let (offset, rate) = crate::sidereal::lahiri_offset_and_rate(input.epoch)?; + (offset, rate, SpeedReference::SelectedReference) + } ZodiacReference::FaganBradley => ( fagan_bradley_ayanamsa(input.epoch)?, crate::sidereal::ayanamsa_daily_rate(input.epoch), @@ -106,8 +110,10 @@ pub fn calculate_chart( .enumerate() .map(|(index, body)| ChartBody { longitude: project_longitude(body.longitude), - speed: if configuration.reference == ZodiacReference::FaganBradley - && index < physical_body_count + speed: if matches!( + configuration.reference, + ZodiacReference::FaganBradley | ZodiacReference::Lahiri + ) && index < physical_body_count { body.speed - rate } else { diff --git a/src/conventions.rs b/src/conventions.rs index c6c1074..4f3b5da 100644 --- a/src/conventions.rs +++ b/src/conventions.rs @@ -12,6 +12,7 @@ pub const CONVENTION_REVISION: &str = "astrology-engine-conventions-v1"; pub enum ZodiacReference { Tropical, FaganBradley, + Lahiri, TrueSky, } diff --git a/src/sidereal.rs b/src/sidereal.rs index afa6859..2f9df3d 100644 --- a/src/sidereal.rs +++ b/src/sidereal.rs @@ -1,7 +1,7 @@ // Copyright (c) Jack Asher // SPDX-License-Identifier: MIT -//! Fagan–Bradley zodiac, using the existing IAU 2006 precession rotation. +//! Fagan–Bradley and current PAC Lahiri longitude offsets. //! //! This is the traditional longitude-offset convention on the ecliptic of //! date, not a three-dimensional rotation onto a fixed B1950 ecliptic. @@ -63,3 +63,61 @@ pub(crate) fn ayanamsa_daily_rate(epoch: Epoch) -> f64 { true_ayanamsa(AstroTime::from_tdb(epoch + half_day)) - true_ayanamsa(AstroTime::from_tdb(epoch - half_day)) } + +// PAC Indian Astronomical Ephemeris 2027, pp. 382, 423 (formula since 2021). +fn lahiri_mean(tt: f64) -> f64 { + let t = tt / 36525.0; + 23.0 + 51.0 / 60.0 + + 25.53 / 3600.0 + + t * (5028.796195 + + t * (1.1054348 + t * (0.00007964 + t * (-0.00023857 - t * 0.0000000383)))) + / 3600.0 +} + +fn lahiri_true(time: AstroTime) -> f64 { + lahiri_mean(time.tt) + e_tilt(time).dpsi / 3600.0 +} + +pub(crate) fn lahiri_offset_and_rate(epoch: Epoch) -> Result<(f64, f64), CalculationError> { + let time = AstroTime::from_tdb(epoch); + if !(MIN_JD..=MAX_JD).contains(&(time.tt + J2000)) { + return Err(CalculationError::InvalidInput( + "Lahiri is supported from 1800-01-01 through 2200-01-01 TT", + )); + } + let half_day = hifitime::Duration::from_days(0.5); + Ok(( + lahiri_true(time), + lahiri_true(AstroTime::from_tdb(epoch + half_day)) + - lahiri_true(AstroTime::from_tdb(epoch - half_day)), + )) +} + +#[cfg(test)] +mod lahiri_tests { + use super::*; + #[test] + fn official_mean_anchor() { + assert!((lahiri_mean(0.0) - (23.0 + 51.0 / 60.0 + 25.53 / 3600.0)).abs() < 1e-12); + } +} + +#[cfg(test)] +mod lahiri_range_tests { + use super::*; + #[test] + fn range_and_rate() { + for jd in [MIN_JD, MAX_JD] { + // Construct UTC instants well inside/outside each boundary; the + // engine derives TT using its existing delta-T convention. + let year = if jd == MIN_JD { "1800" } else { "2199" }; + let epoch: Epoch = format!("{year}-06-01T00:00:00Z").parse().unwrap(); + let (offset, rate) = lahiri_offset_and_rate(epoch).unwrap(); + assert!(offset.is_finite()); + assert!(rate.is_finite() && rate.abs() < 0.001); + } + for utc in ["1799-01-01T00:00:00Z", "2201-01-01T00:00:00Z"] { + assert!(lahiri_offset_and_rate(utc.parse().unwrap()).is_err()); + } + } +} diff --git a/tests/lahiri.rs b/tests/lahiri.rs new file mode 100644 index 0000000..706fe05 --- /dev/null +++ b/tests/lahiri.rs @@ -0,0 +1,18 @@ +use astrology_engine::{ + effective_configuration, SignDivisions, ZodiacConfiguration, ZodiacReference, +}; + +#[test] +fn lahiri_wire_configuration_and_divisions() { + let config: ZodiacConfiguration = + serde_json::from_str(r#"{"reference":"lahiri","divisions":"equal","ophiuchus":null}"#) + .unwrap(); + assert_eq!(config.reference, ZodiacReference::Lahiri); + assert_eq!(effective_configuration(config).unwrap(), config); + assert_eq!(serde_json::to_value(config).unwrap()["reference"], "lahiri"); + assert!(effective_configuration(ZodiacConfiguration { + divisions: SignDivisions::Constellation, + ..config + }) + .is_err()); +}