diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c36cb..b3faad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Electric and Coulomb constants added. [#47](https://github.com/itt-ustutt/quantity/issues/47) +- ATM and POISE added as derived units. [#46](https://github.com/itt-ustutt/quantity/issues/46) ## [0.13.0] - 2026-01-06 ### Changed diff --git a/si-units/CHANGELOG.md b/si-units/CHANGELOG.md index 1b14330..a400b2e 100644 --- a/si-units/CHANGELOG.md +++ b/si-units/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed +- Correctly formats output of arrays with a unit. + +### Added +- Getter and Setter generalized to common NumPy indexing, such that general slicing operations are possible in multiple dimensions. ## [0.11.1] - 2026-01-22 ### Fixed diff --git a/si-units/docs/derived.md b/si-units/docs/derived.md index 94cdf0a..a76f13c 100644 --- a/si-units/docs/derived.md +++ b/si-units/docs/derived.md @@ -23,28 +23,32 @@ For convenience, a number of commonly used units that are not directly combinati These constants simplify the specification of properties, that are not given in SI units. However, as the representation of quantities is unique, they do not appear in formatted outputs. -| Unit | Unit symbol | Quantity | Definition | -| -------- | ------------ | ------------- | --------------------------------------------- | -| ANGSTROM | $\text{Å}$ | length | $10^{-10}~\text{m}$ | -| AMU | $\text{u}$ | mass | $1.6605390671738466\times 10^{-27}~\text{kg}$ | -| AU | $\text{au}$ | length | $149597870700~\text{m}$ | -| BAR | $\text{bar}$ | pressure | $10^5~\text{Pa}$ | -| CALORIE | $\text{cal}$ | energy | $4.184~\text{J}$ | -| DAY | $\text{d}$ | time | $86400~\text{s}$ | -| DEBYE | $\text{De}$ | dipole moment | $\sqrt{10^{-19}~\text{JÅ}^3}$ | -| DEGREES | $^\circ$ | angle | $\frac{\pi}{180}~\text{rad}$ | -| GRAM | $\text{g}$ | mass | $10^{-3}~\text{kg}$ | -| HOUR | $\text{h}$ | time | $3600~\text{s}$ | -| LITER | $\text{l}$ | volume | $10^{-3}~\text{m}^3$ | -| MINUTE | $\text{min}$ | time | $60~\text{s}$ | -| RADIANS | $\text{rad}$ | angle | | +| Unit | Unit symbol | Quantity | Definition | +| -------- | --------------- | ------------- | --------------------------------------------- | +| ANGSTROM | $\text{Å}$ | length | $10^{-10}~\text{m}$ | +| AMU | $\text{u}$ | mass | $1.6605390671738466\times 10^{-27}~\text{kg}$ | +| ATM | $\text{bar}$ | presssure | $101325~\text{N/m^2}$ | +| AU | $\text{au}$ | length | $149597870700~\text{m}$ | +| BAR | $\text{bar}$ | pressure | $10^5~\text{Pa}$ | +| CALORIE | $\text{cal}$ | energy | $4.184~\text{J}$ | +| DAY | $\text{d}$ | time | $86400~\text{s}$ | +| DEBYE | $\text{De}$ | dipole moment | $\sqrt{10^{-19}~\text{JÅ}^3}$ | +| DEGREES | $^\circ$ | angle | $\frac{\pi}{180}~\text{rad}$ | +| GRAM | $\text{g}$ | mass | $10^{-3}~\text{kg}$ | +| HOUR | $\text{h}$ | time | $3600~\text{s}$ | +| LITER | $\text{l}$ | volume | $10^{-3}~\text{m}^3$ | +| MINUTE | $\text{min}$ | time | $60~\text{s}$ | +| POISE | $\text{m Pa s}$ | viscosity | $0.1~\text{Pa s}$ | +| RADIANS | $\text{rad}$ | angle | | ## Additional Constants -| Constant | Name | Symbol | Value | -| -------- | ---------------------- | ------------------------- | ------------------------------------------------------------------- | -| G | Gravitational constant | $G$ | $6.6743\times 10^{-11}~\frac{\text{m}^3}{\text{kg}\cdot\text{s}^2}$ | -| RGAS | Ideal gas constant | $R=N_\text{Av}k_\text{B}$ | $8.31446261815324~\frac{\text{J}}{\text{mol}\cdot\text{K}}$ | +| Constant | Name | Symbol | Value | +| -------- | ---------------------- | ----------------------------------------- | ---------------------------------------------------------------------- | +| G | Gravitational constant | $G$ | $6.6743\times 10^{-11}~\frac{\text{m}^3}{\text{kg}\cdot\text{s}^2}$ | +| EPSILON0 | Electric constant | $\epsilon_0$ | $8.8541878188e-12~\frac{\text{A}\cdot\text{s}}{\text{V}\cdot\text{m}}$ | +| KE | Coulomb constant | $k_\text{e} = \frac{1}{4 \pi \epsilon_0}$ | $8987551786.170797~\frac{\text{N}\cdot\text{m}^2}{\text{C}^2}$ | +| RGAS | Ideal gas constant | $R=N_\text{Av}k_\text{B}$ | $8.31446261815324~\frac{\text{J}}{\text{mol}\cdot\text{K}}$ | ### Example diff --git a/si-units/src/fmt.rs b/si-units/src/fmt.rs index bce1152..6a80bf6 100644 --- a/si-units/src/fmt.rs +++ b/si-units/src/fmt.rs @@ -73,6 +73,15 @@ impl SINumber { format!("{}\\,{}", float_to_latex(self.value), &self.unit.to_latex()) } } + + pub fn into_scaled_parts(&self) -> (f64, String) { + if let Some((unit, symbol, _, _, _)) = DERIVED_UNITS.get(&self.unit) { + if !self.value.is_nan() { + return (self.value / unit.value, symbol.clone()); + } + } + (self.value, self.unit.to_string()) + } } fn float_to_latex(f: f64) -> String { diff --git a/si-units/src/lib.rs b/si-units/src/lib.rs index 9067b22..d473eab 100644 --- a/si-units/src/lib.rs +++ b/si-units/src/lib.rs @@ -1,11 +1,13 @@ #![warn(clippy::all)] #![allow(non_snake_case)] +use std::f64::consts::PI; + use ndarray::{Array1, arr1}; use numpy::IntoPyArray; use pyo3::basic::CompareOp; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; -use pyo3::types::{PyFloat, PyNotImplemented}; +use pyo3::types::{PyDict, PyFloat, PyList, PyNotImplemented, PyTuple}; use pyo3::{PyErr, PyTypeInfo}; use thiserror::Error; @@ -63,6 +65,26 @@ impl From for PyErr { } } +fn unwrap_nested<'py>(py: Python<'py>, obj: &Bound<'py, PyAny>) -> PyResult> { + if let Ok(si_obj) = obj.extract::>() { + Ok(si_obj.value.clone_ref(py).into_bound(py)) + } else if let Ok(tuple) = obj.extract::>() { + let mut new_elements: Vec> = Vec::new(); + for elem in tuple.iter() { + new_elements.push(unwrap_nested(py, &elem)?); + } + Ok(PyTuple::new(py, new_elements)?.into_any()) + } else if let Ok(list) = obj.extract::>() { + let mut new_elements: Vec> = Vec::new(); + for elem in list.iter() { + new_elements.push(unwrap_nested(py, &elem)?); + } + Ok(PyList::new(py, new_elements)?.into_any()) + } else { + Ok(obj.clone()) + } +} + #[pymethods] impl PySIObject { #[new] @@ -78,11 +100,20 @@ impl PySIObject { if let Ok(v) = self.value.extract::(py) { Ok(SINumber::new(v, self.unit).to_string()) } else { - let value = self - .value - .call_method0(py, "__repr__")? - .extract::(py)?; - Ok(format!("{} {}", value, self.unit)) + let (multiplier, symbol) = SINumber::new(1.0, self.unit).into_scaled_parts(); + + let value_str = if (multiplier - 1.0).abs() > 1e-15 { + let scaled_val = self.value.call_method1(py, "__mul__", (multiplier,))?; + scaled_val + .call_method0(py, "__repr__")? + .extract::(py)? + } else { + self.value + .call_method0(py, "__repr__")? + .extract::(py)? + }; + + Ok(format!("{} {}", value_str, symbol)) } } @@ -258,25 +289,199 @@ impl PySIObject { .and_then(|v| v.extract::(py)) } - fn __getitem__(&self, py: Python, idx: isize) -> PyResult { + fn __getitem__(&self, py: Python, idx: &Bound<'_, PyAny>) -> PyResult { let value = self.value.call_method1(py, "__getitem__", (idx,))?; Ok(Self::new(value, self.unit)) } - fn __setitem__(&self, py: Python, idx: isize, value: SINumber) -> PyResult<()> { - if self.unit == value.unit { + fn __setitem__( + &self, + py: Python, + idx: &Bound<'_, PyAny>, + value: &Bound<'_, PyAny>, + ) -> PyResult<()> { + if let Ok(v) = value.extract::() { + if self.unit == v.unit { + self.value.call_method1(py, "__setitem__", (idx, v.value))?; + return Ok(()); + } + return Err(QuantityError::InconsistentUnits { + unit1: self.unit, + unit2: v.unit, + } + .into()); + } + + let ob = value.extract::>()?; + if self.unit == ob.unit { self.value - .call_method1(py, "__setitem__", (idx, value.value))?; + .call_method1(py, "__setitem__", (idx, &ob.value))?; Ok(()) } else { Err(QuantityError::InconsistentUnits { unit1: self.unit, - unit2: value.unit, - })? + unit2: ob.unit, + } + .into()) } } -} + #[pyo3(signature = (ufunc, method, *inputs, **kwargs))] + fn __array_ufunc__<'py>( + &self, + py: Python<'py>, + ufunc: &Bound<'py, PyAny>, + method: &str, + inputs: &Bound<'py, PyTuple>, + kwargs: Option<&Bound<'py, PyDict>>, + ) -> PyResult> { + let name: String = ufunc.getattr("__name__")?.extract()?; + let mut raw_inputs = Vec::new(); + let mut units = Vec::new(); + + for inp in inputs.iter() { + if let Ok(si_obj) = inp.extract::>() { + raw_inputs.push(si_obj.value.clone_ref(py).into_bound(py)); + units.push(Some(si_obj.unit)); + } else { + raw_inputs.push(inp.clone()); + units.push(None); + } + } + let result_unit = match name.as_str() { + "add" | "subtract" | "maximum" | "minimum" => { + let u1 = units[0].unwrap_or(SIUnit::DIMENSIONLESS); + let u2 = units + .get(1) + .and_then(|u| *u) + .unwrap_or(SIUnit::DIMENSIONLESS); + if u1 != u2 { + return Err(QuantityError::InconsistentUnits { + unit1: u1, + unit2: u2, + } + .into()); + } + u1 + } + "multiply" => { + let u1 = units[0].unwrap_or(SIUnit::DIMENSIONLESS); + let u2 = units + .get(1) + .and_then(|u| *u) + .unwrap_or(SIUnit::DIMENSIONLESS); + u1 * u2 + } + "divide" | "true_divide" => { + let u1 = units[0].unwrap_or(SIUnit::DIMENSIONLESS); + let u2 = units + .get(1) + .and_then(|u| *u) + .unwrap_or(SIUnit::DIMENSIONLESS); + u1 / u2 + } + "sqrt" => units[0].unwrap_or(SIUnit::DIMENSIONLESS).sqrt()?, + "cbrt" => units[0].unwrap_or(SIUnit::DIMENSIONLESS).cbrt()?, + "sin" | "cos" | "tan" | "exp" | "log" | "log10" => { + let u1 = units[0].unwrap_or(SIUnit::DIMENSIONLESS); + if !u1.is_dimensionless() { + return Err(QuantityError::InconsistentUnits { + unit1: u1, + unit2: SIUnit::DIMENSIONLESS, + } + .into()); + } + SIUnit::DIMENSIONLESS + } + _ => return Ok(py.NotImplemented().into_bound(py)), + }; + + let raw_inputs_tuple = PyTuple::new(py, raw_inputs)?; + let method_obj = ufunc.getattr(method)?; + let raw_result = method_obj.call(raw_inputs_tuple, kwargs)?; + + let result_obj = PySIObject::new(raw_result.unbind(), result_unit); + Ok(Bound::new(py, result_obj)?.into_any()) + } + + #[pyo3(signature = (func, types, args, kwargs))] + fn __array_function__<'py>( + &self, + py: Python<'py>, + func: &Bound<'py, PyAny>, + types: &Bound<'py, PyTuple>, + args: &Bound<'py, PyTuple>, + kwargs: Option<&Bound<'py, PyDict>>, + ) -> PyResult> { + let _ = types; + let name: String = func.getattr("__name__")?.extract()?; + let mut wrap_in_unit = true; + let result_unit = match name.as_str() { + "sum" | "mean" | "max" | "min" | "std" | "median" | "reshape" | "transpose" + | "squeeze" | "flatten" | "ravel" | "sort" | "zeros_like" | "ones_like" | "copy" + | "concatenate" | "vstack" | "hstack" | "dstack" | "split" | "array_split" => self.unit, + "var" => self.unit * self.unit, + "argsort" | "argmax" | "argmin" | "nonzero" => { + wrap_in_unit = false; + SIUnit::DIMENSIONLESS + } + "where" => { + if args.len() == 3 { + wrap_in_unit = true; + if let Ok(arg_x) = args.get_item(1) { + if let Ok(si) = arg_x.extract::>() { + si.unit + } else { + SIUnit::DIMENSIONLESS + } + } else { + SIUnit::DIMENSIONLESS + } + } else { + wrap_in_unit = false; + SIUnit::DIMENSIONLESS + } + } + "dot" | "matmul" | "cross" => { + let u1 = if let Ok(arg) = args.get_item(0) { + if let Ok(si) = arg.extract::>() { + si.unit + } else { + SIUnit::DIMENSIONLESS + } + } else { + SIUnit::DIMENSIONLESS + }; + let u2 = if let Ok(arg) = args.get_item(1) { + if let Ok(si) = arg.extract::>() { + si.unit + } else { + SIUnit::DIMENSIONLESS + } + } else { + SIUnit::DIMENSIONLESS + }; + u1 * u2 + } + _ => return Ok(py.NotImplemented().into_bound(py).into_any()), + }; + + let mut raw_args: Vec> = Vec::new(); + for arg in args.iter() { + raw_args.push(unwrap_nested(py, &arg)?); + } + + let raw_args_tuple = PyTuple::new(py, raw_args)?; + let raw_result = func.call(raw_args_tuple, kwargs)?; + + if wrap_in_unit { + let result_obj = PySIObject::new(raw_result.unbind(), result_unit); + Ok(Bound::new(py, result_obj)?.into_any()) + } else { + Ok(raw_result) + } + } +} #[derive(Clone, Copy)] pub struct SIObject { value: T, @@ -396,6 +601,9 @@ const _TESLA: SIUnit = SIUnit([0, 1, -2, -1, 0, 0, 0]); const _HENRY: SIUnit = SIUnit([2, 1, -2, -2, 0, 0, 0]); const _METER_PER_SECOND: SIUnit = SIUnit([1, 0, -1, 0, 0, 0, 0]); const _LUMEN_PER_WATT: SIUnit = SIUnit([-2, -1, 3, 0, 0, 0, 1]); +const _FARAD_PER_METER: SIUnit = SIUnit([-3, -1, 4, 2, 0, 0, 0]); +const _METER_PER_FARAD: SIUnit = SIUnit([3, 1, -4, -2, 0, 0, 0]); +const _PASCAL_SECOND: SIUnit = SIUnit([-1, 1, -1, 0, 0, 0, 0]); /// Prefix quecto $\\left(\text{q}=10^{-30}\\right)$ pub const QUECTO: f64 = 1e-30; @@ -471,6 +679,13 @@ pub fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { add_constant(m, "KB", 1.380649e-23, _JOULE_PER_KELVIN)?; add_constant(m, "NAV", 6.02214076e23, _PER_MOL)?; add_constant(m, "KCD", 683.0, _LUMEN_PER_WATT)?; + add_constant(m, "EPSILON0", 8.8541878188e-12, _FARAD_PER_METER)?; + add_constant( + m, + "KE", + 1.0_f64 / (4.0_f64 * PI * 8.8541878188e-12), + _METER_PER_FARAD, + )?; add_constant(m, "HERTZ", 1.0, _HERTZ)?; add_constant(m, "NEWTON", 1.0, _NEWTON)?; @@ -489,6 +704,7 @@ pub fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { add_constant(m, "ANGSTROM", 1e-10, _METER)?; add_constant(m, "AMU", 1.6605390671738466e-27, _KILOGRAM)?; add_constant(m, "AU", 149597870700.0, _METER)?; + add_constant(m, "ATM", 101325.0, _PASCAL)?; add_constant(m, "BAR", 1e5, _PASCAL)?; add_constant(m, "CALORIE", 4.184, _JOULE)?; m.add("CELSIUS", Celsius)?; @@ -499,6 +715,7 @@ pub fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { add_constant(m, "HOUR", 3600.0, _SECOND)?; add_constant(m, "LITER", 1e-3, _CUBIC_METER)?; add_constant(m, "MINUTE", 60.0, _SECOND)?; + add_constant(m, "POISE", 0.1, _PASCAL_SECOND)?; m.add("RADIANS", Angle(1.0))?; add_constant(m, "G", 6.6743e-11, SIUnit([3, -1, -2, 0, 0, 0, 0]))?; diff --git a/si-units/src/si_units/__init__.py b/si-units/src/si_units/__init__.py index c95390d..d73dec0 100644 --- a/si-units/src/si_units/__init__.py +++ b/si-units/src/si_units/__init__.py @@ -17,6 +17,7 @@ >>> print(f"The pressure is {pressure / BAR:.2f} bar") The pressure is 1.24 bar """ + # from si_units._core import SIObject from si_units._core import ( SIObject, @@ -43,6 +44,7 @@ KB, NAV, KCD, + KE, HERTZ, NEWTON, PASCAL, @@ -58,13 +60,16 @@ HENRY, ANGSTROM, AMU, + ATM, AU, BAR, CALORIE, + EPSILON0, GRAM, HOUR, LITER, MINUTE, + POISE, G, RGAS, QUECTO, @@ -119,6 +124,7 @@ "KB", "NAV", "KCD", + "KE", "HERTZ", "NEWTON", "PASCAL", @@ -134,13 +140,16 @@ "HENRY", "ANGSTROM", "AMU", + "ATM", "AU", "BAR", "CALORIE", + "EPSILON0", "GRAM", "HOUR", "LITER", "MINUTE", + "POISE", "G", "RGAS", "QUECTO", diff --git a/src/fmt.rs b/src/fmt.rs index d40d646..13ab9e5 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -44,6 +44,8 @@ impl fmt::Display for Quantity { #[cfg(feature = "pyo3")] pub(crate) trait PrintUnit { const UNIT: &'static str; + + fn scale() -> f64; } macro_rules! impl_fmt { @@ -95,6 +97,10 @@ macro_rules! impl_fmt { #[cfg(feature = "python")] impl PrintUnit for Quantity> { const UNIT: &'static str = $symbol; + + fn scale() -> f64 { + $unit.0 + } } }; } diff --git a/src/lib.rs b/src/lib.rs index 78f03ee..eb54c29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -531,6 +531,10 @@ pub const HOUR: Time = Quantity(3600.0, PhantomData); pub const LITER: Volume = Quantity(1e-3, PhantomData); /// Additional unit minute $\\left(1\\,\text{min}=60,\text{s}\\right)$ pub const MINUTE: Time = Quantity(60.0, PhantomData); +/// Additional unit atmosphere $\left(1\,\text{atm}=101325\,\text{Pa}\right)$ +pub const ATM: Pressure = Quantity(101325.0, PhantomData); +/// Additional unit poise $\left(1\,\text{P}=0.1\,\text{Pa}\cdot\text{s}\right)$ +pub const POISE: Viscosity = Quantity(0.1, PhantomData); /// Boltzmann constant $\\left(k_\text{B}=1.380649\times 10^{-23}\\,\\frac{\text{J}}{\text{K}}\\right)$ pub const KB: Entropy = Quantity(1.380649e-23, PhantomData);