From 80cf56b82c2d9bf86ad5b1c7fdfdd2765a1e0eb1 Mon Sep 17 00:00:00 2001 From: Christian Hill Date: Mon, 22 Apr 2024 14:03:37 +0200 Subject: [PATCH] Add rational exponent functionality to Units --- setup.py | 4 +++- src/pyqn/atom_unit.py | 9 +++++++-- src/pyqn/units.py | 28 +++++++++++++++++++++++----- tests/test_conversions.py | 9 +++++++++ tests/test_units.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/setup.py b/setup.py index f3a66c1..9d37014 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="pyqn", - version="1.3.2", + version="1.4", description="A package for managing physical units and quantities", long_description=long_description, long_description_content_type="text/x-rst", @@ -27,6 +27,8 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Operating System :: OS Independent", ], diff --git a/src/pyqn/atom_unit.py b/src/pyqn/atom_unit.py index f148149..922136b 100644 --- a/src/pyqn/atom_unit.py +++ b/src/pyqn/atom_unit.py @@ -23,6 +23,7 @@ # along with PyQn. If not, see import sys +from fractions import Fraction from pyparsing import Word, Group, Literal, Suppress, ParseException, oneOf, Optional from .si import si_prefixes from .base_unit import BaseUnit, base_unit_stems @@ -33,13 +34,14 @@ lowers = caps.lower() letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_0" digits = "123456789" -exponent = Word(digits + "-") +exponent = Word(digits + "-/") prefix = oneOf(list(si_prefixes.keys())) ustem = Word(letters + "Å" + "Ω") uatom = (Group("1" | (Optional(prefix) + ustem)) + Optional(exponent)) | ( Group("1" | ustem) + Optional(exponent) ) + # floating point equality and its negation, to some suitable tolerance def feq(f1, f2, tol=1.0e-10): return abs(f1 - f2) <= tol @@ -125,7 +127,10 @@ def parse(self, s_unit_atom): # if there is an exponent, determine what it is (default is 1) exponent = 1 if len(uatom_data) == 2: - exponent = int(uatom_data[1]) + if "/" in uatom_data: + exponent = int(uatom_data[1]) + else: + exponent = Fraction(uatom_data[1]) return AtomUnit(prefix, base_unit, exponent) def __pow__(self, power): diff --git a/src/pyqn/units.py b/src/pyqn/units.py index bb094c6..ac6e6c7 100644 --- a/src/pyqn/units.py +++ b/src/pyqn/units.py @@ -20,6 +20,7 @@ # You should have received a copy of the GNU General Public License # along with PyQn. If not, see +import re import copy from .dimensions import Dimensions from .dimensions import d_dimensionless, d_length, d_energy, d_time, d_temperature @@ -82,18 +83,35 @@ def get_dims(self): return dims @classmethod - def parse(self, s_compoundunit): + def parse(self, s_compoundunit, no_divided_units=False): """ Parse the string s_compoundunit and return the corresponding Units object. """ + if no_divided_units: + # s_compoundunit does not consist of any units separated by '/' + # so parse immediately as a sequence of multiplied units. + return Units.parse_mult_units(s_compoundunit) + + # We need to temporarily identify rational exponents (e.g. "Pa-1/2") + # and replace the / with : so that we can split up the atomic units + # properly. + patt = r"\d+/\d+" + rational_exponents = re.findall(patt, s_compoundunit) + for e in rational_exponents: + es = e.replace("/", ":") + s_compoundunit = s_compoundunit.replace(e, es) + div_fields = s_compoundunit.split("/") - ndiv_fields = len(div_fields) - compound_unit = Units.parse_mult_units(div_fields[0]) + # don't forget to put back the "/" character in any rational exponents. + compound_unit = Units.parse_mult_units(div_fields[0].replace(":", "/")) for div_field in div_fields[1:]: - compound_unit = compound_unit / Units.parse(div_field) + div_field = div_field.replace(":", "/") + compound_unit = compound_unit / Units.parse( + div_field, no_divided_units=True + ) return compound_unit @classmethod @@ -244,7 +262,7 @@ def html(self): for i, atom_unit in enumerate(self.atom_units): h.extend([atom_unit.prefix or "", atom_unit.base_unit.stem]) if atom_unit.exponent != 1: - h.append("{:d}".format(atom_unit.exponent)) + h.append(f"{atom_unit.exponent}") if i < n - 1: h.append(" ") return "".join(h) diff --git a/tests/test_conversions.py b/tests/test_conversions.py index 56b7179..c31dda1 100644 --- a/tests/test_conversions.py +++ b/tests/test_conversions.py @@ -52,6 +52,15 @@ def test_spec_conversions(self): self.assertAlmostEqual(u1.conversion(u2, force="spec"), 5.0341165675427096e22) self.assertAlmostEqual(u1.conversion(u3, force="spec"), 1.5091901796421518e33) + def test_rational_units_conversion(self): + u1 = Units("Hz-1/2") + u2 = Units("ns1/2") + self.assertAlmostEqual(u1.conversion(u2), 10**4.5) + + u3 = Units("m-3/2") + u4 = Units("inch-3/2") + self.assertAlmostEqual(u3.conversion(u4), 0.0254**1.5) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_units.py b/tests/test_units.py index b9bab05..cae12be 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -6,6 +6,7 @@ # Unit tests for the Units class. import unittest +from fractions import Fraction from pyqn.units import Units from pyqn.dimensions import Dimensions, d_energy @@ -107,6 +108,34 @@ def test_html(self): u2 = Units("μs.J/m3") self.assertEqual(u2.html, "μs J m-3") + def test_units_with_rational_powers(self): + u1 = Units("m-3.Pa-1/2") + u2 = Units("cm-3.bar-1/2") + self.assertEqual(str(u1), "m-3.Pa-1/2") + self.assertEqual(u1.dims, u2.dims) + self.assertEqual(u1.html, "m-3 Pa-1/2") + + self.assertEqual(u1.dims, Dimensions(M=-0.5, T=1, L=-2.5)) + + u3 = Units("kg1/2.m-3/2") + self.assertEqual(str(u3), "kg1/2.m-3/2") + self.assertEqual(u3.html, "kg1/2 m-3/2") + + def test_rational_units_algebra(self): + u1 = Units("m-3.Pa-1/2") + u2 = Units("m3") + u3 = u1 * u2 + self.assertEqual(str(u3), "Pa-1/2") + + u4 = Units("Pa-2/3.s4") + u5 = u1 / u4 + u6 = Units("m-3.s4.Pa1/6") + self.assertEqual(str(u6), "m-3.s4.Pa1/6") + self.assertEqual( + u6.dims, + Dimensions(M=Fraction("1/6"), T=Fraction("11/3"), L=Fraction("-19/6")), + ) + if __name__ == "__main__": unittest.main()