Skip to content

Commit

Permalink
ucon#9: "implements ratio; adds README" (#14)
Browse files Browse the repository at this point in the history
* adds test_ucon.TestRatio

* adds initial Ratio implementation and updates Number so tests pass

* fixes typo in README

* adds bromine density test case for Ration multiplication

* adds test_ucon.TestNumber.test_as_ratio

* adds Number.as_ratio

* convers RuntimeErrors -> ValueErrors

* adds tests ensuring ValueErrors raised where expected

* adds assertions affirming comparison flexibility between Numbers and Ratios

* implements comparison flexibility between Numbers and Ratios

* fixes assertion in TestRatio.test___mul__ test

* updates Ratio.__mul__ implementation so test passes

* updates README

* classifes project as "Alpha" release in setup.py
  • Loading branch information
withtwoemms committed Sep 7, 2020
1 parent c8fd802 commit 8809498
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 12 deletions.
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,49 @@
# ucon

[![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg))](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests)
[![tests](https://github.com/withtwoemms/ucon/workflows/tests/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Atests)
[![publish](https://github.com/withtwoemms/ucon/workflows/publish/badge.svg)](https://github.com/withtwoemms/ucon/actions?query=workflow%3Apublish)

a tool for dimensional analysis: a "Unit CONverter" :)
> Pronounced: _yoo · cahn_
# Background

Numbers are particularly helpful when describing quantities of some thing (say, 42 ice cream cones 🍦).
They are also useful when describing characteristics of a thing such as it's weight or volume.
Being able to describe a thing by measurements of its defining features and even monitoring said features over time, is foundational to developing an understanding of how a thing works in the world.
"Units" is the general term for descriptors of the defining features used to characterize an object.
Specific units include those like [grams](https://en.wikipedia.org/wiki/Gram) for weight, [liter](https://en.wikipedia.org/wiki/Litre) for volume, and even the [jiffy](https://en.wikipedia.org/wiki/Jiffy_(time)) for time.
With names for an object's physical characteristics, their extent can be communicated using a scale to answer the question _"how many of a given unit accurately describe that aspect of the object?"_.

# Introduction

Since the [metric scale](https://en.wikipedia.org/wiki/Metric_prefix) is fairly ubiquitous and straightfowrward to count with (being base 10 and all..), `ucon` uses the Metric System as the basis for measurement though [binary prefixes](https://en.wikipedia.org/wiki/Binary_prefix) are also supported.
The crux of this tiny library is to provide abstractions that simplify answering of questions like:

> _"If given two milliliters of bromine (liquid Br<sub>2</sub>), how many grams does one have?"_
To best answer this question, we turn to an age-old technique ([dimensional analysis](https://en.wikipedia.org/wiki/Dimensional_analysis)) which essentially allows for the solution to be written as a product of ratios.

```
2 mL bromine | 3.119 g bromine
--------------x----------------- #=> 6.238 g bromine
1 | 1 mL bromine
```

# Usage

The above calculation can be achieved using types defined in the `ucon` module.

```python
two_milliliters_bromine = Number(unit=Units.liter, scale=Scale.milli, quantity=2)
bromine_density = Ratio(numerator=Number(unit=Units.gram, quantity=3.119)
denominator=Number(unit=Units.liter, scale=Scale.milli))
two_milliliters_bromine * bromine_density #=> <6.238 gram>
```

One can also arbitrarily change scale:

```python
answer = two_milliliters_bromine * bromine_density #=> <6.238 gram>
answer.to(Scale.milli) #=> <6238.0 milligram>
answer.to(Scale.kibi) #=> <0.006091796875 kibigram>
```
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
maintainer_email='withtwoemms@gmail.com',
url='https://github.com/withtwoemms/ucon',
classifiers=[
'Development Status :: 1 - Planning',
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'Intended Audience :: Education',
'Intended Audience :: Science/Research',
Expand Down
66 changes: 66 additions & 0 deletions tests/test_ucon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from ucon import Number
from ucon import Exponent
from ucon import Ratio
from ucon import Scale
from ucon import Unit
from ucon import Units
Expand All @@ -24,6 +25,9 @@ def test___truediv__(self):
self.assertEqual(Units.gram, Units.gram / Units.none)
self.assertEqual(Units.gram, Units.none / Units.gram)

with self.assertRaises(ValueError):
Units.gram / Units.liter

def test_all(self):
for unit in Units:
self.assertIsInstance(unit.value, Unit)
Expand All @@ -35,6 +39,10 @@ class TestExponent(TestCase):
thousand = Exponent(10, 3)
thousandth = Exponent(10, -3)

def test___init__(self):
with self.assertRaises(ValueError):
Exponent(5, 3) # no support for base 5 logarithms

def test_parts(self):
self.assertEqual((10, 3), self.thousand.parts())
self.assertEqual((10, -3), self.thousandth.parts())
Expand Down Expand Up @@ -84,6 +92,12 @@ class TestNumber(TestCase):

number = Number(unit=Units.gram, quantity=1)

def test_as_ratio(self):
ratio = self.number.as_ratio()
self.assertIsInstance(ratio, Ratio)
self.assertEqual(ratio.numerator, self.number)
self.assertEqual(ratio.denominator, Number())

def test_simplify(self):
ten_decagrams = Number(unit=Units.gram, scale=Scale.deca, quantity=10)
point_one_decagrams = Number(unit=Units.gram, scale=Scale.deca, quantity=0.1)
Expand Down Expand Up @@ -120,3 +134,55 @@ def test___truediv__(self):
self.assertEqual(another_quotient.value, 100.0)
self.assertEqual(that_quotient.value, 0.00009765625)

def test___eq__(self):
self.assertEqual(self.number, Ratio(self.number)) # 1 gram / 1
with self.assertRaises(ValueError):
self.number == 1


class TestRatio(TestCase):

point_five = Number(quantity=0.5)
one = Number()
two = Number(quantity=2)
three = Number(quantity=3)
four = Number(quantity=4)

one_half = Ratio(numerator=one, denominator=two)
three_fourths = Ratio(numerator=three, denominator=four)
one_ratio = Ratio(numerator=one)
three_halves = Ratio(numerator=three, denominator=two)
two_ratio = Ratio(numerator=two, denominator=one)

bromine_density = Ratio(Number(Units.gram, quantity=3.119), Number(Units.liter, Scale.milli))

def test_evaluate(self):
self.assertEqual(self.one_ratio.numerator, self.one)
self.assertEqual(self.one_ratio.denominator, self.one)
self.assertEqual(self.one_ratio.evaluate(), self.one)
self.assertEqual(self.two_ratio.evaluate(), self.two)

def test_reciprocal(self):
self.assertEqual(self.two_ratio.reciprocal().numerator, self.one)
self.assertEqual(self.two_ratio.reciprocal().denominator, self.two)
self.assertEqual(self.two_ratio.reciprocal().evaluate(), self.point_five)

def test___mul__(self):
self.assertEqual(self.three_halves * self.one_half, self.three_fourths)
self.assertEqual(self.three_halves * self.one_half, self.three_fourths)

# How many grams of bromine are in 2 milliliters?
two_milliliters_bromine = Number(Units.liter, Scale.milli, 2)
answer = two_milliliters_bromine.as_ratio() * self.bromine_density
self.assertEqual(answer.evaluate().value, 6.238) # Grams

def test___eq__(self):
self.assertEqual(self.one_half, self.point_five)
with self.assertRaises(ValueError):
self.one_half == 1/2

def test___repr__(self):
self.assertEqual(str(self.one_ratio), '<1.0 >')
self.assertEqual(str(self.two_ratio), '<2 > / <1 >')
self.assertEqual(str(self.two_ratio.evaluate()), '<2.0 >')

56 changes: 47 additions & 9 deletions ucon.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,20 @@ def __truediv__(self, another_unit) -> Unit:
elif another_unit == Units.none:
return self
else:
# TODO -- support division of different units. Will likely need a concept like "RatioUnits"
raise RuntimeError(f'Unsupported unit division: {self.name} / {another_unit.name}')
raise ValueError(f'Unsupported unit division: {self.name} / {another_unit.name}. Consider using Ratio.')

@staticmethod
def all():
return dict(list(map(lambda x: (x.value, x.value.aliases), Units)))


# TODO -- consider using a dataclass
class Exponent:
bases ={2: log2, 10: log10}

def __init__(self, base: int, power: int):
if base not in self.bases.keys():
raise RuntimeError(f'Only the following bases are supported: {reduce(lambda a,b: f"{a}, {b}", self.bases.keys())}')
raise ValueError(f'Only the following bases are supported: {reduce(lambda a,b: f"{a}, {b}", self.bases.keys())}')
self.base = base
self.power = power
self.evaluated = base ** power
Expand Down Expand Up @@ -118,8 +118,9 @@ def __eq__(self, another_scale):
return self.value == another_scale.value


# TODO -- consider using a dataclass
class Number:
def __init__(self, unit: Unit, scale: Scale = Scale.one, quantity = 1):
def __init__(self, unit: Unit = Units.none, scale: Scale = Scale.one, quantity = 1):
self.unit = unit
self.scale = scale
self.quantity = quantity
Expand All @@ -132,22 +133,59 @@ def to(self, new_scale: Scale):
new_quantity = self.quantity / new_scale.value.evaluated
return Number(unit=self.unit, scale=new_scale, quantity=new_quantity)

def as_ratio(self):
return Ratio(self)

def __mul__(self, another_number):
new_quantity = self.quantity * another_number.quantity
return Number(unit=self.unit, scale=self.scale, quantity=new_quantity)

def __truediv__(self, another_number) -> Number:
unit = self.unit / another_number.unit
scale = self.scale / another_number.scale
quantity = self.quantity / another_number.quantity
return Number(unit, scale, quantity)

def __eq__(self, another_number):
return (self.unit == another_number.unit) and \
(self.quantity == another_number.quantity) and \
(self.value == another_number.value)
if isinstance(another_number, Number):
return (self.unit == another_number.unit) and \
(self.quantity == another_number.quantity) and \
(self.value == another_number.value)
elif isinstance(another_number, Ratio):
return self == another_number.evaluate()
else:
raise ValueError(f'"{another_number}" is not a Number or Ratio. Comparison not possible.')

def __repr__(self):
return f'<{self.quantity} {"" if self.scale.name == "one" else self.scale.name}{self.unit.value.name}>'


# TODO -- write tests
# TODO -- consider using a dataclass
class Ratio:
NotImplemented
def __init__(self, numerator: Number = Number(), denominator: Number = Number()):
self.numerator = numerator
self.denominator = denominator

def reciprocal(self) -> Ratio:
return Ratio(numerator=self.denominator, denominator=self.numerator)

def evaluate(self) -> Number:
return self.numerator / self.denominator

def __mul__(self, another_ratio):
new_numerator = self.numerator / another_ratio.denominator
new_denominator = self.denominator / another_ratio.numerator
return Ratio(numerator=new_numerator, denominator=new_denominator)

def __eq__(self, another_ratio):
if isinstance(another_ratio, Ratio):
return self.evaluate() == another_ratio.evaluate()
elif isinstance(another_ratio, Number):
return self.evaluate() == another_ratio
else:
raise ValueError(f'"{another_ratio}" is not a Ratio or Number. Comparison not possible.')

def __repr__(self):
# TODO -- resolve int/float inconsistency
return f'{self.evaluate()}' if self.numerator == self.denominator else f'{self.numerator} / {self.denominator}'

0 comments on commit 8809498

Please sign in to comment.