Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/chart-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion examples/configurable-chart/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<label>Birth time (UTC)<input name="utc" value="2000-01-01T12:00:00Z" required></label>
<label>Latitude (degrees)<input name="latitude" type="number" min="-90" max="90" step="any" value="51.5074" required></label>
<label>Longitude (degrees)<input name="longitude" type="number" min="-180" max="180" step="any" value="-0.1278" required></label>
<label>Zodiac reference<select name="reference"><option value="tropical">Tropical</option><option value="fagan-bradley">Fagan–Bradley</option><option value="true-sky">Chimenti True Sky</option></select></label>
<label>Zodiac reference<select name="reference"><option value="tropical">Tropical</option><option value="fagan-bradley">Fagan–Bradley</option><option value="lahiri">Lahiri</option><option value="true-sky">Chimenti True Sky</option></select></label>
<label>Sign divisions<select name="divisions"><option value="equal">Twelve equal signs</option><option value="constellation">Constellation midpoints</option></select></label>
<label>Ophiuchus<select name="ophiuchus"><option value="enabled">Enabled</option><option value="disabled">Merged into Scorpio</option></select></label>
<label>Houses<select name="houses"><option value="placidus">Placidus</option><option value="equal">Equal</option><option value="whole-sign">Whole Sign</option></select></label>
Expand Down
12 changes: 9 additions & 3 deletions src/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChartEntry>,
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/conventions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub const CONVENTION_REVISION: &str = "astrology-engine-conventions-v1";
pub enum ZodiacReference {
Tropical,
FaganBradley,
Lahiri,
TrueSky,
}

Expand Down
60 changes: 59 additions & 1 deletion src/sidereal.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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());
}
}
}
18 changes: 18 additions & 0 deletions tests/lahiri.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading