foundation: add length, time, frequency, angle, and temperature units

This commit is contained in:
ApsisRS contributors 2026-09-26 12:55:11 +08:00
parent c08fcd0f90
commit e861789cc1
11 changed files with 836 additions and 3 deletions

3
Cargo.lock generated
View File

@ -207,3 +207,6 @@ version = "0.1.0"
[[package]]
name = "apsis-units"
version = "0.1.0"
dependencies = [
"apsis-core",
]

View File

@ -17,9 +17,9 @@ foundation -> physics -> orbit -> rf -> network -> ops -> sim -> apsis-rs
约束:
- 依赖只能从左向右
- 依赖只能从左向右,禁止反向依赖
- 禁止跨层跳跃,例如 `orbit` 不得直接依赖 `network`
- 禁止同层平级依赖
- 层内允许依赖该层的基准 crate:`foundation` 内 `apsis-core` 为基准,其余 crate 可依赖它;其余层内部以各自的门面 crate(`apsis-orbit`、`apsis-rf`、`apsis-sim` 等)作为整合点
各层内容:

View File

@ -11,3 +11,4 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true

View File

@ -0,0 +1,193 @@
//! Plane angle quantities.
//!
//! The base unit is the radian. [`Radians`] and [`Degrees`] both measure
//! [`Angle`](crate::dimension::Angle) and convert into each other through
//! [`Quantity::to_unit`](apsis_core::Quantity::to_unit).
//!
//! Degrees are not a power of ten multiple of radians, so the conversion
//! factor is the irrational constant pi. The factor is derived from
//! [`f64::consts::PI`](core::f64::consts::PI) rather than from a rounded
//! literal.
use crate::dimension::Angle;
use crate::macros::impl_quantity_ops;
use apsis_core::{Quantity, Unit};
use core::f64::consts::PI;
use core::fmt;
/// An angle in radians.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Radians(f64);
/// An angle in degrees.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Degrees(f64);
impl Radians {
/// Creates an angle from a value in radians.
pub const fn new(value: f64) -> Self {
Self(value)
}
/// Returns this angle in radians.
pub const fn value(self) -> f64 {
self.0
}
/// Converts this angle to degrees.
pub fn to_degrees(self) -> Degrees {
Degrees(self.0 * 180.0 / PI)
}
/// Returns this angle wrapped to the half-open interval `[-pi, pi)`.
///
/// Wrapping uses [`f64::rem_euclid`](f64::rem_euclid) so the result is
/// well defined for negative inputs as well as large ones.
pub fn wrap(self) -> Self {
let two_pi = 2.0 * PI;
let wrapped = (self.0 + PI).rem_euclid(two_pi) - PI;
Self(wrapped)
}
}
impl Degrees {
/// Creates an angle from a value in degrees.
pub const fn new(value: f64) -> Self {
Self(value)
}
/// Returns this angle in degrees.
pub const fn value(self) -> f64 {
self.0
}
/// Converts this angle to radians.
pub fn to_radians(self) -> Radians {
Radians(self.0 * PI / 180.0)
}
}
impl Unit for Radians {
type Dimension = Angle;
fn factor_to_base() -> f64 {
1.0
}
fn symbol() -> &'static str {
"rad"
}
}
impl Unit for Degrees {
type Dimension = Angle;
fn factor_to_base() -> f64 {
PI / 180.0
}
fn symbol() -> &'static str {
"deg"
}
}
impl Quantity for Radians {
type U = Radians;
fn value(self) -> f64 {
self.0
}
fn from_value(value: f64) -> Self {
Self(value)
}
}
impl Quantity for Degrees {
type U = Degrees;
fn value(self) -> f64 {
self.0
}
fn from_value(value: f64) -> Self {
Self(value)
}
}
impl fmt::Display for Radians {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} rad", self.0)
}
}
impl fmt::Display for Degrees {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} deg", self.0)
}
}
impl_quantity_ops!(Radians);
impl_quantity_ops!(Degrees);
#[cfg(test)]
mod tests {
use super::{Degrees, Radians};
use crate::dimension::Angle;
use apsis_core::{Quantity, Unit};
use core::f64::consts::PI;
#[test]
fn direct_conversions_are_consistent() {
assert!((Degrees::new(180.0).to_radians().value() - PI).abs() < 1e-12);
assert!((Radians::new(PI).to_degrees().value() - 180.0).abs() < 1e-12);
assert!((Degrees::new(90.0).to_radians().value() - PI / 2.0).abs() < 1e-12);
}
#[test]
fn generic_conversion_matches_direct_conversion() {
let angle = Degrees::new(45.0);
let via_trait: Radians = angle.to_unit();
assert!((via_trait.value() - angle.to_radians().value()).abs() < 1e-12);
}
#[test]
fn conversion_round_trips() {
let original = Degrees::new(137.5);
let round_tripped: Degrees = original.to_unit::<Radians>().to_unit();
assert!((round_tripped.value() - original.value()).abs() < 1e-12);
}
#[test]
fn wrap_places_angles_in_the_canonical_range() {
for turns in -3..=3 {
let angle = Radians::new(PI + f64::from(turns) * 2.0 * PI);
let wrapped = angle.wrap();
assert!(wrapped.value() >= -PI);
assert!(wrapped.value() < PI);
// Wrapping shifts by whole turns, so the angle is preserved
// modulo two pi.
let difference = angle.value() - wrapped.value();
assert!((difference / (2.0 * PI)).fract().abs() < 1e-12);
}
assert!((Radians::new(0.0).wrap().value()).abs() < 1e-12);
assert!((Radians::new(PI).wrap().value() + PI).abs() < 1e-12);
}
#[test]
fn unit_metadata_is_available() {
assert_eq!(Radians::symbol(), "rad");
assert_eq!(Degrees::symbol(), "deg");
assert_eq!(Radians::factor_to_base(), 1.0);
assert!((Degrees::factor_to_base() - PI / 180.0).abs() < 1e-15);
fn assert_angle<U: Unit<Dimension = Angle>>() {}
assert_angle::<Radians>();
assert_angle::<Degrees>();
}
#[test]
fn display_includes_the_symbol() {
assert_eq!(Radians::new(1.0).to_string(), "1 rad");
assert_eq!(Degrees::new(90.0).to_string(), "90 deg");
}
}

View File

@ -0,0 +1,26 @@
//! Physical dimension markers.
//!
//! Each marker is a zero sized type naming one physical dimension. A
//! [`Unit`](apsis_core::Unit) declares which dimension it measures through
//! its `Dimension` associated type, and conversion between quantities is
//! only permitted when the dimensions match.
/// Length.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Length;
/// Duration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Time;
/// Frequency, the reciprocal of time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Frequency;
/// Plane angle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Angle;
/// Thermodynamic temperature.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Temperature;

View File

@ -0,0 +1,96 @@
//! Frequency quantities.
//!
//! The base unit is the hertz, one cycle per second. [`Hertz`] measures
//! [`Frequency`](crate::dimension::Frequency).
use crate::dimension::Frequency;
use crate::macros::impl_quantity_ops;
use apsis_core::{Quantity, Unit};
use core::fmt;
/// A frequency in hertz.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Hertz(f64);
impl Hertz {
/// Creates a frequency from a value in hertz.
pub const fn new(value: f64) -> Self {
Self(value)
}
/// Returns this frequency in hertz.
pub const fn value(self) -> f64 {
self.0
}
/// Returns the period corresponding to this frequency, in seconds.
///
/// Returns `f64::INFINITY` when the frequency is zero, which is the
/// correct limit of an infinite period.
pub fn period(self) -> f64 {
1.0 / self.0
}
}
impl Unit for Hertz {
type Dimension = Frequency;
fn factor_to_base() -> f64 {
1.0
}
fn symbol() -> &'static str {
"Hz"
}
}
impl Quantity for Hertz {
type U = Hertz;
fn value(self) -> f64 {
self.0
}
fn from_value(value: f64) -> Self {
Self(value)
}
}
impl fmt::Display for Hertz {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} Hz", self.0)
}
}
impl_quantity_ops!(Hertz);
#[cfg(test)]
mod tests {
use super::Hertz;
use crate::dimension::Frequency;
use apsis_core::Unit;
#[test]
fn stores_the_given_value() {
assert_eq!(Hertz::new(1_575.42).value(), 1_575.42);
}
#[test]
fn period_is_the_reciprocal() {
assert!((Hertz::new(2.0).period() - 0.5).abs() < 1e-12);
assert!(Hertz::new(0.0).period().is_infinite());
}
#[test]
fn unit_metadata_is_available() {
assert_eq!(Hertz::symbol(), "Hz");
assert_eq!(Hertz::factor_to_base(), 1.0);
fn assert_frequency<U: Unit<Dimension = Frequency>>() {}
assert_frequency::<Hertz>();
}
#[test]
fn display_includes_the_symbol() {
assert_eq!(Hertz::new(1_000.0).to_string(), "1000 Hz");
}
}

View File

