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());
+}