Skip to content

Instantly share code, notes, and snippets.

@jtt9340
Created December 14, 2021 00:35
Show Gist options
  • Save jtt9340/a5c143937e36ff6f4bad4401ab420ee1 to your computer and use it in GitHub Desktop.
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
// 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