@ -0,0 +1,184 @@
//! Length quantities.
//!
//! The base unit is the meter. [`Meters`] and [`Kilometers`] both measure
//! [`Length`](crate::dimension::Length) and convert into each other through
//! [`Quantity::to_unit`](apsis_core::Quantity::to_unit).
use crate::dimension::Length;
use crate::macros::impl_quantity_ops;
use apsis_core::{Quantity, Unit};
use core::fmt;
/// A length in meters.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Meters(f64);
/// A length in kilometers.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Kilometers(f64);
impl Meters {
/// Creates a length from a value in meters.
pub const fn new(value: f64) -> Self {
Self(value)
}
/// Returns this length in meters.
pub const fn value(self) -> f64 {
self.0
}
/// Converts this length to kilometers.
pub fn to_kilometers(self) -> Kilometers {
Kilometers(self.0 / 1_000.0)
}
}
impl Kilometers {
/// Creates a length from a value in kilometers.
pub const fn new(value: f64) -> Self {
Self(value)
}
/// Returns this length in kilometers.
pub const fn value(self) -> f64 {
self.0
}
/// Converts this length to meters.
pub fn to_meters(self) -> Meters {
Meters(self.0 * 1_000.0)
}
}
impl Unit for Meters {
type Dimension = Length;
fn factor_to_base() -> f64 {
1.0
}
fn symbol() -> &'static str {
"m"
}
}
impl Unit for Kilometers {
type Dimension = Length;
fn factor_to_base() -> f64 {
1_000.0
}
fn symbol() -> &'static str {
"km"
}
}
impl Quantity for Meters {
type U = Meters;
fn value(self) -> f64 {
self.0
}
fn from_value(value: f64) -> Self {
Self(value)
}
}
impl Quantity for Kilometers {
type U = Kilometers;
fn value(self) -> f64 {
self.0
}
fn from_value(value: f64) -> Self {
Self(value)
}
}
impl fmt::Display for Meters {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} m", self.0)
}
}
impl fmt::Display for Kilometers {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} km", self.0)
}
}
impl_quantity_ops!(Meters);
impl_quantity_ops!(Kilometers);
#[cfg(test)]
mod tests {
use super::{Kilometers, Meters};
use crate::dimension::Length;
use apsis_core::{Quantity, Unit};
#[test]
fn stores_the_given_value() {
assert_eq!(Meters::new(12.5).value(), 12.5);
assert_eq!(Kilometers::new(-3.0).value(), -3.0);
}
#[test]
fn direct_conversions_are_consistent() {
assert_eq!(Meters::new(2_500.0).to_kilometers().value(), 2.5);
assert_eq!(Kilometers::new(2.5).to_meters().value(), 2_500.0);
}
#[test]
fn generic_conversion_matches_direct_conversion() {
let distance = Meters::new(2_500.0);
let via_trait: Kilometers = distance.to_unit();
assert_eq!(via_trait, distance.to_kilometers());
}
#[test]
fn conversion_round_trips() {
let original = Kilometers::new(42.195);
let round_tripped: Kilometers = original.to_unit::<Meters>().to_unit();
assert!((round_tripped.value() - original.value()).abs() < 1e-12);
}
#[test]
fn units_report_their_metadata() {
assert_eq!(Meters::symbol(), "m");
assert_eq!(Kilometers::symbol(), "km");
assert_eq!(Meters::factor_to_base(), 1.0);
assert_eq!(Kilometers::factor_to_base(), 1_000.0);
// Both units measure the same dimension, which is what makes the
// generic conversion above type check.
fn assert_length<U: Unit<Dimension = Length>>() {}
assert_length::<Meters>();
assert_length::<Kilometers>();
}
#[test]
fn arithmetic_operates_on_the_stored_value() {
let sum = Meters::new(1.5) + Meters::new(2.5);
assert_eq!(sum.value(), 4.0);
let difference = Meters::new(4.0) - Meters::new(1.0);
assert_eq!(difference.value(), 3.0);
let scaled = Meters::new(3.0) * 2.0;
assert_eq!(scaled.value(), 6.0);
let straddled = 2.0 * Meters::new(3.0);
assert_eq!(straddled.value(), 6.0);
let halved = Meters::new(3.0) / 2.0;
assert_eq!(halved.value(), 1.5);
let ratio = Meters::new(3.0) / Meters::new(1.5);
assert_eq!(ratio, 2.0);
assert_eq!((-Meters::new(1.0)).value(), -1.0);
}
#[test]
fn display_includes_the_symbol() {
assert_eq!(Meters::new(1.0).to_string(), "1 m");
assert_eq!(Kilometers::new(2.0).to_string(), "2 km");
}
}

