foundation: add error trait, result alias, constants, and unit trait

This commit is contained in:
ApsisRS contributors 2026-09-26 12:53:54 +08:00
parent 539f29c7b5
commit b95cf4adf3
4 changed files with 616 additions and 1 deletions

View File

@ -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);
}
}

View File

@ -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<String>,
source: Option<Box<dyn StdError + Send + Sync + 'static>>,
}
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<String>) -> Self {
Self {
kind,
message: Some(message.into()),
source: None,
}
}
/// Creates an error with the given kind and a source error.
pub fn with_source<E>(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<String>) -> Self {
self.message = Some(message.into());
self
}
/// Attaches a source error, replacing any existing source.
pub fn caused_by<E>(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<ErrorKind> for Error {
fn from(kind: ErrorKind) -> Self {
Self::new(kind)
}
}
/// A [`Result`](core::result::Result) whose error type is [`Error`].
pub type Result<T> = core::result::Result<T, Error>;
#[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<u32> {
Err(Error::new(ErrorKind::NotFinite))
}
let outcome = fail();
assert!(outcome.is_err());
assert_eq!(
outcome.err().map(|error| error.kind()),
Some(ErrorKind::NotFinite)
);
}
}

View File

@ -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};

View File

@ -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<U>(self) -> U
where
U: Quantity,
<U as Quantity>::U: Unit<Dimension = <Self::U as Unit>::Dimension>,
{
let base = self.value() * Self::U::factor_to_base();
U::from_value(base / <U as Quantity>::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::<Meters>().to_unit();
assert!((round_tripped.value() - 7.25).abs() < 1e-12);
}
#[test]
fn unit_metadata_is_available() {
assert_eq!(<Meter as Unit>::symbol(), "m");
assert_eq!(<Kilometer as Unit>::factor_to_base(), 1_000.0);
}
}