From 819dda9da7ded1660b5515f5bf691cb3b0f6d9cd Mon Sep 17 00:00:00 2001 From: Rolf Stierle Date: Fri, 6 Mar 2026 14:26:15 +0100 Subject: [PATCH 1/3] Generalized indexing, correct formatting for arrays, new constants. --- CHANGELOG.md | 3 ++ si-units/CHANGELOG.md | 5 +++ si-units/docs/derived.md | 42 +++++++++++--------- si-units/src/fmt.rs | 9 +++++ si-units/src/lib.rs | 65 +++++++++++++++++++++++++------ si-units/src/si_units/__init__.py | 9 +++++ src/fmt.rs | 6 +++ src/lib.rs | 4 ++ 8 files changed, 112 insertions(+), 31 deletions(-) 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..71d7980 100644 --- a/si-units/src/lib.rs +++ b/si-units/src/lib.rs @@ -1,5 +1,7 @@ #![warn(clippy::all)] #![allow(non_snake_case)] +use std::f64::consts::PI; + use ndarray::{Array1, arr1}; use numpy::IntoPyArray; use pyo3::basic::CompareOp; @@ -78,11 +80,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 +269,43 @@ 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()) } } } - #[derive(Clone, Copy)] pub struct SIObject { value: T, @@ -396,6 +425,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 +503,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 +528,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 +539,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); From 50c705dec391f838f1a2c71e92e05bc91f4c5acd Mon Sep 17 00:00:00 2001 From: Rolf Stierle Date: Fri, 6 Mar 2026 17:30:13 +0100 Subject: [PATCH 2/3] ufuncs implemented. --- si-units/src/lib.rs | 178 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 177 insertions(+), 1 deletion(-) diff --git a/si-units/src/lib.rs b/si-units/src/lib.rs index 71d7980..dd70cb8 100644 --- a/si-units/src/lib.rs +++ b/si-units/src/lib.rs @@ -7,7 +7,7 @@ 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; @@ -65,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] @@ -305,6 +325,162 @@ impl PySIObject { .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" => { + 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 { From ad20538280bcbfa8cea65fb248bdc58e3e1122ed Mon Sep 17 00:00:00 2001 From: Rolf Stierle Date: Thu, 26 Mar 2026 08:55:59 +0100 Subject: [PATCH 3/3] Minimum, maximum added. --- si-units/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/si-units/src/lib.rs b/si-units/src/lib.rs index dd70cb8..d473eab 100644 --- a/si-units/src/lib.rs +++ b/si-units/src/lib.rs @@ -349,7 +349,7 @@ impl PySIObject { } } let result_unit = match name.as_str() { - "add" | "subtract" => { + "add" | "subtract" | "maximum" | "minimum" => { let u1 = units[0].unwrap_or(SIUnit::DIMENSIONLESS); let u2 = units .get(1)