View File

@ -1 +1,45 @@
//! Strongly typed newtypes for physical quantities such as meters, seconds, hertz, radians, and degrees.
//! Strongly typed newtypes for physical quantities.
//!
//! Each quantity is a newtype over `f64` that carries its unit in the type
//! system, so a length cannot be passed where a duration is expected. Units
//! of the same dimension convert through
//! [`Quantity::to_unit`](apsis_core::Quantity::to_unit), and each unit also
//! offers direct conversions for the common cases.
//!
//! A newtype implements both [`Unit`] and [`Quantity`], with its own type as
//! the associated unit. For example [`Meters`] is a [`Length`](dimension::Length)
//! unit and a quantity whose unit is itself.
//!
//! The dimensions covered here are length, time, frequency, angle, and
//! thermodynamic temperature. Temperature is the only affine scale in the
//! set, so Celsius is handled by explicit methods on [`Kelvin`] rather than
//! by the linear [`Unit`] conversion factor.
//!
//! ```
//! use apsis_core::Quantity;
//! use apsis_units::{Kilometers, Meters};
//!
//! let distance = Meters::new(2_500.0);
//! let in_kilometers: Kilometers = distance.to_unit();
//! assert!((in_kilometers.value() - 2.5).abs() < 1e-12);
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
pub mod dimension;
mod macros;
mod angle;
mod frequency;
mod length;
mod temperature;
mod time;
pub use angle::{Degrees, Radians};
pub use frequency::Hertz;
pub use length::{Kilometers, Meters};
pub use temperature::Kelvin;
pub use time::Seconds;
pub use apsis_core::{Quantity, Unit};

View File

@ -0,0 +1,73 @@
//! Internal helpers that generate the arithmetic operators for a quantity
//! newtype.
//!
//! The operators are mechanical, so they are generated from one definition
//! rather than repeated per unit. The macro is crate internal; the crate
//! documentation describes the public behaviour.
/// Implements `Add`, `Sub`, `Neg`, `Mul<f64>`, `Div<f64>`, `Mul<Self> for
/// f64`, and `Div<Self>` for a `f64` backed quantity newtype.
///
/// The newtype must be a tuple struct with a single `f64` field visible in
/// the invoking module.
macro_rules! impl_quantity_ops {
($quantity:ident) => {
impl core::ops::Add for $quantity {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Self(self.0 + rhs.0)
}
}
impl core::ops::Sub for $quantity {
type Output = Self;
fn sub(self, rhs: Self) -> Self {
Self(self.0 - rhs.0)
}
}
impl core::ops::Neg for $quantity {
type Output = Self;
fn neg(self) -> Self {
Self(-self.0)
}
}
impl core::ops::Mul<f64> for $quantity {
type Output = Self;
fn mul(self, rhs: f64) -> Self {
Self(self.0 * rhs)
}
}
impl core::ops::Div<f64> for $quantity {
type Output = Self;
fn div(self, rhs: f64) -> Self {
Self(self.0 / rhs)
}
}
impl core::ops::Mul<$quantity> for f64 {
type Output = $quantity;
fn mul(self, rhs: $quantity) -> $quantity {
$quantity(self * rhs.0)
}
}
impl core::ops::Div<$quantity> for $quantity {
type Output = f64;
fn div(self, rhs: Self) -> f64 {
self.0 / rhs.0
}
}
};
}
pub(crate) use impl_quantity_ops;

View File

