diff --git a/crates/foundation/apsis-core/src/constant.rs b/crates/foundation/apsis-core/src/constant.rs new file mode 100644 index 0000000..c4ad0f2 --- /dev/null +++ b/crates/foundation/apsis-core/src/constant.rs @@ -0,0 +1,177 @@ +//! Physical and astronomical constants used across the workspace. +//! +//! All values are expressed in SI units unless the name or doc comment +//! states otherwise. Values that are fixed by definition are given exactly; +//! measured values carry the number of significant digits justified by the +//! public source. +//! +//! Sources: +//! +//! - SI defining constants: BIPM, *The International System of Units (SI)*, +//! 9th edition, 2019. +//! - CODATA 2018 recommended values: E. Tiesinga et al., *CODATA +//! recommended values of the fundamental physical constants: 2018*, +//! Rev. Mod. Phys. 93, 025010, 2021. +//! - Earth gravitational parameter and equatorial radius: WGS 84, +//! NIMA TR8350.2, 2000. +//! - Astronomical unit: IAU 2012 Resolution B2. +//! +//! Where a value is defined exactly by a standard, the doc comment says so, +//! because several derived quantities depend on the exact value rather than +//! the rounded printed one. + +/// Speed of light in vacuum, in meters per second. +/// +/// Exact by definition of the metre in the SI. +pub const SPEED_OF_LIGHT: f64 = 299_792_458.0; + +/// Newtonian constant of gravitation, in cubic meters per kilogram per +/// second squared. +/// +/// CODATA 2018 value with its standard uncertainty of +/// `1.5e-11 m^3 kg^-1 s^-2`. +pub const GRAVITATIONAL_CONSTANT: f64 = 6.674_30e-11; + +/// Boltzmann constant, in joules per kelvin. +/// +/// Exact by definition of the kelvin in the SI. +pub const BOLTZMANN: f64 = 1.380_649e-23; + +/// Planck constant, in joule seconds. +/// +/// Exact by definition of the kilogram in the SI. +pub const PLANCK: f64 = 6.626_070_15e-34; + +/// Elementary charge, in coulombs. +/// +/// Exact by definition of the ampere in the SI. +pub const ELEMENTARY_CHARGE: f64 = 1.602_176_634e-19; + +/// Astronomical unit, in meters. +/// +/// Exact by IAU 2012 Resolution B2. +pub const ASTRONOMICAL_UNIT: f64 = 149_597_870_700.0; + +/// Standard gravity, in meters per second squared. +/// +/// Exact by definition of the standard atmosphere. +pub const STANDARD_GRAVITY: f64 = 9.806_65; + +/// One minute, in seconds. +pub const MINUTE: f64 = 60.0; + +/// One hour, in seconds. +pub const HOUR: f64 = 3_600.0; + +/// One day of 24 hours, in seconds. +/// +/// This is the SI day. It is not the mean solar day, which is slightly +/// longer, and not the Julian day of 86 400 seconds used in Julian date +/// arithmetic, which is identical to this value. +pub const DAY: f64 = 86_400.0; + +/// One Julian year of 365.25 days, in seconds. +/// +/// Exact by definition of the Julian year. +pub const JULIAN_YEAR: f64 = 31_557_600.0; + +/// One Julian century of 36 525 days, in seconds. +/// +/// Exact by definition of the Julian year, used by precession and secular +/// rate expressions quoted per Julian century. +pub const JULIAN_CENTURY: f64 = 3_155_760_000.0; + +/// Julian date of the Unix epoch, 1970-01-01T00:00:00 UTC. +/// +/// This is `2 440 587.5` in the Julian date system. +pub const JULIAN_DATE_UNIX_EPOCH: f64 = 2_440_587.5; + +/// Julian date of the J2000.0 epoch, 2000-01-01T12:00:00 TT. +/// +/// Exact by definition of the J2000.0 epoch. +pub const JULIAN_DATE_J2000: f64 = 2_451_545.0; + +/// The number of seconds between the J2000.0 epoch and the Unix epoch. +/// +/// Derived from [`JULIAN_DATE_J2000`] and [`JULIAN_DATE_UNIX_EPOCH`]. +pub const SECONDS_J2000_TO_UNIX: f64 = (JULIAN_DATE_J2000 - JULIAN_DATE_UNIX_EPOCH) * DAY; + +/// Earth gravitational parameter, in cubic kilometers per second squared. +/// +/// WGS 84 value, `GM = 398 600.4418 km^3/s^2`, with an uncertainty that is +/// dominated by the uncertainty in the Newtonian constant of gravitation. +/// Orbit propagation uses kilometers, so the value is given in those units +/// rather than in SI. +pub const EARTH_MU: f64 = 398_600.441_8; + +/// Earth equatorial radius, in kilometers. +/// +/// WGS 84 semi-major axis, `a = 6 378.137 km`. +pub const EARTH_EQUATORIAL_RADIUS: f64 = 6_378.137; + +/// Earth flattening, dimensionless. +/// +/// WGS 84 value, `f = 1 / 298.257 223 563`. +pub const EARTH_FLATTENING: f64 = 1.0 / 298.257_223_563; + +/// Earth rotation rate, in radians per second. +/// +/// WGS 84 value. This is the nominal mean angular velocity of the Earth, +/// `omega = 7.292 115e-5 rad/s`. +pub const EARTH_ROTATION_RATE: f64 = 7.292_115_0e-5; + +/// Earth second zonal harmonic, dimensionless. +/// +/// WGS 84 value, `J2 = 1.082 626 683e-3`. +pub const EARTH_J2: f64 = 1.082_626_683e-3; + +/// Standard atmosphere sea level pressure, in pascals. +/// +/// Exact by definition of the standard atmosphere. +pub const STANDARD_ATMOSPHERE: f64 = 101_325.0; + +/// Zero degrees Celsius on the thermodynamic temperature scale, in kelvin. +/// +/// Exact by definition of the Celsius scale. +pub const ZERO_CELSIUS_IN_KELVIN: f64 = 273.15; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defining_constants_are_exact() { + assert_eq!(SPEED_OF_LIGHT, 299_792_458.0); + assert_eq!(BOLTZMANN, 1.380_649e-23); + assert_eq!(PLANCK, 6.626_070_15e-34); + assert_eq!(ELEMENTARY_CHARGE, 1.602_176_634e-19); + assert_eq!(ASTRONOMICAL_UNIT, 149_597_870_700.0); + assert_eq!(STANDARD_GRAVITY, 9.806_65); + } + + #[test] + fn time_constants_are_consistent() { + assert_eq!(HOUR, MINUTE * 60.0); + assert_eq!(DAY, HOUR * 24.0); + assert_eq!(JULIAN_YEAR, DAY * 365.25); + assert_eq!(JULIAN_CENTURY, JULIAN_YEAR * 100.0); + } + + #[test] + fn j2000_minus_unix_epoch_is_the_expected_span() { + // The two epochs are 10 957 days apart. + assert_eq!(JULIAN_DATE_J2000 - JULIAN_DATE_UNIX_EPOCH, 10_957.5); + assert_eq!(SECONDS_J2000_TO_UNIX, 10_957.5 * DAY); + assert_eq!(SECONDS_J2000_TO_UNIX, 946_728_000.0); + } + + #[test] + fn earth_flattening_matches_the_inverse_value() { + assert!((1.0 / EARTH_FLATTENING - 298.257_223_563).abs() < 1e-9); + } + + #[test] + fn celsius_offset_is_exact() { + assert_eq!(ZERO_CELSIUS_IN_KELVIN, 273.15); + } +} diff --git a/crates/foundation/apsis-core/src/error.rs b/crates/foundation/apsis-core/src/error.rs new file mode 100644 index 0000000..3f266de --- /dev/null +++ b/crates/foundation/apsis-core/src/error.rs @@ -0,0 +1,245 @@ +//! The workspace error type and result alias. +//! +//! The workspace does not use an external error crate. Errors are modelled +//! as a single [`Error`] struct carrying an [`ErrorKind`], an optional +//! message, and an optional source. `ErrorKind` is a fieldless enum so that +//! callers can match on the failure category without allocating. + +use core::error::Error as StdError; +use core::fmt; + +/// The category of an [`Error`]. +/// +/// The variants are grouped by the layer that most often produces them, but +/// any layer may return any variant. Callers should match on the variant +/// that is relevant to their own recovery logic and treat the rest as +/// opaque. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub enum ErrorKind { + /// An input value is outside the range the function accepts. + OutOfRange, + /// An input does not satisfy a documented precondition. + InvalidInput, + /// An operation is not defined for the given inputs, such as a + /// singularity in a coordinate transformation. + Undefined, + /// A requested item does not exist. + NotFound, + /// Two inputs are inconsistent with each other. + Inconsistent, + /// An operation did not converge within the allowed number of steps. + NotConverged, + /// Text could not be parsed into the expected structure. + Parse, + /// A numeric operation produced a non-finite result. + NotFinite, + /// A feature or capability is not implemented. + Unsupported, + /// A failure that does not fit any other category. + Other, +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::OutOfRange => "value out of range", + Self::InvalidInput => "invalid input", + Self::Undefined => "operation undefined for these inputs", + Self::NotFound => "not found", + Self::Inconsistent => "inconsistent inputs", + Self::NotConverged => "did not converge", + Self::Parse => "parse error", + Self::NotFinite => "non-finite result", + Self::Unsupported => "unsupported", + Self::Other => "error", + }; + f.write_str(text) + } +} + +/// The error type used across the workspace. +/// +/// `Error` carries a machine matchable [`ErrorKind`] plus a human readable +/// message. An optional source records the lower level error that caused +/// this one. +pub struct Error { + kind: ErrorKind, + message: Option, + source: Option>, +} + +impl Error { + /// Creates an error with the given kind and no message. + pub fn new(kind: ErrorKind) -> Self { + Self { + kind, + message: None, + source: None, + } + } + + /// Creates an error with the given kind and message. + /// + /// The message should describe the concrete cause of this specific + /// failure. It is meant for humans reading logs, not for matching. + pub fn with_message(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: Some(message.into()), + source: None, + } + } + + /// Creates an error with the given kind and a source error. + pub fn with_source(kind: ErrorKind, source: E) -> Self + where + E: StdError + Send + Sync + 'static, + { + Self { + kind, + message: None, + source: Some(Box::new(source)), + } + } + + /// Attaches a message to this error, replacing any existing message. + pub fn with_context(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + + /// Attaches a source error, replacing any existing source. + pub fn caused_by(mut self, source: E) -> Self + where + E: StdError + Send + Sync + 'static, + { + self.source = Some(Box::new(source)); + self + } + + /// Returns the category of this error. + pub fn kind(&self) -> ErrorKind { + self.kind + } + + /// Returns the message attached to this error, if any. + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("Error"); + debug.field("kind", &self.kind); + if let Some(message) = &self.message { + debug.field("message", message); + } + if let Some(source) = &self.source { + debug.field("source", source); + } + debug.finish() + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.message { + Some(message) => write!(f, "{}: {message}", self.kind), + None => write!(f, "{}", self.kind), + } + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_ref() + .map(|source| source.as_ref() as &(dyn StdError + 'static)) + } +} + +impl From for Error { + fn from(kind: ErrorKind) -> Self { + Self::new(kind) + } +} + +/// A [`Result`](core::result::Result) whose error type is [`Error`]. +pub type Result = core::result::Result; + +#[cfg(test)] +mod tests { + use super::{Error, ErrorKind, Result}; + use core::fmt; + + #[derive(Debug)] + struct Underlying; + + impl fmt::Display for Underlying { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("underlying failure") + } + } + + impl core::error::Error for Underlying {} + + #[test] + fn kind_is_reported() { + let error = Error::new(ErrorKind::Parse); + assert_eq!(error.kind(), ErrorKind::Parse); + assert_eq!(error.message(), None); + } + + #[test] + fn message_is_reported() { + let error = Error::with_message(ErrorKind::OutOfRange, "semi-major axis is negative"); + assert_eq!(error.kind(), ErrorKind::OutOfRange); + assert_eq!(error.message(), Some("semi-major axis is negative")); + } + + #[test] + fn display_includes_kind_and_message() { + let error = Error::with_message(ErrorKind::Parse, "bad checksum"); + assert_eq!(error.to_string(), "parse error: bad checksum"); + assert_eq!(Error::new(ErrorKind::NotFound).to_string(), "not found"); + } + + #[test] + fn source_is_exposed() { + let error = Error::with_source(ErrorKind::Other, Underlying); + assert!(std::error::Error::source(&error).is_some()); + assert_eq!(error.kind(), ErrorKind::Other); + } + + #[test] + fn context_and_cause_replace_previous_values() { + let error = Error::new(ErrorKind::Other) + .with_context("first") + .with_context("second") + .caused_by(Underlying); + assert_eq!(error.message(), Some("second")); + assert!(std::error::Error::source(&error).is_some()); + } + + #[test] + fn converts_from_kind() { + let error: Error = ErrorKind::Unsupported.into(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + } + + #[test] + fn works_in_result_position() { + fn fail() -> Result { + Err(Error::new(ErrorKind::NotFinite)) + } + + let outcome = fail(); + assert!(outcome.is_err()); + assert_eq!( + outcome.err().map(|error| error.kind()), + Some(ErrorKind::NotFinite) + ); + } +} diff --git a/crates/foundation/apsis-core/src/lib.rs b/crates/foundation/apsis-core/src/lib.rs index c5e093e..9ded66b 100644 --- a/crates/foundation/apsis-core/src/lib.rs +++ b/crates/foundation/apsis-core/src/lib.rs @@ -1 +1,24 @@ -//! Shared foundation types for the ApsisRS workspace: error traits, result aliases, and common physical constants. +//! Shared foundation types for the ApsisRS workspace: error handling, +//! result aliases, physical constants, and the unit marker trait. +//! +//! This crate sits at the bottom of the dependency graph. It has no +//! dependency on any other ApsisRS crate, so every other crate may use it. +//! +//! Three concerns live here: +//! +//! - [`Error`] and [`Result`] give the workspace a single error surface +//! without pulling in an external error handling crate. +//! - [`constant`] holds physical and astronomical constants expressed in +//! SI units. +//! - [`Quantity`] marks newtypes that carry a physical unit, so generic +//! code can accept any quantity and still recover its unit type. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub mod constant; +mod error; +mod quantity; + +pub use error::{Error, ErrorKind, Result}; +pub use quantity::{Quantity, Unit}; diff --git a/crates/foundation/apsis-core/src/quantity.rs b/crates/foundation/apsis-core/src/quantity.rs new file mode 100644 index 0000000..a83ee9e --- /dev/null +++ b/crates/foundation/apsis-core/src/quantity.rs @@ -0,0 +1,170 @@ +//! Marker traits for quantities that carry a physical unit. +//! +//! A quantity newtype stores a numeric value together with its unit in the +//! type. The [`Quantity`] trait exposes that unit through an associated +//! type, so generic code can be written over any quantity and still recover +//! which unit it was given. +//! +//! The unit itself is any type implementing [`Unit`]. A unit knows how many +//! of its kind are in the corresponding base unit, which allows conversion +//! between units of the same dimension. The base unit of a dimension always +//! has a factor of one. + +use core::fmt; + +/// A unit of a single physical dimension. +/// +/// Implementors are zero sized marker types. Two units that implement +/// `Unit` for the same dimension convert through +/// [`Quantity::to_unit`](crate::Quantity::to_unit). +pub trait Unit: Copy + fmt::Debug + fmt::Display + 'static { + /// The physical dimension this unit measures, such as length or time. + type Dimension: fmt::Debug + 'static; + + /// The number of base units in one of this unit. + /// + /// For example a kilometer has a factor of 1000 when the base unit is + /// the meter. The base unit of a dimension has a factor of exactly one. + fn factor_to_base() -> f64; + + /// The symbol used to print the unit, such as `m` or `s`. + fn symbol() -> &'static str; +} + +/// A value paired with a unit. +/// +/// Implementors are newtypes over `f64` and a [`Unit`]. The trait gives +/// generic code access to the stored value and its unit without assuming +/// which newtype it was handed. +pub trait Quantity: Copy { + /// The unit this quantity is expressed in. + type U: Unit; + + /// Returns the stored numeric value in this quantity's own unit. + fn value(self) -> f64; + + /// Builds a quantity from a numeric value in this quantity's own unit. + fn from_value(value: f64) -> Self; + + /// Converts this quantity to another unit of the same dimension. + /// + /// The value is scaled by the ratio of the two unit factors. The + /// conversion is exact only when the factors are exact powers of ten, + /// so callers that need a specific unit for output should convert once + /// at the boundary rather than repeatedly. + fn to_unit(self) -> U + where + U: Quantity, + ::U: Unit::Dimension>, + { + let base = self.value() * Self::U::factor_to_base(); + U::from_value(base / ::U::factor_to_base()) + } +} + +#[cfg(test)] +mod tests { + use super::{Quantity, Unit}; + use core::fmt; + + #[derive(Debug, Clone, Copy, PartialEq)] + struct Length; + + #[derive(Debug, Clone, Copy)] + struct Meter; + + #[derive(Debug, Clone, Copy)] + struct Kilometer; + + impl fmt::Display for Meter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("m") + } + } + + impl fmt::Display for Kilometer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("km") + } + } + + impl Unit for Meter { + type Dimension = Length; + + fn factor_to_base() -> f64 { + 1.0 + } + + fn symbol() -> &'static str { + "m" + } + } + + impl Unit for Kilometer { + type Dimension = Length; + + fn factor_to_base() -> f64 { + 1_000.0 + } + + fn symbol() -> &'static str { + "km" + } + } + + #[derive(Debug, Clone, Copy, PartialEq)] + struct Meters(f64); + + #[derive(Debug, Clone, Copy, PartialEq)] + struct Kilometers(f64); + + impl Quantity for Meters { + type U = Meter; + + fn value(self) -> f64 { + self.0 + } + + fn from_value(value: f64) -> Self { + Self(value) + } + } + + impl Quantity for Kilometers { + type U = Kilometer; + + fn value(self) -> f64 { + self.0 + } + + fn from_value(value: f64) -> Self { + Self(value) + } + } + + #[test] + fn value_round_trips() { + let distance = Meters::from_value(1_500.0); + assert_eq!(distance.value(), 1_500.0); + } + + #[test] + fn converts_between_units_of_one_dimension() { + let distance = Meters::from_value(2_500.0); + let in_kilometers: Kilometers = distance.to_unit(); + assert!((in_kilometers.value() - 2.5).abs() < 1e-12); + } + + #[test] + fn conversion_round_trips() { + let original = Kilometers::from_value(7.25); + let round_tripped: Kilometers = original.to_unit::().to_unit(); + assert!((round_tripped.value() - 7.25).abs() < 1e-12); + } + + #[test] + fn unit_metadata_is_available() { + assert_eq!(::symbol(), "m"); + assert_eq!(::factor_to_base(), 1_000.0); + } +}