diff --git a/VERSION.toml b/VERSION.toml index a6f897a..c4096f2 100644 --- a/VERSION.toml +++ b/VERSION.toml @@ -5,5 +5,5 @@ # ======================================================================================== major = 0 -minor = 2 +minor = 3 patch = 0 diff --git a/core/Cargo.toml b/core/Cargo.toml index 275675c..409302b 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dv" -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = [ "Alex Tacescu ",] description = "Core Rust library for DimensionalVariable, a multi-language library for handling physical quantities with units." diff --git a/core/src/lib.rs b/core/src/lib.rs index 3a80f66..a318250 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -43,13 +43,24 @@ impl DimensionalVariable { .map_err(|e| format!("Failed to parse unit '{}': {}", unit_str, e))?; // Check if the units are compatible - if self.unit != unit { + if !self.check_compatibility(unit) { return Err(format!("Incompatible unit conversion to: {}", unit_str)); } return Ok(self.value / conversion_factor); } + pub fn check_compatibility(&self, other_unit: [f64; units::BASE_UNITS_SIZE]) -> bool { + + // If the angle unit == 1, then allow compatibility to linear units + if self.unit[7] == 1.0 { + // Compare only the first 7 base units (ignore the angle unit) + return &self.unit[..units::BASE_UNITS_SIZE - 1] == &other_unit[..units::BASE_UNITS_SIZE - 1]; + } + + return self.unit == other_unit; + } + /// Returns the base unit array of this DimensionalVariable. pub fn unit(&self) -> [f64; units::BASE_UNITS_SIZE] { return self.unit; @@ -62,7 +73,7 @@ impl DimensionalVariable { /// Fallible add with unit compatibility check. pub fn try_add(&self, other: &DimensionalVariable) -> Result { - if self.unit != other.unit { + if !self.check_compatibility(other.unit) { return Err("Incompatible units for addition".to_string()); } return Ok(DimensionalVariable { value: self.value + other.value, unit: self.unit }); @@ -70,7 +81,7 @@ impl DimensionalVariable { /// Fallible subtraction with unit compatibility check. pub fn try_sub(&self, other: &DimensionalVariable) -> Result { - if self.unit != other.unit { + if !self.check_compatibility(other.unit) { return Err("Incompatible units for subtraction".to_string()); } return Ok(DimensionalVariable { value: self.value - other.value, unit: self.unit }); @@ -136,30 +147,66 @@ impl DimensionalVariable { self.unit[units::BASE_UNITS_SIZE - 1] == 1.0 } - /// Sine function. Requires angle (radians) or unitless. + /// Sine function. Requires angle (radians). pub fn sin(&self) -> Result { - if !self.is_unitless() && !self.is_angle() { - return Err("sin requires an angle or unitless quantity".to_string()); + if !self.is_angle() { + return Err("sin requires an angle quantity".to_string()); } Ok(self.value.sin()) } - /// Cosine function. Requires angle (radians) or unitless. + /// Cosine function. Requires angle (radians). pub fn cos(&self) -> Result { - if !self.is_unitless() && !self.is_angle() { - return Err("cos requires an angle or unitless quantity".to_string()); + if !self.is_angle() { + return Err("cos requires an angle quantity".to_string()); } Ok(self.value.cos()) } - /// Tangent function. Requires angle or unitless. + /// Tangent function. Requires angle (radians). pub fn tan(&self) -> Result { - if !self.is_unitless() && !self.is_angle() { - return Err("tan requires an angle or unitless quantity".to_string()); + if !self.is_angle() { + return Err("tan requires an angle quantity".to_string()); } Ok(self.value.tan()) } + /// Arcsine function. Requires unitless value in [-1, 1]. Returns angle in radians. + pub fn asin(&self) -> Result { + if !self.is_unitless() { + return Err("asin requires a unitless quantity".to_string()); + } + if self.value < -1.0 || self.value > 1.0 { + return Err("asin requires a value in the range [-1, 1]".to_string()); + } + let mut unit = [0.0; units::BASE_UNITS_SIZE]; + unit[units::BASE_UNITS_SIZE - 1] = 1.0; // radians + Ok(DimensionalVariable { value: self.value.asin(), unit }) + } + + /// Arccosine function. Requires unitless value in [-1, 1]. Returns angle in radians. + pub fn acos(&self) -> Result { + if !self.is_unitless() { + return Err("acos requires a unitless quantity".to_string()); + } + if self.value < -1.0 || self.value > 1.0 { + return Err("acos requires a value in the range [-1, 1]".to_string()); + } + let mut unit = [0.0; units::BASE_UNITS_SIZE]; + unit[units::BASE_UNITS_SIZE - 1] = 1.0; // radians + Ok(DimensionalVariable { value: self.value.acos(), unit }) + } + + /// Arctangent function. Requires unitless value. Returns angle in radians. + pub fn atan(&self) -> Result { + if !self.is_unitless() { + return Err("atan requires a unitless quantity".to_string()); + } + let mut unit = [0.0; units::BASE_UNITS_SIZE]; + unit[units::BASE_UNITS_SIZE - 1] = 1.0; // radians + Ok(DimensionalVariable { value: self.value.atan(), unit }) + } + // ---- Scalar helpers on single values ---- /// Negate the value, keeping the same unit. pub fn neg(&self) -> DimensionalVariable { @@ -173,6 +220,21 @@ impl DimensionalVariable { } +/// Arcsin function for f64 input, returns DimensionalVariable in radians. +pub fn asin(x: f64) -> Result { + return DimensionalVariable::new(x, "").unwrap().asin(); +} + +/// Arccos function for f64 input, returns DimensionalVariable in radians. +pub fn acos(x: f64) -> Result { + return DimensionalVariable::new(x, "").unwrap().acos(); +} + +/// Arctan function for f64 input, returns DimensionalVariable in radians. +pub fn atan(x: f64) -> Result { + return DimensionalVariable::new(x, "").unwrap().atan(); +} + /// Convert a unit string like "m/s^2" or "kg-m/s^2" into base unit exponents and a conversion factor. /// Returns an error if the unit string is invalid or contains unknown units. fn unit_str_to_base_unit(units_str: &str) -> Result<([f64; units::BASE_UNITS_SIZE], f64), String> { @@ -304,8 +366,7 @@ use std::cmp::Ordering; impl<'a, 'b> Add<&'b DimensionalVariable> for &'a DimensionalVariable { type Output = DimensionalVariable; fn add(self, rhs: &'b DimensionalVariable) -> Self::Output { - assert!(self.unit == rhs.unit, "Incompatible units for addition: {:?} vs {:?}", self.unit, rhs.unit); - DimensionalVariable { value: self.value + rhs.value, unit: self.unit } + return self.try_add(rhs).expect("Incompatible units for addition"); } } @@ -334,8 +395,7 @@ impl<'a> Add for &'a DimensionalVariable { impl<'a, 'b> Sub<&'b DimensionalVariable> for &'a DimensionalVariable { type Output = DimensionalVariable; fn sub(self, rhs: &'b DimensionalVariable) -> Self::Output { - assert!(self.unit == rhs.unit, "Incompatible units for subtraction: {:?} vs {:?}", self.unit, rhs.unit); - DimensionalVariable { value: self.value - rhs.value, unit: self.unit } + return self.try_sub(rhs).expect("Incompatible units for subtraction"); } } @@ -419,15 +479,13 @@ impl<'a> Div for &'a DimensionalVariable { // Assignment ops: implement only for &DimensionalVariable RHS. Owned RHS will autoref. impl AddAssign<&DimensionalVariable> for DimensionalVariable { fn add_assign(&mut self, rhs: &DimensionalVariable) { - assert!(self.unit == rhs.unit, "Incompatible units for addition assignment: {:?} vs {:?}", self.unit, rhs.unit); - self.value += rhs.value; + *self = self.try_add(rhs).expect("Incompatible units for addition assignment"); } } impl SubAssign<&DimensionalVariable> for DimensionalVariable { fn sub_assign(&mut self, rhs: &DimensionalVariable) { - assert!(self.unit == rhs.unit, "Incompatible units for subtraction assignment: {:?} vs {:?}", self.unit, rhs.unit); - self.value -= rhs.value; + *self = self.try_sub(rhs).expect("Incompatible units for subtraction assignment"); } } @@ -533,14 +591,16 @@ impl Neg for DimensionalVariable { // ---- Comparisons: equalities and ordering ---- impl PartialEq for DimensionalVariable { fn eq(&self, other: &Self) -> bool { - if self.unit != other.unit { return false; } + if !self.check_compatibility(other.unit) { + return false; + } self.value == other.value } } impl PartialOrd for DimensionalVariable { fn partial_cmp(&self, other: &Self) -> Option { - if self.unit != other.unit { return None; } + if !self.check_compatibility(other.unit) { return None; } self.value.partial_cmp(&other.value) } } diff --git a/core/tests/operator_tests.rs b/core/tests/operator_tests.rs index a319e91..6d48733 100644 --- a/core/tests/operator_tests.rs +++ b/core/tests/operator_tests.rs @@ -1,4 +1,5 @@ use dv_rs::DimensionalVariable as dv; +use dv_rs::{asin, acos, atan}; const FAIL_MSG: &str = "Failed to create DimensionalVariable"; @@ -420,4 +421,58 @@ fn absolute_value() { let a = m.abs(); assert_eq!(a.value(), 5.0); assert_eq!(a.unit(), [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); +} + +#[test] +fn inverse_trig_functions() { + use std::f64::consts::PI; + + // asin on unitless value + let half = dv::new(0.5, "").expect(FAIL_MSG); + let angle = half.asin().expect(FAIL_MSG); + assert!((angle.value() - 0.5_f64.asin()).abs() < 1e-12); + assert_eq!(angle.unit(), [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]); // radians + + assert!(asin(half.value()).unwrap().value() - angle.value() < 1e-12); + + // acos on unitless value + let angle2 = half.acos().expect(FAIL_MSG); + assert!((angle2.value() - 0.5_f64.acos()).abs() < 1e-12); + assert_eq!(angle2.unit(), [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]); + + assert!(acos(half.value()).unwrap().value() - angle2.value() < 1e-12); + + // atan on unitless value + let one = dv::new(1.0, "").expect(FAIL_MSG); + let angle3 = one.atan().expect(FAIL_MSG); + assert!((angle3.value() - PI / 4.0).abs() < 1e-12); + assert_eq!(angle3.unit(), [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]); + + assert!(atan(one.value()).unwrap().value() - angle3.value() < 1e-12); + + // asin/acos edge cases at -1 and 1 + let neg_one = dv::new(-1.0, "").expect(FAIL_MSG); + let pos_one = dv::new(1.0, "").expect(FAIL_MSG); + assert!((neg_one.asin().unwrap().value() - (-PI / 2.0)).abs() < 1e-12); + assert!((pos_one.asin().unwrap().value() - (PI / 2.0)).abs() < 1e-12); + assert!((neg_one.acos().unwrap().value() - PI).abs() < 1e-12); + assert!((pos_one.acos().unwrap().value() - 0.0).abs() < 1e-12); + + // Round-trip: sin(asin(x)) == x + let val = dv::new(0.7, "").expect(FAIL_MSG); + let angle4 = val.asin().expect(FAIL_MSG); + assert!((angle4.sin().unwrap() - 0.7).abs() < 1e-12); + + // Error: asin/acos on non-unitless + let m = dv::new(0.5, "m").expect(FAIL_MSG); + assert!(m.asin().is_err()); + assert!(m.acos().is_err()); + assert!(m.atan().is_err()); + + // Error: asin/acos out of range + let out_of_range = dv::new(2.0, "").expect(FAIL_MSG); + assert!(out_of_range.asin().is_err()); + assert!(out_of_range.acos().is_err()); + // atan has no domain restriction + assert!(out_of_range.atan().is_ok()); } \ No newline at end of file diff --git a/core/tests/units_tests.rs b/core/tests/units_tests.rs index bfc70c3..8647320 100644 --- a/core/tests/units_tests.rs +++ b/core/tests/units_tests.rs @@ -35,6 +35,18 @@ fn angle_conversions() { assert!((arcsec.value_in("deg").expect(FAIL_MSG) - 1.0).abs() < 1e-10); } +#[test] +fn angle_to_linear_conversions() { + let torque = dv::new(10.0, "N-m").expect(FAIL_MSG); + let angular_speed = dv::new(2.0, "rad/s").expect(FAIL_MSG); + + let power = torque * angular_speed; + + // Power = Torque * Angular Speed + assert_eq!(power.unit(), [2.0, 1.0, -3.0, 0.0, 0.0, 0.0, 0.0, 1.0], "Incorrect unit for angular power."); + assert!((power.value_in("W").expect(FAIL_MSG) - 20.0).abs() < 1e-10); +} + #[test] fn unit_str_to_unit_simple_simple() { let dv = dv::new(4.6483, "m/s").expect(FAIL_MSG); diff --git a/cpp/capi/src/lib.rs b/cpp/capi/src/lib.rs index 00b4b55..42bfc04 100644 --- a/cpp/capi/src/lib.rs +++ b/cpp/capi/src/lib.rs @@ -191,3 +191,59 @@ pub extern "C" fn dv_var_sqrt(a: *const dv_var) -> *mut dv_var { Err(e) => { set_last_error(e); ptr::null_mut() } } } + +#[no_mangle] +pub extern "C" fn dv_var_asin(a: *const dv_var) -> *mut dv_var { + if a.is_null() { set_last_error("null operand".to_string()); return ptr::null_mut(); } + let a = unsafe { &(*a) }; + match a.inner.asin() { + Ok(v) => Box::into_raw(Box::new(dv_var { inner: v })), + Err(e) => { set_last_error(e); ptr::null_mut() } + } +} + +#[no_mangle] +pub extern "C" fn dv_var_acos(a: *const dv_var) -> *mut dv_var { + if a.is_null() { set_last_error("null operand".to_string()); return ptr::null_mut(); } + let a = unsafe { &(*a) }; + match a.inner.acos() { + Ok(v) => Box::into_raw(Box::new(dv_var { inner: v })), + Err(e) => { set_last_error(e); ptr::null_mut() } + } +} + +#[no_mangle] +pub extern "C" fn dv_var_atan(a: *const dv_var) -> *mut dv_var { + if a.is_null() { set_last_error("null operand".to_string()); return ptr::null_mut(); } + let a = unsafe { &(*a) }; + match a.inner.atan() { + Ok(v) => Box::into_raw(Box::new(dv_var { inner: v })), + Err(e) => { set_last_error(e); ptr::null_mut() } + } +} + +// Free-standing trigonometric functions that take raw f64 and return angle in radians + +#[no_mangle] +pub extern "C" fn dv_asin(x: c_double) -> *mut dv_var { + match dv_rs::asin(x) { + Ok(v) => Box::into_raw(Box::new(dv_var { inner: v })), + Err(e) => { set_last_error(e); ptr::null_mut() } + } +} + +#[no_mangle] +pub extern "C" fn dv_acos(x: c_double) -> *mut dv_var { + match dv_rs::acos(x) { + Ok(v) => Box::into_raw(Box::new(dv_var { inner: v })), + Err(e) => { set_last_error(e); ptr::null_mut() } + } +} + +#[no_mangle] +pub extern "C" fn dv_atan(x: c_double) -> *mut dv_var { + match dv_rs::atan(x) { + Ok(v) => Box::into_raw(Box::new(dv_var { inner: v })), + Err(e) => { set_last_error(e); ptr::null_mut() } + } +} diff --git a/cpp/include/dv.hpp b/cpp/include/dv.hpp index c177639..5b637b2 100644 --- a/cpp/include/dv.hpp +++ b/cpp/include/dv.hpp @@ -51,6 +51,11 @@ class DV { DV powf(double e) const { return from_new(dv_var_powf(cptr(), e)); } DV sqrt() const { return from_new(dv_var_sqrt(cptr())); } + // Inverse trigonometric functions (return angle in radians) + DV asin() const { return from_new(dv_var_asin(cptr())); } + DV acos() const { return from_new(dv_var_acos(cptr())); } + DV atan() const { return from_new(dv_var_atan(cptr())); } + private: static DV from_new(dv_var* p) { if (!p) throw std::runtime_error(last_error()); @@ -58,8 +63,17 @@ class DV { } dv_var* ptr_; + + friend DV asin(double x); + friend DV acos(double x); + friend DV atan(double x); }; inline size_t base_units_size() { return dv_base_units_size(); } +// Free-standing inverse trigonometric functions (take raw double, return angle in radians) +inline DV asin(double x) { return DV::from_new(dv_asin(x)); } +inline DV acos(double x) { return DV::from_new(dv_acos(x)); } +inline DV atan(double x) { return DV::from_new(dv_atan(x)); } + } // namespace dv diff --git a/cpp/include/dv_c.h b/cpp/include/dv_c.h index 41a078d..37a93ba 100644 --- a/cpp/include/dv_c.h +++ b/cpp/include/dv_c.h @@ -39,6 +39,16 @@ dv_var* dv_var_powi(const dv_var* a, int exp); dv_var* dv_var_powf(const dv_var* a, double exp); dv_var* dv_var_sqrt(const dv_var* a); +// Inverse trigonometric functions (return angle in radians) +dv_var* dv_var_asin(const dv_var* a); +dv_var* dv_var_acos(const dv_var* a); +dv_var* dv_var_atan(const dv_var* a); + +// Free-standing inverse trigonometric functions (take raw double, return angle in radians) +dv_var* dv_asin(double x); +dv_var* dv_acos(double x); +dv_var* dv_atan(double x); + #ifdef __cplusplus } #endif diff --git a/docs/docs/faqs.md b/docs/docs/faqs.md index 7f254eb..bb12f3b 100644 --- a/docs/docs/faqs.md +++ b/docs/docs/faqs.md @@ -10,7 +10,7 @@ See the [intro page](./intro.md)! ## Aren't Angles unitless? -Yes, technically! However, angles are often used in engineering, and keeping track of angles separately to other units can be very useful. Therefore, we've decided to add it as an official 8th unit type. If you'd like to disucss this decision more, please feel free to open a ticket or pull request if you have a better idea! +Yes, technically! However, we found keeping track of angle as a unit to be very useful. [Read more about it here.](./intro.md#angles-as-units) ## How do I contribute to DV? diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 7464d8d..5279fbb 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -35,12 +35,22 @@ The vector follows this standard: `[m, kg, s, K, A, mol, cd, rad]`, where each u Example: `9.81 km/s^2` stores as: `{9810.0, [1.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0]}`. -You may ask - aren't angles unitless? You'd be technically correct, but enough users use angles for projects that it made sense to have it as its own unit. See the FAQ for more information. - The full list of available core units can be found under `core/src/units.rs`. There will be a future feature for users to be able to add their own additional units without needing to re-compile or rebuild the library. +### Angles as units?!?! + +You may ask - aren't angles unitless? You'd be technically correct, but we chose to do something a bit special to add some useful functionality. + +Angles are often used in engineering, and keeping track of angles separately can be very useful! When you get into more complex math, especially with angle math operators like `sin/cos/tan` and their inverses `arcsin/arccos/arctan`, relying on having the proper units becomes very necessary. + +Therefore, this library treats angle units in a slightly special way. As expected trigonometry functions (`sin/cos/tan`) requre a dimension that is only an angle, and return a unitless dimension. The inverse trigonometyr functions (`asin/acos/atan`) do the exact opposite - they require a unitless dimension and return an angle dimension. + +Additionally, the DimensionalVariable system allow for conversion from angular dimensions (angle exponent == 1) to also be equivalent to non-angular dimenions (angle exponent != 1), but not the other way around. This allows for equations (torque * angular speed = power), but protects against accidental math (frequency should not equal angular speed). + +If you come up with an edge case that breaks this logic, please open a GitHub issue to start the discussion, or propose a new fix! + ### Parsing Unit Strings DV intelligently can determine what units to parse down to based on some simple string parsing that happens at the creation of the object and when data is extracted out (importantly not during math operations). To effectively parse the unit strings, users must follow these rules: @@ -65,7 +75,8 @@ The DV library overrides common math operators to add in additional checks and f | addition / subtraction | unit exponent vectors must match | | multiplication / division | no checks, unit exponent vectors are added | | power / sqrt | powi multiplies exponents by integer power; powf and sqrt supported with fractional exponents; value must be valid for sqrt; logs/trig remain unitless-only | -| sin / cos / tan | DV must be angle (radians) or unit-less | +| sin / cos / tan | DV must be angle (radians), returns a unitless value | +| asin / acos / atan | DV must be unitless, returns an angled value | | neg / abs | no checks | ## Why make another unit management system? diff --git a/docs/docs/python.md b/docs/docs/python.md index 05cbb56..76791d1 100644 --- a/docs/docs/python.md +++ b/docs/docs/python.md @@ -166,7 +166,7 @@ def main(): print(f" 180 degrees = {angle_deg.value_in('deg')} deg") print(f" 180 degrees = {angle_deg.value_in('rad'):.4f} rad") - # Trigonometric functions + # Trigonometric functions require angle unit angle = DV(math.pi / 4, "rad") print(f" sin(π/4) = {angle.sin().value():.4f}") print(f" cos(π/4) = {angle.cos().value():.4f}\n") diff --git a/docs/docs/rust.md b/docs/docs/rust.md index 158e9ff..1058fde 100644 --- a/docs/docs/rust.md +++ b/docs/docs/rust.md @@ -87,7 +87,7 @@ let angle_rad = dv::new(std::f64::consts::PI, "rad").unwrap(); let angle_deg = dv::new(180.0, "deg").unwrap(); assert_eq!(angle_rad.value_in("rad").unwrap(), angle_deg.value_in("rad").unwrap()); -// Trigonometric functions require radians +// Trigonometric functions require angle unit (radians or degrees) use std::f64::consts::PI; let angle = dv::new(PI / 4.0, "rad").unwrap(); assert!((angle.sin().unwrap() - (PI / 4.0).sin()).abs() < 1e-12); diff --git a/examples/cpp/main.cpp b/examples/cpp/main.cpp index 79f0dac..8e501ef 100644 --- a/examples/cpp/main.cpp +++ b/examples/cpp/main.cpp @@ -14,5 +14,13 @@ int main() { std::cout << "45 degrees = " << angle_deg.value_in("rad") << " radians\n"; std::cout << "π/4 radians = " << angle_rad.value_in("deg") << " degrees\n"; + // Free-standing inverse trig functions + dv::DV angle_from_asin = dv::asin(0.5); + dv::DV angle_from_acos = dv::acos(0.5); + dv::DV angle_from_atan = dv::atan(1.0); + std::cout << "asin(0.5) = " << angle_from_asin.value_in("rad") << " rad\n"; + std::cout << "acos(0.5) = " << angle_from_acos.value_in("rad") << " rad\n"; + std::cout << "atan(1.0) = " << angle_from_atan.value_in("deg") << " deg (should be 45)\n"; + return 0; } diff --git a/examples/python/main.py b/examples/python/main.py index ce990fb..fe0bf86 100644 --- a/examples/python/main.py +++ b/examples/python/main.py @@ -8,7 +8,7 @@ - Working with angles (radians and degrees) """ -from dv_py import DimensionalVariable, DVError +from dv_py import DimensionalVariable, DVError, asin, acos, atan def main(): @@ -114,7 +114,12 @@ def main(): # Trigonometric functions with radians angle = DimensionalVariable(math.pi / 4, "rad") print(f" sin(π/4) = {angle.sin().value():.4f}") - print(f" cos(π/4) = {angle.cos().value():.4f}\n") + print(f" cos(π/4) = {angle.cos().value():.4f}") + + # Free-standing inverse trig functions + print(f" asin(0.5) = {asin(0.5).value_in('rad'):.4f} rad") + print(f" acos(0.5) = {acos(0.5).value_in('deg'):.2f} deg") + print(f" atan(1.0) = {atan(1.0).value_in('deg'):.2f} deg (should be 45)\n") print("=== Example Complete ===") diff --git a/python/Cargo.toml b/python/Cargo.toml index 5d646f5..e436848 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dv_py" -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = [ "Alex Tacescu ",] homepage = "https://dv.alextac.com" diff --git a/python/dv_py/__init__.py b/python/dv_py/__init__.py index 46edbdf..b17e7d0 100644 --- a/python/dv_py/__init__.py +++ b/python/dv_py/__init__.py @@ -23,5 +23,8 @@ DimensionalVariable = dv.DimensionalVariable DVError = dv.DVError +asin = dv.asin +acos = dv.acos +atan = dv.atan -__all__ = ["DimensionalVariable", "DVError"] \ No newline at end of file +__all__ = ["DimensionalVariable", "DVError", "asin", "acos", "atan"] \ No newline at end of file diff --git a/python/pyproject.toml b/python/pyproject.toml index fc40ac9..f84dac9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "dv_py" -version = "0.2.0" +version = "0.3.0" description = "Python bindings for dv (DimensionalVariable) - keeping track of units and dimensions for physical quantities" readme = "pip-readme.md" requires-python = ">=3.8" diff --git a/python/src/lib.rs b/python/src/lib.rs index ad9a777..366f66e 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -291,13 +291,13 @@ impl PyDV { } } - /// Sine (requires angle or unitless value). + /// Sine (requires angle). /// /// Returns: /// DV: The sine (unitless) /// /// Raises: - /// DVError: If the value is not an angle or unitless + /// DVError: If the value is not an angle fn sin(&self) -> PyResult { match self.inner.sin() { Ok(result) => Ok(PyDV { inner: DimensionalVariable { value: result, unit: [0.0; 8] } }), @@ -305,13 +305,13 @@ impl PyDV { } } - /// Cosine (requires angle or unitless value). + /// Cosine (requires angle). /// /// Returns: /// DV: The cosine (unitless) /// /// Raises: - /// DVError: If the value is not an angle or unitless + /// DVError: If the value is not an angle fn cos(&self) -> PyResult { match self.inner.cos() { Ok(result) => Ok(PyDV { inner: DimensionalVariable { value: result, unit: [0.0; 8] } }), @@ -319,19 +319,88 @@ impl PyDV { } } - /// Tangent (requires angle or unitless value). + /// Tangent (requires angle). /// /// Returns: /// DV: The tangent (unitless) /// /// Raises: - /// DVError: If the value is not an angle or unitless + /// DVError: If the value is not an angle fn tan(&self) -> PyResult { match self.inner.tan() { Ok(result) => Ok(PyDV { inner: DimensionalVariable { value: result, unit: [0.0; 8] } }), Err(e) => Err(DVError::new_err(e)), } } + + /// Arcsine (requires unitless value in [-1, 1]). + /// + /// Returns: + /// DV: The arcsine as an angle in radians + /// + /// Raises: + /// DVError: If the value is not unitless or outside [-1, 1] + fn asin(&self) -> PyResult { + match self.inner.asin() { + Ok(result) => Ok(PyDV { inner: result }), + Err(e) => Err(DVError::new_err(e)), + } + } + + /// Arccosine (requires unitless value in [-1, 1]). + /// + /// Returns: + /// DV: The arccosine as an angle in radians + /// + /// Raises: + /// DVError: If the value is not unitless or outside [-1, 1] + fn acos(&self) -> PyResult { + match self.inner.acos() { + Ok(result) => Ok(PyDV { inner: result }), + Err(e) => Err(DVError::new_err(e)), + } + } + + /// Arctangent (requires unitless value). + /// + /// Returns: + /// DV: The arctangent as an angle in radians + /// + /// Raises: + /// DVError: If the value is not unitless + fn atan(&self) -> PyResult { + match self.inner.atan() { + Ok(result) => Ok(PyDV { inner: result }), + Err(e) => Err(DVError::new_err(e)), + } + } +} + +/// Arcsine function for f64 input, returns DimensionalVariable in radians. +#[pyfunction] +fn asin(x: f64) -> PyResult { + match dv_rs::asin(x) { + Ok(result) => Ok(PyDV { inner: result }), + Err(e) => Err(DVError::new_err(e)), + } +} + +/// Arccosine function for f64 input, returns DimensionalVariable in radians. +#[pyfunction] +fn acos(x: f64) -> PyResult { + match dv_rs::acos(x) { + Ok(result) => Ok(PyDV { inner: result }), + Err(e) => Err(DVError::new_err(e)), + } +} + +/// Arctangent function for f64 input, returns DimensionalVariable in radians. +#[pyfunction] +fn atan(x: f64) -> PyResult { + match dv_rs::atan(x) { + Ok(result) => Ok(PyDV { inner: result }), + Err(e) => Err(DVError::new_err(e)), + } } /// Python module for dv (DimensionalVariable). @@ -339,5 +408,8 @@ impl PyDV { fn dv_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add("DVError", m.py().get_type::())?; + m.add_function(wrap_pyfunction!(asin, m)?)?; + m.add_function(wrap_pyfunction!(acos, m)?)?; + m.add_function(wrap_pyfunction!(atan, m)?)?; Ok(()) } diff --git a/python/tests/test_dv.py b/python/tests/test_dv.py index 67821dd..bc34025 100644 --- a/python/tests/test_dv.py +++ b/python/tests/test_dv.py @@ -1,7 +1,7 @@ """Unit tests for the Python DimensionalVariable bindings.""" import pytest -from dv_py import DimensionalVariable, DVError +from dv_py import DimensionalVariable, DVError, asin, acos, atan class TestConstruction: @@ -261,27 +261,165 @@ def test_log10_unitless(self): result = v.log10() assert result.value() == pytest.approx(2.0) - def test_sin_unitless(self): - """Test sine of a unitless value.""" + def test_sin_angle(self): + """Test sine of an angle value.""" import math - v = DimensionalVariable(math.pi / 2, "") + v = DimensionalVariable(math.pi / 2, "rad") result = v.sin() assert result.value() == pytest.approx(1.0) - def test_cos_unitless(self): - """Test cosine of a unitless value.""" + def test_cos_angle(self): + """Test cosine of an angle value.""" import math - v = DimensionalVariable(0.0, "") + v = DimensionalVariable(0.0, "rad") result = v.cos() assert result.value() == pytest.approx(1.0) - def test_tan_unitless(self): - """Test tangent of a unitless value.""" + def test_tan_angle(self): + """Test tangent of an angle value.""" import math - v = DimensionalVariable(math.pi / 4, "") + v = DimensionalVariable(math.pi / 4, "rad") result = v.tan() assert result.value() == pytest.approx(1.0) + def test_asin_unitless(self): + """Test arcsine of a unitless value.""" + import math + v = DimensionalVariable(0.5, "") + result = v.asin() + assert result.value() == pytest.approx(math.asin(0.5)) + # Result should be in radians + assert result.base_units() == (0, 0, 0, 0, 0, 0, 0, 1) + + def test_acos_unitless(self): + """Test arccosine of a unitless value.""" + import math + v = DimensionalVariable(0.5, "") + result = v.acos() + assert result.value() == pytest.approx(math.acos(0.5)) + # Result should be in radians + assert result.base_units() == (0, 0, 0, 0, 0, 0, 0, 1) + + def test_atan_unitless(self): + """Test arctangent of a unitless value.""" + import math + v = DimensionalVariable(1.0, "") + result = v.atan() + assert result.value() == pytest.approx(math.pi / 4) + # Result should be in radians + assert result.base_units() == (0, 0, 0, 0, 0, 0, 0, 1) + + def test_asin_with_units_fails(self): + """Test that asin of a value with units raises DVError.""" + v = DimensionalVariable(0.5, "m") + with pytest.raises(DVError): + v.asin() + + def test_acos_with_units_fails(self): + """Test that acos of a value with units raises DVError.""" + v = DimensionalVariable(0.5, "m") + with pytest.raises(DVError): + v.acos() + + def test_atan_with_units_fails(self): + """Test that atan of a value with units raises DVError.""" + v = DimensionalVariable(0.5, "m") + with pytest.raises(DVError): + v.atan() + + def test_asin_out_of_range_fails(self): + """Test that asin of a value outside [-1, 1] raises DVError.""" + v = DimensionalVariable(2.0, "") + with pytest.raises(DVError): + v.asin() + + def test_acos_out_of_range_fails(self): + """Test that acos of a value outside [-1, 1] raises DVError.""" + v = DimensionalVariable(2.0, "") + with pytest.raises(DVError): + v.acos() + + def test_sin_with_unitless_fails(self): + """Test that sin of a unitless value raises DVError.""" + import math + v = DimensionalVariable(math.pi / 2, "") + with pytest.raises(DVError): + v.sin() + + def test_cos_with_unitless_fails(self): + """Test that cos of a unitless value raises DVError.""" + v = DimensionalVariable(0.0, "") + with pytest.raises(DVError): + v.cos() + + def test_tan_with_unitless_fails(self): + """Test that tan of a unitless value raises DVError.""" + import math + v = DimensionalVariable(math.pi / 4, "") + with pytest.raises(DVError): + v.tan() + + def test_inverse_trig_round_trip(self): + """Test that sin(asin(x)) == x.""" + import math + v = DimensionalVariable(0.7, "") + angle = v.asin() + # sin(asin(0.7)) should equal 0.7 + assert angle.sin().value() == pytest.approx(0.7) + + +class TestFreeStandingTrigFunctions: + """Tests for free-standing inverse trigonometric functions.""" + + def test_asin_function(self): + """Test free-standing asin function.""" + import math + result = asin(0.5) + assert result.value() == pytest.approx(math.asin(0.5)) + # Result should be in radians + assert result.base_units() == (0, 0, 0, 0, 0, 0, 0, 1) + + def test_acos_function(self): + """Test free-standing acos function.""" + import math + result = acos(0.5) + assert result.value() == pytest.approx(math.acos(0.5)) + # Result should be in radians + assert result.base_units() == (0, 0, 0, 0, 0, 0, 0, 1) + + def test_atan_function(self): + """Test free-standing atan function.""" + import math + result = atan(1.0) + assert result.value() == pytest.approx(math.pi / 4) + # Result should be in radians + assert result.base_units() == (0, 0, 0, 0, 0, 0, 0, 1) + + def test_asin_out_of_range_fails(self): + """Test that free-standing asin with value outside [-1, 1] raises DVError.""" + with pytest.raises(DVError): + asin(2.0) + + def test_acos_out_of_range_fails(self): + """Test that free-standing acos with value outside [-1, 1] raises DVError.""" + with pytest.raises(DVError): + acos(-1.5) + + def test_free_standing_round_trip(self): + """Test that sin(asin(x)) == x using free-standing function.""" + import math + angle = asin(0.7) + # sin(asin(0.7)) should equal 0.7 + assert angle.sin().value() == pytest.approx(0.7) + + def test_atan_special_values(self): + """Test atan with special values.""" + import math + # atan(0) should be 0 + assert atan(0.0).value() == pytest.approx(0.0) + # atan(inf) should approach π/2 + assert atan(1e10).value() == pytest.approx(math.pi / 2, rel=1e-6) + class TestComplexExamples: """Tests for complex real-world examples."""