@ -0,0 +1,122 @@
//! Thermodynamic temperature quantities.
//!
//! The base unit is the kelvin. [`Kelvin`] measures
//! [`Temperature`](crate::dimension::Temperature).
//!
//! Temperature is the one affine scale in this crate. The Celsius scale
//! shares the kelvin's magnitude but not its zero, so converting between
//! them requires an offset and cannot be expressed by the linear
//! [`Unit`] factor. Celsius is therefore offered
//! through explicit methods rather than as a second [`Unit`] type.
use crate::dimension::Temperature;
use crate::macros::impl_quantity_ops;
use apsis_core::{Quantity, Unit};
use core::fmt;
/// A thermodynamic temperature in kelvin.
///
/// Values below absolute zero are not rejected, because intermediate
/// arithmetic may legitimately pass through them; callers that require a
/// physical temperature should check the value.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Kelvin(f64);
impl Kelvin {
/// Creates a temperature from a value in kelvin.
pub const fn new(value: f64) -> Self {
Self(value)
}
/// Creates a temperature from a value in degrees Celsius.
///
/// Uses the exact offset defined by the Celsius scale.
pub fn from_celsius(celsius: f64) -> Self {
Self(celsius + apsis_core::constant::ZERO_CELSIUS_IN_KELVIN)
}
/// Returns this temperature in kelvin.
pub const fn value(self) -> f64 {
self.0
}
/// Converts this temperature to degrees Celsius.
pub fn to_celsius(self) -> f64 {
self.0 - apsis_core::constant::ZERO_CELSIUS_IN_KELVIN
}
}
impl Unit for Kelvin {
type Dimension = Temperature;
fn factor_to_base() -> f64 {
1.0
}
fn symbol() -> &'static str {
"K"
}
}
impl Quantity for Kelvin {
type U = Kelvin;
fn value(self) -> f64 {
self.0
}
fn from_value(value: f64) -> Self {
Self(value)
}
}
impl fmt::Display for Kelvin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} K", self.0)
}
}
impl_quantity_ops!(Kelvin);
#[cfg(test)]
mod tests {
use super::Kelvin;
use crate::dimension::Temperature;
use apsis_core::Unit;
use apsis_core::constant::ZERO_CELSIUS_IN_KELVIN;
#[test]
fn stores_the_given_value() {
assert_eq!(Kelvin::new(290.0).value(), 290.0);
}
#[test]
fn celsius_conversion_uses_the_exact_offset() {
let freezing = Kelvin::from_celsius(0.0);
assert_eq!(freezing.value(), ZERO_CELSIUS_IN_KELVIN);
assert_eq!(freezing.to_celsius(), 0.0);
let boiling = Kelvin::from_celsius(100.0);
assert!((boiling.to_celsius() - 100.0).abs() < 1e-12);
}
#[test]
fn celsius_conversion_round_trips() {
let original = Kelvin::new(273.15 + 21.5);
assert!(
(Kelvin::from_celsius(original.to_celsius()).value() - original.value()).abs() < 1e-12
);
}
#[test]
fn unit_metadata_is_available() {
assert_eq!(Kelvin::symbol(), "K");
assert_eq!(Kelvin::factor_to_base(), 1.0);
fn assert_temperature<U: Unit<Dimension = Temperature>>() {}
assert_temperature::<Kelvin>();
}
#[test]
fn display_includes_the_symbol() {
assert_eq!(Kelvin::new(300.0).to_string(), "300 K");
}
}

View File

@ -0,0 +1,91 @@
//! Duration quantities.
//!
//! The base unit is the second. [`Seconds`] is the only unit defined here;
//! it measures [`Time`](crate::dimension::Time).
use crate::dimension::Time;
use crate::macros::impl_quantity_ops;
use apsis_core::{Quantity, Unit};
use core::fmt;
/// A duration in seconds.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Seconds(f64);
impl Seconds {
/// Creates a duration from a value in seconds.
pub const fn new(value: f64) -> Self {
Self(value)
}
/// Returns this duration in seconds.
pub const fn value(self) -> f64 {
self.0
}
}
impl Unit for Seconds {
type Dimension = Time;
fn factor_to_base() -> f64 {
1.0
}
fn symbol() -> &'static str {
"s"
}
}
impl Quantity for Seconds {
type U = Seconds;
fn value(self) -> f64 {
self.0
}
fn from_value(value: f64) -> Self {
Self(value)
}
}
impl fmt::Display for Seconds {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} s", self.0)
}
}
impl_quantity_ops!(Seconds);
#[cfg(test)]
mod tests {
use super::Seconds;
use crate::dimension::Time;
use apsis_core::Unit;
#[test]
fn stores_the_given_value() {
assert_eq!(Seconds::new(0.5).value(), 0.5);
}
#[test]
fn unit_metadata_is_available() {
assert_eq!(Seconds::symbol(), "s");
assert_eq!(Seconds::factor_to_base(), 1.0);
fn assert_time<U: Unit<Dimension = Time>>() {}
assert_time::<Seconds>();
}
#[test]
fn arithmetic_operates_on_the_stored_value() {
assert_eq!((Seconds::new(1.0) + Seconds::new(2.0)).value(), 3.0);
assert_eq!((Seconds::new(3.0) - Seconds::new(2.0)).value(), 1.0);
assert_eq!((Seconds::new(3.0) * 2.0).value(), 6.0);
assert_eq!((Seconds::new(3.0) / 3.0).value(), 1.0);
assert_eq!(Seconds::new(6.0) / Seconds::new(3.0), 2.0);
}
#[test]
fn display_includes_the_symbol() {
assert_eq!(Seconds::new(1.5).to_string(), "1.5 s");
}
}