Created
December 14, 2021 00:35
-
-
Save jtt9340/a5c143937e36ff6f4bad4401ab420ee1 to your computer and use it in GitHub Desktop.
An exploration of using Rust's algebraic datatype-style enums to implement dimensional analysis
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Compile with: rustc --test angle.rs | |
/// This module explores the idea of using Rust's enums-as-algebraic-data-types to model | |
/// dimensional analysis. For a given quantity, each of the possible units is a variant of that | |
/// enum. Methods can convert between the different units, so a function expecting a certain unit | |
/// can just accept that quantity and call one of the unit-converting methods. | |
/// | |
/// The inspiration for this module was [F#'s units of measure][fsuom], which is built into F#'s | |
/// type system. A unique feature of that implementation of dimensional analysis is that it can | |
/// understand multiplication and division of units, something that this implementation cannot. For | |
/// example, if you have a quantity of type "meters" and another quantity of type "seconds", | |
/// dividing these two quantities yields a value of type "meters per second", whereas using the | |
/// technqiue of enums demonstrated in this module would require defining three types: `Distance`, | |
/// `Time`, and `Velocity` and overloading the division operator to return a `Velocity` when its | |
/// operands are of type `Distance` and `Time`, respectively. There are pre-existing Rust crates | |
/// that offer a more general implementation of dimensional analysis: | |
/// | |
/// | **Crate Name** | **Commentary** | | |
/// |:----------------|:------------------------------------------------------------------------------------------------------------------------| | |
/// | [units] | Seems really underdeveloped | | |
/// | [uom] | More developed/mature | | |
/// | [yaiouom] | Even more developed/mature, but relies on custom external program to verify the correctness of the dimensional analysis | | |
/// | [Metric] | Seems pretty straightforward | | |
/// | [dimensioned] | Hasn't been touched in a while | | |
/// | |
/// but if you want something simple this could be a good idea. More remarks on the utility of this | |
/// implementation is given below. | |
/// | |
/// In this example, we use degrees and radians for the units to convert between, all encapsulated | |
/// by an `Angle` type. Using enums I feel is surely the easiest/most straightforward way to model | |
/// dimensional analysis. However, this method may not scale well when adding new units after the | |
/// fact, as now each method needs to be updated to account for the new unit. A better approach may | |
/// be to make each unit its own type, for example | |
/// ``` | |
/// pub struct Degrees(f64); | |
/// pub struct Radians(f64); | |
/// ``` | |
/// and then use Rust's `Into` and `From` conversion traits to define conversions between the two. | |
/// Thus adding a new unit would not break any existing methods on any existing units. However, | |
/// this approach may be slightly more complex than the enum approach, since now if you want a | |
/// function that accepts a quantity in degrees, instread of doing | |
/// ``` | |
/// pub fn gimme_degrees(angle: Angle) { | |
/// let degrees = angle.to_degrees(); | |
/// // ... | |
/// } | |
/// ``` | |
/// you would be encouraged to use generics (or possibly trait objects?), as in | |
/// ``` | |
/// pub fn gimme_degrees<D>(angle: D) | |
/// where D: Into<Degrees> | |
/// { | |
/// let degrees = angle.to_degrees(); | |
/// // ... | |
/// } | |
/// ``` | |
/// Using generics instead of enums can bloat your binaries due to how Rust uses monomorphization | |
/// to handle generics. | |
/// | |
/// [fsuom]: https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/units-of-measure | |
/// [units]: https://crates.io/crates/units | |
/// [uom]: https://crates.io/crates/uom | |
/// [yaiouom]: https://crates.io/crates/yaiouom | |
/// [Metric]: https://crates.io/crates/Metric | |
/// [dimensioned]: https://crates.io/crates/dimensioned | |
pub mod angle { | |
use std::{error, fmt, num::ParseFloatError, str}; | |
/// A wrapper type used for conveniently converting between degrees and radians. | |
/// | |
/// Many APIs involving geometry and trigonometry require users to read the documentation on | |
/// whether angles are expected in degrees or radians, and angles are passed in as untyped floating | |
/// point numbers. What makes things more confusing is that angles representing rotations are | |
/// typically expected in degrees but angles used in trigonometric calculations are typically | |
/// expected in radians, which is something the user must commit to memory, Although not difficult | |
/// to memorize, this type takes the guesswork out of passing quantities representing angles to | |
/// functions, as the function can convert to whichever angle measure (degrees or radians) that is | |
/// requires regardless of whichever angle measure is passed in. | |
/* | |
Currently use f64 as backing type but could change to num_traits::Float or num_traits::real::Real | |
in the future. | |
Also currently only has two variants: Degrees and Radians but could add Revolutions in the future. | |
*/ | |
#[derive(Clone, Copy, Debug)] | |
pub enum Angle { | |
/// An angle in degrees, where one degree is defined as 1/360 of a circle. | |
Degrees(f64), | |
/// An angle in radians, where one radian is defined as 2π of a revolution. | |
Radians(f64), | |
} | |
impl Angle { | |
/// Get the underlying number that this `Angle` wraps. | |
pub fn unwrap(self) -> f64 { | |
match self { | |
Angle::Degrees(deg) => deg, | |
Angle::Radians(rad) => rad, | |
} | |
} | |
/// Determines if the given `Angle` is in `Degrees`. | |
pub fn is_degrees(&self) -> bool { | |
if let Angle::Degrees(_) = *self { | |
true | |
} else { | |
false | |
} | |
} | |
/// Determines if the given `Angle` is in `Radians`. | |
pub fn is_radians(&self) -> bool { | |
if let Angle::Radians(_) = *self { | |
true | |
} else { | |
false | |
} | |
} | |
/// Consume the given `Angle` and return a new one, with the new `Angle` in `Degrees`. | |
/// | |
/// If the given `Angle` is already in degrees, then this function just returns the given angle. | |
/// Otherwise, this function performs the conversion. | |
pub fn to_degrees(self) -> Self { | |
match self { | |
Angle::Degrees(_) => self, | |
Angle::Radians(rad) => Angle::Degrees(rad.to_degrees()), | |
} | |
} | |
/// Consume the given `Angle` and return a new one, with the new `Angle` in `Radians`. | |
/// | |
/// If the given `Angle` is already in radians, then this function just returns the given angle. | |
/// Otherwise, this function performs the conversion. | |
pub fn to_radians(self) -> Self { | |
match self { | |
Angle::Degrees(deg) => Angle::Radians(deg.to_radians()), | |
Angle::Radians(_) => self, | |
} | |
} | |
/// Convert the given `Angle` to degrees, minutes, and seconds. | |
/// | |
/// Returns a tuple of three integers: the first represents the number of degrees in the given `Angle`, the second represents | |
/// the number of minutes in the given `Angle`, and the third represents the number of seconds in the given `Angle`. While a | |
/// whole angle can be negative, the number of minutes and seconds in an angle cannot, so the first integer in the tuple is | |
/// signed while the the other two are not. | |
/// | |
/// A minute is 1/60 of a degree and a second is 1/60 of a minute (1/3600 of a degree). | |
pub fn to_dms(self) -> (i32, u32, u32) { | |
let dd = self.to_degrees().unwrap(); | |
let d = dd.trunc(); | |
let m = ((dd - d) * 60.0).trunc(); | |
let s = ((dd - d - m / 60.0) * 3600.0).round(); | |
(d as i32, m as u32, s as u32) | |
} | |
/// Create a new `Angle` from a tuple of degrees, minutes, and seconds. | |
/// | |
/// This method is the inverse of `to_dms`, i.e. passing the `Angle` returned by this function to `to_dms` will return the same tuple | |
/// used to invoke this function. | |
pub fn from_dms(theta: (i32, u32, u32)) -> Self { | |
let d = theta.0 as f64; | |
let m = theta.1 as f64; | |
let s = theta.2 as f64; | |
let dd = d + m / 60.0 + s / 3600.0; | |
Angle::Degrees(dd) | |
} | |
} | |
impl fmt::Display for Angle { | |
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
match *self { | |
Angle::Degrees(deg) => write!(f, "{}º", deg), | |
Angle::Radians(rad) => write!(f, "{} rad.", rad), | |
} | |
} | |
} | |
impl PartialEq for Angle { | |
fn eq(&self, other: &Self) -> bool { | |
match (*self, *other) { | |
(Self::Degrees(d0), Self::Degrees(d1)) => d0 == d1, | |
(Self::Radians(r0), Self::Radians(r1)) => r0 == r1, | |
// Allow for some imprecision that occurs when converting between units | |
(Self::Degrees(d), Self::Radians(r)) => (d - r.to_degrees()).abs() < 1e-10, | |
(Self::Radians(r), Self::Degrees(d)) => (r - d.to_radians()).abs() < 1e-10, | |
} | |
} | |
} | |
#[derive(Debug, Clone, Eq, PartialEq)] | |
pub enum ParseAngleError { | |
UnrecognizedUnit, | |
ParseFloatError(ParseFloatError), | |
} | |
impl fmt::Display for ParseAngleError { | |
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | |
match self { | |
ParseAngleError::UnrecognizedUnit => { | |
f.write_str("Could not determine if the angle is in degrees or radians") | |
} | |
ParseAngleError::ParseFloatError(e) => write!(f, "{}", e), | |
} | |
} | |
} | |
impl error::Error for ParseAngleError { | |
fn source(&self) -> Option<&(dyn error::Error + 'static)> { | |
match self { | |
ParseAngleError::UnrecognizedUnit => None, | |
ParseAngleError::ParseFloatError(e) => Some(e), | |
} | |
} | |
} | |
impl str::FromStr for Angle { | |
type Err = ParseAngleError; | |
fn from_str(s: &str) -> Result<Self, Self::Err> { | |
let s = s.trim(); | |
if s.ends_with('º') { | |
let deg_str = s.trim_end_matches('º').trim_end(); | |
let deg = match deg_str.parse::<f64>() { | |
Ok(d) => d, | |
Err(e) => return Err(ParseAngleError::ParseFloatError(e)), | |
}; | |
Ok(Angle::Degrees(deg)) | |
} else if s.ends_with("rad.") { | |
let rad_str = s.trim_end_matches("rad.").trim_end(); | |
let rad = match rad_str.parse::<f64>() { | |
Ok(r) => r, | |
Err(e) => return Err(ParseAngleError::ParseFloatError(e)), | |
}; | |
Ok(Angle::Radians(rad)) | |
} else { | |
Err(ParseAngleError::UnrecognizedUnit) | |
} | |
} | |
} | |
#[cfg(test)] | |
mod tests { | |
use super::*; | |
use std::collections::HashMap; | |
#[test] | |
fn test_unwrap() { | |
let degrees = 30.0; | |
let alpha = Angle::Degrees(degrees); | |
assert_eq!(alpha.unwrap(), degrees); | |
let radians = std::f64::consts::FRAC_PI_4; | |
let beta = Angle::Radians(radians); | |
assert_eq!(beta.unwrap(), radians); | |
} | |
#[test] | |
fn test_is_degrees() { | |
let degrees = 30.0; | |
let alpha = Angle::Degrees(degrees); | |
assert!(alpha.is_degrees()); | |
let radians = std::f64::consts::FRAC_PI_4; | |
let beta = Angle::Radians(radians); | |
assert!(!beta.is_degrees()); | |
} | |
#[test] | |
fn test_is_radians() { | |
let degrees = 30.0; | |
let alpha = Angle::Degrees(degrees); | |
assert!(!alpha.is_radians()); | |
let radians = std::f64::consts::FRAC_PI_4; | |
let beta = Angle::Radians(radians); | |
assert!(beta.is_radians()); | |
} | |
#[test] | |
fn test_to_degrees() { | |
let radians = std::f64::consts::FRAC_PI_4; | |
let theta = Angle::Radians(radians); | |
let diff = (theta.to_degrees().unwrap() - 45.0).abs(); | |
assert!(diff < 1e-10); | |
assert_eq!(Angle::Degrees(30.0).to_degrees(), Angle::Degrees(30.0)); | |
} | |
#[test] | |
fn test_to_radians() { | |
let degrees = 30.0; | |
let theta = Angle::Degrees(degrees); | |
let diff = (theta.to_radians().unwrap() - (std::f64::consts::FRAC_PI_6)).abs(); | |
assert!(diff < 1e-10); | |
assert_eq!(Angle::Radians(1.5).to_radians(), Angle::Radians(1.5)); | |
} | |
#[test] | |
fn test_to_dms() { | |
let degrees = 21.61361111; | |
let theta = Angle::Degrees(degrees); | |
assert_eq!(theta.to_dms(), (21, 36, 49)); | |
} | |
#[test] | |
fn test_from_dms() { | |
let degrees = 21; | |
let minutes = 36; | |
let seconds = 49; | |
let diff = (Angle::from_dms((degrees, minutes, seconds)) | |
.to_degrees() | |
.unwrap() | |
- 21.61361111) | |
.abs(); | |
assert!(diff < 1e-5); | |
} | |
#[test] | |
fn test_partial_eq() { | |
assert_eq!(Angle::Degrees(30.0), Angle::Degrees(30.0)); | |
assert_eq!( | |
Angle::Radians(std::f64::consts::FRAC_PI_6), | |
Angle::Radians(std::f64::consts::FRAC_PI_6) | |
); | |
assert_eq!( | |
Angle::Degrees(30.0), | |
Angle::Radians(std::f64::consts::FRAC_PI_6) | |
); | |
assert_eq!( | |
Angle::Radians(std::f64::consts::FRAC_PI_6), | |
Angle::Degrees(30.0) | |
); | |
} | |
#[test] | |
fn test_display_angle() { | |
assert_eq!(format!("{}", Angle::Degrees(30.0)).as_str(), "30º"); | |
assert_eq!(format!("{}", Angle::Radians(1.5)).as_str(), "1.5 rad."); | |
} | |
fn is_parse_float_error(err: &ParseAngleError) -> bool { | |
if let ParseAngleError::ParseFloatError(_) = *err { | |
true | |
} else { | |
false | |
} | |
} | |
#[test] | |
fn test_from_str() { | |
let test_cases = HashMap::from([ | |
("37.1º", Angle::Degrees(37.1)), | |
("1.4rad.", Angle::Radians(1.4)), | |
(" -721.05º ", Angle::Degrees(-721.05)), | |
(" 1.517 rad.", Angle::Radians(1.517)), | |
]); | |
for (input, expected) in &test_cases { | |
assert_eq!(input.parse(), Ok(*expected)); | |
} | |
assert_eq!( | |
"13".parse::<Angle>(), | |
Err(ParseAngleError::UnrecognizedUnit) | |
); | |
assert!(is_parse_float_error(&"13pdº".parse::<Angle>().unwrap_err())); | |
assert!(is_parse_float_error( | |
&"-2.7-.rad.".parse::<Angle>().unwrap_err() | |
)); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment