Skip to content

Commit

Permalink
Adding the Troy system of weights, with tests against Avoirdupois. (#16)
Browse files Browse the repository at this point in the history
This required a deeper refactoring of conversions to make a pass at "collapsing"
units of the same dimension, in kind of a pre-processing step before the
rest of conversion.

Fixes #7
  • Loading branch information
chrisguidry authored Jun 25, 2022
1 parent 25a3547 commit d10ffa0
Show file tree
Hide file tree
Showing 9 changed files with 160 additions and 12 deletions.
4 changes: 4 additions & 0 deletions docs/troy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Troy system

# Reference
::: measured.troy
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ nav:
- astronomical.md
- us.md
- avoirdupois.md
- troy.md
- formatting.md
- serialization.md
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dev =
cloudpickle
flake8
flake8-black
icecream
isort
ipython
mkdocs
Expand Down Expand Up @@ -79,6 +80,9 @@ strict = True
[mypy-cloudpickle]
ignore_missing_imports = True

[mypy-icecream]
ignore_missing_imports = True

[tool:pytest]
minversion = 7.1
addopts =
Expand Down
59 changes: 55 additions & 4 deletions src/measured/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ def recursive_gcd(*integers: SupportsIndex) -> int:
else: # pragma: no cover
from math import gcd


try:
from icecream import ic
except ImportError: # pragma: no cover
ic = lambda *a: None if not a else (a[0] if len(a) == 1 else a) # noqa


__version__ = version("measured")

NUMERIC_CLASSES = (int, float)
Expand Down Expand Up @@ -1394,6 +1401,9 @@ def convert(self, quantity: Quantity, other_unit: Unit) -> Quantity:
this = quantity.in_base_units()
other = (1 * other_unit).in_base_units()

this = self._collapse_by_dimension(this)
other = self._collapse_by_dimension(other)

this_numerator, this_denominator = this.unit.as_ratio()
other_numerator, other_denominator = other.unit.as_ratio()

Expand All @@ -1409,13 +1419,12 @@ def convert(self, quantity: Quantity, other_unit: Unit) -> Quantity:
f"No conversion from {this_denominator} to {other_denominator}"
)

numerator = this.magnitude / other.magnitude

numerator = this.magnitude
for scale, offset, _ in numerator_path:
numerator *= scale
numerator += offset

