diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1205b25..778a8e7 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -125,6 +125,7 @@ jobs: cache-dependency-path: tools/jpl/requirements.lock - run: python -m pip install --require-hashes -r tools/jpl/requirements.lock - run: python tools/verification/test_compare.py + - run: python tools/verification/test_chart_migration.py - run: python tools/verification/test_acquisition.py - run: python tools/verification/test_generation.py worker: diff --git a/README.md b/README.md index b008bf6..33b6c3c 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,66 @@ # Astrology Engine -A Rust library for calculating astrology charts: planetary positions, chart -angles, and house cusps. - -For a Fagan–Bradley sidereal chart, use `calculate_sidereal_chart` with the same -`ChartInput`. It returns the date's ayanamsa, sidereal positions, twelve house -cusps, and the actual house method (including Porphyry fallback). The tropical -`calculate_chart` API remains available. The sidereal model covers 1800–2200 TT; -chart dates must also lie within the supplied dataset. - -For explicit zodiac and sign-division choices, use `calculate_configured_chart`. -It adds Tropical, Fagan–Bradley and Chimenti True Sky configuration, optional -Ophiuchus merging, sign assignments and zodiac-aware houses. See the -[convention contract](docs/chart-conventions.md) and the standalone -[browser/Wasm example](examples/configurable-chart/README.md). +High-precision, Wasm-first astrology engine in Rust. Calculates planetary +positions, angles, and houses for the browser, Cloudflare Workers, and server +runtimes. + +## What it calculates + +- Planetary and derived body positions, longitudinal speeds, and declinations, + plus the Ascendant, Midheaven, Descendant, and Imum Coeli. +- Tropical, Fagan–Bradley sidereal, and Chimenti True Sky references, with sign + assignments and equal or True Sky constellation divisions. +- Equal, Whole Sign, and Placidus houses, with twelve cusps and explicit reporting + when Placidus falls back to Porphyry. +- Sun-longitude crossings, including the design-date search used in Human Design. + +One `calculate_chart` function takes explicit zodiac and house settings and +returns positions, signs, cusps, and calculation metadata. See the +[chart conventions](docs/chart-conventions.md) for reference models, supported +combinations, speed conventions, and polar-house safeguards. Fagan–Bradley's +model covers 1800–2200 TT; every chart also requires coverage in its dataset. + +## Use it in your runtime + +The runtime accepts caller-supplied ephemeris bytes. It performs no network or +filesystem access; your application loads the data and resolves place names and +local birth times into coordinates and an unambiguous instant. + +- **Browser/Wasm:** the [standalone chart example](examples/configurable-chart/README.md) + builds Rust to Wasm and provides interactive zodiac and house settings. +- **Cloudflare Workers:** the [HTTP example](examples/cloudflare-worker/) loads + data from R2. Follow the [Worker runbook](docs/worker-runbook.md) for local + testing and deployment. Its HTTP interface uses tropical charts. +- **Native/server Rust:** parse the dataset once with `Ephemeris::parse` and reuse + it across calls, as in the quickstart below. ## Calculate a natal chart -Supply a birth instant, latitude and longitude, and a house system. -`calculate_chart` returns the chart's body positions, angles, and optional house -cusps using a reusable ephemeris dataset. - -To run the example, use the Rust toolchain specified in -[`rust-toolchain.toml`](rust-toolchain.toml). You also need an ephemeris dataset: -a binary file containing the data the library uses to calculate celestial -positions. The file must use the library's `HDCHEB01` format; the examples call -it `cheb.bin`. - -Dataset files are not included in the repository. Follow the -[dataset generation guide](tools/README.md) to create one, then run this command -from the repository root, replacing `/path/to/cheb.bin` with your file's path: +Use the Rust toolchain specified in [`rust-toolchain.toml`](rust-toolchain.toml). +You also need an ephemeris dataset: a binary file of prepared celestial-position +data in the engine's `HDCHEB01` format. The examples call this file `cheb.bin`. +Dataset files are supplied separately and are not included in the repository. +Follow [Build a dataset](tools/README.md#build-a-dataset), then run: ```sh -cargo run --locked --example natal_chart -- /path/to/cheb.bin +cargo run --locked --example natal_chart -- /absolute/path/to/cheb.bin ``` -The example uses a birth instant of **1 January 2000 at 12:00 UTC**, a London -location, and equal houses. Convert the person's local birth date and time to -UTC before calling the library. The dataset must cover the birth time and the -times 12 hours before and after it, which the library uses to calculate speeds. +This example calculates a tropical chart for **1 January 2000 at 12:00 UTC**, +London coordinates, and Equal houses. Convert local birth times to UTC before +calling the engine. The dataset must support the instant and samples 12 hours +before and after it for speed calculations. The complete source is [`examples/natal_chart.rs`](examples/natal_chart.rs): ```rust -use astrology_engine::{calculate_chart, ChartInput, Ephemeris, Epoch, HouseSystem, Location}; +// Copyright (c) Jack Asher +// SPDX-License-Identifier: MPL-2.0 + +use astrology_engine::{ + calculate_chart, ChartInput, Ephemeris, Epoch, HouseSystem, Location, SignDivisions, + ZodiacConfiguration, ZodiacReference, +}; fn main() -> Result<(), Box> { let dataset = std::env::args_os() @@ -62,64 +79,58 @@ fn main() -> Result<(), Box> { longitude: -0.1278, }, house_system: HouseSystem::Equal, + zodiac: ZodiacConfiguration { + reference: ZodiacReference::Tropical, + divisions: SignDivisions::Equal, + ophiuchus: None, + }, }, )?; println!("Birth moment: {birth_moment}"); println!("Longitudes in degrees:"); for body in &chart.bodies { - println!("{:<20} {:>12.6}", body.name, body.longitude); - } - if let Some(cusps) = &chart.cusps { - println!("House cusps in degrees: {cusps:?}"); + println!("{:<20} {:>12.6}", body.values.name, body.values.longitude); } + println!("House cusps in degrees: {:?}", chart.cusps); Ok(()) } ``` -The example prints the body and angle longitudes and the twelve equal-house -cusps. Each body entry also contains longitudinal speed and declination. The -returned `ChartValues` contains the numerical results your application can use -to draw a chart. - -Load and parse the ephemeris once, then reuse it for subsequent charts. +`ChartValues` contains bodies with numerical values and sign assignments, +twelve house cusps, effective zodiac settings, and the requested and actual +house methods. Body longitudes, declinations, and cusps use degrees; speeds use +degrees per day. Load and parse the ephemeris once, then reuse it for later charts. ## Human Design design-date search -The library can also find the design instant used in Human Design: the time -before birth when the Sun's longitude was 88° less than its longitude at birth. -Starting with a natal chart, subtract 88° from the birth Sun's longitude, adjust -the result to the range from 0° inclusive to 360° exclusive, and search backward -with `find_sun_crossing` and `Direction::Backward`. - -The offset is an angle; the search determines the corresponding date and time. -You can then call `calculate_chart` at that instant to obtain its planetary -positions, using the same ephemeris. +The design instant is the time before birth when the tropical Sun's longitude +was 88° less than its birth longitude. Calculate a tropical birth chart, subtract +88° from the Sun's longitude, wrap into [0°, 360°), and call `find_sun_crossing` +with `Direction::Backward`. Calculate another chart at the returned instant. -The [birth and design example](examples/birth_and_design.rs) demonstrates the -complete workflow: +The [birth and design example](examples/birth_and_design.rs) demonstrates this +workflow: ```sh -cargo run --locked --example birth_and_design -- /path/to/cheb.bin +cargo run --locked --example birth_and_design -- /absolute/path/to/cheb.bin ``` -This search requires at least 89 days of dataset coverage before birth. The -dataset must also cover the returned design instant and the times 12 hours -before and after it, which the library uses to calculate speeds. +The search needs at least 89 days of dataset coverage before birth. Calculating +the design chart also requires speed-sampling support around the returned instant. -## Architecture and entry points +## Architecture and verification -Read [From JPL data to a natal chart](docs/architecture.md) to learn how NASA JPL -data becomes an ephemeris dataset and how the library uses that dataset to -calculate charts. +[From JPL data to a natal chart](docs/architecture.md) explains the separation +between native dataset preparation and the immutable Rust/Wasm runtime. +[The public Rust API](src/lib.rs) exports chart calculation, ephemeris evaluation, +and solar-longitude search. -- [Public Rust API](src/lib.rs): chart calculation, ephemeris evaluation, and - solar longitude search. -- [Example Cloudflare Worker](examples/cloudflare-worker/): an HTTP adapter that - loads the dataset from R2 and calls the library. -- [Deploy the Worker demo](docs/worker-runbook.md): generate a dataset, test - locally, deploy a public demo in your Cloudflare account, and clean up. +[Verification guidance](tools/README.md#run-focused-checks) distinguishes +structural checks, local Wasm execution, frozen regression parity, and dataset +regeneration. Recorded regression outputs preserve previous engine behavior; +these checks do not establish independent astronomical accuracy. ## License and distribution diff --git a/docs/architecture.md b/docs/architecture.md index e40c4a7..2640a48 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ parsed ephemeris across calculations. File access, storage, and checking the artifact's SHA-256 belong to the caller; the library accepts the bytes. For a natal chart, `calculate_chart` takes that ephemeris, an unambiguous birth -instant, latitude and longitude, and a house system. The caller resolves local +instant, latitude and longitude, a house system, and explicit zodiac settings. The caller resolves local time and timezone ambiguity before passing the instant. For each stored body coordinate, the runtime selects the segment containing the @@ -82,12 +82,12 @@ The runtime combines those body values with angles and houses calculated from the birth instant, birthplace, and selected house system. Birthplace affects this chart geometry; it does not alter the stored geocentric body positions. -The result is `ChartValues`: a list of body and angle entries, plus optional -house cusps. These are the numerical values an application can use to display a +The result is `ChartValues`: body and angle entries with sign assignments, twelve +house cusps, and metadata describing the effective zodiac and actual house method. These are the numerical values an application can use to display a chart. The current result contains 15 body/node entries and four chart angles. The same pipeline supports the Human Design design chart's planetary positions. -The caller first calculates the birth chart, subtracts 88° from the birth Sun's +The caller first calculates a tropical birth chart, subtracts 88° from the birth Sun's longitude, and asks `find_sun_crossing` to search backward. It then calls `calculate_chart` at the returned instant, reusing the same ephemeris. Human Design gate calculations remain outside this library. The diff --git a/docs/chart-conventions.md b/docs/chart-conventions.md index 16c9054..e6793d6 100644 --- a/docs/chart-conventions.md +++ b/docs/chart-conventions.md @@ -1,23 +1,28 @@ # Configurable natal charts -`calculate_configured_chart` accepts the existing `ChartInput` and an explicit -`ZodiacConfiguration`. Its references are Tropical, Fagan–Bradley and Chimenti -True Sky. Sign divisions can be equal or, for True Sky, constellation midpoints. -The original `calculate_chart` and `calculate_sidereal_chart` contracts remain -available unchanged, including absent tropical Whole Sign cusps. +`calculate_chart` accepts an explicit `ChartInput`, including its `zodiac` field +of type `ZodiacConfiguration`. References are Tropical, Fagan–Bradley and Chimenti True +Sky. Sign divisions can be equal or, for True Sky, constellation midpoints. +There is one chart entry point and one `ChartValues` result contract. ```rust use astrology_engine::{ - calculate_configured_chart, ChartInput, Ephemeris, Ophiuchus, + calculate_chart, ChartInput, Ephemeris, Ophiuchus, SignDivisions, ZodiacConfiguration, ZodiacReference, }; fn chart(ephemeris: &Ephemeris, input: ChartInput) -> Result<(), Box> { - let values = calculate_configured_chart(ephemeris, input, ZodiacConfiguration { - reference: ZodiacReference::TrueSky, - divisions: SignDivisions::Constellation, - ophiuchus: Some(Ophiuchus::Enabled), - })?; + let values = calculate_chart( + ephemeris, + ChartInput { + zodiac: ZodiacConfiguration { + reference: ZodiacReference::TrueSky, + divisions: SignDivisions::Constellation, + ophiuchus: Some(Ophiuchus::Enabled), + }, + ..input + }, + )?; println!("{}: {:?}", values.convention_revision, values.cusps); Ok(()) } @@ -53,8 +58,8 @@ retains the original instant's precision. True Sky preserves the source reading layer's tropical longitudinal speeds and equatorial declinations. Consequently `speed_reference` is `tropical`, even -though the returned longitudes use True Sky. Fagan–Bradley delegates to the -existing engine implementation and returns `selected-reference` speeds. The four +though the returned longitudes use True Sky. Fagan–Bradley applies the engine's +ayanamsa and its daily rate, returning `selected-reference` speeds. The four angle speeds remain zero, meaning not calculated. Speeds are degrees per day; longitudes, declinations, offsets, sector starts and widths are degrees. @@ -78,6 +83,29 @@ starts in the merged Scorpio sector at 210.1972°. The twelve house starts are: With Ophiuchus disabled, the same longitude is Scorpio, 17.8028° into its 25.5846° sector. The house starts are unchanged. +Tropical preserves the underlying numerical positions. At extreme latitudes, +floating-point angle calculations can round to 360° (equivalent to 0°); sign +lookup wraps those longitudes into [0°, 360°). + +## Migrating the chart API + +Replace `calculate_sidereal_chart` and `calculate_configured_chart` calls with +`calculate_chart`, and put the chosen `ZodiacConfiguration` in `ChartInput.zodiac`. +Former tropical callers must explicitly choose Tropical with Equal divisions and +`ophiuchus: None`. The runtime supplies no defaults or legacy wrappers. + +`ChartValues` replaces the former configured and sidereal result types. Each +body is a `ChartEntry` with `values: ChartBody` and `sign: SignPosition`; serialized +body fields remain flattened. Use `reference_offset_degrees` for the reference +offset (the ayanamsa for Fagan–Bradley), and the typed `house_system` for the actual +method. Cusps are always `[f64; 12]`, including tropical Whole Sign houses. +Overlapping polar house sets now fail for every reference. + +The Worker example continues to use its existing tropical HTTP request and +response fields, including omitted Whole Sign cusps. Overlapping polar charts +follow its existing calculation-error mapping: public HTTP 500 with `internal`, +and the explicit overlap message in verification captures. + ## Browser and host integration The [standalone browser/Wasm example](../examples/configurable-chart/README.md) @@ -91,8 +119,8 @@ remains the host's responsibility. [Regression replay instructions](../tools/verification/conventions.md) describe how to compare current outputs with the committed fixtures. Structural checks -also cover invalid settings, non-finite input, coverage admission and existing -API compatibility. +also cover invalid settings, non-finite input, coverage admission and unified +chart behavior. Boundary tables and assignments match the recorded outputs exactly. Chart comparisons use a 1e-12 absolute tolerance for native/Wasm floating-point diff --git a/examples/birth_and_design.rs b/examples/birth_and_design.rs index b95ab53..a267b9c 100644 --- a/examples/birth_and_design.rs +++ b/examples/birth_and_design.rs @@ -3,7 +3,7 @@ use astrology_engine::{ calculate_chart, find_sun_crossing, Body, ChartInput, Direction, Ephemeris, Epoch, HouseSystem, - Location, SunSearchInput, + Location, SignDivisions, SunSearchInput, ZodiacConfiguration, ZodiacReference, }; fn main() -> Result<(), Box> { @@ -25,6 +25,11 @@ fn main() -> Result<(), Box> { epoch, location, house_system: HouseSystem::Equal, + zodiac: ZodiacConfiguration { + reference: ZodiacReference::Tropical, + divisions: SignDivisions::Equal, + ophiuchus: None, + }, }, ) }; @@ -33,9 +38,9 @@ fn main() -> Result<(), Box> { let birth_sun = birth_chart .bodies .iter() - .find(|body| body.name == Body::Sun.wire_name()) + .find(|body| body.values.name == Body::Sun.wire_name()) .ok_or("Birth chart has no Sun")?; - let target_longitude = (birth_sun.longitude - 88.0).rem_euclid(360.0); + let target_longitude = (birth_sun.values.longitude - 88.0).rem_euclid(360.0); let design_moment = find_sun_crossing( &ephemeris, SunSearchInput { @@ -54,7 +59,7 @@ fn main() -> Result<(), Box> { for (birth, design) in birth_chart.bodies.iter().zip(&design_chart.bodies) { println!( "{:<20} {:>12.6} {:>12.6}", - birth.name, birth.longitude, design.longitude + birth.values.name, birth.values.longitude, design.values.longitude ); } diff --git a/examples/cloudflare-worker/AGENTS.md b/examples/cloudflare-worker/AGENTS.md index 95b6ead..9bc14bc 100644 --- a/examples/cloudflare-worker/AGENTS.md +++ b/examples/cloudflare-worker/AGENTS.md @@ -17,9 +17,9 @@ requests, deployment and cleanup procedures. only after loading, hashing and parsing succeed so failed initialization can recover. In the [local harness](verify.mjs), cold/warm requests and retry after invalid storage exercise this lifecycle across the R2 and Rust boundaries. -- HTTP representation is distinct from library values: absent Whole Sign - cusps are omitted in JSON, and public search results discard sub-minute - precision. The [capture adapter](src/verification.rs) retains floating-point +- HTTP representation is distinct from library values: Whole Sign + cusps are omitted in JSON even though the runtime returns twelve cusps. Public + search results discard sub-minute precision. The [capture adapter](src/verification.rs) retains floating-point bits and precise instants for parity. Keep public formatting separate from capture transport, including lossless large-integer handling in `verify.mjs`. diff --git a/examples/cloudflare-worker/src/lib.rs b/examples/cloudflare-worker/src/lib.rs index b54b810..3e20a4d 100644 --- a/examples/cloudflare-worker/src/lib.rs +++ b/examples/cloudflare-worker/src/lib.rs @@ -3,7 +3,8 @@ use astrology_engine::{ calculate_chart, find_sun_crossing, Body, CalculationError, ChartBody, ChartInput, Direction, - Ephemeris, Epoch, HouseSystem, Location, SunSearchInput, + Ephemeris, Epoch, HouseSystem, Location, SignDivisions, SunSearchInput, ZodiacConfiguration, + ZodiacReference, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -188,14 +189,20 @@ async fn build_chart( epoch: base, location, house_system: parse_house_system(request.house_system.as_deref()), + zodiac: ZodiacConfiguration { + reference: ZodiacReference::Tropical, + divisions: SignDivisions::Equal, + ophiuchus: None, + }, }, ) })?; Ok(Chart { utc_date: request.date.clone(), location, - bodies: chart.bodies, - cusps: chart.cusps, + bodies: chart.bodies.into_iter().map(|entry| entry.values).collect(), + cusps: (parse_house_system(request.house_system.as_deref()) != HouseSystem::WholeSign) + .then(|| chart.cusps.to_vec()), }) } diff --git a/examples/cloudflare-worker/src/verification.rs b/examples/cloudflare-worker/src/verification.rs index 9cf48ef..b2a2072 100644 --- a/examples/cloudflare-worker/src/verification.rs +++ b/examples/cloudflare-worker/src/verification.rs @@ -69,10 +69,10 @@ async fn validate_dataset(_: Request, ctx: RouteContext<()>) -> Result for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] { let epoch = Epoch::from_et_seconds(domain.lo_et + (domain.hi_et - domain.lo_et) * fraction); let chart = calculate_chart(ephemeris, ChartInput { - epoch, location: Location { latitude: 45.0, longitude: -90.0 }, house_system: HouseSystem::Placidus, + epoch, location: Location { latitude: 45.0, longitude: -90.0 }, house_system: HouseSystem::Placidus, zodiac: ZodiacConfiguration { reference: ZodiacReference::Tropical, divisions: SignDivisions::Equal, ophiuchus: None }, })?; assert_eq!(chart.bodies.len(), 19); - assert!(chart.bodies.iter().all(|b| b.longitude.is_finite() && b.speed.is_finite() && b.declination.is_finite())); + assert!(chart.bodies.iter().all(|b| b.values.longitude.is_finite() && b.values.speed.is_finite() && b.values.declination.is_finite())); if fraction == 0.5 { for direction in [Direction::Backward, Direction::Forward] { assert!(find_sun_crossing(ephemeris, SunSearchInput { epoch, direction, target_longitude: 0.0 })?.is_some()); diff --git a/examples/configurable-chart/README.md b/examples/configurable-chart/README.md index aed9f99..6ac8e83 100644 --- a/examples/configurable-chart/README.md +++ b/examples/configurable-chart/README.md @@ -1,6 +1,6 @@ # Configurable chart in a browser -This example runs the public configurable-chart API as Wasm. The browser fetches +This example runs the public `calculate_chart` API as Wasm. The browser fetches `/ephemeris.bin` from the local server and reuses those bytes for subsequent calculations. Enter a UTC birth time, coordinates and chart settings. There are no private website or birth-resolver dependencies. @@ -61,7 +61,7 @@ The result includes effective settings, the convention revision, sign sectors, twelve house cusps, and requested/actual house systems. True Sky preserves tropical speeds, identified by `speed_reference: "tropical"`. See [the convention contract](../../docs/chart-conventions.md) for the separate -zodiac and house tables, Ophiuchus handling, and Fagan–Bradley compatibility. +zodiac and house tables, Ophiuchus handling, and Fagan–Bradley conventions. To replay recorded regression charts through this Wasm adapter: diff --git a/examples/configurable-chart/wasm/src/lib.rs b/examples/configurable-chart/wasm/src/lib.rs index 58d4a3c..13cea12 100644 --- a/examples/configurable-chart/wasm/src/lib.rs +++ b/examples/configurable-chart/wasm/src/lib.rs @@ -2,8 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 use astrology_engine::{ - calculate_configured_chart, ChartInput, Ephemeris, Epoch, HouseSystem, Location, - ZodiacConfiguration, + calculate_chart, ChartInput, Ephemeris, Epoch, HouseSystem, Location, ZodiacConfiguration, }; use serde::Deserialize; use wasm_bindgen::prelude::*; @@ -40,7 +39,7 @@ pub fn calculate(bytes: &[u8], request_json: &str) -> Result { Instant::Et(_) => return Err("ET seconds must be finite".into()), }; let ephemeris = Ephemeris::parse(bytes.to_vec()).map_err(|error| error.to_string())?; - let chart = calculate_configured_chart( + let chart = calculate_chart( &ephemeris, ChartInput { epoch, @@ -49,8 +48,8 @@ pub fn calculate(bytes: &[u8], request_json: &str) -> Result { longitude: request.longitude, }, house_system: request.house_system, + zodiac: request.configuration, }, - request.configuration, ) .map_err(|error| error.to_string())?; serde_json::to_string(&chart).map_err(|error| error.to_string()) diff --git a/examples/natal_chart.rs b/examples/natal_chart.rs index 89ad269..35fcbdd 100644 --- a/examples/natal_chart.rs +++ b/examples/natal_chart.rs @@ -1,7 +1,10 @@ // Copyright (c) Jack Asher // SPDX-License-Identifier: MPL-2.0 -use astrology_engine::{calculate_chart, ChartInput, Ephemeris, Epoch, HouseSystem, Location}; +use astrology_engine::{ + calculate_chart, ChartInput, Ephemeris, Epoch, HouseSystem, Location, SignDivisions, + ZodiacConfiguration, ZodiacReference, +}; fn main() -> Result<(), Box> { let dataset = std::env::args_os() @@ -20,17 +23,20 @@ fn main() -> Result<(), Box> { longitude: -0.1278, }, house_system: HouseSystem::Equal, + zodiac: ZodiacConfiguration { + reference: ZodiacReference::Tropical, + divisions: SignDivisions::Equal, + ophiuchus: None, + }, }, )?; println!("Birth moment: {birth_moment}"); println!("Longitudes in degrees:"); for body in &chart.bodies { - println!("{:<20} {:>12.6}", body.name, body.longitude); - } - if let Some(cusps) = &chart.cusps { - println!("House cusps in degrees: {cusps:?}"); + println!("{:<20} {:>12.6}", body.values.name, body.values.longitude); } + println!("House cusps in degrees: {:?}", chart.cusps); Ok(()) } diff --git a/src/chart.rs b/src/chart.rs index 5b3c571..f47c99e 100644 --- a/src/chart.rs +++ b/src/chart.rs @@ -1,28 +1,76 @@ // Copyright (c) Jack Asher // SPDX-License-Identifier: MPL-2.0 -use crate::astro::houses::{angle_longitudes, compute_angles, house_set}; +//! One chart pipeline with explicit zodiac and house conventions. +use crate::astro::houses::{ + angle_longitudes, compute_angles, house_set, placidus_cusps_with_fallback, +}; use crate::astro::AstroTime; -use crate::{Body, CalculationError, ChartBody, Ephemeris, Epoch, HouseSystem, Location}; -use serde::{Deserialize, Serialize}; +use crate::conventions::{checked_cusps, house_sectors, normalize, position, sectors}; +use crate::{ + effective_configuration, fagan_bradley_ayanamsa, true_sky_offset, whole_sign_cusps, + ActualHouseSystem, Body, CalculationError, ChartBody, Ephemeris, Epoch, HouseSystem, Location, + SignPosition, SignSector, SpeedReference, ZodiacConfiguration, ZodiacReference, + CONVENTION_REVISION, +}; +use serde::Serialize; #[derive(Clone, Copy, Debug)] pub struct ChartInput { pub epoch: Epoch, pub location: Location, pub house_system: HouseSystem, + pub zodiac: ZodiacConfiguration, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct ChartEntry { + #[serde(flatten)] + pub values: ChartBody, + pub sign: SignPosition, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct ChartValues { - pub bodies: Vec, - pub cusps: Option>, + pub configuration: ZodiacConfiguration, + pub convention_revision: &'static str, + pub reference_offset_degrees: f64, + /// True Sky retains tropical speeds; Fagan–Bradley corrects them. + /// Angle speeds remain zero (not calculated) for every reference. + pub speed_reference: SpeedReference, + pub bodies: Vec, + pub zodiac_sectors: Vec, + /// Present only for Whole Sign; may differ from the zodiac sectors. + pub house_sectors: Option>, + pub cusps: [f64; 12], + pub requested_house_system: HouseSystem, + pub house_system: ActualHouseSystem, } +/// Calculate bodies, angles, signs and twelve validated house cusps. +/// The caller supplies the dataset, instant, location and all chart settings. pub fn calculate_chart( ephemeris: &Ephemeris, input: ChartInput, ) -> Result { + let configuration = effective_configuration(input.zodiac)?; + 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::FaganBradley => ( + fagan_bradley_ayanamsa(input.epoch)?, + crate::sidereal::ayanamsa_daily_rate(input.epoch), + SpeedReference::SelectedReference, + ), + }; + // Keep inherited tropical values, including angles that round to 360°. + let project_longitude = |longitude: f64| { + if configuration.reference == ZodiacReference::Tropical { + longitude + } else { + (longitude - offset).rem_euclid(360.0) + } + }; let lat = input.location.latitude; let lon = input.location.longitude; if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) { @@ -33,6 +81,7 @@ pub fn calculate_chart( crate::check_coverage(ephemeris, input.epoch.to_et_seconds(), 0.0)?; let mut bodies = crate::cheb::chart::chart_bodies(ephemeris, input.epoch) .map_err(CalculationError::Evaluation)?; + let physical_body_count = bodies.len(); let time = AstroTime::from_tdb(input.epoch); let angles = compute_angles(time, lat, lon); let angle_names = [ @@ -41,11 +90,10 @@ pub fn calculate_chart( Body::Descendant, Body::ImumCoeli, ]; - let angle_lons = angle_longitudes(angles); bodies.extend( angle_names .iter() - .zip(angle_lons) + .zip(angle_longitudes(angles)) .map(|(body, longitude)| ChartBody { name: body.wire_name().to_string(), longitude, @@ -53,6 +101,75 @@ pub fn calculate_chart( declination: 0.0, }), ); - let cusps = house_set(input.house_system, angles, time, lat, lon).cusps; - Ok(ChartValues { bodies, cusps }) + let bodies: Vec<_> = bodies + .into_iter() + .enumerate() + .map(|(index, body)| ChartBody { + longitude: project_longitude(body.longitude), + speed: if configuration.reference == ZodiacReference::FaganBradley + && index < physical_body_count + { + body.speed - rate + } else { + body.speed + }, + ..body + }) + .collect(); + if bodies.iter().any(|body| { + !body.longitude.is_finite() || !body.speed.is_finite() || !body.declination.is_finite() + }) { + return Err(CalculationError::InvalidInput( + "Chart contains non-finite body values", + )); + } + let (cusps, actual) = match input.house_system { + HouseSystem::WholeSign => ( + whole_sign_cusps(project_longitude(angles.ascendant), configuration)?.to_vec(), + ActualHouseSystem::WholeSign, + ), + HouseSystem::Equal => ( + house_set(HouseSystem::Equal, angles, time, lat, lon) + .cusps + .expect("Equal houses supply cusps") + .into_iter() + .map(project_longitude) + .collect(), + ActualHouseSystem::Equal, + ), + HouseSystem::Placidus => { + let (cusps, fallback) = placidus_cusps_with_fallback(angles, time, lat, lon); + ( + cusps.into_iter().map(project_longitude).collect(), + if fallback { + ActualHouseSystem::Porphyry + } else { + ActualHouseSystem::Placidus + }, + ) + } + }; + let table = sectors(configuration); + Ok(ChartValues { + configuration, + convention_revision: CONVENTION_REVISION, + reference_offset_degrees: offset, + speed_reference, + bodies: bodies + .into_iter() + .map(|values| ChartEntry { + sign: position( + normalize(values.longitude).expect("body longitudes are finite"), + &table, + ), + values, + }) + .collect(), + zodiac_sectors: table, + house_sectors: (input.house_system == HouseSystem::WholeSign) + .then(|| house_sectors(configuration)), + cusps: checked_cusps(cusps)?, + requested_house_system: input.house_system, + house_system: actual, + }) } diff --git a/src/conventions.rs b/src/conventions.rs index 14abd3e..7f7e104 100644 --- a/src/conventions.rs +++ b/src/conventions.rs @@ -2,12 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 //! Explicit chart conventions preserved by regression fixtures. -use crate::astro::houses::{compute_angles, placidus_cusps_with_fallback}; -use crate::astro::AstroTime; -use crate::{ - calculate_chart, calculate_sidereal_chart, Body, CalculationError, ChartBody, ChartInput, - Ephemeris, Epoch, HouseSystem, -}; +use crate::{CalculationError, Epoch}; use serde::{Deserialize, Serialize}; pub const CONVENTION_REVISION: &str = "astrology-engine-conventions-v1"; @@ -126,7 +121,7 @@ pub struct SignPosition { pub width_degrees: f64, } -fn sectors(config: ZodiacConfiguration) -> Vec { +pub(crate) fn sectors(config: ZodiacConfiguration) -> Vec { let table: &[(Sign, f64)] = match config.divisions { SignDivisions::Equal => &EQUAL, SignDivisions::Constellation => &CONSTELLATION, @@ -154,7 +149,7 @@ pub fn zodiac_sectors(config: ZodiacConfiguration) -> Result, Ca Ok(sectors(effective_configuration(config)?)) } -fn normalize(longitude: f64) -> Result { +pub(crate) fn normalize(longitude: f64) -> Result { if !longitude.is_finite() { return Err(CalculationError::InvalidInput("Longitude must be finite")); } @@ -166,7 +161,7 @@ fn normalize(longitude: f64) -> Result { }) } -fn position(longitude: f64, table: &[SignSector]) -> SignPosition { +pub(crate) fn position(longitude: f64, table: &[SignSector]) -> SignPosition { let sector = table .iter() .rev() @@ -187,7 +182,7 @@ pub fn assign_sign( Ok(position(normalize(longitude)?, &zodiac_sectors(config)?)) } -fn house_sectors(config: ZodiacConfiguration) -> Vec { +pub(crate) fn house_sectors(config: ZodiacConfiguration) -> Vec { sectors(ZodiacConfiguration { ophiuchus: Some(Ophiuchus::Disabled), ..config @@ -243,31 +238,7 @@ pub enum SpeedReference { SelectedReference, } -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct ConfiguredBody { - #[serde(flatten)] - pub values: ChartBody, - pub sign: SignPosition, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct ConfiguredChartValues { - pub configuration: ZodiacConfiguration, - pub convention_revision: &'static str, - pub reference_offset_degrees: f64, - /// True Sky preserves source tropical speeds; Fagan–Bradley corrects them. - /// The four angle speeds remain zero (not calculated) for every reference. - pub speed_reference: SpeedReference, - pub bodies: Vec, - pub zodiac_sectors: Vec, - /// Present only for Whole Sign; this table may differ from zodiac sectors. - pub house_sectors: Option>, - pub cusps: [f64; 12], - pub requested_house_system: HouseSystem, - pub house_system: ActualHouseSystem, -} - -fn checked_cusps(cusps: Vec) -> Result<[f64; 12], CalculationError> { +pub(crate) fn checked_cusps(cusps: Vec) -> Result<[f64; 12], CalculationError> { let cusps: [f64; 12] = cusps .try_into() .map_err(|_| CalculationError::InvalidInput("Expected twelve house cusps"))?; @@ -282,118 +253,3 @@ fn checked_cusps(cusps: Vec) -> Result<[f64; 12], CalculationError> { } Ok(cusps) } - -/// Calculate with explicit conventions while retaining the original entry points. -pub fn calculate_configured_chart( - ephemeris: &Ephemeris, - input: ChartInput, - config: ZodiacConfiguration, -) -> Result { - let configuration = effective_configuration(config)?; - let (bodies, cusps, offset, actual, speed_reference) = - if configuration.reference == ZodiacReference::FaganBradley { - let chart = calculate_sidereal_chart(ephemeris, input)?; - let actual = match chart.house_system { - "equal" => ActualHouseSystem::Equal, - "whole-sign" => ActualHouseSystem::WholeSign, - "porphyry" => ActualHouseSystem::Porphyry, - _ => ActualHouseSystem::Placidus, - }; - ( - chart.bodies, - chart.cusps, - chart.ayanamsa_degrees, - actual, - SpeedReference::SelectedReference, - ) - } else { - let chart = calculate_chart(ephemeris, input)?; - let offset = if configuration.reference == ZodiacReference::TrueSky { - true_sky_offset(input.epoch)? - } else { - 0.0 - }; - let bodies = chart - .bodies - .into_iter() - .map(|body| { - Ok(ChartBody { - longitude: normalize(body.longitude - offset)?, - ..body - }) - }) - .collect::, CalculationError>>()?; - let (cusps, actual) = match input.house_system { - HouseSystem::WholeSign => { - let ascendant = bodies - .iter() - .find(|body| body.name == Body::AscendantSymbol.wire_name()) - .expect("chart appends Ascendant") - .longitude; - ( - whole_sign_cusps(ascendant, configuration)?.to_vec(), - ActualHouseSystem::WholeSign, - ) - } - HouseSystem::Equal => ( - chart - .cusps - .expect("Equal houses supply cusps") - .into_iter() - .map(|c| normalize(c - offset)) - .collect::, _>>()?, - ActualHouseSystem::Equal, - ), - HouseSystem::Placidus => { - let time = AstroTime::from_tdb(input.epoch); - let angles = - compute_angles(time, input.location.latitude, input.location.longitude); - let (cusps, fallback) = placidus_cusps_with_fallback( - angles, - time, - input.location.latitude, - input.location.longitude, - ); - ( - cusps - .into_iter() - .map(|c| normalize(c - offset)) - .collect::, _>>()?, - if fallback { - ActualHouseSystem::Porphyry - } else { - ActualHouseSystem::Placidus - }, - ) - } - }; - (bodies, cusps, offset, actual, SpeedReference::Tropical) - }; - if bodies.iter().any(|body| { - !body.longitude.is_finite() || !body.speed.is_finite() || !body.declination.is_finite() - }) { - return Err(CalculationError::InvalidInput( - "Chart contains non-finite body values", - )); - } - let table = sectors(configuration); - Ok(ConfiguredChartValues { - configuration, - convention_revision: CONVENTION_REVISION, - reference_offset_degrees: offset, - speed_reference, - bodies: bodies - .into_iter() - .map(|values| ConfiguredBody { - sign: position(values.longitude, &table), - values, - }) - .collect(), - zodiac_sectors: table, - house_sectors: (input.house_system == HouseSystem::WholeSign) - .then(|| house_sectors(configuration)), - cusps: checked_cusps(cusps)?, - requested_house_system: input.house_system, - house_system: actual, - }) -} diff --git a/src/lib.rs b/src/lib.rs index 3fab817..24d4222 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,18 +13,17 @@ mod types; pub mod chart; pub mod conventions; pub use conventions::{ - assign_sign, calculate_configured_chart, effective_configuration, true_sky_offset, - whole_sign_cusps, zodiac_sectors, ActualHouseSystem, ConfiguredBody, ConfiguredChartValues, - Ophiuchus, Sign, SignDivisions, SignPosition, SignSector, SpeedReference, ZodiacConfiguration, - ZodiacReference, CONVENTION_REVISION, + assign_sign, effective_configuration, true_sky_offset, whole_sign_cusps, zodiac_sectors, + ActualHouseSystem, Ophiuchus, Sign, SignDivisions, SignPosition, SignSector, SpeedReference, + ZodiacConfiguration, ZodiacReference, CONVENTION_REVISION, }; pub mod search; pub mod sidereal; -pub use sidereal::{calculate_sidereal_chart, fagan_bradley_ayanamsa, SiderealChartValues}; +pub use sidereal::fagan_bradley_ayanamsa; pub use astro::find_moment::Direction; pub use astro::houses::HouseSystem; -pub use chart::{calculate_chart, ChartInput, ChartValues}; +pub use chart::{calculate_chart, ChartEntry, ChartInput, ChartValues}; pub use cheb::chart::earth_from_sun; pub use cheb::{BlobError, ChebPlace, Ephemeris, EvalError, Quantity, SeriesBody}; pub use coverage_window::CoverageWindow; diff --git a/src/sidereal.rs b/src/sidereal.rs index 168fb9b..ec441b6 100644 --- a/src/sidereal.rs +++ b/src/sidereal.rs @@ -6,12 +6,8 @@ //! This is the traditional longitude-offset convention on the ecliptic of //! date, not a three-dimensional rotation onto a fixed B1950 ecliptic. use crate::astro::frames::{e_tilt, precession_matrix}; -use crate::astro::houses::{compute_angles, placidus_cusps_with_fallback}; use crate::astro::AstroTime; -use crate::{ - calculate_chart, Body, CalculationError, ChartBody, ChartInput, Ephemeris, Epoch, HouseSystem, -}; -use serde::Serialize; +use crate::{CalculationError, Epoch}; const J2000: f64 = 2_451_545.0; const B1950: f64 = 2_433_282.423_459_05; @@ -42,16 +38,6 @@ fn mean_ayanamsa(tt: f64) -> f64 { B1950_OFFSET - MODERN_PRECESSION_CORRECTION - longitude } -fn sidereal_longitude(tropical: f64, offset: f64) -> f64 { - (tropical - offset).rem_euclid(360.0) -} - -fn whole_sign_cusps(ascendant: f64) -> Vec { - (0..12) - .map(|i| ((ascendant / 30.0).floor() * 30.0 + f64::from(i) * 30.0).rem_euclid(360.0)) - .collect() -} - fn true_ayanamsa(time: AstroTime) -> f64 { mean_ayanamsa(time.tt) + e_tilt(time).dpsi / 3600.0 } @@ -71,111 +57,9 @@ pub fn fagan_bradley_ayanamsa(epoch: Epoch) -> Result { Ok(true_ayanamsa(time)) } -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct SiderealChartValues { - pub bodies: Vec, - pub cusps: Vec, - pub ayanamsa_degrees: f64, - /// Actual cusp construction, including `porphyry` when Placidus falls back. - pub house_system: &'static str, -} - -/// Calculate a Fagan–Bradley chart without changing the tropical API. -/// Equal/Placidus cusps are shifted into the sidereal zodiac; Whole Sign -/// boundaries are rebuilt from the sidereal Ascendant. Declinations remain -/// equatorial. Angle speeds retain the tropical API's zero (not calculated). -pub fn calculate_sidereal_chart( - ephemeris: &Ephemeris, - input: ChartInput, -) -> Result { - let offset = fagan_bradley_ayanamsa(input.epoch)?; - let tropical = calculate_chart(ephemeris, input)?; - let time = AstroTime::from_tdb(input.epoch); - // Match the engine's centered one-day speed interval and its time model. +/// Match the engine's centered one-day speed interval and time model. +pub(crate) fn ayanamsa_daily_rate(epoch: Epoch) -> f64 { let half_day = hifitime::Duration::from_days(0.5); - let rate = true_ayanamsa(AstroTime::from_tdb(input.epoch + half_day)) - - true_ayanamsa(AstroTime::from_tdb(input.epoch - half_day)); - let bodies: Vec<_> = tropical - .bodies - .into_iter() - .enumerate() - .map(|(index, body)| ChartBody { - longitude: sidereal_longitude(body.longitude, offset), - speed: if index < 15 { - body.speed - rate - } else { - body.speed - }, - ..body - }) - .collect(); - let ascendant = bodies - .iter() - .find(|body| body.name == Body::AscendantSymbol.wire_name()) - .expect("calculate_chart appends the Ascendant") - .longitude; - let (cusps, house_system) = match input.house_system { - HouseSystem::WholeSign => (whole_sign_cusps(ascendant), "whole-sign"), - HouseSystem::Equal => ( - tropical - .cusps - .unwrap() - .into_iter() - .map(|c| sidereal_longitude(c, offset)) - .collect(), - "equal", - ), - HouseSystem::Placidus => { - let angles = compute_angles(time, input.location.latitude, input.location.longitude); - let (cusps, fallback) = placidus_cusps_with_fallback( - angles, - time, - input.location.latitude, - input.location.longitude, - ); - ( - cusps - .into_iter() - .map(|c| sidereal_longitude(c, offset)) - .collect(), - if fallback { "porphyry" } else { "placidus" }, - ) - } - }; - // Some inherited polar Placidus/Porphyry configurations wind around the - // zodiac three times. Do not present overlapping sectors as twelve houses. - let widths: Vec<_> = (0..cusps.len()) - .map(|i| (cusps[(i + 1) % cusps.len()] - cusps[i]).rem_euclid(360.0)) - .collect(); - if cusps.len() != 12 - || widths - .iter() - .any(|width| !width.is_finite() || *width <= 0.0) - || (widths.iter().sum::() - 360.0).abs() > 1e-8 - { - return Err(CalculationError::InvalidInput( - "These house cusps overlap at this time and latitude. Choose Equal or Whole Sign houses.", - )); - } - Ok(SiderealChartValues { - bodies, - cusps, - ayanamsa_degrees: offset, - house_system, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn wraparound_and_whole_sign_boundaries() { - let offset = 24.75; - assert_eq!(sidereal_longitude(offset, offset), 0.0); - assert_eq!(sidereal_longitude(offset - 0.25, offset), 359.75); - assert_eq!(whole_sign_cusps(29.999)[0], 0.0); - assert_eq!(whole_sign_cusps(30.0)[0], 30.0); - assert_eq!(whole_sign_cusps(359.999)[1], 0.0); - assert_eq!(whole_sign_cusps(0.0)[11], 330.0); - } + true_ayanamsa(AstroTime::from_tdb(epoch + half_day)) + - true_ayanamsa(AstroTime::from_tdb(epoch - half_day)) } diff --git a/tests/conventions.rs b/tests/conventions.rs index 938770e..95f5bc6 100644 --- a/tests/conventions.rs +++ b/tests/conventions.rs @@ -2,10 +2,9 @@ // SPDX-License-Identifier: MPL-2.0 use astrology_engine::{ - assign_sign, calculate_chart, calculate_configured_chart, calculate_sidereal_chart, - effective_configuration, true_sky_offset, whole_sign_cusps, zodiac_sectors, ActualHouseSystem, - ChartInput, Ephemeris, Epoch, HouseSystem, Location, Ophiuchus, SignDivisions, SpeedReference, - ZodiacConfiguration, ZodiacReference, + assign_sign, calculate_chart, effective_configuration, true_sky_offset, whole_sign_cusps, + zodiac_sectors, ActualHouseSystem, ChartInput, Ephemeris, Epoch, HouseSystem, Location, + Ophiuchus, SignDivisions, SpeedReference, ZodiacConfiguration, ZodiacReference, }; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -111,7 +110,7 @@ fn invalid_inputs_and_explicit_effective_configuration() { } let ephemeris = Ephemeris::parse(common::polynomial_blob()).unwrap(); for latitude in [f64::NAN, f64::INFINITY, -91.0, 91.0] { - assert!(calculate_configured_chart( + assert!(calculate_chart( &ephemeris, ChartInput { epoch: Epoch::from_et_seconds(0.0), @@ -119,14 +118,14 @@ fn invalid_inputs_and_explicit_effective_configuration() { latitude, longitude: 0.0 }, - house_system: HouseSystem::Equal + house_system: HouseSystem::Equal, + zodiac: c, }, - c ) .is_err()); } for et in [-50_000_001.0, 50_000_001.0] { - assert!(calculate_configured_chart( + assert!(calculate_chart( &ephemeris, ChartInput { epoch: Epoch::from_et_seconds(et), @@ -134,62 +133,79 @@ fn invalid_inputs_and_explicit_effective_configuration() { latitude: 0.0, longitude: 0.0 }, - house_system: HouseSystem::Equal + house_system: HouseSystem::Equal, + zodiac: c, }, - c ) .is_err()); } } #[test] -fn additive_api_preserves_existing_entry_points_and_reports_fallback() { +fn all_references_return_complete_house_and_sign_metadata() { let ephemeris = Ephemeris::parse(common::polynomial_blob()).unwrap(); - for latitude in [0.0, 51.5, 80.0, -80.0] { + for reference in [ + ZodiacReference::Tropical, + ZodiacReference::FaganBradley, + ZodiacReference::TrueSky, + ] { for house_system in [ HouseSystem::Equal, HouseSystem::WholeSign, HouseSystem::Placidus, ] { - let input = ChartInput { - epoch: "2000-01-01T12:00:00Z".parse().unwrap(), - location: Location { - latitude, - longitude: 90.0, - }, - house_system, - }; - let old_tropical = calculate_chart(&ephemeris, input).unwrap(); - let old_sidereal = calculate_sidereal_chart(&ephemeris, input); - let config = ZodiacConfiguration { - reference: ZodiacReference::FaganBradley, + let zodiac = ZodiacConfiguration { + reference, divisions: SignDivisions::Equal, ophiuchus: None, }; - let new = calculate_configured_chart(&ephemeris, input, config); - match (old_sidereal, new) { - (Ok(old), Ok(new)) => { - assert_eq!( - new.bodies - .iter() - .map(|b| b.values.clone()) - .collect::>(), - old.bodies - ); - assert_eq!(new.cusps.to_vec(), old.cusps); - assert_eq!(new.reference_offset_degrees, old.ayanamsa_degrees); - assert_eq!(new.speed_reference, SpeedReference::SelectedReference); - if old.house_system == "porphyry" { - assert_eq!(new.house_system, ActualHouseSystem::Porphyry); - } + let chart = calculate_chart( + &ephemeris, + ChartInput { + epoch: "2000-01-01T12:00:00Z".parse().unwrap(), + location: Location { + latitude: 80.0, + longitude: 90.0, + }, + house_system, + zodiac, + }, + ) + .unwrap(); + assert_eq!(chart.configuration, zodiac); + assert_eq!(chart.cusps.len(), 12); + assert_eq!(chart.bodies.len(), 19); + assert_eq!(chart.requested_house_system, house_system); + assert_eq!( + chart.speed_reference, + if reference == ZodiacReference::FaganBradley { + SpeedReference::SelectedReference + } else { + SpeedReference::Tropical } - (Err(old), Err(new)) => assert_eq!(old.to_string(), new.to_string()), - other => panic!("Compatibility mismatch: {other:?}"), - } - assert_eq!(calculate_chart(&ephemeris, input).unwrap(), old_tropical); - if house_system == HouseSystem::WholeSign { - assert!(old_tropical.cusps.is_none()); + ); + assert_eq!( + chart.house_system, + match house_system { + HouseSystem::Equal => ActualHouseSystem::Equal, + HouseSystem::WholeSign => ActualHouseSystem::WholeSign, + HouseSystem::Placidus => ActualHouseSystem::Porphyry, + } + ); + for entry in &chart.bodies { + assert_eq!( + entry.sign, + assign_sign(entry.values.longitude, zodiac).unwrap() + ); } + assert_eq!( + chart.house_sectors.is_some(), + house_system == HouseSystem::WholeSign + ); + let json = serde_json::to_value(&chart).unwrap(); + assert!(json["bodies"][0].get("longitude").is_some()); + assert!(json["bodies"][0].get("values").is_none()); + assert!(json["bodies"][0].get("sign").is_some()); } } } @@ -226,8 +242,9 @@ fn source_generated_full_charts() { longitude: input["longitude"].as_f64().unwrap(), }, house_system, + zodiac: config, }; - let result = calculate_configured_chart(&ephemeris, input, config); + let result = calculate_chart(&ephemeris, input); let expected: Vec = serde_json::from_value(case["cusps"].clone()).unwrap(); let sweep: f64 = (0..12) .map(|i| (expected[(i + 1) % 12] - expected[i]).rem_euclid(360.0)) diff --git a/tests/sidereal.rs b/tests/sidereal.rs index 96ca172..782cf5c 100644 --- a/tests/sidereal.rs +++ b/tests/sidereal.rs @@ -4,17 +4,25 @@ #[path = "../tools/verification/common/mod.rs"] mod common; use astrology_engine::{ - calculate_chart, calculate_sidereal_chart, coverage, fagan_bradley_ayanamsa, Body, ChartInput, - Ephemeris, Epoch, HouseSystem, Location, + calculate_chart, coverage, fagan_bradley_ayanamsa, ActualHouseSystem, ChartInput, Ephemeris, + Epoch, HouseSystem, Location, SignDivisions, ZodiacConfiguration, ZodiacReference, }; +fn zodiac(reference: ZodiacReference) -> ZodiacConfiguration { + ZodiacConfiguration { + reference, + divisions: SignDivisions::Equal, + ophiuchus: None, + } +} + #[test] -fn sidereal_conversion_keeps_tropical_results_and_physical_declinations() { +fn sidereal_conversion_preserves_physical_declinations_and_angle_speeds() { let ephemeris = Ephemeris::parse(common::polynomial_blob()).unwrap(); let window = coverage(&ephemeris); for et in [window.lo_et, 0.0, window.hi_et] { for latitude in [0.0, 51.5, 80.0, -80.0] { - for system in [ + for house_system in [ HouseSystem::Equal, HouseSystem::WholeSign, HouseSystem::Placidus, @@ -25,49 +33,52 @@ fn sidereal_conversion_keeps_tropical_results_and_physical_declinations() { latitude, longitude: 0.0, }, - house_system: system, + house_system, + zodiac: zodiac(ZodiacReference::Tropical), + }; + let tropical = calculate_chart(&ephemeris, input); + let sidereal = calculate_chart( + &ephemeris, + ChartInput { + zodiac: zodiac(ZodiacReference::FaganBradley), + ..input + }, + ); + let (tropical, sidereal) = match (tropical, sidereal) { + (Ok(t), Ok(s)) => (t, s), + (Err(t), Err(s)) => { + assert!(t.to_string().contains("overlap")); + assert_eq!(t.to_string(), s.to_string()); + continue; + } + other => panic!("Reference validation mismatch: {other:?}"), }; - let tropical = calculate_chart(&ephemeris, input).unwrap(); - let result = calculate_sidereal_chart(&ephemeris, input); - let tropical_sweep: f64 = tropical.cusps.as_ref().map_or(360.0, |cusps| { - (0..12) - .map(|i| (cusps[(i + 1) % 12] - cusps[i]).rem_euclid(360.0)) - .sum() - }); - if (tropical_sweep - 360.0).abs() > 1e-8 { - assert!(result.is_err()); - assert_eq!(calculate_chart(&ephemeris, input).unwrap(), tropical); - continue; - } - let sidereal = result.unwrap(); - assert_eq!(calculate_chart(&ephemeris, input).unwrap(), tropical); assert_eq!(sidereal.bodies.len(), 19); for (index, (t, s)) in tropical.bodies.iter().zip(&sidereal.bodies).enumerate() { - assert_eq!(t.name, s.name); - assert_eq!(t.declination, s.declination); + assert_eq!(t.values.name, s.values.name); + assert_eq!(t.values.declination, s.values.declination); assert_eq!( - s.longitude, - (t.longitude - sidereal.ayanamsa_degrees).rem_euclid(360.0) + s.values.longitude, + (t.values.longitude - sidereal.reference_offset_degrees).rem_euclid(360.0) ); if index >= 15 { - assert_eq!(s.speed, 0.0); + assert_eq!(s.values.speed, 0.0); } } - assert_eq!(sidereal.cusps.len(), 12); - if system == HouseSystem::WholeSign { - let asc = sidereal - .bodies - .iter() - .find(|b| b.name == Body::AscendantSymbol.wire_name()) - .unwrap() - .longitude; - assert_eq!(sidereal.cusps[0], (asc / 30.0).floor() * 30.0); - assert!(sidereal.cusps.iter().all(|cusp| cusp % 30.0 == 0.0)); - assert_eq!(sidereal.house_system, "whole-sign"); - assert!(tropical.cusps.is_none()); + if house_system == HouseSystem::WholeSign { + assert_eq!( + sidereal.cusps[0], + (sidereal.bodies[15].values.longitude / 30.0).floor() * 30.0 + ); + assert!(sidereal.cusps.iter().all(|c| c % 30.0 == 0.0)); + assert_eq!(sidereal.house_system, ActualHouseSystem::WholeSign); + assert_eq!(tropical.cusps.len(), 12); } else { - for (t, s) in tropical.cusps.unwrap().iter().zip(&sidereal.cusps) { - assert_eq!(*s, (t - sidereal.ayanamsa_degrees).rem_euclid(360.0)); + for (t, s) in tropical.cusps.iter().zip(&sidereal.cusps) { + assert_eq!( + *s, + (t - sidereal.reference_offset_degrees).rem_euclid(360.0) + ); } } } @@ -85,10 +96,11 @@ fn speeds_include_the_changing_origin() { longitude: 0.0, }, house_system: HouseSystem::Placidus, + zodiac: zodiac(ZodiacReference::FaganBradley), }; - let chart = calculate_sidereal_chart(&ephemeris, input).unwrap(); - assert_eq!(chart.house_system, "placidus"); - let before = calculate_sidereal_chart( + let chart = calculate_chart(&ephemeris, input).unwrap(); + assert_eq!(chart.house_system, ActualHouseSystem::Placidus); + let before = calculate_chart( &ephemeris, ChartInput { epoch: Epoch::from_et_seconds(-43200.0), @@ -96,7 +108,7 @@ fn speeds_include_the_changing_origin() { }, ) .unwrap(); - let after = calculate_sidereal_chart( + let after = calculate_chart( &ephemeris, ChartInput { epoch: Epoch::from_et_seconds(43200.0), @@ -105,58 +117,70 @@ fn speeds_include_the_changing_origin() { ) .unwrap(); for i in 0..15 { - let rate = (after.bodies[i].longitude - before.bodies[i].longitude + 180.0) + let rate = (after.bodies[i].values.longitude - before.bodies[i].values.longitude + 180.0) .rem_euclid(360.0) - 180.0; - assert!((chart.bodies[i].speed - rate).abs() < 1e-12); + assert!((chart.bodies[i].values.speed - rate).abs() < 1e-12); } for utc in ["1700-01-01T00:00:00Z", "2300-01-01T00:00:00Z"] { assert!(fagan_bradley_ayanamsa(utc.parse().unwrap()).is_err()); + assert!(calculate_chart( + &ephemeris, + ChartInput { + epoch: utc.parse().unwrap(), + ..input + } + ) + .is_err()); + } + for utc in ["1800-01-02T00:00:00 TT", "2199-12-31T00:00:00 TT"] { + assert!(fagan_bradley_ayanamsa(utc.parse().unwrap()).is_ok()); } } #[test] -fn valid_polar_fallback_is_reported_but_overlapping_cusps_are_rejected() { +fn all_references_report_valid_polar_fallback_and_reject_overlapping_cusps() { let ephemeris = Ephemeris::parse(common::polynomial_blob()).unwrap(); - let input = ChartInput { - epoch: "2000-01-01T12:00:00Z".parse().unwrap(), - location: Location { - latitude: 80.0, - longitude: 90.0, - }, - house_system: HouseSystem::Placidus, - }; - let valid = calculate_sidereal_chart(&ephemeris, input).unwrap(); - assert_eq!(valid.house_system, "porphyry"); - let sweep: f64 = (0..12) - .map(|i| (valid.cusps[(i + 1) % 12] - valid.cusps[i]).rem_euclid(360.0)) - .sum(); - assert!((sweep - 360.0).abs() < 1e-8); - let invalid = ChartInput { - location: Location { - latitude: 80.0, - longitude: 20.0, - }, - ..input - }; - let tropical = calculate_chart(&ephemeris, invalid).unwrap(); - let cusps = tropical.cusps.unwrap(); - let sweep: f64 = (0..12) - .map(|i| (cusps[(i + 1) % 12] - cusps[i]).rem_euclid(360.0)) - .sum(); - assert!((sweep - 1080.0).abs() < 1e-8); - let error = calculate_sidereal_chart(&ephemeris, invalid) - .unwrap_err() - .to_string(); - assert!(error.contains("Choose Equal or Whole Sign")); - for house_system in [HouseSystem::Equal, HouseSystem::WholeSign] { - assert!(calculate_sidereal_chart( - &ephemeris, - ChartInput { - house_system, - ..invalid - } - ) - .is_ok()); + for reference in [ + ZodiacReference::Tropical, + ZodiacReference::FaganBradley, + ZodiacReference::TrueSky, + ] { + let input = ChartInput { + epoch: "2000-01-01T12:00:00Z".parse().unwrap(), + location: Location { + latitude: 80.0, + longitude: 90.0, + }, + house_system: HouseSystem::Placidus, + zodiac: zodiac(reference), + }; + let valid = calculate_chart(&ephemeris, input).unwrap(); + assert_eq!(valid.house_system, ActualHouseSystem::Porphyry); + let sweep: f64 = (0..12) + .map(|i| (valid.cusps[(i + 1) % 12] - valid.cusps[i]).rem_euclid(360.0)) + .sum(); + assert!((sweep - 360.0).abs() < 1e-8); + let invalid = ChartInput { + location: Location { + latitude: 80.0, + longitude: 20.0, + }, + ..input + }; + assert!(calculate_chart(&ephemeris, invalid) + .unwrap_err() + .to_string() + .contains("Choose Equal or Whole Sign")); + for house_system in [HouseSystem::Equal, HouseSystem::WholeSign] { + assert!(calculate_chart( + &ephemeris, + ChartInput { + house_system, + ..invalid + } + ) + .is_ok()); + } } } diff --git a/tools/README.md b/tools/README.md index 640e987..52961ba 100644 --- a/tools/README.md +++ b/tools/README.md @@ -123,6 +123,7 @@ responses: ```sh .venv/bin/python tools/verification/test_compare.py +.venv/bin/python tools/verification/test_chart_migration.py .venv/bin/python tools/verification/test_acquisition.py .venv/bin/python tools/verification/test_generation.py .venv/bin/python tools/verification/check_boundaries.py diff --git a/tools/verification/conventions.md b/tools/verification/conventions.md index b9f09b2..032fa8d 100644 --- a/tools/verification/conventions.md +++ b/tools/verification/conventions.md @@ -28,16 +28,25 @@ bun run verify /path/to/cheb.bin ../../target/conventions-wasm-report.json ``` The first command executes dataset-free boundary/projection checks and -structural compatibility checks. The full-chart native check is explicitly +unified chart structural checks. The full-chart native check is explicitly ignored until the dataset is supplied. The Wasm runner executes the public example adapter and its JSON configuration contract. In each full-chart run, 440 valid charts are compared and 16 overlapping polar house sets are rejected under the engine's existing safeguards. Recorded speeds remain unchanged in the -True Sky projection; the existing Fagan–Bradley model is checked for API -compatibility separately. +True Sky projection; the existing Fagan–Bradley model is checked through the unified API +separately. The tropical/search regression harness remains a separate check: ```sh python3 tools/verification/parity.py --dataset /path/to/cheb.bin --report target/conventions-frozen-parity.json ``` + +The frozen tropical/search runner preserves the original captures and hashes. +Its migration policy independently identifies 247 overlapping house sets from +the frozen cusps and requires the explicit overlap error for exactly those rows. +The 1 overlapping HTTP adapter cases likewise require HTTP 500 with `internal`. +All other rows, including Whole Sign HTTP cusp omission and precise Sun-search +results, still compare exactly. Run `python3 tools/verification/test_chart_migration.py` +for negative controls covering unexpected errors, missing/extra rows, and changed +migration counts. diff --git a/tools/verification/parity.py b/tools/verification/parity.py index a71ab6c..94e0ae8 100644 --- a/tools/verification/parity.py +++ b/tools/verification/parity.py @@ -5,6 +5,7 @@ import gzip import hashlib import json +import math import subprocess import tempfile from pathlib import Path @@ -14,6 +15,41 @@ ROOT = Path(__file__).resolve().parents[2] +OVERLAP_ERROR = { + 'status': 500, + 'error': 'These house cusps overlap at this time and latitude. Choose Equal or Whole Sign houses.', +} + + +def overlapping_cusps(cusps): + if cusps is None: + return False + if len(cusps) != 12: + raise ValueError('Unexpected frozen cusp count') + widths = [(cusps[(i + 1) % 12] - cusps[i]) % 360 for i in range(12)] + return any(not math.isfinite(width) or width <= 0 for width in widths) or abs(sum(widths) - 360) > 1e-8 + + +def migration_expectations(expected, overlap_count): + """Apply only the agreed cusp-validation change to immutable baseline rows.""" + overlap_ids = sorted(key for key, value in expected.items() + if overlapping_cusps(value.get('chart', {}).get('cusps'))) + if len(overlap_ids) != overlap_count: + raise ValueError(f'Expected {overlap_count} frozen overlapping sets, found {len(overlap_ids)}') + admitted = set(overlap_ids) + return {key: OVERLAP_ERROR.copy() if key in admitted else value for key, value in expected.items()}, overlap_ids + + +def adapter_migration(cases, overlap_count): + overlap_ids = sorted(case['id'] for case in cases + if overlapping_cusps(case['value'].get('body', {}).get('cusps'))) + if len(overlap_ids) != overlap_count: + raise ValueError(f'Expected {overlap_count} frozen HTTP overlapping sets, found {len(overlap_ids)}') + admitted = set(overlap_ids) + return [{**case, 'value': {'status': 500, 'body': {'error': 'internal'}}} + if case['id'] in admitted else case for case in cases], overlap_ids + + def main(): parser = argparse.ArgumentParser() parser.add_argument('--dataset', type=Path, required=True) @@ -32,18 +68,22 @@ def main(): if hashlib.sha256(content).hexdigest() != identity['sha256']: raise ValueError('Frozen capture hash mismatch: ' + name) (staging / name).write_bytes(content) + adapter_cases = [json.loads(line) for line in (staging / 'adapter.jsonl').read_text().splitlines()] + migrated_adapter, adapter_overlap_ids = adapter_migration(adapter_cases, 1) + (staging / 'adapter-migration.jsonl').write_text(''.join(json.dumps(case) + '\n' for case in migrated_adapter)) for prefix in ['', 'events-']: candidate = staging / (prefix + 'candidate.jsonl') command = ['node', str(ROOT / 'examples/cloudflare-worker/verify.mjs'), '--dataset', str(args.dataset.resolve()), '--inputs', str(staging / (prefix + 'inputs.json')), '--results', str(candidate)] if not prefix: - command += ['--adapter', str(staging / 'adapter.jsonl')] + command += ['--adapter', str(staging / 'adapter-migration.jsonl')] subprocess.run(command, cwd=ROOT, check=True) - results[prefix or 'matrix'] = compare(load(staging / (prefix + 'results.jsonl')), load(candidate)) + expected, overlap_ids = migration_expectations(load(staging / (prefix + 'results.jsonl')), 0 if prefix else 247) + results[prefix or 'matrix'] = {**compare(expected, load(candidate)), 'intentional_overlap_rejections': overlap_ids} report = {'passed': all(result['passed'] for result in results.values()), 'runtime': 'wasm32-unknown-unknown/workerd', 'dataset_sha256': actual_sha, - 'source_adapter_cases': 40, 'comparisons': results} + 'source_adapter_cases': 40, 'intentional_http_overlap_rejections': adapter_overlap_ids, 'comparisons': results} args.report.write_text(json.dumps(report, indent=2) + '\n') print(json.dumps(report, indent=2)) raise SystemExit(0 if report['passed'] else 1) diff --git a/tools/verification/structural.rs b/tools/verification/structural.rs index 450ad30..442ab38 100644 --- a/tools/verification/structural.rs +++ b/tools/verification/structural.rs @@ -4,7 +4,8 @@ mod common; use astrology_engine::{ calculate_chart, coverage, find_sun_crossing, BlobError, Body, CalculationError, ChartInput, - Direction, Ephemeris, Epoch, EvalError, HouseSystem, Location, SeriesBody, SunSearchInput, + Direction, Ephemeris, Epoch, EvalError, HouseSystem, Location, SeriesBody, SignDivisions, + SunSearchInput, ZodiacConfiguration, ZodiacReference, }; fn manufactured_polynomials_keep_chart_relationships_and_house_options() { @@ -23,6 +24,11 @@ fn manufactured_polynomials_keep_chart_relationships_and_house_options() { longitude: 0.0, }, house_system: system, + zodiac: ZodiacConfiguration { + reference: ZodiacReference::Tropical, + divisions: SignDivisions::Equal, + ophiuchus: None, + }, }, ) .unwrap(); @@ -30,25 +36,30 @@ fn manufactured_polynomials_keep_chart_relationships_and_house_options() { chart .bodies .iter() - .map(|b| b.name.as_str()) + .map(|b| b.values.name.as_str()) .collect::>(), Body::ALL.map(Body::wire_name) ); assert_eq!( - chart.bodies[1].longitude, - (chart.bodies[0].longitude + 180.0).rem_euclid(360.0) + chart.bodies[1].values.longitude, + (chart.bodies[0].values.longitude + 180.0).rem_euclid(360.0) + ); + assert_eq!(chart.bodies[0].values.speed, chart.bodies[1].values.speed); + assert_eq!( + chart.bodies[0].values.declination, + chart.bodies[1].values.declination ); - assert_eq!(chart.bodies[0].speed, chart.bodies[1].speed); - assert_eq!(chart.bodies[0].declination, chart.bodies[1].declination); - assert_eq!(chart.bodies[3].declination, chart.bodies[4].declination); - assert_eq!(chart.bodies[3].speed, chart.bodies[4].speed); + assert_eq!( + chart.bodies[3].values.declination, + chart.bodies[4].values.declination + ); + assert_eq!(chart.bodies[3].values.speed, chart.bodies[4].values.speed); assert!(chart.bodies[15..] .iter() - .all(|b| b.speed == 0.0 && b.declination == 0.0)); + .all(|b| b.values.speed == 0.0 && b.values.declination == 0.0)); + assert_eq!(chart.cusps.len(), 12); if system == HouseSystem::WholeSign { - assert!(chart.cusps.is_none()); - } else { - assert_eq!(chart.cusps.unwrap().len(), 12); + assert!(chart.cusps.iter().all(|c| c % 30.0 == 0.0)); } } } @@ -97,7 +108,12 @@ fn requests_use_the_published_domain_and_keep_the_search_margin_in_both_directio latitude: 0.0, longitude: 0.0 }, - house_system: HouseSystem::Equal + house_system: HouseSystem::Equal, + zodiac: ZodiacConfiguration { + reference: ZodiacReference::Tropical, + divisions: SignDivisions::Equal, + ophiuchus: None + }, } ), Err(CalculationError::OutOfCoverage { .. }) @@ -116,7 +132,12 @@ fn admission_rejects_nonfinite_locations_and_invalid_search_targets() { latitude, longitude: 0.0 }, - house_system: HouseSystem::Equal + house_system: HouseSystem::Equal, + zodiac: ZodiacConfiguration { + reference: ZodiacReference::Tropical, + divisions: SignDivisions::Equal, + ophiuchus: None + }, } ), Err(CalculationError::InvalidInput(_)) diff --git a/tools/verification/test_chart_migration.py b/tools/verification/test_chart_migration.py new file mode 100644 index 0000000..b519ce1 --- /dev/null +++ b/tools/verification/test_chart_migration.py @@ -0,0 +1,68 @@ +# Copyright (c) Jack Asher +# SPDX-License-Identifier: MPL-2.0 + +"""Negative controls for the narrow frozen-chart migration policy.""" +import copy +import gzip +import json +from pathlib import Path + +from compare import compare +from parity import OVERLAP_ERROR, adapter_migration, migration_expectations + + +def test_migration_policy(): + root = Path(__file__).resolve().parents[2] + with gzip.open(root / 'tests/fixtures/results.jsonl.gz', 'rt') as stream: + original = {row['id']: row['value'] for row in map(json.loads, stream)} + before = copy.deepcopy(original) + expected, admitted = migration_expectations(original, 247) + assert original == before + assert len(admitted) == 247 + assert all(expected[key] == OVERLAP_ERROR for key in admitted) + assert all(expected[key] == value for key, value in original.items() if key not in admitted) + assert compare(expected, copy.deepcopy(expected))['passed'] + altered = copy.deepcopy(expected) + altered[admitted[0]] = original[admitted[0]] + assert not compare(expected, altered)['passed'] + altered[admitted[0]] = {'status': 500, 'error': 'unrelated failure'} + assert not compare(expected, altered)['passed'] + unaffected = next(key for key in original if key not in admitted) + altered = copy.deepcopy(expected) + altered[unaffected] = OVERLAP_ERROR + assert not compare(expected, altered)['passed'] + assert not compare(expected, {key: value for key, value in expected.items() if key != admitted[0]})['passed'] + assert not compare(expected, {**expected, 'extra': OVERLAP_ERROR})['passed'] + try: + migration_expectations(original, 246) + except ValueError: + pass + else: + raise AssertionError('Unexpected admission count was accepted') + with gzip.open(root / 'tests/fixtures/adapter.jsonl.gz', 'rt') as stream: + adapter = [json.loads(line) for line in stream] + original_adapter = copy.deepcopy(adapter) + migrated, admitted_http = adapter_migration(adapter, 1) + assert adapter == original_adapter + assert admitted_http == ['adapter-10'] + for original_row, new_row in zip(adapter, migrated): + if original_row['id'] == 'adapter-10': + assert new_row['value'] == {'status': 500, 'body': {'error': 'internal'}} + assert new_row['input'] == original_row['input'] + else: + assert new_row == original_row + try: + adapter_migration(adapter, 0) + except ValueError: + pass + else: + raise AssertionError('Unexpected HTTP admission count was accepted') + with gzip.open(root / 'tests/fixtures/events-results.jsonl.gz', 'rt') as stream: + events = {row['id']: row['value'] for row in map(json.loads, stream)} + unchanged, admitted = migration_expectations(events, 0) + assert unchanged == events and not admitted + + +if __name__ == '__main__': + test_migration_policy() + print('Chart migration negative controls passed')