From 0978a66aae30a308a78bf1388de85663685b99cb Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Sat, 4 Apr 2020 12:25:28 +0200 Subject: [PATCH] Switch to Decimal based values --- .bandit | 2 + .editorconfig | 18 + .github/workflows/ci.yml | 23 +- .gitignore | 1 + LICENSE | 2 +- README.rst | 113 ++-- docs/caliper.svg | 101 +++ docs/conf.py | 30 +- docs/custom_measures.rst | 12 + docs/index.rst | 38 +- docs/measures.rst | 77 +++ docs/topics/creating_your_own_class.rst | 150 ---- docs/topics/installation.rst | 14 - docs/topics/measures.rst | 128 ---- docs/topics/use.rst | 64 -- measurement/base.py | 826 +++++++++-------------- measurement/measures/__init__.py | 22 +- measurement/measures/capacitance.py | 14 - measurement/measures/current.py | 15 - measurement/measures/distance.py | 156 ----- measurement/measures/electromagnetism.py | 78 +++ measurement/measures/energy.py | 34 +- measurement/measures/frequency.py | 15 - measurement/measures/geometry.py | 258 +++++++ measurement/measures/mass.py | 35 - measurement/measures/mechanics.py | 71 ++ measurement/measures/pressure.py | 26 - measurement/measures/radioactivity.py | 23 +- measurement/measures/resistance.py | 12 - measurement/measures/speed.py | 15 - measurement/measures/temperature.py | 47 +- measurement/measures/time.py | 40 +- measurement/measures/voltage.py | 10 - measurement/measures/volume.py | 68 -- measurement/measures/volumetric_flow.py | 36 - measurement/utils.py | 30 +- setup.cfg | 29 +- tests/base.py | 5 - tests/measures/__init__.py | 0 tests/measures/test_electromagnetism.py | 36 + tests/measures/test_energy.py | 9 + tests/measures/test_geometry.py | 99 +++ tests/{ => measures}/test_speed.py | 93 +-- tests/{ => measures}/test_temperature.py | 19 +- tests/measures/test_time.py | 9 + tests/test_base.py | 213 ++++++ tests/test_distance.py | 58 -- tests/test_energy.py | 13 - tests/test_utils.py | 23 +- tests/test_volume.py | 11 - 50 files changed, 1581 insertions(+), 1640 deletions(-) create mode 100644 .bandit create mode 100644 .editorconfig create mode 100644 docs/caliper.svg create mode 100644 docs/custom_measures.rst create mode 100644 docs/measures.rst delete mode 100644 docs/topics/creating_your_own_class.rst delete mode 100644 docs/topics/installation.rst delete mode 100644 docs/topics/measures.rst delete mode 100644 docs/topics/use.rst delete mode 100644 measurement/measures/capacitance.py delete mode 100644 measurement/measures/current.py delete mode 100644 measurement/measures/distance.py create mode 100644 measurement/measures/electromagnetism.py delete mode 100644 measurement/measures/frequency.py create mode 100644 measurement/measures/geometry.py delete mode 100644 measurement/measures/mass.py create mode 100644 measurement/measures/mechanics.py delete mode 100644 measurement/measures/pressure.py delete mode 100644 measurement/measures/resistance.py delete mode 100644 measurement/measures/speed.py delete mode 100644 measurement/measures/voltage.py delete mode 100644 measurement/measures/volume.py delete mode 100644 measurement/measures/volumetric_flow.py delete mode 100644 tests/base.py create mode 100644 tests/measures/__init__.py create mode 100644 tests/measures/test_electromagnetism.py create mode 100644 tests/measures/test_energy.py create mode 100644 tests/measures/test_geometry.py rename tests/{ => measures}/test_speed.py (50%) rename tests/{ => measures}/test_temperature.py (53%) create mode 100644 tests/measures/test_time.py create mode 100644 tests/test_base.py delete mode 100644 tests/test_distance.py delete mode 100644 tests/test_energy.py delete mode 100644 tests/test_volume.py diff --git a/.bandit b/.bandit new file mode 100644 index 0000000..fba0d25 --- /dev/null +++ b/.bandit @@ -0,0 +1,2 @@ +[bandit] +exclude: tests diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e24ad58 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf +max_line_length = 88 + +[*.{json,yml,yaml}] +indent_size = 2 + +[LICENSE] +insert_final_newline = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35d3cc2..d0404d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/setup-python@v1 - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: python -m pip install isort - run: isort --check-only --diff --recursive . + flake8: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v1 + - uses: actions/checkout@v2 + - run: python -m pip install flake8 + - run: flake8 . + pydocstyle: runs-on: ubuntu-latest steps: - uses: actions/setup-python@v1 - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: python -m pip install pydocstyle - run: pydocstyle . @@ -27,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/setup-python@v1 - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: python -m pip install black - run: black --check --diff . @@ -36,22 +44,23 @@ jobs: - isort - pydocstyle - black + - flake8 strategy: matrix: os: - ubuntu-latest + - windows-latest + - macos-latest python-version: - - 3.5 - - 3.6 - 3.7 - 3.8 runs-on: ${{ matrix.os }} steps: - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1.1.1 + uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: python -m pip install --upgrade setuptools wheel codecov - run: python setup.py test - run: codecov diff --git a/.gitignore b/.gitignore index 1daf286..9e32c83 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ include/ lib/ docs/_build/ .coverage +htmlcov/ diff --git a/LICENSE b/LICENSE index 79885eb..20f1ded 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014 Adam Coddington +Copyright (c) 2020 Johannes Hoppe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.rst b/README.rst index 73c6d71..0961862 100644 --- a/README.rst +++ b/README.rst @@ -1,55 +1,86 @@ -.. image:: https://travis-ci.org/coddingtonbear/python-measurement.svg?branch=master - :target: https://travis-ci.org/coddingtonbear/python-measurement +================== +Python measurement +================== -Easily use and manipulate unit-aware measurement objects in Python. +**High precision unit-aware measurement objects in Python.** -`django.contrib.gis.measure `_ -has these wonderful 'Distance' objects that can be used not only for storing a -unit-aware distance measurement, but also for converting between different -units and adding/subtracting these objects from one another. + >>> from measurement import measures + >>> measures.Distance("12 megaparsec")["British yard"] + Decimal('404948208659679393828910.8771') -This module not only provides those Distance and Area measurement -objects, but also other measurements including: +This package provides a large reference collection of various measure and +their corresponding SI (Metric), US or Imperial units. Its high precision +supports use cases all the way from quantum mechanics to astrophysics. -- Energy -- Speed -- Temperature -- Time -- Volume -- Weight +- Documentation for python-measurement is available an + `ReadTheDocs `_. +- Please post issues on + `Github `_. -Example: +Installation +============ -.. code-block:: python +You can install the latest version of the package with Pip:: - >>> from measurement.measures import Weight - >>> weight_1 = Weight(lb=125) - >>> weight_2 = Weight(kg=40) - >>> added_together = weight_1 + weight_2 - >>> added_together - Weight(lb=213.184976807) - >>> added_together.kg # Maybe I actually need this value in kg? - 96.699 + python3 -m pip install measurement -.. warning:: - Measurements are stored internally by converting them to a - floating-point number of a (generally) reasonable SI unit. Given that - floating-point numbers are very slightly lossy, you should be aware of - any inaccuracies that this might cause. +Usage +===== - TLDR: Do not use this in - `navigation algorithms guiding probes into the atmosphere of extraterrestrial worlds `_. +Using Measurement Objects +------------------------- -- Documentation for python-measurement is available an - `ReadTheDocs `_. -- Please post issues on - `Github `_. -- Test status available on - `Travis-CI `_. +You can import any of the above measures from `measurement.measures` +and use it for easily handling measurements like so: + + >>> from measurement.measures import Mass + >>> m = Mass(lb=135) # Represents 135 lbs + >>> print(m) + 135 lb + >>> print(m["long ton"]) + 0.06027063971456692913385826772 + +You can create a measurement unit using any compatible unit and can transform +it into any compatible unit. See :doc:`measures` for information about which +units are supported by which measures. + +.. seealso:: + Should you be planing to go to Mars, you might need to increase your + `decimal precision`_, like so: + + >>> import decimal + >>> decimal.getcontext().prec = 28 + +.. _decimal precision: https://docs.python.org/3.8/library/decimal.html + +Guessing Measurements +--------------------- + +If you happen to be in a situation where you are processing a list of +value/unit pairs (like you might find at the beginning of a recipe), you can +use the `guess` function to give you a measurement object.: + + >>> from measurement.utils import guess + >>> m = guess(10, "mg") + >>> print(repr(m)) + Mass(gram="0.010") +By default, this will check all built-in measures, and will return the first +measure having an appropriate unit. You may want to constrain the list of +measures checked (or your own measurement classes, too) to make sure +that your measurement is not mis-guessed, and you can do that by specifying +the ``measures`` keyword argument: + >>> from measurement.measures import Distance, Temperature, Volume + >>> m = guess(24, "°F", measures=[Distance, Volume, Temperature]) + >>> print(repr(m)) + Temperature(fahrenheit="24.00000000000000000000000008") -.. image:: https://d2weczhvl823v0.cloudfront.net/coddingtonbear/python-measurement/trend.png - :alt: Bitdeli badge - :target: https://bitdeli.com/free +If no match is found, a :class:`ValueError` exception will be raised. +.. note:: + It is absolutely possible for this to misguess due to common measurement + abbreviations overlapping -- for example, both Temperature and Energy + accept the argument ``c`` for representing degrees celsius and calories + respectively. It is advisible that you constrain the list of measurements + to check to ones that you would consider appropriate for your input data. diff --git a/docs/caliper.svg b/docs/caliper.svg new file mode 100644 index 0000000..7e5d247 --- /dev/null +++ b/docs/caliper.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/conf.py b/docs/conf.py index b0036fe..3b1e003 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,21 +1,49 @@ """Sphinx configuration file.""" +import inspect + from pkg_resources import get_distribution +from measurement.base import AbstractMeasure + project = "python-measurement" -copyright = "2013, Adam Coddington" +copyright = "2020, Johannes Hoppe" release = get_distribution("measurement").version version = ".".join(release.split(".")[:2]) +html_theme = "python_docs_theme" master_doc = "index" +html_logo = "caliper.svg" + extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", "sphinx.ext.doctest", + "sphinx.ext.inheritance_diagram", ] intersphinx_mapping = { "python": ("https://docs.python.org/3", None), } + +inheritance_graph_attrs = dict(rankdir="TB") + +graphviz_output_format = "svg" + +autodoc_member_order = "bysource" + + +def process_measures(app, what, name, obj, options, lines): + if inspect.isclass(obj) and issubclass(obj, AbstractMeasure): + lines.append("**Supported Units:**") + lines.extend( + f" :{obj._attr_to_unit(name)}: {', '.join(value.symbols)}" + for name, value in obj._org_units.items() + ) + return lines + + +def setup(app): + app.connect("autodoc-process-docstring", process_measures) diff --git a/docs/custom_measures.rst b/docs/custom_measures.rst new file mode 100644 index 0000000..78a215b --- /dev/null +++ b/docs/custom_measures.rst @@ -0,0 +1,12 @@ +Custom Measures +=============== + +API reference +------------- + +.. inheritance-diagram:: measurement.base + +.. automodule:: measurement.base + :show-inheritance: + :members: + :inherited-members: diff --git a/docs/index.rst b/docs/index.rst index 64bf23b..2ef3030 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,33 +1,4 @@ -.. python-measurement documentation master file, created by - sphinx-quickstart on Tue Jan 22 20:02:38 2013. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -python-measurement -================== - -.. image:: https://travis-ci.org/coddingtonbear/python-measurement.svg?branch=master - :target: https://travis-ci.org/coddingtonbear/python-measurement - -Easily use and manipulate unit-aware measurement objects in Python. - -`django.contrib.gis.measure `_ -has these wonderful 'Distance' objects that can be used not only for storing a -unit-aware distance measurement, but also for converting between different -units and adding/subtracting these objects from one another. - -This module not only provides those Distance and Area measurement objects -(courtesy of Django), but also other measurements including Weight, Volume, and -Temperature. - -.. warning:: - Measurements are stored internally by converting them to a - floating-point number of a (generally) reasonable SI unit. Given that - floating-point numbers are very slightly lossy, you should be aware of - any inaccuracies that this might cause. - - TLDR: Do not use this in - `navigation algorithms guiding probes into the atmosphere of extraterrestrial worlds `_. +.. include:: ../README.rst Contents: @@ -35,7 +6,8 @@ Contents: :maxdepth: 2 :glob: - topics/* + measures + custom_measures Indices and tables @@ -45,3 +17,7 @@ Indices and tables * :ref:`modindex` * :ref:`search` +Icons made by Freepik_ from Flaticon_. + +.. _Freepik: https://www.flaticon.com/authors/freepik +.. _Flaticon: https://www.flaticon.com/ diff --git a/docs/measures.rst b/docs/measures.rst new file mode 100644 index 0000000..782072b --- /dev/null +++ b/docs/measures.rst @@ -0,0 +1,77 @@ +Measures +======== + +There two different ways to instanciate a measure. The recommended way to instaciate +a measure object is with a :class:`Decimal` value +and a :class:`String` unit: + + >>> from measurement import measures + >>> measures.Distance('1.0', 'm') + Distance(metre="1.0") + +Additinally, you may either pass a string +containing the value and unit separated by a string: + + >>> from measurement import measures + >>> measures.Distance("1 m") + Distance(metre="1") + +or you can pass the value to the right unit argument: + + >>> from measurement import measures + >>> measures.Distance(m=1) + Distance(metre="1") + +To concert a measure into another unit you can get the correct unit key: + + >>> from measurement import measures + >>> measures.Distance("1 m")['ft'] + Decimal('3.280839895013123359580052493') + +or attribute: + + >>> from measurement import measures + >>> measures.Distance("1 m").ft + Decimal('3.280839895013123359580052493') + +.. note:: + Python has restrictions on attribute names. + E.g. you can not use white spaces: + + >>> from measurement import measures + >>> measures.Distance(Nautical Mile=1).km + Traceback (most recent call last): + File "", line 1 + measures.Distance(Nautical Mile=1).km + ^ + SyntaxError: invalid syntax + + In this case, you may use underscrose instead of spaces: + + >>> from measurement import measures + >>> measures.Distance(nautical_mile=1).km + Decimal('1.852') + + or preferably the string version: + + >>> measures.Distance('1 Nautical Mile').km + Decimal('1.852') + + See also: https://docs.python.org/3/reference/lexical_analysis.html#identifiers + +Some Units have `Metric Prefixes`_ like ``kilometre`` or ``kilogram``. +Prefixes are supported for all metric units in their short and long version, e.g: + + >>> from measurement import measures + >>> measures.Distance('1 μm')['hectometer'] + Decimal('1E-8') + +.. _`Metric Prefixes`: https://en.wikipedia.org/wiki/Metric_prefix + +Supported Measures and Units +---------------------------- + +.. automodule:: measurement.measures + :members: + :undoc-members: + :imported-members: diff --git a/docs/topics/creating_your_own_class.rst b/docs/topics/creating_your_own_class.rst deleted file mode 100644 index f2bf7ec..0000000 --- a/docs/topics/creating_your_own_class.rst +++ /dev/null @@ -1,150 +0,0 @@ - -Creating your own Measure Class -=============================== - -You can create your own measures easily by subclassing either -``measurement.base.MeasureBase`` or ``measurement.base.BidimensionalMeasure``. - - -Simple Measures ---------------- - -If your measure is not a measure dependent upon another measure (e.g speed, -distance/time) you can create new measurement by creating a subclass of -``measurement.base.MeasureBase``. - -A simple example is Weight: - -.. code-block:: python - - from measurement.base import MeasureBase - - class Weight(MeasureBase): - STANDARD_UNIT = 'g' - UNITS = { - 'g': 1.0, - 'tonne': 1000000.0, - 'oz': 28.3495, - 'lb': 453.592, - 'stone': 6350.29, - 'short_ton': 907185.0, - 'long_ton': 1016000.0, - } - ALIAS = { - 'gram': 'g', - 'ton': 'short_ton', - 'metric tonne': 'tonne', - 'metric ton': 'tonne', - 'ounce': 'oz', - 'pound': 'lb', - 'short ton': 'short_ton', - 'long ton': 'long_ton', - } - SI_UNITS = ['g'] - -Important details: - -* ``STANDARD_UNIT`` defines what unit will be used internally by the library - for storing the value of this measurement. -* ``UNITS`` provides a mapping relating a unit of your ``STANDARD_UNIT`` to - any number of defined units. In the example above, you will see that - we've established ``28.3495 g`` to be equal to ``1 oz``. -* ``ALIAS`` provides a list of aliases mapping keyword arguments to ``UNITS``. - these values are allowed to be used as keyword arguments when either creating - a new unit or guessing a measurement using ``measurement.utils.guess``. -* ``SI_UNITS`` provides a list of units that are SI Units. Units in this list - will automatically have new units and aliases created for each of the main - SI magnitudes. In the above example, this causes the list of ``UNITS`` - and ``ALIAS`` es to be extended to include the following units (aliases): - ``yg`` (yottagrams), ``zg`` (zeptograms), ``ag`` (attograms), - ``fg`` (femtograms), ``pg`` (picograms), ``ng`` (nanograms), - ``ug`` (micrograms), ``mg`` (milligrams), ``kg`` (kilograms), - ``Mg`` (megagrams), ``Gg`` (gigagrams), ``Tg`` (teragrams), - ``Pg`` (petagrams), ``Eg`` (exagrams), ``Zg`` (zetagrams), - ``Yg`` (yottagrams). - -Using formula-based conversions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -In some situations, your conversions between units may not be simple enough -to be accomplished by using simple conversions (e.g. temperature); for -situations like that, you should use ``sympy`` to create expressions relating -your measure's standard unit and the unit you're defining: - -.. code-block:: python - - from sympy import S, Symbol - from measurement.base import MeasureBase - - class Temperature(MeasureBase): - SU = Symbol('kelvin') - STANDARD_UNIT = 'k' - UNITS = { - 'c': SU - S(273.15), - 'f': (SU - S(273.15)) * S('9/5') + 32, - 'k': 1.0 - } - ALIAS = { - 'celsius': 'c', - 'fahrenheit': 'f', - 'kelvin': 'k', - } - -Important details: - -* See above 'Important Details' under `Normal Measures`. -* ``SU`` must define the symbol used in expressions relating your measure's - ``STANDARD_UNIT`` to the unit you're defining. - - -Bi-dimensional Measures ------------------------ - -Some measures are really just compositions of two separate measures -- Speed, -being a measure of the amount of distance covered over a unit of time, is one -common example of such a measure. - -You can create such measures by subclassing -``measurement.base.BidimensionalMeasure``. - -.. code-block:: python - - from measurement.base import BidimensionalMeasure - - from measurement.measures.distance import Distance - from measurement.measures.time import Time - - - class Speed(BidimensionalMeasure): - PRIMARY_DIMENSION = Distance - REFERENCE_DIMENSION = Time - - ALIAS = { - 'mph': 'mi__hr', - 'kph': 'km__hr', - } - -Important details: - -* ``PRIMARY_DIMENSION`` is a class that measures the variable dimension of - this measure. In the case of 'miles-per-hour', this would be the 'miles' - or 'distance' dimension of the measurement. -* ``REFERENCE_DIMENSION`` is a class that measures the unit (reference) - dimension of the measure. In the case of 'miles-per-hour', this would be - the 'hour' or 'time' dimension of the measurement. -* ``ALIAS`` defines a list of convenient abbreviations for use either when - creating or defining a new instance of this measurement. In the above case, - you can create an instance of speed like ``Speed(mph=10)`` (equivalent to - ``Speed(mile__hour=10)``) or convert to an existing measurement ( - ``speed_measurement``) into one of the aliased measures by accessing - the attribute named -- ``speed_measurement.kph`` (equivalent to - ``speed_measurement.kilometer__hour``). - -.. note:: - - Although unit aliases defined in a bi-dimensional measurement's ``ALIAS`` - dictionary can be used either as keyword arguments or as attributes used - for conversion, unit aliases defined in simple measurements (those - subclassing ``measurement.base.MeasureBase``) can be used only as keyword - arguments. - diff --git a/docs/topics/installation.rst b/docs/topics/installation.rst deleted file mode 100644 index e73291c..0000000 --- a/docs/topics/installation.rst +++ /dev/null @@ -1,14 +0,0 @@ - -Installation -============ - -You can either install from pip:: - - pip install measurement - -*or* checkout and install the source from the `github repository `_:: - - git clone https://github.com/coddingtonbear/python-measurement.git - cd python-measurement - python setup.py install - diff --git a/docs/topics/measures.rst b/docs/topics/measures.rst deleted file mode 100644 index f46bfc4..0000000 --- a/docs/topics/measures.rst +++ /dev/null @@ -1,128 +0,0 @@ -Measures -======== - -This application provides the following measures: - -.. note:: - Python has restrictions on what can be used as a method attribute; if you - are not very familiar with python, the below chart outlines which - units can be used only when creating a new measurement object ('Acceptable - as Arguments') and which are acceptable for use either when creating a - new measurement object, or for converting a measurement object to a - different unit ('Acceptable as Arguments or Attributes') - - Units that are acceptable as arguments (like the distance measurement - term ``km``) can be used like:: - - >>> from measurement.measures import Distance - >>> distance = Distance(km=10) - - or can be used for converting other measures into kilometers: - - >>> from measurement.measures import Distance - >>> distance = Distance(mi=10).km - - but units that are only acceptable as arguments (like the distance - measurement term ``kilometer``) can *only* be used to create a measurement: - - >>> from measurement.measures import Distance - >>> distance = Distance(kilometer=10) - - You also might notice that some measures have arguments having spaces in - their name marked as 'Acceptable as Arguments'; their primary use is for - when using ``measurement.guess``:: - - >>> from measurement.utils import guess - >>> unit = 'U.S. Foot' - >>> value = 10 - >>> measurement = guess(value, unit) - >>> print measurement - 10.0 U.S. Foot - - -Area ----- - -* *Acceptable as Arguments or Attributes*: ``acre``, ``hectare``, ``sq_Em``, ``sq_Gm``, ``sq_Mm``, ``sq_Pm``, ``sq_Tm``, ``sq_Ym``, ``sq_Zm``, ``sq_am``, ``sq_british_chain_benoit``, ``sq_british_chain_sears_truncated``, ``sq_british_chain_sears``, ``sq_british_ft``, ``sq_british_yd``, ``sq_chain_benoit``, ``sq_chain_sears``, ``sq_chain``, ``sq_clarke_ft``, ``sq_clarke_link``, ``sq_cm``, ``sq_dam``, ``sq_dm``, ``sq_fathom``, ``sq_fm``, ``sq_ft``, ``sq_german_m``, ``sq_gold_coast_ft``, ``sq_hm``, ``sq_inch``, ``sq_indian_yd``, ``sq_km``, ``sq_link_benoit``, ``sq_link_sears``, ``sq_link``, ``sq_m``, ``sq_mi``, ``sq_mm``, ``sq_nm_uk``, ``sq_nm``, ``sq_pm``, ``sq_rod``, ``sq_sears_yd``, ``sq_survey_ft``, ``sq_um``, ``sq_yd``, ``sq_ym``, ``sq_zm`` -* *Acceptable as Arguments*: ``Acre``, ``British chain (Benoit 1895 B)``, ``British chain (Sears 1922 truncated)``, ``British chain (Sears 1922)``, ``British foot (Sears 1922)``, ``British foot``, ``British yard (Sears 1922)``, ``British yard``, ``Chain (Benoit)``, ``Chain (Sears)``, ``Clarke's Foot``, ``Clarke's link``, ``Foot (International)``, ``German legal metre``, ``Gold Coast foot``, ``ha``, ``Hectare``, ``Indian yard``, ``Link (Benoit)``, ``Link (Sears)``, ``Nautical Mile (UK)``, ``Nautical Mile``, ``U.S. Foot``, ``US survey foot``, ``Yard (Indian)``, ``Yard (Sears)``, ``attometer``, ``attometre``, ``centimeter``, ``centimetre``, ``decameter``, ``decametre``, ``decimeter``, ``decimetre``, ``exameter``, ``exametre``, ``femtometer``, ``femtometre``, ``foot``, ``gigameter``, ``gigametre``, ``hectometer``, ``hectometre``, ``in``, ``inches``, ``kilometer``, ``kilometre``, ``megameter``, ``megametre``, ``meter``, ``metre``, ``micrometer``, ``micrometre``, ``mile``, ``millimeter``, ``millimetre``, ``nanometer``, ``nanometre``, ``petameter``, ``petametre``, ``picometer``, ``picometre``, ``terameter``, ``terametre``, ``yard``, ``yoctometer``, ``yoctometre``, ``yottameter``, ``yottametre``, ``zeptometer``, ``zeptometre``, ``zetameter``, ``zetametre`` - -Distance --------- - -* *Acceptable as Arguments or Attributes*: ``Em``, ``Gm``, ``Mm``, ``Pm``, ``Tm``, ``Ym``, ``Zm``, ``am``, ``british_chain_benoit``, ``british_chain_sears_truncated``, ``british_chain_sears``, ``british_ft``, ``british_yd``, ``chain_benoit``, ``chain_sears``, ``chain``, ``clarke_ft``, ``clarke_link``, ``cm``, ``dam``, ``dm``, ``fathom``, ``fm``, ``ft``, ``german_m``, ``gold_coast_ft``, ``hm``, ``inch``, ``indian_yd``, ``km``, ``link_benoit``, ``link_sears``, ``link``, ``m``, ``mi``, ``mm``, ``nm_uk``, ``nm``, ``pm``, ``rod``, ``sears_yd``, ``survey_ft``, ``um``, ``yd``, ``ym``, ``zm`` -* *Acceptable as Arguments*: ``British chain (Benoit 1895 B)``, ``British chain (Sears 1922 truncated)``, ``British chain (Sears 1922)``, ``British foot (Sears 1922)``, ``British foot``, ``British yard (Sears 1922)``, ``British yard``, ``Chain (Benoit)``, ``Chain (Sears)``, ``Clarke's Foot``, ``Clarke's link``, ``Foot (International)``, ``German legal metre``, ``Gold Coast foot``, ``Indian yard``, ``Link (Benoit)``, ``Link (Sears)``, ``Nautical Mile (UK)``, ``Nautical Mile``, ``U.S. Foot``, ``US survey foot``, ``Yard (Indian)``, ``Yard (Sears)``, ``attometer``, ``attometre``, ``centimeter``, ``centimetre``, ``decameter``, ``decametre``, ``decimeter``, ``decimetre``, ``exameter``, ``exametre``, ``femtometer``, ``femtometre``, ``foot``, ``gigameter``, ``gigametre``, ``hectometer``, ``hectometre``, ``inches``, ``kilometer``, ``kilometre``, ``megameter``, ``megametre``, ``meter``, ``metre``, ``micrometer``, ``micrometre``, ``mile``, ``millimeter``, ``millimetre``, ``nanometer``, ``nanometre``, ``petameter``, ``petametre``, ``picometer``, ``picometre``, ``terameter``, ``terametre``, ``yard``, ``yoctometer``, ``yoctometre``, ``yottameter``, ``yottametre``, ``zeptometer``, ``zeptometre``, ``zetameter``, ``zetametre`` - -Energy ------- - -* *Acceptable as Arguments or Attributes*: ``C``, ``EJ``, ``Ec``, ``GJ``, ``Gc``, ``J``, ``MJ``, ``Mc``, ``PJ``, ``Pc``, ``TJ``, ``Tc``, ``YJ``, ``Yc``, ``ZJ``, ``Zc``, ``aJ``, ``ac``, ``cJ``, ``c``, ``cc``, ``dJ``, ``daJ``, ``dac``, ``dc``, ``fJ``, ``fc``, ``hJ``, ``hc``, ``kJ``, ``kc``, ``mJ``, ``mc``, ``nJ``, ``nc``, ``pJ``, ``pc``, ``uJ``, ``uc``, ``yJ``, ``yc``, ``zJ``, ``zc`` -* *Acceptable as Arguments*: ``Calorie``, ``attocalorie``, ``attojoule``, ``calorie``, ``centicalorie``, ``centijoule``, ``decacalorie``, ``decajoule``, ``decicalorie``, ``decijoule``, ``exacalorie``, ``exajoule``, ``femtocalorie``, ``femtojoule``, ``gigacalorie``, ``gigajoule``, ``hectocalorie``, ``hectojoule``, ``joule``, ``kilocalorie``, ``kilojoule``, ``megacalorie``, ``megajoule``, ``microcalorie``, ``microjoule``, ``millicalorie``, ``millijoule``, ``nanocalorie``, ``nanojoule``, ``petacalorie``, ``petajoule``, ``picocalorie``, ``picojoule``, ``teracalorie``, ``terajoule``, ``yoctocalorie``, ``yoctojoule``, ``yottacalorie``, ``yottajoule``, ``zeptocalorie``, ``zeptojoule``, ``zetacalorie``, ``zetajoule`` - -Speed ------ - -.. note:: - This is a bi-dimensional measurement; bi-dimensional - measures are created by finding an appropriate unit in the - measure's primary measurement class, and an appropriate - in the measure's reference class, and using them as a - double-underscore-separated keyword argument (or, if - converting to another unit, as an attribute). - - For example, to create an object representing 24 miles-per - hour:: - - >>> from measurement.measure import Speed - >>> my_speed = Speed(mile__hour=24) - >>> print my_speed - 24.0 mi/hr - >>> print my_speed.km__hr - 38.624256 - -* *Primary Measurement*: Distance -* *Reference Measurement*: Time - -Temperature ------------ - -* *Acceptable as Arguments or Attributes*: ``c``, ``f``, ``k`` -* *Acceptable as Arguments*: ``celsius``, ``fahrenheit``, ``kelvin`` - -.. warning:: - - Be aware that, unlike other measures, the zero points of the Celsius - and Farenheit scales are arbitrary and non-zero. - - If you attempt, for example, to calculate the average of a series of - temperatures using ``sum``, be sure to supply your 'start' (zero) - value as zero Kelvin (read: absolute zero) rather than zero - degrees Celsius (which is rather warm comparatively):: - - >>> temperatures = [Temperature(c=10), Temperature(c=20)] - >>> average = sum(temperatures, Temperature(k=0)) / len(temperatures) - >>> print average # The value will be shown in Kelvin by default since that is the starting unit - 288.15 k - >>> print average.c # But, you can easily get the Celsius value - 15.0 - >>> average.unit = 'c' # Or, make the measurement report its value in Celsius by default - >>> print average - 15.0 c - -Time ----- - -* *Acceptable as Arguments or Attributes*: ``Esec``, ``Gsec``, ``Msec``, ``Psec``, ``Tsec``, ``Ysec``, ``Zsec``, ``asec``, ``csec``, ``dasec``, ``day``, ``dsec``, ``fsec``, ``hr``, ``hsec``, ``ksec``, ``min``, ``msec``, ``nsec``, ``psec``, ``sec``, ``usec``, ``ysec``, ``zsec`` -* *Acceptable as Arguments*: ``attosecond``, ``centisecond``, ``day``, ``decasecond``, ``decisecond``, ``exasecond``, ``femtosecond``, ``gigasecond``, ``hectosecond``, ``hour``, ``kilosecond``, ``megasecond``, ``microsecond``, ``millisecond``, ``minute``, ``nanosecond``, ``petasecond``, ``picosecond``, ``second``, ``terasecond``, ``yoctosecond``, ``yottasecond``, ``zeptosecond``, ``zetasecond`` - -Volume ------- - -* *Acceptable as Arguments or Attributes*: ``El``, ``Gl``, ``Ml``, ``Pl``, ``Tl``, ``Yl``, ``Zl``, ``al``, ``cl``, ``acre_ft``, ``acre_in``, ``cubic_centimeter``, ``cubic_foot``, ``cubic_inch``, ``cubic_meter``, ``dal``, ``dl``, ``fl``, ``hl``, ``imperial_g``, ``imperial_oz``, ``imperial_pint``, ``imperial_qt``, ``imperial_tbsp``, ``imperial_tsp``, ``kl``, ``l``, ``mil_us_gal``, ``ml``, ``nl``, ``pl``, ``ul``, ``us_cup``, ``us_g``, ``us_oz``, ``us_pint``, ``us_qt``, ``us_tbsp``, ``us_tsp``, ``yl``, ``zl`` -* *Acceptable as Arguments*: ``af``, ``acre-ft``, ``acre-in``, ``Imperial Gram``, ``Imperial Ounce``, ``Imperial Pint``, ``Imperial Quart``, ``Imperial Tablespoon``, ``Imperial Teaspoon``, ``Million US Gallons``, ``US Cup``, ``US Fluid Ounce``, ``US Gallon``, ``US Ounce``, ``US Pint``, ``US Quart``, ``US Tablespoon``, ``US Teaspoon``, ``attoliter``, ``attolitre``, ``centiliter``, ``centilitre``, ``cubic centimeter``, ``cubic foot``, ``cubic inch``, ``cubic meter``, ``decaliter``, ``decalitre``, ``deciliter``, ``decilitre``, ``exaliter``, ``exalitre``, ``femtoliter``, ``femtolitre``, ``gigaliter``, ``gigalitre``, ``hectoliter``, ``hectolitre``, ``kiloliter``, ``kilolitre``, ``liter``, ``litre``, ``megaliter``, ``megalitre``, ``microliter``, ``microlitre``, ``milliliter``, ``millilitre``, ``nanoliter``, ``nanolitre``, ``petaliter``, ``petalitre``, ``picoliter``, ``picolitre``, ``teraliter``, ``teralitre``, ``yoctoliter``, ``yoctolitre``, ``yottaliter``, ``yottalitre``, ``zeptoliter``, ``zeptolitre``, ``zetaliter``, ``zetalitre`` - -Weight ------- - -* *Acceptable as Arguments or Attributes*: ``Eg``, ``Gg``, ``Mg``, ``Pg``, ``Tg``, ``Yg``, ``Zg``, ``ag``, ``cg``, ``dag``, ``dg``, ``fg``, ``g``, ``hg``, ``kg``, ``lb``, ``long_ton``, ``mg``, ``ng``, ``oz``, ``pg``, ``short_ton``, ``stone``, ``tonne``, ``ug``, ``yg``, ``zg`` -* *Acceptable as Arguments*: ``attogram``, ``centigram``, ``decagram``, ``decigram``, ``exagram``, ``femtogram``, ``gigagram``, ``gram``, ``hectogram``, ``kilogram``, ``long ton``, ``mcg``, ``megagram``, ``metric ton``, ``metric tonne``, ``microgram``, ``milligram``, ``nanogram``, ``ounce``, ``petagram``, ``picogram``, ``pound``, ``short ton``, ``teragram``, ``ton``, ``yoctogram``, ``yottagram``, ``zeptogram``, ``zetagram`` - diff --git a/docs/topics/use.rst b/docs/topics/use.rst deleted file mode 100644 index 9da5680..0000000 --- a/docs/topics/use.rst +++ /dev/null @@ -1,64 +0,0 @@ - -Using Measurement Objects -========================= - -You can import any of the above measures from `measurement.measures` -and use it for easily handling measurements like so:: - - >>> from measurement.measures import Weight - >>> w = Weight(lb=135) # Represents 135lbs - >>> print w - 135.0 lb - >>> print w.kg - 61.234919999999995 - -You can create a measurement unit using any compatible unit and can transform -it into any compatible unit. See :doc:`measures` for information about which -units are supported by which measures. - -To access the raw integer value of a measurement in the unit it was defined in, -you can use the 'value' property:: - - >>> print w.value - 135.0 - - -Guessing Measurements -===================== - -If you happen to be in a situation where you are processing a list of -value/unit pairs (like you might find at the beginning of a recipe), you can -use the `guess` function to give you a measurement object.:: - - >>> from measurement.utils import guess - >>> m = guess(10, 'mg') - >>> print repr(m) - Weight(mg=10.0) - -By default, this will check all built-in measures, and will return the first -measure having an appropriate unit. You may want to constrain the list of -measures checked (or your own measurement classes, too) to make sure -that your measurement is not mis-guessed, and you can do that by specifying -the ``measures`` keyword argument:: - - >>> from measurement.measures import Distance, Temperature, Volume - >>> m = guess(24, 'f', measures=[Distance, Volume, Temperature]) - >>> print repr(m) - Temperature(f=24) - -.. warning:: - It is absolutely possible for this to misguess due to common measurement - abbreviations overlapping -- for example, both Temperature and Energy - accept the argument ``c`` for representing degrees celsius and calories - respectively. It is advisible that you constrain the list of measurements - to check to ones that you would consider appropriate for your input data. - -If no match is found, a ``ValueError`` exception will be raised:: - - >>> m = guess(24, 'f', measures=[Distance, Volume]) - Traceback (most recent call last): - File "", line 1, in - File "measurement/utils.py", line 61, in guess - ', '.join([m.__name__ for m in measures]) - ValueError: No valid measure found for 24 f; checked Distance, Volume - diff --git a/measurement/base.py b/measurement/base.py index 1df49c0..3dd1af0 100644 --- a/measurement/base.py +++ b/measurement/base.py @@ -1,597 +1,373 @@ -# Copyright (c) 2007, Robert Coup -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without modification, -# are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# 3. Neither the name of Distance nor the names of its contributors may be used -# to endorse or promote products derived from this software without -# specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -from decimal import Decimal +"""Collection of helpers and base classes to build measure.""" +import abc +import dataclasses +import decimal +import inspect +import warnings from functools import total_ordering +from typing import Any, Dict, Iterable, List, Optional, Tuple, Type, Union -import sympy -from sympy.solvers import solve_linear -NUMERIC_TYPES = int, float, Decimal +def qualname(obj: Any) -> str: + return obj.__qualname__ if inspect.isclass(obj) else type(obj).__qualname__ -def pretty_name(obj): - return obj.__name__ if obj.__class__ == type else obj.__class__.__name__ +class ImmutableKeyDict(Dict): + """Like :class:`.dict` but any key may only assigned to a value once.""" + def __setitem__(self, key, value): + """ + Map item to key once and raise error if the same key is set twice. -class classproperty(property): - def __get__(self, cls, owner): - return self.fget.__get__(None, owner)() - - -@total_ordering -class MeasureBase(object): - STANDARD_UNIT = None - ALIAS = {} - UNITS = {} - SI_UNITS = [] - SI_PREFIXES = { - "yocto": "y", - "zepto": "z", - "atto": "a", - "femto": "f", - "pico": "p", - "nano": "n", - "micro": "u", - "milli": "m", - "centi": "c", - "deci": "d", - "deca": "da", - "hecto": "h", - "kilo": "k", - "mega": "M", - "giga": "G", - "tera": "T", - "peta": "P", - "exa": "E", - "zeta": "Z", - "yotta": "Y", - } - SI_MAGNITUDES = { - "yocto": 1e-24, - "zepto": 1e-21, - "atto": 1e-18, - "femto": 1e-15, - "pico": 1e-12, - "nano": 1e-9, - "micro": 1e-6, - "milli": 1e-3, - "centi": 1e-2, - "deci": 1e-1, - "deca": 1e1, - "hecto": 1e2, - "kilo": 1e3, - "mega": 1e6, - "giga": 1e9, - "tera": 1e12, - "peta": 1e15, - "exa": 1e18, - "zeta": 1e21, - "yotta": 1e24, - } - - def __init__(self, default_unit=None, **kwargs): - value, default = self.default_units(kwargs) - self._default_unit = default - setattr(self, self.STANDARD_UNIT, value) - if default_unit and isinstance(default_unit, str): - self._default_unit = default_unit - - @classmethod - def get_units(cls): - units = cls.UNITS.copy() - for unit in cls.SI_UNITS: - unit_value = units[unit] - for magnitude, value in cls.SI_MAGNITUDES.items(): - unit_abbreviation = cls.SI_PREFIXES[magnitude] + unit - units[unit_abbreviation] = unit_value * value - return units - - @classmethod - def get_si_aliases(cls): - si_aliases = {} - for alias, abbrev in cls.ALIAS.items(): - if abbrev in cls.SI_UNITS: - si_aliases[alias] = abbrev - return si_aliases - - @classmethod - def get_aliases(cls): - aliases = cls.ALIAS.copy() - si_aliases = cls.get_si_aliases() - for si_alias, unit_abbrev in si_aliases.items(): - for magnitude, _ in cls.SI_MAGNITUDES.items(): - magnitude_alias = magnitude + si_alias - prefix = cls.SI_PREFIXES[magnitude] - aliases[magnitude_alias] = prefix + unit_abbrev - return aliases + Raises: + KeyError: If key has been already assigned to a different item. + """ + if key in self.keys() and value is not self[key]: + raise KeyError(f"Key '{key}' already exists with value '{self[key]}'.") + dict.__setitem__(self, key, value) - @classmethod - def get_lowercase_aliases(self): - lowercased = {} - for alias, value in self.get_aliases().items(): - lowercased[alias.lower()] = value - return lowercased - @property - def standard(self): - return getattr(self, self.STANDARD_UNIT) +class AbstractUnit(abc.ABC): + """ + Helper class to define units of measurement in relation to their SI definition. - @standard.setter - def standard(self, value): - setattr(self, self.STANDARD_UNIT, value) + Nowerdays all units of measurement are defined based on fundamental SI units. + This class provides behavior to convert a SI unit based measure to another Unit. + """ - @property - def value(self): - return getattr(self, self._default_unit) + name = None - @value.setter - def value(self, value): - units = self.get_units() - u1 = units[self.STANDARD_UNIT] - u2 = units[self.unit] + @abc.abstractmethod + def to_si(self, value: decimal.Decimal) -> decimal.Decimal: + """Return SI measure based on given value in the unit defined by this class.""" - self.standard = value * (u2 / u1) + @abc.abstractmethod + def from_si(self, value: decimal.Decimal) -> decimal.Decimal: + """Return measure in the unit defined by this class based on given SI measure.""" - @property - def unit(self): - return self._default_unit - - @unit.setter - def unit(self, value): - aliases = self.get_aliases() - laliases = self.get_lowercase_aliases() - units = self.get_units() - unit = None - if value in self.UNITS: - unit = value - elif value in aliases: - unit = aliases[value] - elif value.lower() in units: - unit = value.lower() - elif value.lower() in laliases: - unit = laliases[value.lower] - if not unit: - raise ValueError("Invalid unit %s" % value) - self._default_unit = unit - - def __getattr__(self, name): - units = self.get_units() - if name in units: - return self._convert_value_to(units[name], self.standard,) - else: - raise AttributeError("Unknown unit type: %s" % name) - - def __repr__(self): - return "%s(%s=%s)" % ( - pretty_name(self), - self.unit, - getattr(self, self._default_unit), - ) + @abc.abstractmethod + def get_symbols(self) -> Iterable[Tuple[str, Type["AbstractUnit"]]]: + """Return list of symbol names and their :class:`.AbstractUnit` representation.""" def __str__(self): - return "%s %s" % (getattr(self, self._default_unit), self.unit) - - # **** Comparison methods **** - - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.standard == other.standard - else: - return NotImplemented - - def __lt__(self, other): - if isinstance(other, self.__class__): - return self.standard < other.standard - else: - return NotImplemented - - # **** Operators methods **** - - def __add__(self, other): - if isinstance(other, self.__class__): - return self.__class__( - default_unit=self._default_unit, - **{self.STANDARD_UNIT: (self.standard + other.standard)} - ) - else: - raise TypeError( - "%(class)s must be added with %(class)s" % {"class": pretty_name(self)} - ) + return str(self.name) + + +@dataclasses.dataclass +class Unit(AbstractUnit): + """ + Helper class for units that are defined as a multiple of an SI unit. + + Usage:: + + from measurements.base import AbstractMeasure, Unit + + + class Distance(AbstractMeasure): + metre = Unit("1", ['m', 'meter']) + inch = Unit("0.0254", ["in", "inches"]) + + In the example aboce we implemented a simple distance measure. This first + argument of a unit is it's SI unit factor. For the SI unit itself – + in this example metre – it's "1". For inches, the factor is by which number + you would need to multiple a meter to get to an inch. Or, 0.0254 metres make + an inch. + + The second argument is a list of symbols, that are used to describe the unit. + In the case of a metre, it is ``m`` and also the American English `meter`. + Note that the attribute name itself, will also be added automatically to + the list of symboles. + """ + + factor: Union[str, decimal.Decimal] = None + """ + Factor of given measure based on SI unit. + + The given value must be either a :class:`Decimal` + or a string that can be used to construct a decimal. + """ + + symbols: List[str] = dataclasses.field(default_factory=list) + """Symbols used to describe this unit.""" + + def __post_init__(self): + if self.factor is not None: + self.factor = decimal.Decimal(self.factor) + + def to_si(self, value): + return value * self.factor + + def from_si(self, value): + return value / self.factor + + def get_symbols(self): + yield self.name.replace("_", " "), Unit(self.factor) + yield from ((name, Unit(self.factor)) for name in self.symbols) + + +@dataclasses.dataclass +class MetricUnit(Unit): + """Like :class:`.Unit` but with metric prefixes like ``kilo`` for ``kilometre``.""" + + small_metric_symbol: List[str] = dataclasses.field(default_factory=list) + """ + List of symboles that are used with single letter metric prefixes, + such as ``m`` for ``km`` or ``μm``. + """ + + metric_prefix: List[str] = dataclasses.field(default_factory=list) + """ + List of symboles that are used with full words metric prefixes, + such as ``metre`` for ``kilometre``. + """ + + SI_PREFIXE_SYMBOLS = { + "y": decimal.Decimal("1e-24"), + "z": decimal.Decimal("1e-21"), + "a": decimal.Decimal("1e-18"), + "f": decimal.Decimal("1e-15"), + "p": decimal.Decimal("1e-12"), + "n": decimal.Decimal("1e-9"), + "u": decimal.Decimal("1e-6"), + "μ": decimal.Decimal("1e-6"), + "m": decimal.Decimal("1e-3"), + "c": decimal.Decimal("1e-2"), + "d": decimal.Decimal("1e-1"), + "da": decimal.Decimal("1e1"), + "h": decimal.Decimal("1e2"), + "k": decimal.Decimal("1e3"), + "M": decimal.Decimal("1e6"), + "G": decimal.Decimal("1e9"), + "T": decimal.Decimal("1e12"), + "P": decimal.Decimal("1e15"), + "E": decimal.Decimal("1e18"), + "Z": decimal.Decimal("1e21"), + "Y": decimal.Decimal("1e24"), + } - def __iadd__(self, other): - if isinstance(other, self.__class__): - self.standard += other.standard - return self - else: - raise TypeError( - "%(class)s must be added with %(class)s" % {"class": pretty_name(self)} - ) + SI_PREFIXES = { + "yocto": decimal.Decimal("1e-24"), + "zepto": decimal.Decimal("1e-21"), + "atto": decimal.Decimal("1e-18"), + "femto": decimal.Decimal("1e-15"), + "pico": decimal.Decimal("1e-12"), + "nano": decimal.Decimal("1e-9"), + "micro": decimal.Decimal("1e-6"), + "milli": decimal.Decimal("1e-3"), + "centi": decimal.Decimal("1e-2"), + "deci": decimal.Decimal("1e-1"), + "deca": decimal.Decimal("1e1"), + "hecto": decimal.Decimal("1e2"), + "kilo": decimal.Decimal("1e3"), + "mega": decimal.Decimal("1e6"), + "giga": decimal.Decimal("1e9"), + "tera": decimal.Decimal("1e12"), + "peta": decimal.Decimal("1e15"), + "exa": decimal.Decimal("1e18"), + "zeta": decimal.Decimal("1e21"), + "yotta": decimal.Decimal("1e24"), + } - def __sub__(self, other): - if isinstance(other, self.__class__): - return self.__class__( - default_unit=self._default_unit, - **{self.STANDARD_UNIT: (self.standard - other.standard)} - ) - else: - raise TypeError( - "%(class)s must be subtracted from %(class)s" - % {"class": pretty_name(self)} - ) + def get_symbols(self): + yield from super().get_symbols() + yield from ( + (f"{prefix}{s}", Unit(factor=self.factor * factor)) + for prefix, factor in self.SI_PREFIXE_SYMBOLS.items() + for s in self.small_metric_symbol + ) + yield from ( + (f"{prefix}{s}", Unit(factor=self.factor * factor)) + for prefix, factor in self.SI_PREFIXES.items() + for s in self.metric_prefix + ) + yield from ( + (f"{prefix}{s}".title(), Unit(factor=self.factor * factor)) + for prefix, factor in self.SI_PREFIXES.items() + for s in self.metric_prefix + ) - def __isub__(self, other): - if isinstance(other, self.__class__): - self.standard -= other.standard - return self - else: - raise TypeError( - "%(class)s must be subtracted from %(class)s" - % {"class": pretty_name(self)} - ) - def __mul__(self, other): - if isinstance(other, NUMERIC_TYPES): - return self.__class__( - default_unit=self._default_unit, - **{self.STANDARD_UNIT: (self.standard * other)} - ) - else: - raise TypeError( - "%(class)s must be multiplied with number" - % {"class": pretty_name(self)} - ) +class MeasureBase(type): + """ + Create Measure class by unpacking all symbols into a dictionary. - def __imul__(self, other): - if isinstance(other, NUMERIC_TYPES): - self.standard *= float(other) - return self - else: - raise TypeError( - "%(class)s must be multiplied with number" - % {"class": pretty_name(self)} - ) + Units can be added to measure by adding :class:`.AbstractUnit` instances as + class attributes to a Measure. This metaclass removes to attributes and + creates a dictionary mapping all sympbols to their corresponding + :class:`.AbstractUnit` implementation. - def __rmul__(self, other): - return self * other + Raises: + KeyError: If the same symbol is used for multiple units. - def __truediv__(self, other): - if isinstance(other, self.__class__): - return self.standard / other.standard - if isinstance(other, NUMERIC_TYPES): - return self.__class__( - default_unit=self._default_unit, - **{self.STANDARD_UNIT: (self.standard / other)} - ) - else: - raise TypeError( - "%(class)s must be divided with number or %(class)s" - % {"class": pretty_name(self)} - ) + """ - def __itruediv__(self, other): - if isinstance(other, NUMERIC_TYPES): - self.standard /= float(other) - return self - else: - raise TypeError( - "%(class)s must be divided with number" % {"class": pretty_name(self)} - ) - - def __bool__(self): - return bool(self.standard) - - def _convert_value_to(self, unit, value): - if not isinstance(value, float): - value = float(value) - - if isinstance(unit, sympy.Expr): - result = unit.evalf(subs={self.SU: value}) - return float(result) - return value / unit - - def _convert_value_from(self, unit, value): - if not isinstance(value, float): - value = float(value) - - if isinstance(unit, sympy.Expr): - _, result = solve_linear(unit, value) - return result - return unit * value - - def default_units(self, kwargs): - """Return the unit value and default units as specified by the given arguments.""" - aliases = self.get_aliases() - laliases = self.get_lowercase_aliases() - units = self.get_units() - val = 0.0 - default_unit = self.STANDARD_UNIT - for unit, value in kwargs.items(): - if unit in units: - val = self._convert_value_from(units[unit], value) - default_unit = unit - elif unit in aliases: - u = aliases[unit] - val = self._convert_value_from(units[u], value) - default_unit = u + def __new__(mcs, name, bases, attrs): + mcs.freeze_org_units(attrs) + symbols = ImmutableKeyDict() + new_attr = {} + for attr_name, attr in attrs.items(): + if isinstance(attr, AbstractUnit): + attr.name = attr_name + for symbol, unit in attr.get_symbols(): + unit.name = attr_name + symbols[symbol] = unit else: - lower = unit.lower() - if lower in units: - val = self._convert_value_from(units[lower], value) - default_unit = lower - elif lower in laliases: - u = laliases[lower] - val = self._convert_value_from(units[u], value) - default_unit = u - else: - raise AttributeError("Unknown unit type: %s" % unit) - return val, default_unit + new_attr[attr_name] = attr - @classmethod - def unit_attname(cls, unit_str): - """ - Retrieve the unit attribute name for the given unit string. + cls = super().__new__(mcs, name, bases, new_attr) + cls._units = symbols + return cls - For example, if the given unit string is 'metre', 'm' would be returned. - An exception is raised if an attribute cannot be found. - """ - laliases = cls.get_lowercase_aliases() - units = cls.get_units() - lower = unit_str.lower() - if unit_str in units: - return unit_str - elif lower in units: - return lower - elif lower in laliases: - return laliases[lower] - else: - raise Exception( - 'Could not find a unit keyword associated with "%s"' % (unit_str,) - ) + @staticmethod + def freeze_org_units(attrs: Dict[str, Any]): + if "_org_units" in attrs: + return + attrs["_org_units"] = { + attr_name: attr + for attr_name, attr in attrs.items() + if isinstance(attr, Unit) + } -@total_ordering -class BidimensionalMeasure(object): - PRIMARY_DIMENSION = None - REFERENCE_DIMENSION = None - - ALIAS = {} - def __init__(self, **kwargs): - if "primary" in kwargs and "reference" in kwargs: - self.primary = kwargs["primary"] - self.reference = kwargs["reference"] - else: - if len(kwargs) > 1: - raise ValueError("Only one keyword argument is expected") - measure_string, value = kwargs.popitem() - - self.primary, self.reference = self._get_measures(measure_string, value) +@total_ordering +class AbstractMeasure(metaclass=MeasureBase): + """Abstract super class to all measures.""" + + def __init__( + self, + value: Union[str, decimal.Decimal, int, None] = None, + unit: Optional[str] = None, + **kwargs: Union[str, decimal.Decimal, int, None], + ): + if kwargs: + unit, value = kwargs.popitem() + if unit is None: + value, unit = value.split(maxsplit=1) + value = decimal.Decimal(value) + + if isinstance(value, int): + value = decimal.Decimal(str(value)) + + if not isinstance(value, (decimal.Decimal, str, int)): + warnings.warn(f"'value' expects type Decimal not {qualname(value)}") + value = decimal.Decimal(value) + + unit = self._attr_to_unit(unit) + self.unit = self._units[unit] + self.unit.org_name = unit + self.si_value = self.unit.to_si(value) - def _get_unit_parts(self, measure_string): - if measure_string in self.ALIAS: - measure_string = self.ALIAS[measure_string] + def __getattr__(self, name): try: - primary_unit, reference_unit = measure_string.split("__") - except ValueError: + unit = self._units[self._attr_to_unit(name)] + except KeyError as e: raise AttributeError( - ( - "Unit not found: '%s';" - "Units should be expressed using double-underscore " - "separated units; for example: meters-per-second would be " - "expressed with either 'meter__second' or 'm__sec'." - ) - % (measure_string) - ) - return primary_unit, reference_unit - - def _get_measures(self, measure_string, value): - primary_unit, reference_unit = self._get_unit_parts(measure_string) - primary = self.PRIMARY_DIMENSION(**{primary_unit: value}) - reference = self.REFERENCE_DIMENSION(**{reference_unit: 1}) - - return primary, reference + f"{qualname(self)} object has no attribute '{name}'" + ) from e + else: + return unit.from_si(self.si_value) - @property - def standard(self): - return self.primary.standard / self.reference.standard + def __getitem__(self, item): + try: + unit = self._units[self._attr_to_unit(item)] + except KeyError as e: + raise KeyError(f"{qualname(self)} object has no key '{item}'") from e + else: + return unit.from_si(self.si_value) - @classproperty @classmethod - def STANDARD_UNIT(self): - return "%s__%s" % ( - self.PRIMARY_DIMENSION.STANDARD_UNIT, - self.REFERENCE_DIMENSION.STANDARD_UNIT, - ) + def _attr_to_unit(cls, name: str) -> str: + return name.replace("_", " ") - @property - def value(self): - return self.primary.value + unit = None + """Return :class:`~Unit` initially given to construct the measure.""" @property - def unit(self): - return "%s__%s" % (self.primary.unit, self.reference.unit,) - - @unit.setter - def unit(self, value): - primary, reference = value.split("__") - reference_units = self.REFERENCE_DIMENSION.get_units() - if reference != self.reference.unit: - reference_chg = ( - reference_units[self.reference.unit] / reference_units[reference] - ) - self.primary.standard = self.primary.standard / reference_chg - self.primary.unit = primary - self.reference.unit = reference - - def _normalize(self, other): - std_value = getattr(other, self.unit) - - primary = self.PRIMARY_DIMENSION(**{self.primary.unit: std_value}) - reference = self.REFERENCE_DIMENSION(**{self.reference.unit: 1}) - - return self.__class__(primary=primary, reference=reference) - - def __getattr__(self, measure_string): - primary_units = self.PRIMARY_DIMENSION.get_units() - reference_units = self.REFERENCE_DIMENSION.get_units() - - p1, r1 = self.primary.unit, self.reference.unit - p2, r2 = self._get_unit_parts(measure_string) - - primary_chg = primary_units[p2] / primary_units[p1] - reference_chg = reference_units[r2] / reference_units[r1] - - return self.primary.value / primary_chg * reference_chg + def _value(self) -> decimal.Decimal: + """Return :class:`~Decimal` value of measure in the given :attr:`.unit`.""" + return getattr(self, self.unit.name) def __repr__(self): - return "%s(%s__%s=%s)" % ( - pretty_name(self), - self.primary.unit, - self.reference.unit, - self.primary.value, - ) + return f'{qualname(self)}({self.unit.name}="{getattr(self, self.unit.name)}")' def __str__(self): - return "%s %s/%s" % ( - self.primary.value, - self.primary.unit, - self.reference.unit, - ) + return "%s %s" % (getattr(self, self.unit.org_name), self.unit.org_name) + + def __format__(self, format_spec): + decimal_format = getattr(self, self.unit.org_name).__format__(format_spec) + return f"{decimal_format} {self.unit.org_name}" def __eq__(self, other): - if isinstance(other, self.__class__): - return self.standard == other.standard - else: + if not isinstance(other, type(self)): return NotImplemented + return self.si_value == other.si_value def __lt__(self, other): - if isinstance(other, self.__class__): - return self.standard < other.standard - else: + if not isinstance(other, type(self)): return NotImplemented + return self.si_value < other.si_value + + def __gt__(self, other): + if not isinstance(other, type(self)): + return NotImplemented + return self.si_value > other.si_value def __add__(self, other): - if isinstance(other, self.__class__): - normalized = self._normalize(other) - total_value = normalized.primary.value + self.primary.value - return self.__class__( - primary=self.PRIMARY_DIMENSION(**{self.primary.unit: total_value}), - reference=self.REFERENCE_DIMENSION(**{self.reference.unit: 1}), - ) - else: - raise TypeError( - "%(class)s must be added with %(class)s" % {"class": pretty_name(self)} - ) + if not isinstance(other, type(self)): + raise TypeError(f"can't add type '{qualname(self)}' to '{qualname(other)}'") + return type(self)( + value=self._value + getattr(other, self.unit.name), unit=self.unit.name + ) def __iadd__(self, other): - if isinstance(other, self.__class__): - normalized = self._normalize(other) - self.primary.standard += normalized.primary.standard - return self - else: - raise TypeError( - "%(class)s must be added with %(class)s" % {"class": pretty_name(self)} - ) + return self + other def __sub__(self, other): - if isinstance(other, self.__class__): - normalized = self._normalize(other) - total_value = self.primary.value - normalized.primary.value - return self.__class__( - primary=self.PRIMARY_DIMENSION(**{self.primary.unit: total_value}), - reference=self.REFERENCE_DIMENSION(**{self.reference.unit: 1}), - ) - else: + if not isinstance(other, type(self)): raise TypeError( - "%(class)s must be added with %(class)s" % {"class": pretty_name(self)} + f"can't substract type '{qualname(other)}' from '{qualname(self)}'" ) + return type(self)( + value=self._value - getattr(other, self.unit.name), unit=self.unit.name + ) + def __isub__(self, other): - if isinstance(other, self.__class__): - normalized = self._normalize(other) - self.primary.standard -= normalized.primary.standard - return self - else: - raise TypeError( - "%(class)s must be added with %(class)s" % {"class": pretty_name(self)} - ) + return self - other def __mul__(self, other): - if isinstance(other, NUMERIC_TYPES): - total_value = self.primary.value * other - return self.__class__( - primary=self.PRIMARY_DIMENSION(**{self.primary.unit: total_value}), - reference=self.REFERENCE_DIMENSION(**{self.reference.unit: 1}), - ) - else: + try: + value = getattr(self, self.unit.org_name) * other + except TypeError as e: raise TypeError( - "%(class)s must be multiplied with number" - % {"class": pretty_name(self)} - ) + f"can't multiply type '{qualname(self)}' and '{qualname(other)}'" + ) from e + return type(self)(value=value, unit=self.unit.org_name) - def __rmul__(self, other): + def __imul__(self, other): return self * other - def __imul__(self, other): - if isinstance(other, NUMERIC_TYPES): - self.primary.standard *= float(other) - return self - else: - raise TypeError( - "%(class)s must be multiplied with number" - % {"class": pretty_name(self)} - ) + def __rmul__(self, other): + return self * other def __truediv__(self, other): - if isinstance(other, self.__class__): - normalized = self._normalize(other) - return self.primary.standard / normalized.primary.standard - if isinstance(other, NUMERIC_TYPES): - total_value = self.primary.value / other - return self.__class__( - primary=self.PRIMARY_DIMENSION(**{self.primary.unit: total_value}), - reference=self.REFERENCE_DIMENSION(**{self.reference.unit: 1}), - ) - else: + if isinstance(other, type(self)): + return self.si_value / other.si_value + try: + value = getattr(self, self.unit.org_name) / other + except TypeError as e: raise TypeError( - "%(class)s must be divided with number or %(class)s" - % {"class": pretty_name(self)} - ) + f"can't devide type '{qualname(self)}' by '{qualname(other)}'" + ) from e + else: + return type(self)(value=value, unit=self.unit.org_name) def __itruediv__(self, other): - if isinstance(other, NUMERIC_TYPES): - self.primary.standard /= float(other) - return self - else: - raise TypeError( - "%(class)s must be divided with number" % {"class": pretty_name(self)} - ) + return self / other + + def __rtruediv__(self, other): + return self / other def __bool__(self): - return bool(self.primary.standard) + return bool(self.si_value) diff --git a/measurement/measures/__init__.py b/measurement/measures/__init__.py index 30cf530..c136c25 100644 --- a/measurement/measures/__init__.py +++ b/measurement/measures/__init__.py @@ -1,15 +1,7 @@ -from measurement.measures.capacitance import * -from measurement.measures.current import * -from measurement.measures.distance import * -from measurement.measures.energy import * -from measurement.measures.frequency import * -from measurement.measures.mass import * -from measurement.measures.pressure import * -from measurement.measures.radioactivity import * -from measurement.measures.resistance import * -from measurement.measures.speed import * -from measurement.measures.temperature import * -from measurement.measures.time import * -from measurement.measures.voltage import * -from measurement.measures.volume import * -from measurement.measures.volumetric_flow import * +from .electromagnetism import * # NoQA +from .energy import * # NoQA +from .geometry import * # NoQA +from .mechanics import * # NoQA +from .radioactivity import * # NoQA +from .temperature import * # NoQA +from .time import * # NoQA diff --git a/measurement/measures/capacitance.py b/measurement/measures/capacitance.py deleted file mode 100644 index 42f0966..0000000 --- a/measurement/measures/capacitance.py +++ /dev/null @@ -1,14 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = ["Capacitance"] - - -class Capacitance(MeasureBase): - STANDARD_UNIT = "F" - UNITS = { - "F": 1.0, - } - ALIAS = { - "farad": "F", - } - SI_UNITS = ["F"] diff --git a/measurement/measures/current.py b/measurement/measures/current.py deleted file mode 100644 index 62dcce6..0000000 --- a/measurement/measures/current.py +++ /dev/null @@ -1,15 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = ["Current"] - - -class Current(MeasureBase): - STANDARD_UNIT = "A" - UNITS = { - "A": 1.0, - } - ALIAS = { - "amp": "A", - "ampere": "A", - } - SI_UNITS = ["A"] diff --git a/measurement/measures/distance.py b/measurement/measures/distance.py deleted file mode 100644 index f1a4850..0000000 --- a/measurement/measures/distance.py +++ /dev/null @@ -1,156 +0,0 @@ -# Copyright (c) 2007, Robert Coup -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without modification, -# are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# 3. Neither the name of Distance nor the names of its contributors may be used -# to endorse or promote products derived from this software without -# specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -""" -Distance and Area objects to allow for sensible and convenient calculation and conversions. - -Authors: Robert Coup, Justin Bronn, Riccardo Di Virgilio - -Inspired by GeoPy (http://exogen.case.edu/projects/geopy/) -and Geoff Biggs' PhD work on dimensioned units for robotics. -""" -from measurement.base import NUMERIC_TYPES, MeasureBase, pretty_name - -__all__ = [ - "Distance", - "Area", -] - - -AREA_PREFIX = "sq_" - - -class Distance(MeasureBase): - STANDARD_UNIT = "m" - UNITS = { - "chain": 20.1168, - "chain_benoit": 20.116782, - "chain_sears": 20.1167645, - "british_chain_benoit": 20.1167824944, - "british_chain_sears": 20.1167651216, - "british_chain_sears_truncated": 20.116756, - "british_ft": 0.304799471539, - "british_yd": 0.914398414616, - "clarke_ft": 0.3047972654, - "clarke_link": 0.201166195164, - "fathom": 1.8288, - "ft": 0.3048, - "german_m": 1.0000135965, - "gold_coast_ft": 0.304799710181508, - "indian_yd": 0.914398530744, - "inch": 0.0254, - "link": 0.201168, - "link_benoit": 0.20116782, - "link_sears": 0.20116765, - "m": 1.0, - "mi": 1609.344, - "nm_uk": 1853.184, - "rod": 5.0292, - "sears_yd": 0.91439841, - "survey_ft": 0.304800609601, - "yd": 0.9144, - } - SI_UNITS = ["m"] - - # Unit aliases for `UNIT` terms encountered in Spatial Reference WKT. - ALIAS = { - "foot": "ft", - "inches": "inch", - "in": "inch", - "meter": "m", - "metre": "m", - "mile": "mi", - "yard": "yd", - "British chain (Benoit 1895 B)": "british_chain_benoit", - "British chain (Sears 1922)": "british_chain_sears", - "British chain (Sears 1922 truncated)": ("british_chain_sears_truncated"), - "British foot (Sears 1922)": "british_ft", - "British foot": "british_ft", - "British yard (Sears 1922)": "british_yd", - "British yard": "british_yd", - "Clarke's Foot": "clarke_ft", - "Clarke's link": "clarke_link", - "Chain (Benoit)": "chain_benoit", - "Chain (Sears)": "chain_sears", - "Foot (International)": "ft", - "German legal metre": "german_m", - "Gold Coast foot": "gold_coast_ft", - "Indian yard": "indian_yd", - "Link (Benoit)": "link_benoit", - "Link (Sears)": "link_sears", - "Nautical Mile": "nm", - "Nautical Mile (UK)": "nm_uk", - "US survey foot": "survey_ft", - "U.S. Foot": "survey_ft", - "Yard (Indian)": "indian_yd", - "Yard (Sears)": "sears_yd", - } - - def __mul__(self, other): - if isinstance(other, self.__class__): - return Area( - default_unit=AREA_PREFIX + self._default_unit, - **{AREA_PREFIX + self.STANDARD_UNIT: (self.standard * other.standard)} - ) - elif isinstance(other, NUMERIC_TYPES): - return self.__class__( - default_unit=self._default_unit, - **{self.STANDARD_UNIT: (self.standard * other)} - ) - else: - raise TypeError( - "%(dst)s must be multiplied with number or %(dst)s" - % {"dst": pretty_name(self.__class__),} - ) - - -class Area(MeasureBase): - STANDARD_UNIT = AREA_PREFIX + Distance.STANDARD_UNIT - # Getting the square units values and the alias dictionary. - UNITS = { - **{"%s%s" % (AREA_PREFIX, k): v ** 2 for k, v in Distance.get_units().items()}, - "acre": 43560 * (Distance(ft=1).m ** 2), - "hectare": (10000), # 10,000 sq_m - } - ALIAS = { - **{k: "%s%s" % (AREA_PREFIX, v) for k, v in Distance.get_aliases().items()}, - "Acre": "acre", - "Hectare": "hectare", - "ha": "hectare", - } - - def __truediv__(self, other): - if isinstance(other, NUMERIC_TYPES): - return self.__class__( - default_unit=self._default_unit, - **{self.STANDARD_UNIT: (self.standard / other)} - ) - else: - raise TypeError( - "%(class)s must be divided by a number" % {"class": pretty_name(self)} - ) diff --git a/measurement/measures/electromagnetism.py b/measurement/measures/electromagnetism.py new file mode 100644 index 0000000..9bca7b7 --- /dev/null +++ b/measurement/measures/electromagnetism.py @@ -0,0 +1,78 @@ +from measurement.base import AbstractMeasure, MetricUnit + +__all__ = [ + "Capacitance", + "Current", + "ElectricPower", + "Inductance", + "Resistance", + "Voltage", +] + + +class Capacitance(AbstractMeasure): + farad = MetricUnit("1", ["F", "Farad"], ["F"], ["farad"]) + + +class Current(AbstractMeasure): + ampere = MetricUnit("1", ["A", "amp", "Ampere"], ["A"], ["ampere", "amp"]) + + def __mul__(self, other): + if isinstance(other, Voltage): + return ElectricPower(self.si_value * other.si_value, "W") + return super().__mul__(other) + + +class Resistance(AbstractMeasure): + ohm = MetricUnit("1", ["Ohm", "Ω"], ["Ω"], ["ohm"]) + + +class Voltage(AbstractMeasure): + volt = MetricUnit("1", ["V", "Volt"], ["V"], ["volt"]) + + def __mul__(self, other): + if isinstance(other, Current): + return ElectricPower(self.si_value * other.si_value, "W") + return super().__mul__(other) + + +class Inductance(AbstractMeasure): + henry = MetricUnit("1", ["H", "Henry"], ["H"], ["henry"]) + + +class ElectricPower(AbstractMeasure): + """ + Electric power can is defined as :class:`Voltage` multiplied by :class:`Current`. + + This is why you can devided :class:`Current` to get the :class:`Voltage` + by :class:`Voltage` to get the :class:`Current` + or by :class:`Current` to get the :class:`Voltage`: + + >>> from measurement import measures + >>> measures.ElectricPower('24 W') / measures.Voltage('12 V') + Current(ampere="2") + >>> measures.ElectricPower('24 W') / measures.Current('4 A') + Voltage(volt="6") + + + Analog to this, you can also multiply both :class:`Current` + and :class:`Voltage` to get :class:`Current` to get the :class:`Voltage`: + + >>> from measurement import measures + >>> measures.Current('2 A') * measures.Voltage('12 V') + ElectricPower(watt="24") + + """ + + watt = MetricUnit( + "1", ["W", "VA", "Watt", "Voltampere"], ["W", "VA"], ["watt", "voltampere"] + ) + + def __truediv__(self, other): + if isinstance(other, Current): + return Voltage(volt=self.si_value / other.si_value) + + if isinstance(other, Voltage): + return Current(ampere=self.si_value / other.si_value) + + return super().__truediv__(other) diff --git a/measurement/measures/energy.py b/measurement/measures/energy.py index 92d0d49..91abba5 100644 --- a/measurement/measures/energy.py +++ b/measurement/measures/energy.py @@ -1,20 +1,20 @@ -from measurement.base import MeasureBase +from measurement.base import AbstractMeasure, MetricUnit, Unit -__all__ = ["Energy"] +__all__ = ["Energy", "Heat"] -class Energy(MeasureBase): - STANDARD_UNIT = "J" - UNITS = { - "c": 4.18400, - "C": 4184.0, - "J": 1.0, - "eV": 1.602177e-19, - "tonne_tnt": 4184000000, - } - ALIAS = { - "joule": "J", - "calorie": "c", - "Calorie": "C", - } - SI_UNITS = ["J", "c", "eV", "tonne_tnt"] +class Energy(AbstractMeasure): + joule = MetricUnit("1", ["J", "Joule"], ["J"], ["joule"]) + calorie = MetricUnit( + "4184.0", ["c", "cal", "Cal", "Calorie", "C"], ["cal"], ["calorie"] + ) + electronvolt = MetricUnit( + "1.602177e-19", + ["eV", "electron-volt", "electron volt"], + ["eV"], + ["electronvolt"], + ) + tonne_tnt = Unit("4184000000") + + +Heat = Energy diff --git a/measurement/measures/frequency.py b/measurement/measures/frequency.py deleted file mode 100644 index 30b7d24..0000000 --- a/measurement/measures/frequency.py +++ /dev/null @@ -1,15 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = ["Frequency"] - - -class Frequency(MeasureBase): - STANDARD_UNIT = "Hz" - UNITS = { - "Hz": 1.0, - "rpm": 1.0 / 60, - } - ALIAS = { - "hertz": "Hz", - } - SI_UNITS = ["Hz"] diff --git a/measurement/measures/geometry.py b/measurement/measures/geometry.py new file mode 100644 index 0000000..2184e0c --- /dev/null +++ b/measurement/measures/geometry.py @@ -0,0 +1,258 @@ +import decimal + +from measurement.base import AbstractMeasure, MeasureBase, MetricUnit, Unit + +__all__ = ["Distance", "Area", "Volume"] + + +class Distance(AbstractMeasure): + """ + A distance the factor for both :class:`Area` and :class:`Volume`. + + If you multiply a :class:`Distance` with another :class:`Distance`, + you will get an :class:`Area`: + + >>> from measurement import measures + >>> measures.Distance('2 m') * measures.Distance('3 m') + Area(metre²="6") + + If you multiply a :class:`Distance` with an :class:`Area` or two + :class:`Distances`, you will get an :class:`Volume`: + + >>> from measurement import measures + >>> measures.Distance('2 m') * measures.Area('6 m²') + Volume(metre³="12") + + You can also build the second and thrid power of a :class:`Distance`, + to get an :class:`Area` or :class:`Volume`. + + >>> from measurement import measures + >>> measures.Distance('2 m') ** 2 + Area(metre²="4") + >>> measures.Distance('2 m') ** 3 + Volume(metre³="8") + + """ + + metre = MetricUnit("1", ["m", "meter", "Meter", "Metre"], ["m"], ["metre", "meter"]) + parsec = MetricUnit("3.0857E+16", ["Parsec", "pc"], ["pc"], ["parsec"]) + astronomical_unit = MetricUnit( + "1.495978707E+11", ["au", "ua", "AU"], ["au", "ua", "AU"] + ) + foot = Unit("0.3048", ["ft", "feet", "Foot (International)"]) + inch = Unit("0.0254", ["in", "inches"]) + mile = Unit("1609.344", ["mi"]) + chain = Unit("20.1168") + chain_benoit = Unit("20.116782", ["Chain (Benoit)"]) + chain_sears = Unit("20.1167645", ["Chain (Sears)"]) + british_chain_benoit = Unit("20.1167824944", ["British chain (Benoit 1895 B)"]) + british_chain_sears = Unit("20.1167651216", ["British chain (Sears 1922)"]) + british_chain_sears_truncated = Unit( + "20.116756", ["British chain (Sears 1922 truncated)"] + ) + british_foot = Unit( + "0.304799471539", ["british_ft", "British foot", "British foot (Sears 1922)"] + ) + british_yard = Unit( + "0.914398414616", ["british_yd", "British yard", "British yard (Sears 1922)"] + ) + clarke_ft = Unit("0.3047972654", ["Clarke's Foot"]) + clarke_link = Unit("0.201166195164", ["Clarke's link"]) + fathom = Unit("1.8288") + german_meter = Unit("1.0000135965", ["german_m", "German legal metre"]) + gold_coast_foot = Unit("0.304799710181508", ["gold_coast_ft", "Gold Coast foot"]) + indian_yard = Unit("0.914398530744", ["indian_yd", "Indian yard", "Yard (Indian)"]) + link = Unit("0.201168", ["Link"]) + link_benoit = Unit("0.20116782", ["Link (Benoit)"]) + link_sears = Unit("0.20116765", ["Link (Sears)"]) + nautical_mile = Unit("1852", ["Nautical Mile", "NM", "nmi"]) + nautical_mile_uk = Unit("1853.184", ["nm_uk", "Nautical Mile (UK)"]) + rod = Unit("5.0292") + sears_yard = Unit("0.91439841", ["sears_yd", "Yard (Sears"]) + survey_foot = Unit("0.304800609601", ["survey_ft", "US survey foot", "U.S. Foot"]) + yard = Unit("0.9144", ["yd"]) + + def __mul__(self, other): + if isinstance(other, Distance): + return Area(sq_metre=self.si_value * other.si_value) + + if isinstance(other, Area): + return Volume(cubic_metre=self.si_value * other.si_value) + + return super().__mul__(other) + + def __pow__(self, power, modulo=None): + if power == 2: + return self * self + if power == 3: + return self * self * self + return NotImplemented + + +class AreaBase(MeasureBase): + def __new__(mcs, name, bases, attrs): + mcs.freeze_org_units(attrs) + x, y = attrs["__factors__"] + if x is y: + attrs.update(mcs.square(x)) + + cls = super().__new__(mcs, name, bases, attrs) + return cls + + @staticmethod + def square(klass): + for name, unit in klass._units.items(): + qs_unit = Unit(factor=unit.factor ** 2) + qs_unit.name = f"{qs_unit.name}²" + yield f"{name}²", qs_unit + + +class Area(AbstractMeasure, metaclass=AreaBase): + """ + An area is defined as :class:`Distance` multipled by :class:`Distance`. + + This is why you can multiple two :class:`Distances` to get + an :class:`Area` or devide an :class:`Area` to get a :class:`Distance`: + + >>> from measurement import measures + >>> measures.Distance('2 m') * measures.Distance('3 m') + Area(metre²="6") + >>> measures.Area('6 m²') / measures.Distance('2 m') + Distance(metre="3") + + If if multiple an :class:`Area` with a :class:`Distance`, + you will get a :class:`Volume`: + + >>> measures.Area('6 m²') * measures.Distance('2 m') + Volume(metre³="12") + + """ + + __factors__ = (Distance, Distance) + + acre = Unit( + (decimal.Decimal("43560") * (Distance(ft=decimal.Decimal(1)).m ** 2)), ["Acre"], + ) + hectare = Unit("10000", ["Hectare", "ha"]) + + @classmethod + def _attr_to_unit(cls, name): + if name[:3] == "sq_": + name = f"{name[3:]}²" + return super()._attr_to_unit(name) + + def __truediv__(self, other): + if isinstance(other, Distance): + return Distance(metre=self.si_value / other.si_value) + + return super().__truediv__(other) + + def __mul__(self, other): + if isinstance(other, Distance): + return Volume(cubic_metre=self.si_value * other.si_value) + + return super().__mul__(other) + + +class VolumeBase(MeasureBase): + def __new__(mcs, name, bases, attrs): + mcs.freeze_org_units(attrs) + if "__factors__" in attrs: + x, y, z = attrs["__factors__"] + attrs.update(mcs.cubic(x)) + cls = super().__new__(mcs, name, bases, attrs) + return cls + + @staticmethod + def cubic(klass): + for name, unit in klass._units.items(): + qs_unit = Unit(factor=unit.factor ** 3) + qs_unit.name = f"{qs_unit.name}³" + yield f"{name}³", qs_unit + + +class Volume(AbstractMeasure, metaclass=VolumeBase): + """ + A volume is defined as :class:`Area` multipled by :class:`Distance`. + + This is why you can multiple three :class:`Distances` to get + a :class:`Volume`, multiple an :class:`Area` by a :class:`Distance` + or devide a :class:`Volume` by both :class:`Distance` and :class:`Area`: + + >>> from measurement import measures + >>> measures.Distance('2 m') * measures.Distance('3 m') * measures.Distance('4 m') + Volume(metre³="24") + >>> measures.Distance('2 m') * measures.Area('6 m²') + Volume(metre³="12") + >>> measures.Volume('12 m³') / measures.Area('6 m²') + Distance(metre="2") + >>> measures.Volume('12 m³') / measures.Distance('6 m') + Area(metre²="2") + + """ + + __factors__ = (Distance, Distance, Distance) + + litre = MetricUnit( + "1e-3", ["liter", "L", "l", "ℓ"], ["L", "l", "ℓ"], ["litre", "liter"] + ) + + us_gallon = Unit( + "3.785411784e-3", ["US gallon", "US gal", "US fluid gallon", "gallon (US)"] + ) + us_fluid_ounce = Unit("29.57353e-6", ["US oz", "US fl oz"]) + us_fluid_ounce_food = Unit("30e-6", ["US fluid ounce (food nutrition labelling)"]) + us_liquid_quart = Unit("0.946352946e-3", ["US liquid quart"]) + us_liquid_pint = Unit("473.176473e-6") + us_gill = Unit("118.29411825e-6") + us_tablespoon = Unit("14.78676478125e-6", ["US tablespoon", "Us tbsp", "Us Tbsp"]) + us_tsp = Unit("4.92892159375e-6", ["US tsp"]) + us_fluid_darm = Unit("3.6966911953125e-6") + + us_dry_gallon = Unit( + "4.40488377086e-3", ["US dry gallon", "corn gallon", "grain gallon"] + ) + us_dry_quart = Unit("1.101220942715e-3") + us_dry_pint = Unit("550.6104713575e-6") + us_bushel = Unit("35.23907016688e-3", ["US bsh", "US bu"]) + + cubic_inch = Unit("16.387064e-6", ["cu in"]) + cubic_foot = Unit("0.02832", ["cu ft"]) + + imperial_gallon = Unit( + "4.54609e-3", ["Imperial gallon", "Imperial gal", "gallon (Imperial)"] + ) + imperial_fluid_ounce = Unit("28.41306e-6", ["Imperial fluid ounce", "imp fl oz"]) + imperial_quart = Unit("1.1365225e-6", ["Imperial quart"]) + imperial_pint = Unit("568.26125e-6", ["Imperial pint"]) + imperial_gill = Unit("142.0653125e-6", ["Imperial gill"]) + imperial_bushel = Unit("36.36872e-3", ["imp bsh", "imp bu"]) + imperial_fluid_darm = Unit("3.5516328125e-6", ["luid drachm"]) + + au_tablespoon = Unit("20e-6", ["Australian tablespoon", "Australian tbsp"]) + au_teaspoon = Unit( + "5e-6", + [ + "US tsp (food nutrition labelling)", + "metic teaspoon", + "metric tsp", + "Australian tsp", + ], + ) + + oil_barrel = Unit("158.987294928e-3", ["oil bbl", "bbl"]) + + @classmethod + def _attr_to_unit(cls, name): + if name[:6] in ["cubic_", "cubic "]: + name = f"{name[6:]}³" + return super()._attr_to_unit(name) + + def __truediv__(self, other): + if isinstance(other, Distance): + return Area(sq_metre=self.si_value / other.si_value) + + if isinstance(other, Area): + return Distance(metre=self.si_value / other.si_value) + + return super().__truediv__(other) diff --git a/measurement/measures/mass.py b/measurement/measures/mass.py deleted file mode 100644 index 3589925..0000000 --- a/measurement/measures/mass.py +++ /dev/null @@ -1,35 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = [ - "Mass", - "Weight", -] - - -class Mass(MeasureBase): - STANDARD_UNIT = "g" - UNITS = { - "g": 1.0, - "tonne": 1000000.0, - "oz": 28.3495, - "lb": 453.592, - "stone": 6350.29, - "short_ton": 907185.0, - "long_ton": 1016000.0, - } - ALIAS = { - "mcg": "ug", - "gram": "g", - "ton": "short_ton", - "metric tonne": "tonne", - "metric ton": "tonne", - "ounce": "oz", - "pound": "lb", - "short ton": "short_ton", - "long ton": "long_ton", - } - SI_UNITS = ["g"] - - -# For backward compatibility -Weight = Mass diff --git a/measurement/measures/mechanics.py b/measurement/measures/mechanics.py new file mode 100644 index 0000000..ef8c57c --- /dev/null +++ b/measurement/measures/mechanics.py @@ -0,0 +1,71 @@ +import decimal + +from measurement.base import AbstractMeasure, MeasureBase, MetricUnit, Unit + +from .geometry import Distance, Volume +from .time import Time + +__all__ = ["Mass", "Pressure", "VolumetricFlowRate", "Speed"] + + +class FractionMeasureBase(MeasureBase): + def __new__(mcs, name, bases, attrs): + mcs.freeze_org_units(attrs) + numerator = attrs["__numerator__"] + denomminator = attrs["__denomimator__"] + attrs.update(mcs.div(numerator, denomminator)) + + cls = super().__new__(mcs, name, bases, attrs) + return cls + + @staticmethod + def div(numerator, denomminator): + for numerator_name, numerator_unit in numerator._units.items(): + for (denomminator_name, denomminator_unit,) in denomminator._units.items(): + name = f"{numerator_name}/{denomminator_name}" + factor = numerator_unit.factor / denomminator_unit.factor + unit = Unit(factor=factor) + unit.name = name + yield name, unit + + +class VolumetricFlowRate(AbstractMeasure, metaclass=FractionMeasureBase): + """Volumetric Flow measurements (generally for water flow).""" + + __numerator__ = Volume + __denomimator__ = Time + + @classmethod + def _attr_to_unit(cls, name): + return super()._attr_to_unit(name.replace("__", "/")) + + +class Speed(AbstractMeasure, metaclass=FractionMeasureBase): + __numerator__ = Distance + __denomimator__ = Time + + mph = Unit("0.44704") + kph = Unit(1 / decimal.Decimal("3.6")) + + @classmethod + def _attr_to_unit(cls, name): + return super()._attr_to_unit(name.replace("__", "/")) + + +class Mass(AbstractMeasure): + gram = MetricUnit("1", ["g", "Gram"], ["g"], ["gram"]) + tonne = Unit("1000000", ["t", "metric ton", "metric tonne"]) + ounce = Unit("28.34952", ["oz"]) + pound = Unit("453.59237", ["lb"]) + stone = Unit("6350.293") + short_ton = Unit("907185.0", ["ton"]) + long_ton = Unit("1016000.0") + + +class Pressure(AbstractMeasure): + pascal = MetricUnit("1", ["pa"], ["pa"], ["pascal"]) + bar = Unit("100000") + atmosphere = Unit("101325", ["atm"]) + technical_atmosphere = Unit("98066.5", ["at"]) + torr = Unit("133.322") + psi = Unit("6894.757293168", ["pounds per square inch"]) diff --git a/measurement/measures/pressure.py b/measurement/measures/pressure.py deleted file mode 100644 index 5290fc8..0000000 --- a/measurement/measures/pressure.py +++ /dev/null @@ -1,26 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = ["Pressure"] - - -class Pressure(MeasureBase): - """Pressure measurements.""" - - STANDARD_UNIT = "pa" - UNITS = { - "pa": 1.0, - "bar": 100000, - "at": 98066.5, - "atm": 101325, - "torr": 133.322, - "psi": 6894.757293168, - } - ALIAS = { - "pascal": "pa", - "bar": "bar", - "technical atmosphere": "at", - "atmosphere": "atm", - "torr": "torr", - "pounds per square inch": "psi", - } - SI_UNITS = ["pa"] diff --git a/measurement/measures/radioactivity.py b/measurement/measures/radioactivity.py index d6ab804..27c5463 100644 --- a/measurement/measures/radioactivity.py +++ b/measurement/measures/radioactivity.py @@ -1,24 +1,11 @@ -from measurement.base import MeasureBase +from measurement.base import AbstractMeasure, MetricUnit __all__ = ["Radioactivity"] -class Radioactivity(MeasureBase): +class Radioactivity(AbstractMeasure): """Radioactivity measurements.""" - STANDARD_UNIT = "bq" - UNITS = { - "bq": 1, - "ci": 37000000000, - "rd": 1000000, - "dpm": 1 / 60, - } - ALIAS = { - "becquerel": "bq", - "Bq": "bq", - "curie": "ci", - "Ci": "ci", - "rutherford": "rd", - "disintegrations per minute": "dpm", - } - SI_UNITS = ["bq"] + becquerel = MetricUnit("1", ["Bq"], ["Bq"]) + curie = MetricUnit("37000000000", ["Ci"], ["Ci"]) + rutherford = MetricUnit("1000000", ["Rd"], ["Rd"]) diff --git a/measurement/measures/resistance.py b/measurement/measures/resistance.py deleted file mode 100644 index efc3739..0000000 --- a/measurement/measures/resistance.py +++ /dev/null @@ -1,12 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = ["Resistance"] - - -class Resistance(MeasureBase): - STANDARD_UNIT = "ohm" - UNITS = { - "ohm": 1.0, - } - ALIAS = {} - SI_UNITS = ["ohm"] diff --git a/measurement/measures/speed.py b/measurement/measures/speed.py deleted file mode 100644 index 6c947a0..0000000 --- a/measurement/measures/speed.py +++ /dev/null @@ -1,15 +0,0 @@ -from measurement.base import BidimensionalMeasure -from measurement.measures.distance import Distance -from measurement.measures.time import Time - -__all__ = ["Speed"] - - -class Speed(BidimensionalMeasure): - PRIMARY_DIMENSION = Distance - REFERENCE_DIMENSION = Time - - ALIAS = { - "mph": "mi__hr", - "kph": "km__hr", - } diff --git a/measurement/measures/temperature.py b/measurement/measures/temperature.py index b8afbca..ad70491 100644 --- a/measurement/measures/temperature.py +++ b/measurement/measures/temperature.py @@ -1,16 +1,41 @@ -from sympy import S, Symbol +import dataclasses +import decimal +from typing import List -from measurement.base import MeasureBase +from ..base import AbstractMeasure, AbstractUnit, MetricUnit __all__ = ["Temperature"] -class Temperature(MeasureBase): - SU = Symbol("kelvin") - STANDARD_UNIT = "k" - UNITS = {"c": SU - S(273.15), "f": (SU - S(273.15)) * S("9/5") + 32, "k": 1.0} - ALIAS = { - "celsius": "c", - "fahrenheit": "f", - "kelvin": "k", - } +@dataclasses.dataclass +class DegreeUnit(AbstractUnit): + symbols: List[str] = dataclasses.field(default_factory=list) + """Symbols used to describe this unit.""" + + def get_symbols(self): + yield self.name.replace("_", " "), type(self)() + yield from ((name, type(self)()) for name in self.symbols) + + +class DegreeCelcius(DegreeUnit): + def to_si(self, value): + return value + decimal.Decimal("273.15") + + def from_si(self, value): + return value - decimal.Decimal("273.15") + + +class DegreeFahrenheit(DegreeUnit): + def to_si(self, value): + celsius = (value - 32) * 5 / 9 + return celsius + decimal.Decimal("273.15") + + def from_si(self, value): + celsius = value - decimal.Decimal("273.15") + return celsius * 9 / 5 + 32 + + +class Temperature(AbstractMeasure): + kelvin = MetricUnit("1", ["K", "Kelvin"], ["K"], ["kelvin"]) + celsius = DegreeCelcius(["°C"]) + fahrenheit = DegreeFahrenheit(["°F"]) diff --git a/measurement/measures/time.py b/measurement/measures/time.py index 42660a8..a314d25 100644 --- a/measurement/measures/time.py +++ b/measurement/measures/time.py @@ -1,11 +1,11 @@ -from measurement.base import MeasureBase +import decimal -__all__ = [ - "Time", -] +from measurement.base import AbstractMeasure, MetricUnit, Unit +__all__ = ["Time", "Frequency"] -class Time(MeasureBase): + +class Time(AbstractMeasure): """ Time measurements (generally for multidimensional measures). @@ -14,13 +14,23 @@ class Time(MeasureBase): functionality for handling intervals of time than this class provides. """ - STANDARD_UNIT = "s" - UNITS = {"s": 1.0, "min": 60.0, "hr": 3600.0, "day": 86400.0} - ALIAS = { - "second": "s", - "sec": "s", # For backward compatibility - "minute": "min", - "hour": "hr", - "day": "day", - } - SI_UNITS = ["s"] + second = Unit("1", ["s", "sec", "seconds"]) + minute = Unit("60", ["min", "minutes"]) + hour = Unit("3600", ["hr", "h", "hours"]) + day = Unit("86400", ["d", "days"]) + julian_year = MetricUnit( + "31557600", + ["year", "a", "aj", "years", "annum", "Julian year"], + ["a"], + ["annum"], + ) + + +class Frequency(AbstractMeasure): + hertz = MetricUnit("1", ["Hz", "Hertz"], ["Hz"], ["hertz"]) + rpm = Unit(decimal.Decimal("1.0") / decimal.Decimal("60"), ["RPM", "bpm", "BPM"]) + + def __mul__(self, other): + if isinstance(other, Time): + return self.si_value * other.si_value + return super().__mul__(other) diff --git a/measurement/measures/voltage.py b/measurement/measures/voltage.py deleted file mode 100644 index 6c75c1d..0000000 --- a/measurement/measures/voltage.py +++ /dev/null @@ -1,10 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = ["Voltage"] - - -class Voltage(MeasureBase): - STANDARD_UNIT = "V" - UNITS = {"V": 1.0} - ALIAS = {"volt": "V"} - SI_UNITS = ["V"] diff --git a/measurement/measures/volume.py b/measurement/measures/volume.py deleted file mode 100644 index 37a7858..0000000 --- a/measurement/measures/volume.py +++ /dev/null @@ -1,68 +0,0 @@ -from measurement.base import MeasureBase - -__all__ = [ - "Volume", -] - - -class Volume(MeasureBase): - STANDARD_UNIT = "cubic_meter" - UNITS = { - "us_g": 0.00378541, - "mil_us_g": 3785.41, - "us_qt": 0.000946353, - "us_pint": 0.000473176, - "us_cup": 0.000236588, - "us_oz": 2.9574e-5, - "us_tbsp": 1.4787e-5, - "us_tsp": 4.9289e-6, - "cubic_millimeter": 0.000000001, - "cubic_centimeter": 0.000001, - "cubic_decimeter": 0.001, - "cubic_meter": 1.0, - "l": 0.001, - "cubic_foot": 0.0283168, - "cubic_inch": 1.6387e-5, - "cubic_yard": 0.76455486121558, - "imperial_g": 0.00454609, - "imperial_qt": 0.00113652, - "imperial_pint": 0.000568261, - "imperial_oz": 2.8413e-5, - "imperial_tbsp": 1.7758e-5, - "imperial_tsp": 5.9194e-6, - "acre_in": 102.79015461, - "acre_ft": 1233.48185532, - } - ALIAS = { - "US Gallon": "us_g", - "Million US Gallons": "mil_us_g", - "US Quart": "us_qt", - "US Pint": "us_pint", - "US Cup": "us_cup", - "US Ounce": "us_oz", - "US Fluid Ounce": "us_oz", - "US Tablespoon": "us_tbsp", - "US Teaspoon": "us_tsp", - "cubic millimeter": "cubic_millimeter", - "cubic centimeter": "cubic_centimeter", - "cubic decimeter": "cubic_decimeter", - "cubic meter": "cubic_meter", - "liter": "l", - "litre": "l", - "cubic foot": "cubic_foot", - "cubic inch": "cubic_inch", - "cubic yard": "cubic_yard", - "Imperial Gram": "imperial_g", - "Imperial Quart": "imperial_qt", - "Imperial Pint": "imperial_pint", - "Imperial Ounce": "imperial_oz", - "Imperial Tablespoon": "imperial_tbsp", - "Imperial Teaspoon": "imperial_tsp", - "acre-in": "acre_in", - "acre-ft": "acre_ft", - "af": "acre_ft", - } - SI_UNITS = ["l"] - - def __init__(self, *args, **kwargs): - super(Volume, self).__init__(*args, **kwargs) diff --git a/measurement/measures/volumetric_flow.py b/measurement/measures/volumetric_flow.py deleted file mode 100644 index b5aab35..0000000 --- a/measurement/measures/volumetric_flow.py +++ /dev/null @@ -1,36 +0,0 @@ -from measurement.base import BidimensionalMeasure -from measurement.measures.time import Time -from measurement.measures.volume import Volume - -__all__ = ["VolumetricFlow"] - - -class VolumetricFlow(BidimensionalMeasure): - """Volumetric Flow measurements (generally for water flow).""" - - PRIMARY_DIMENSION = Volume - REFERENCE_DIMENSION = Time - - ALIAS = { - "cfs": "cubic_foot__s", - "cubic feet per second": "cubic_foot__s", - "cubic feet per minute": "cubic_foot__min", - "cubic feet per hour": "cubic_foot__hr", - "cubic feet per day": "cubic_foot__day", - "cubic yards per second": "cubic_yard__s", - "cubic yards per minute": "cubic_yard__min", - "cubic yards per hour": "cubic_yard__hr", - "cubic yards per day": "cubic_yard__day", - "gps": "us_g__s", - "gpm": "us_g__min", - "gph": "us_g__hr", - "gpd": "us_g__day", - "cms": "cubic_meter__s", - "cumecs": "cubic_meter__s", - "million gallons per hour": "mil_us_g__hr", - "million gallons per day": "mil_us_g__day", - "acre-inches per hour": "acre_in__hr", - "acre-inches per day": "acre_in__day", - "acre-feet per hour": "acre_ft__hr", - "acre-feet per day": "acre_ft__day", - } diff --git a/measurement/utils.py b/measurement/utils.py index a70c00b..568599f 100644 --- a/measurement/utils.py +++ b/measurement/utils.py @@ -1,25 +1,19 @@ -import inspect - +def guess(value, unit, measures=None): + """ + Return measurement instance based on given unit. -def get_all_measures(): - from measurement import measures + Raises: + ValueError: If measurement type cannot be guessed. - m = [] - for name, obj in inspect.getmembers(measures): - if inspect.isclass(obj): - m.append(obj) - return m + Returns: + MeasureBase: Measurement instance based on given unit. + """ + from measurement.base import AbstractMeasure -def guess(value, unit, measures=None): - if measures is None: - measures = get_all_measures() - for measure in measures: + for measure in measures or AbstractMeasure.__subclasses__(): try: return measure(**{unit: value}) - except AttributeError: + except KeyError: pass - raise ValueError( - "No valid measure found for %s %s; checked %s" - % (value, unit, ", ".join([m.__name__ for m in measures])) - ) + raise ValueError(f"can't guess measure for '{value} {unit}'") diff --git a/setup.cfg b/setup.cfg index 003397a..5c63320 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,20 +13,30 @@ classifier = License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python - Programming Language :: Python :: 2 Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Topic :: Utilities + Topic :: Scientific/Engineering + Topic :: Scientific/Engineering :: Astronomy + Topic :: Scientific/Engineering :: Atmospheric Science + Topic :: Scientific/Engineering :: Chemistry + Topic :: Scientific/Engineering :: GIS + Topic :: Scientific/Engineering :: Mathematics + Topic :: Scientific/Engineering :: Physics + Topic :: Software Development :: Localization keywords = measurement +python_requires = '>=3.7' [options] packages = find: -include_package_data = True -install_requires = - sympy>=0.7.3 setup_requires = setuptools_scm sphinx + python-docs-theme pytest-runner tests_require = pytest @@ -44,12 +54,23 @@ test = pytest [tool:pytest] addopts = + --doctest-glob='*.rst' + --doctest-modules --cov=measurement +[coverage:report] +show_missing = True + [build_sphinx] source-dir = docs build-dir = docs/_build +[flake8] +max-line-length=88 +select = C,E,F,W,B,B950 +ignore = E203, E501, W503 +exclude = venv,.tox,.eggs + [pydocstyle] add-ignore = D1 diff --git a/tests/base.py b/tests/base.py deleted file mode 100644 index 757232b..0000000 --- a/tests/base.py +++ /dev/null @@ -1,5 +0,0 @@ -import unittest - - -class MeasurementTestBase(unittest.TestCase): - pass diff --git a/tests/measures/__init__.py b/tests/measures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/measures/test_electromagnetism.py b/tests/measures/test_electromagnetism.py new file mode 100644 index 0000000..f94cf7d --- /dev/null +++ b/tests/measures/test_electromagnetism.py @@ -0,0 +1,36 @@ +from measurement import measures + + +class TestCurrent: + def test_mul(self): + assert measures.Current("2 A") * measures.Voltage( + "12 V" + ) == measures.ElectricPower("24 W") + + def test_mul__super(self): + assert measures.Current("2 A") * 2 == measures.Current("4 A") + + +class TestVoltage: + def test_mul(self): + assert measures.Voltage("12 V") * measures.Current( + "2 A" + ) == measures.ElectricPower("24 W") + + def test_mul__super(self): + assert measures.Voltage("6 V") * 2 == measures.Voltage("12 V") + + +class TestElectricPower: + def test_truediv__voltage(self): + assert measures.ElectricPower("24 W") / measures.Voltage( + "12 V" + ) == measures.Current("2 A") + + def test_truediv__current(self): + assert measures.ElectricPower("24 W") / measures.Current( + "4 A" + ) == measures.Voltage("6 V") + + def test_truediv__super(self): + assert measures.ElectricPower("24 W") / 2 == measures.ElectricPower("12 W") diff --git a/tests/measures/test_energy.py b/tests/measures/test_energy.py new file mode 100644 index 0000000..f1326de --- /dev/null +++ b/tests/measures/test_energy.py @@ -0,0 +1,9 @@ +from measurement.measures import Energy + + +class TestEnergy: + def test_dietary_calories_kwarg(self): + calories = Energy(Calorie=2000) + kilojoules = Energy(kJ=8368) + + assert calories.si_value == kilojoules.si_value diff --git a/tests/measures/test_geometry.py b/tests/measures/test_geometry.py new file mode 100644 index 0000000..adc9610 --- /dev/null +++ b/tests/measures/test_geometry.py @@ -0,0 +1,99 @@ +import decimal + +import pytest + +from measurement.measures import Area, Distance, Volume + + +class TestDistance: + def test_conversion_equivalence(self): + miles = Distance(mi=1) + kilometers = Distance(km=decimal.Decimal("1.609344")) + + assert miles.km == kilometers.km + + def test_attrib_conversion(self): + kilometers = Distance(km=1) + expected_meters = 1000 + + assert kilometers.m == expected_meters + + def test_identity_conversion(self): + expected_miles = 10 + miles = Distance(mi=expected_miles) + + assert miles.mi == expected_miles + + def test_auto_si_kwargs(self): + meters = Distance(meter=1e6) + megameters = Distance(megameter=1) + + assert meters == megameters + + def test_auto_si_attrs(self): + one_meter = Distance(m=1) + + micrometers = one_meter.um + + assert one_meter.si_value * 10 ** 6 == micrometers + + def test_area_sq_km(self): + one_sq_km = Area(sq_km=10) + miles_sqd = Area(sq_mi=decimal.Decimal("3.861021585424458472628811394")) + + assert one_sq_km.si_value == miles_sqd.si_value + + def test_mul__distance(self): + assert Distance(m=1) * Distance(m=1) == Area("1 m²") + + def test_mul__area(self): + assert Distance(m=1) * Area(sq_m=1) == Volume("1 m³") + + def test_mul__super(self): + assert Distance(m=1) * 3 == Distance("3 m") + + def test_pow(self): + assert Distance(m=1) ** 2 == Area("1 m²") + assert Distance(m=1) ** 3 == Volume("1 m³") + + with pytest.raises(TypeError): + Distance(m=1) ** 4 + + +class TestArea: + def test_truediv(self): + assert Area("1 m²") / Distance("1 m") == Distance("1 m") + + def test_truediv__super(self): + assert Area("1 m²") / 2 == Area("0.5 m²") + + def test_mul(self): + assert Area("1 m²") * Distance("1 m") == Volume("1 m³") + + def test_mul__super(self): + assert Area("1 m²") * 2 == Area("2 m²") + + def test_attr_to_unit(self): + assert Area._attr_to_unit("sq_m") == "m²" + assert Area._attr_to_unit("m²") == "m²" + + +class TestVolume: + def test_truediv__distance(self): + assert Volume("1 m³") / Distance("1 m") == Area("1 m²") + + def test_truediv__area(self): + assert Volume("1 m³") / Area("1 m²") == Distance("1 m") + + def test_truediv__super(self): + assert Volume("1 m³") / 2 == Volume("0.5 m³") + + def test_litre(self): + assert Volume("1 cubic metre") == Volume("1000 L") + assert Volume("1 mL") == Volume("1e-6 m³") + + def test_us_fluid_ounce(self): + assert Volume("29.57353 mL") == Volume("1 US fl oz") + + def test_imperial_flud_ounce(self): + assert Volume("28.41306 mL") == Volume("1 imp fl oz") diff --git a/tests/test_speed.py b/tests/measures/test_speed.py similarity index 50% rename from tests/test_speed.py rename to tests/measures/test_speed.py index 8c2c824..51ca1c1 100644 --- a/tests/test_speed.py +++ b/tests/measures/test_speed.py @@ -1,20 +1,9 @@ -from measurement.measures import Speed +from measurement.measures.mechanics import Speed -from .base import MeasurementTestBase - -class SpeedTest(MeasurementTestBase): +class TestSpeed: def test_attrconversion(self): - meters_per_second = Speed(meter__second=10) - miles_per_hour = 22.3694 - - self.assertAlmostEqual(miles_per_hour, meters_per_second.mi__hr, places=3) - - def test_attrconversion_nonstandard(self): - miles_per_hour = Speed(mi__hr=22.3694) - kilometers_per_minute = 0.599748864 - - self.assertAlmostEqual(kilometers_per_minute, miles_per_hour.km__min, places=3) + assert Speed("10 m/s") == Speed("36 km/h") def test_addition(self): train_1 = Speed(mile__hour=10) @@ -23,7 +12,7 @@ def test_addition(self): actual_value = train_1 + train_2 expected_value = Speed(mile__hour=15) - self.assertEqual(actual_value, expected_value) + assert actual_value == expected_value def test_iadd(self): train_1 = Speed(mile__hour=10) @@ -33,9 +22,7 @@ def test_iadd(self): actual_value += train_2 expected_value = Speed(mile__hour=15) - self.assertEqual( - actual_value, expected_value, - ) + assert actual_value == expected_value def test_sub(self): train_1 = Speed(mile__hour=10) @@ -44,7 +31,7 @@ def test_sub(self): expected_value = Speed(mile__hour=5) actual_value = train_1 - train_2 - self.assertEqual(expected_value, actual_value) + assert expected_value == actual_value def test_isub(self): train_1 = Speed(mile__hour=10) @@ -54,9 +41,7 @@ def test_isub(self): actual_value = train_1 actual_value -= train_2 - self.assertEqual( - expected_value, actual_value, - ) + assert expected_value == actual_value def test_mul(self): train_1 = Speed(mile__hour=10) @@ -65,9 +50,7 @@ def test_mul(self): actual_value = multiplier * train_1 expected_value = Speed(mile__hour=20) - self.assertEqual( - actual_value, expected_value, - ) + assert expected_value == actual_value def test_imul(self): train_1 = Speed(mile__hour=10) @@ -77,9 +60,7 @@ def test_imul(self): actual_value *= multiplier expected_value = Speed(mile__hour=20) - self.assertEqual( - actual_value, expected_value, - ) + assert expected_value == actual_value def test_div(self): train_1 = Speed(mile__hour=10) @@ -88,9 +69,7 @@ def test_div(self): actual_value = train_1 / divider expected_value = Speed(mile__hour=5) - self.assertEqual( - actual_value, expected_value, - ) + assert expected_value == actual_value def test_idiv(self): train_1 = Speed(mile__hour=10) @@ -100,70 +79,44 @@ def test_idiv(self): actual_value /= divider expected_value = Speed(mile__hour=5) - self.assertEqual( - actual_value, expected_value, - ) + assert expected_value == actual_value def test_equals(self): train_1 = Speed(mile__hour=10) train_2 = Speed(mile__hour=10) - self.assertEqual( - train_1, train_2, - ) + assert train_1 == train_2 def test_lt(self): train_1 = Speed(mile__hour=5) train_2 = Speed(mile__hour=10) - self.assertTrue(train_1 < train_2) + assert train_1 < train_2 def test_bool_true(self): train_1 = Speed(mile__hour=5) - self.assertTrue(train_1) + assert train_1 def test_bool_false(self): train_1 = Speed(mile__hour=0) - self.assertFalse(train_1) + assert not train_1 def test_abbreviations(self): train_1 = Speed(mph=4) train_2 = Speed(mile__hour=4) - self.assertEqual(train_1, train_2) + assert train_1 == train_2 def test_different_units_addition(self): - train = Speed(mile__hour=10) - increase = Speed(km__day=2) - - two_km_day_in_mph = 0.0517809327 - - expected_speed = Speed(mile__hour=10 + two_km_day_in_mph) - actual_speed = train + increase - - self.assertAlmostEqual( - expected_speed.standard, actual_speed.standard, - ) - - def test_aliases(self): - speed = Speed(mph=10) - - expected_kph = 16.09344 - actual_kph = speed.kph - - self.assertAlmostEqual( - expected_kph, actual_kph, - ) + train = Speed(km__h=1) + increase = Speed(m__h=10) - def test_set_unit(self): - speed = Speed(mi__hr=10) - speed.unit = "m__s" + assert train + increase == Speed("1.01 km/h") - expected_value = 4.4704 - actual_value = speed.value + def test_mph(self): + assert Speed(mph=10) == Speed(mi__h=10), Speed._units["mi__h"].factor - self.assertAlmostEqual( - expected_value, actual_value, - ) + def test_kph(self): + assert Speed(kph=10) == Speed(km__h=10), Speed._units["km__h"].factor diff --git a/tests/test_temperature.py b/tests/measures/test_temperature.py similarity index 53% rename from tests/test_temperature.py rename to tests/measures/test_temperature.py index 527d642..7411b46 100644 --- a/tests/test_temperature.py +++ b/tests/measures/test_temperature.py @@ -1,24 +1,23 @@ -from measurement.measures import Temperature +import decimal + +import pytest -from .base import MeasurementTestBase +from measurement.measures import Temperature -class TemperatureTest(MeasurementTestBase): +class TestTemperature: def test_sanity(self): fahrenheit = Temperature(fahrenheit=70) celsius = Temperature(celsius=21.1111111) - self.assertAlmostEqual(fahrenheit.k, celsius.k) + assert fahrenheit.K == pytest.approx(celsius.K) def test_conversion_to_non_si(self): celsius = Temperature(celsius=21.1111111) - expected_farenheit = 70 + expected_farenheit = decimal.Decimal("70") - self.assertAlmostEqual(celsius.f, expected_farenheit) + assert celsius.fahrenheit == pytest.approx(expected_farenheit) def test_ensure_that_we_always_output_float(self): kelvin = Temperature(kelvin=10) - - celsius = kelvin.c - - self.assertTrue(isinstance(celsius, float)) + assert isinstance(kelvin.celsius, decimal.Decimal) diff --git a/tests/measures/test_time.py b/tests/measures/test_time.py new file mode 100644 index 0000000..5832cde --- /dev/null +++ b/tests/measures/test_time.py @@ -0,0 +1,9 @@ +from measurement import measures + + +class TestFrequency: + def test_mul(self): + assert measures.Frequency("60 Hz") * measures.Time("2 s") == 120 + + def test_mul__super(self): + assert measures.Frequency("60 Hz") * 2 == measures.Frequency("120 Hz") diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..70ffc34 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,213 @@ +import decimal + +import pytest + +from measurement.base import ImmutableKeyDict, MetricUnit, Unit, qualname +from measurement.measures import Distance + + +def test_qualname(): + assert qualname(Distance) == "Distance" + assert qualname(Distance("1 m")) == "Distance" + + +class TestImmutableKeyDict: + def test_setitem(self): + d = ImmutableKeyDict() + d["foo"] = "bar" + d["foo"] = "bar" + with pytest.raises(KeyError) as e: + d["foo"] = "baz" + + assert "Key 'foo' already exists with value 'bar'." in str(e.value) + + +class TestUnit: + def test_post_init(self): + inch = Unit("0.0254", ["in", "inches"]) + assert inch.factor == decimal.Decimal("0.0254") + + def test_get_symbols(self): + inch = Unit("0.0254", ["in", "inches"]) + inch.name = "inch" + assert list(inch.get_symbols()) == [ + ("inch", Unit("0.0254")), + ("in", Unit("0.0254")), + ("inches", Unit("0.0254")), + ] + + def test_to_si(self): + assert Unit("1").to_si(decimal.Decimal("10")) == decimal.Decimal("10") + assert Unit("10").to_si(decimal.Decimal("10")) == decimal.Decimal("100") + assert Unit("1E-3").to_si(decimal.Decimal("10")) == decimal.Decimal("1E-2") + + def test_from_si(self): + assert Unit("1").from_si(decimal.Decimal("10")) == decimal.Decimal("10") + assert Unit("10").from_si(decimal.Decimal("10")) == decimal.Decimal("1") + assert Unit("1E-3").from_si(decimal.Decimal("10")) == decimal.Decimal("1E+4") + + +class TestMetricUnit: + def test_get_symbols(self): + metre = MetricUnit("1", ["m", "meter"], ["m"], ["metre", "meter"]) + metre.name = "metre" + symbols = list(metre.get_symbols()) + + assert ("metre", Unit("1")) in symbols + assert ("m", Unit("1")) in symbols + assert ("meter", Unit("1")) in symbols + + assert ("km", Unit("1E+3")) in symbols + assert ("μm", Unit("1E-6")) in symbols + + assert ("Kilometre", Unit("1E+3")) in symbols + assert ("kilometre", Unit("1E+3")) in symbols + + assert ("Kilometer", Unit("1E+3")) in symbols + assert ("kilometer", Unit("1E+3")) in symbols + + assert ("nanometer", Unit("1E-9")) in symbols + + def test_get_symbols__unique_names(self): + metre = MetricUnit("1", ["m", "meter"], ["m"], ["metre", "meter"]) + metre.name = "metre" + symbols = list(metre.get_symbols()) + assert len([k for k, v in symbols]) == len({k for k, v in symbols}) + + +class TestAbstractMeasure: + measure = Distance + unit = "m" + + def test_repr(self): + assert repr(Distance("1 km")) == 'Distance(metre="1E+3")' + + def test_str(self): + assert str(Distance("1 km")) == "1 km" + + def test_format(self): + assert f"{Distance('1 km') / 3:5.3f}" == "0.333 km" + assert f"{Distance(km=1/3):5.3f}" == "0.333 km" + with pytest.raises(ValueError): + f"{Distance('1 km') / 3:5.3x}" + + def test_getitem(self): + assert Distance("1 km")["m"] == decimal.Decimal("1000") + with pytest.raises(KeyError) as e: + Distance("1 km")["does not exist"] + + assert "Distance object has no key 'does not exist'" in str(e.value) + + def test_custom_string(self): + m = Distance("1 km") / 3 + assert f"{m._value:.3f} {m.unit}" == "333.333 metre" + + def test_eq(self): + assert self.measure(**{self.unit: 1}) == self.measure(**{self.unit: 1}) + + def test_eq__not_implemented(self): + assert self.measure(**{self.unit: 1}).__eq__("not-valid") is NotImplemented + + def test_tl(self): + assert self.measure(**{self.unit: 1}) < self.measure(**{self.unit: 2}) + assert not self.measure(**{self.unit: 2}) < self.measure(**{self.unit: 1}) + + def test_lt__not_implemented(self): + assert self.measure(**{self.unit: 1}).__lt__("not-valid") is NotImplemented + + def test_gt(self): + assert self.measure(**{self.unit: 2}) > self.measure(**{self.unit: 1}) + assert not self.measure(**{self.unit: 1}) > self.measure(**{self.unit: 1}) + + def test_gt__not_implemented(self): + assert self.measure(**{self.unit: 1}).__gt__("not-valid") is NotImplemented + + def test_add(self): + assert self.measure(**{self.unit: 2}) + self.measure( + **{self.unit: 1} + ) == self.measure(**{self.unit: 3}) + + def test_add__raise__type_error(self): + with pytest.raises(TypeError) as e: + self.measure(**{self.unit: 2}) + "not-allowed" + assert str(e.value) == f"can't add type '{qualname(self.measure)}' to 'str'" + + def test_iadd(self): + d = self.measure(**{self.unit: 2}) + d += self.measure(**{self.unit: 1}) + assert d == self.measure(**{self.unit: 3}) + + def test_sub(self): + assert self.measure(**{self.unit: 2}) - self.measure( + **{self.unit: 1} + ) == self.measure(**{self.unit: 1}) + + def test_sub__raise__type_error(self): + with pytest.raises(TypeError) as e: + self.measure(**{self.unit: 2}) - "not-allowed" + assert ( + str(e.value) + == f"can't substract type 'str' from '{qualname(self.measure)}'" + ) + + def test_isub(self): + d = self.measure(**{self.unit: 2}) + d -= self.measure(**{self.unit: 1}) + assert d == self.measure(**{self.unit: 1}) + + def test_mul(self): + assert self.measure(**{self.unit: 2}) * 2 == self.measure(**{self.unit: 4}) + + def test_mul__raise__type_error(self): + with pytest.raises(TypeError) as e: + self.measure(**{self.unit: 2}) * "not-allowed" + assert ( + str(e.value) == f"can't multiply type '{qualname(self.measure)}' and 'str'" + ) + + def test_imul(self): + d = self.measure(**{self.unit: 2}) + d *= 2 + assert d == self.measure(**{self.unit: 4}) + + d = 2 + d *= self.measure(**{self.unit: 2}) + assert d == self.measure(**{self.unit: 4}) + + def test_rmul(self): + assert 2 * self.measure(**{self.unit: 2}) == self.measure(**{self.unit: 4}) + + def test_truediv(self): + assert self.measure(**{self.unit: 2}) / 2 == self.measure(**{self.unit: 1}) + assert self.measure(**{self.unit: 2}) / self.measure(**{self.unit: 2}) == 1 + + def test_truediv__raise__type_error(self): + with pytest.raises(TypeError) as e: + self.measure(**{self.unit: 2}) / "not-allowed" + assert str(e.value) == f"can't devide type '{qualname(self.measure)}' by 'str'" + + def test_itruediv(self): + d = self.measure(**{self.unit: 2}) + d /= 2 + assert d == self.measure(**{self.unit: 1}) + + d = 2 + d /= self.measure(**{self.unit: 2}) + assert d == self.measure(**{self.unit: 1}) + + d = self.measure(**{self.unit: 2}) + d /= self.measure(**{self.unit: 2}) + assert d == 1 + + def test_rtruediv(self): + assert 2 / self.measure(**{self.unit: 2}) == self.measure(**{self.unit: 1}) + + def test_bool(self): + assert self.measure(**{self.unit: 1}) + assert self.measure(**{self.unit: -11}) + assert not self.measure(**{self.unit: 0}) + + def test_getattr(self): + assert Distance(m=1).inch == pytest.approx(decimal.Decimal("40"), abs=1) + with pytest.raises(AttributeError): + Distance(m=1).does_not_exist diff --git a/tests/test_distance.py b/tests/test_distance.py deleted file mode 100644 index 3a273e7..0000000 --- a/tests/test_distance.py +++ /dev/null @@ -1,58 +0,0 @@ -# -*- coding: utf-8 -*- -from measurement.measures import Area, Distance - -from .base import MeasurementTestBase - - -class DistanceTest(MeasurementTestBase): - def test_conversion_equivalence(self): - miles = Distance(mi=1) - kilometers = Distance(km=1.609344) - - self.assertAlmostEqual(miles.km, kilometers.km) - - def test_attrib_conversion(self): - kilometers = Distance(km=1) - expected_meters = 1000 - - self.assertAlmostEqual(kilometers.m, expected_meters) - - def test_identity_conversion(self): - expected_miles = 10 - miles = Distance(mi=expected_miles) - - self.assertAlmostEqual(miles.mi, expected_miles) - - def test_auto_si_kwargs(self): - meters = Distance(meter=1e6) - megameters = Distance(megameter=1) - - self.assertEqual( - meters, megameters, - ) - - def test_auto_si_attrs(self): - one_meter = Distance(m=1) - - micrometers = one_meter.um - - self.assertEqual(one_meter.value * 10 ** 6, micrometers) - - def test_area_sq_km(self): - one_sq_km = Area(sq_km=10) - miles_sqd = Area(sq_mi=3.8610216) - - self.assertAlmostEqual(one_sq_km.standard, miles_sqd.standard, places=1) - - def test_set_value(self): - distance = Distance(mi=10) - - expected_standard = 16093.44 - self.assertEqual( - distance.standard, expected_standard, - ) - - distance.value = 11 - - expected_standard = 17702.784 - self.assertEqual(distance.standard, expected_standard) diff --git a/tests/test_energy.py b/tests/test_energy.py deleted file mode 100644 index 9d76b0e..0000000 --- a/tests/test_energy.py +++ /dev/null @@ -1,13 +0,0 @@ -from measurement.measures import Energy - -from .base import MeasurementTestBase - - -class EnergyTest(MeasurementTestBase): - def test_dietary_calories_kwarg(self): - calories = Energy(Calorie=2000) - kilojoules = Energy(kJ=8368) - - self.assertEqual( - calories.standard, kilojoules.standard, - ) diff --git a/tests/test_utils.py b/tests/test_utils.py index ea7cebb..b6eb467 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,21 +1,22 @@ +import pytest + from measurement.measures import Distance, Mass, Temperature from measurement.utils import guess -from .base import MeasurementTestBase +def test_guess_weight(): + assert guess(23, "g") == Mass(g=23) -class UtilsTest(MeasurementTestBase): - def test_guess_weight(self): - result = guess(23, "g") - self.assertEqual(result, Mass(g=23)) +def test_guess_distance(): + assert guess(30, "mi") == Distance(mi=30) - def test_guess_distance(self): - result = guess(30, "mi") - self.assertEqual(result, Distance(mi=30)) +def test_guess_temperature(): + assert guess(98, "°F") == Temperature(fahrenheit=98) - def test_guess_temperature(self): - result = guess(98, "f") - self.assertEqual(result, Temperature(f=98)) +def test_guess__raise__value_error(): + with pytest.raises(ValueError) as e: + guess(98, "does-not-exist") + assert str(e.value) == "can't guess measure for '98 does-not-exist'" diff --git a/tests/test_volume.py b/tests/test_volume.py deleted file mode 100644 index a37026f..0000000 --- a/tests/test_volume.py +++ /dev/null @@ -1,11 +0,0 @@ -from measurement.measures import Volume - -from .base import MeasurementTestBase - - -class VolumeTest(MeasurementTestBase): - def test_sub_one_base_si_measure(self): - milliliters = Volume(ml=200) - fl_oz = Volume(us_oz=6.76280454) - - self.assertAlmostEqual(milliliters.standard, fl_oz.standard)