denominator = 1.0
denominator = other.magnitude
for scale, offset, _ in denominator_path:
denominator *= scale
denominator += offset
Expand All @@ -1430,7 +1439,9 @@ def _find(
start_terms = self._terms_by_dimension(start)
end_terms = self._terms_by_dimension(end)

assert start_terms.keys() == end_terms.keys()
assert (
start_terms.keys() == end_terms.keys()
), f"{start_terms.keys()} != {end_terms.keys()}"

path: List[Tuple[Ratio, Offset, Unit]] = []
for dimension in start_terms:
Expand All @@ -1449,6 +1460,46 @@ def _terms_by_dimension(cls, unit: Unit) -> Dict[Dimension, List[Unit]]:
terms[factor.dimension].append(factor)
return terms

def _collapse_by_dimension(self, quantity: Quantity) -> Quantity:
"""Return a new quantity with at most a single unit in each dimension, by
converting individual terms"""
magnitude = quantity.magnitude
by_dimension: Dict[Dimension, Tuple[Unit, int]] = {}
for unit, exponent in quantity.unit.factors.items():
dimension = unit.dimension
quantified = unit.quantify()

if dimension not in by_dimension:
magnitude *= quantified.magnitude**exponent
by_dimension[dimension] = (quantified.unit, exponent)
continue

current_unit, current_exponent = by_dimension[dimension]

path = self._find_path(quantified.unit, current_unit)
if not path:
raise ConversionNotFound(
f"No conversion between {dimension} units {quantified.unit} "
f"and {current_unit}"
)

for scale, offset, _ in path:
magnitude *= scale**exponent
magnitude += offset

by_dimension[dimension] = (current_unit, current_exponent + exponent)

factors = {
unit: exponent for unit, exponent in by_dimension.values() if exponent != 0
} or {One: 1}

final_dimension = Number
for dimension in by_dimension.keys():
final_dimension *= dimension

final = Quantity(magnitude, Unit(IdentityPrefix, factors, final_dimension))
return final

def _find_path(
self,
start: Unit,
Expand Down
4 changes: 2 additions & 2 deletions src/measured/astronomical.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

from measured import Length, Mass, Time, Unit, si
from measured.fundamental import SpeedOfLight
from measured.si import Kilogram, Meter, Second
from measured.si import Kilogram, Meter

# Measures of Time

Expand All @@ -55,7 +55,7 @@

# https://en.wikipedia.org/wiki/Light-year
LightYear = Length.unit("light-year", "ly")
LightYear.equals(SpeedOfLight * (1 * JulianYear).in_unit(Second))
LightYear.equals((SpeedOfLight * JulianYear).in_unit(Meter))

# https://en.wikipedia.org/wiki/Parsec#Calculating_the_value_of_a_parsec
Parsec = Length.unit("parsec", "pc")
Expand Down
37 changes: 37 additions & 0 deletions src/measured/troy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
Defines the [Troy weights][1] and their conversions to SI
[1]: https://en.wikipedia.org/wiki/Troy_weight
Attributes: Units of mass ("weight")
Grain (Unit): The same `Grain` as the Avoirdupois system
Pennyweight (Unit): 24 grains
Ounce (Unit): 20 troy pennyweights
Pound (Unit): 12 troy ounces
"""

from measured import Mass, avoirdupois
from measured.si import Gram

# https://en.wikipedia.org/wiki/Troy_weight#Troy_grain
# There is no specific 'troy grain'. All Imperial systems use the same measure of mass
# called a grain
Grain = avoirdupois.Grain

Pennyweight = Mass.unit("pennyweight", "dwt")
Pennyweight.equals(24 * Grain)

Ounce = Mass.unit("troy ounce", "oz t")
Ounce.equals(480 * Grain)
Ounce.equals(20 * Pennyweight)
Ounce.equals(31.10348 * Gram)

Pound = Mass.unit("troy pound", "lb t")
Pound.equals(12 * Ounce)
Pound.equals(240 * Pennyweight)
Pound.equals(373.24172 * Gram)
10 changes: 5 additions & 5 deletions tests/test_astronomical.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
from measured.astronomical import (
AstronomicalUnit,
EarthMass,
JulianYear,
JupiterMass,
LightYear,
Parsec,
SolarMass,
)
from measured.fundamental import GravitationalConstant
from measured.si import Day, Meter, Second
from measured.si import Meter


def test_parsec() -> None:
Expand All @@ -27,11 +28,10 @@ def test_light_year() -> None:

def test_deriving_solar_mass() -> None:
# https://en.wikipedia.org/wiki/Solar_mass#Calculation
year = (365 * Day).in_unit(Second)
calculated = (4 * π**2 * AstronomicalUnit**3).in_unit(Meter**3) / (
GravitationalConstant * year**2
calculated = (4 * π**2 * AstronomicalUnit**3) / (
GravitationalConstant * JulianYear**2
)
calculated.assert_approximates(1 * SolarMass, within=2.74 + 27)
calculated.assert_approximates(1 * SolarMass, within=2.74e27)


def test_solar_system_masses() -> None:
Expand Down
23 changes: 22 additions & 1 deletion tests/test_quantity_conversions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import pytest

from measured import Area, ConversionNotFound, Length, Mass, Numeric, Unit, conversions
from measured import (
Area,
ConversionNotFound,
Length,
Mass,
Number,
Numeric,
One,
Unit,
conversions,
)
from measured.si import Meter, Second
from measured.us import Acre, Foot, Inch

Expand Down Expand Up @@ -103,3 +113,14 @@ def test_failing_to_convert_denominator() -> None:

with pytest.raises(ConversionNotFound):
(1 * Meter / Flib).in_unit(Meter / Flob)


def test_failing_to_collapse_dimensions() -> None:
Jib = Mass.unit("jibbity", "jibbity")
Job = Mass.unit("jobbity", "jobbity")

assert (Jib / Job).dimension == Number
assert (Job / Jib).dimension == Number

with pytest.raises(ConversionNotFound):
((1 * Jib) / (1 * Job)).in_unit(One)
30 changes: 30 additions & 0 deletions tests/test_troy_versus_avoirdupois.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from measured import One, avoirdupois, troy


def test_grain_is_the_same() -> None:
assert avoirdupois.Grain == troy.Grain


# https://en.wikipedia.org/wiki/Troy_weight#Troy_ounce_(oz_t)


def test_troy_ounce_is_heavier() -> None:
assert (1 * troy.Ounce) > 1 * avoirdupois.Ounce


def test_troy_ounce_ratio() -> None:
assert (1 * troy.Ounce) == 480 / 437.5 * avoirdupois.Ounce


def test_troy_ounce_conversion() -> None:
(1 * troy.Ounce).in_unit(
avoirdupois.Ounce
) == 1.0971428571428572 * avoirdupois.Ounce

assert (1 * avoirdupois.Ounce).in_unit(
troy.Ounce
) == 0.9114583333333334 * troy.Ounce


def test_troy_ounce_percentage() -> None:
assert (1 * troy.Ounce) / (1 * avoirdupois.Ounce) == 1.0971428571428572 * One

0 comments on commit d10ffa0

Please sign in to comment.