From 9d10736975ced24c893b5bc0e1f18c2559f60270 Mon Sep 17 00:00:00 2001 From: Ken Kundert Date: Mon, 26 Dec 2022 21:24:30 -0800 Subject: [PATCH] added scale-factor conversions --- doc/accessories.rst | 6 +- doc/releases.rst | 10 +- doc/user.rst | 502 ++++++++++++++++++++----------- quantiphy/__init__.py | 6 + quantiphy/quantiphy.py | 174 +++++++---- quantiphy/quantiphy.pyi | 6 +- tests/test_doctests.py | 2 +- tests/test_format.py | 12 +- tests/test_quantity_functions.py | 58 ++++ tests/test_unit_conversion.py | 17 +- tests/test_unit_conversion2.py | 141 +++++++++ 11 files changed, 691 insertions(+), 243 deletions(-) create mode 100644 tests/test_quantity_functions.py create mode 100644 tests/test_unit_conversion2.py diff --git a/doc/accessories.rst b/doc/accessories.rst index 1af6d5a..f62db9a 100644 --- a/doc/accessories.rst +++ b/doc/accessories.rst @@ -42,9 +42,9 @@ circuit:: > cat ~/.ecrc # define some functions useful in phasor analysis - (2pi * "rads/s")to_omega # convert frequency in Herts to radians/s + (2pi * "rads/s")to_omega # convert frequency in Hertz to radians/s (mag 2pi / "Hz")to_freq # convert frequency in radians/s to Hertz - (j2pi * "rads/s")to_jomega # convert frequency in Herts to imaginary radians/s + (j2pi * "rads/s")to_jomega # convert frequency in Hertz to imaginary radians/s > cat ./.ecrc # define default values for parameters @@ -143,7 +143,7 @@ programs that are useful in their own right, but also act as demonstrators as to how to use the library. They are *list-psf* and *plot-psf*. The first lists the available signals in a file, and the other displays them. -*QuantiPhy* is used by *plot-psf* when generating the axis lables. +*QuantiPhy* is used by *plot-psf* when generating the axis labels. The source code is available from the `psf_utils repository `_ on GitHub, or you can install it diff --git a/doc/releases.rst b/doc/releases.rst index 01b0a56..bf32c2c 100644 --- a/doc/releases.rst +++ b/doc/releases.rst @@ -8,11 +8,17 @@ Latest development release | Version: 2.19a2 | Released: 2022-12-17 -- Add new standard SI scale factors (*Q*, *R*, *r*, *q*). + +2.19 (2023-01-??) +----------------- +- Added new standard SI scale factors (*Q*, *R*, *r*, *q*). - Subclasses of :class:`Quantity` with units now convert values to the desired units rather than allowing the units of the class to be overridden by those of the value. -- Added “cover” option to *strip_radix*.. +- Added scale factor conversion. +- Added quantity functions: :func:`as_real`, :func:`as_tuple`, :func:`render`, + :func:`fixed`, and :func:`binary`. +- Added “cover” option to *strip_radix* preference. - Added type hints. diff --git a/doc/user.rst b/doc/user.rst index c3736a8..50c2553 100644 --- a/doc/user.rst +++ b/doc/user.rst @@ -241,8 +241,9 @@ When given as a string, the number may use any of the following scale factors | _ (1) | c (10\ :sup:`-2`) centi | m (10\ :sup:`-3`) milli -| u (10\ :sup:`-6`) micro -| μ (10\ :sup:`-6`) micro +| u (10\ :sup:`-6`) micro (ASCII) +| µ (10\ :sup:`-6`) micro (unicode micro) +| μ (10\ :sup:`-6`) micro (unicode Greek mu) | n (10\ :sup:`-9`) nano | p (10\ :sup:`-12`) pico | f (10\ :sup:`-15`) fempto @@ -436,8 +437,9 @@ the number and assign the units when creating the quantity: 2.529 kg In this case the value is given in kilograms, and is converted to the base units -of grams by multiplying the given value by 1000. This can also be expressed as -follows: +of grams by multiplying the given value by 1000. You always want to convert to +base units (units with no scale factor) when creating a :class:`Quantity`. This +can also be expressed as follows: .. code-block:: python @@ -456,7 +458,7 @@ conversion is :index:`not linear `: >>> Quantity('-100 dBV', scale=from_dB) Quantity('10 uV') -.. notice: +.. note:: Since version 2.18 the first argument, in this case *value*, is guaranteed to be a :class:`Quantity` that contains both the units and any parameters needed @@ -472,19 +474,19 @@ quantity to have: >>> print(Tboil) 373.15 K -or if you pass in a subclass of :class:`Quantity` that has units: +or if you employ a subclass of :class:`Quantity` that has units: .. code-block:: python >>> class Kelvin(Quantity): ... units = 'K' - >>> Tboil = Quantity('212 °F', scale=Kelvin) + >>> Tboil = Kelvin('212 °F') >>> print(Tboil) 373.15 K This assumes that the initial value is specified with units. If not, you need to -provide them for this mechanism to work. +provide them for these mechanisms to work. .. code-block:: python @@ -494,7 +496,7 @@ provide them for this mechanism to work. To do this conversion, *QuantiPhy* examines the given units (°F) and the desired units (K) and chooses the appropriate converter. No scaling is done if the -given units are the same as the desired units. Thus you can use the scaling +given units are equivalent as the desired units. Thus you can use the scaling mechanism to convert a collection of data with mixed units to values with consistent units. For example: @@ -840,7 +842,7 @@ performed. >>> print(T.render(scale=to_dB)) -20 dBV -.. notice: +.. note:: Since version 2.18 the first argument, in this case *value*, is guaranteed to be a :class:`Quantity` that contains both the units and any parameters needed @@ -1191,6 +1193,185 @@ information back into the original units: single: μ₀ (permeability of free space) single: Z0 (characteristic impedance of free space) +You can add a scale factor to the units, in which case the number will be scaled +accordingly: + +.. code-block:: python + + >>> for p in range(1, 5): + ... bytes = Quantity(256**p, 'B') + ... print(f"An {8*p} bit bus addresses {bytes:,pkB}.") + An 8 bit bus addresses 0.256 kB. + An 16 bit bus addresses 65.536 kB. + An 24 bit bus addresses 16,777.216 kB. + An 32 bit bus addresses 4,294,967.296 kB. + +Generally you should only specify base units when using a format that renders +with scale factors as otherwise you could see two scale factors on the same +number. For example, if the ``q`` format was used in the above example, the +last address space would be rendered as 4.295 MkB. + + +.. index:: + single: Kelvin/kilo ambiguity + single: meter/milli ambiguity + single: ambiguity of scale factors and units + +.. _ambiguity: + +Ambiguity of Scale Factors and Units +------------------------------------ + +By default, *QuantiPhy* treats both the scale factor and the units as being +optional. With the scale factor being optional, the meaning of some +specifications can be ambiguous. For example, '1m' may represent 1 milli or it +may represent 1 meter. Similarly, '1meter' my represent 1 meter or +1 milli-eter. In this case *QuantiPhy* gives preference to the scale factor, so +'1m' normally converts to 1e-3. To allow you to avoid this ambiguity, +*QuantiPhy* accepts '_' as the unity scale factor. In this way '1_m' is +unambiguously 1 meter. You can instruct *QuantiPhy* to output '_' as the unity +scale factor by specifying the *unity_sf* argument to +:meth:`Quantity.set_prefs()`: + +.. code-block:: python + + >>> Quantity.set_prefs(unity_sf='_', spacer='') + >>> l = Quantity(1, 'm') + >>> print(l) + 1_m + +This is often a good way to go if you are outputting numbers intended to be read +unambiguously or by both people and machines. + +If you need to interpret numbers that have units and are known not to have scale +factors, you can specify the *ignore_sf* preference: + +.. code-block:: python + + >>> Quantity.set_prefs(ignore_sf=True, unity_sf='', spacer=' ') + >>> l = Quantity('1000m') + >>> l.as_tuple() + (1000.0, 'm') + + >>> print(l) + 1 km + + >>> Quantity.set_prefs(ignore_sf=False) + >>> l = Quantity('1000m') + >>> l.as_tuple() + (1.0, '') + +If there are scale factors that you know you will never use, you can instruct +*QuantiPhy* to interpret a specific set and ignore the rest using the *input_sf* +preference. + +.. code-block:: python + + >>> Quantity.set_prefs(input_sf='GMk') + >>> l = Quantity('1000m') + >>> l.as_tuple() + (1000.0, 'm') + + >>> print(l) + 1 km + +Specifying *input_sf=None* causes *QuantiPhy* to again accept all known scale +factors. + +.. code-block:: python + + >>> Quantity.set_prefs(input_sf=None) + >>> l = Quantity('1000m') + >>> l.as_tuple() + (1.0, '') + +Alternatively, you can specify the units you wish to use whose leading character +is a scale factor. Once known, these units no longer confuse *QuantiPhy*. +These units can be specified as a list or as a string. If specified as a string +the string is split to form the list. Specifying the known units replaces any +existing known units. + +.. code-block:: python + + >>> d1 = Quantity('1 au') # astronomical unit + >>> d2 = Quantity('1000 pc') # parsec + >>> p = Quantity('138 Pa') # Pascal + >>> print(d1.render(form='eng'), d2, p, sep='\n') + 1e-18 u + 1 nc + 138e15 a + + >>> Quantity.set_prefs(known_units='au pc Pa') + >>> d1 = Quantity('1 au') + >>> d2 = Quantity('1000 pc') + >>> p = Quantity('138 Pa') + >>> print(d1.render(form='eng'), d2, p, sep='\n') + 1 au + 1 kpc + 138 Pa + +This same issue comes up for temperature quantities when given in Kelvin. There +are again several ways to handle this. First you can specify the acceptable +input scale factors leaving out 'K', ex. *input_sf* = 'TGMkmunpfa', or: + +.. code-block:: python + + >>> Quantity.set_prefs(input_sf=Quantity.get_pref('input_sf').replace('K', '')) + >>> temp = Quantity('100K') + >>> print(temp.as_tuple()) + (100.0, 'K') + + >>> temp = Quantity('100k') + >>> print(temp.as_tuple()) + (100000.0, '') + + >>> temp = Quantity('100k', 'K') + >>> print(temp.as_tuple()) + (100000.0, 'K') + +Alternatively, you can specify 'K' as one of the known units. Finally, if you +know exactly when you will be converting a temperature to a quantity, you can +specify *ignore_sf* for that specific conversion. The effect is the same either +way, 'K' is interpreted as a unit rather than a scale factor. + +The same techniques would be used to handle volumes in cubic centimeters: + + >>> vol = Quantity('10 cc') + >>> print(vol.as_tuple()) + (0.1, 'c') + + >>> with Quantity.prefs(input_sf=Quantity.get_pref('input_sf').replace('c', '')): + ... vol = Quantity('10 cc') + >>> print(vol.as_tuple()) + (10.0, 'cc') + + >>> with Quantity.prefs(known_units='cc'): + ... vol = Quantity('100 cc') + >>> print(vol.as_tuple()) + (100.0, 'cc') + +Percentages are a special case. *QuantiPhy* can treat the % character as either +a unit or a scale factor (0.01). By default it is treated as a unit: + +.. code-block:: python + + >>> tolerance = Quantity('10%') + >>> change = Quantity('10%Δ') + >>> print(tolerance.as_tuple(), change.as_tuple(),) + (10.0, '%') (10.0, '%Δ') + +If, however, you add % as a known scale factor, it then acts as a scale factor. + + >>> with Quantity.prefs(input_sf = Quantity.get_pref('input_sf') + '%'): + ... tolerance = Quantity('10%') + ... change = Quantity('10%Δ') + ... print(tolerance.as_tuple(), change.as_tuple(),) + (0.1, '') (0.1, 'Δ') + +In general you cannot simply add to the list of known scale factors. The % +character is an exception as *QuantiPhy* knows about it but disables it by +default. + .. _subclassing Quantity: @@ -1352,6 +1533,9 @@ conversions occur, from hours to seconds, as a result of the scale request, and from seconds to days, to convert to the units expected by the class. +.. index:: + single: unit conversions + .. _unit converters: Unit Converters @@ -1369,13 +1553,22 @@ conversion factors. Once defined, a relationship is available anywhere in >>> m_smoot = UnitConversion('m', 'smoots', 1.7) - >>> length_of_harvard_bridge = Quantity('619.48_m') - >>> print(length_of_harvard_bridge.render(scale='smoots', prec=3)) - 364.4 smoots + >>> length_of_harvard_bridge = Quantity('364.4 smoots') + >>> print(length_of_harvard_bridge.render(scale='m', prec=1)) + 620 m This is a linear conversion. This unit conversion says, when converting *smoots* to *m*, multiply by 1.7. When going the other way, divide by 1.7. +You can also specify units with a scale factor when scaling a number. For +example, you can explicitly direct that the length of the bridge should be +output in kilometers using: + +.. code-block:: python + + >>> print(f"{length_of_harvard_bridge:.2pkm}") + 0.62 km + QuantiPhy* provides a collection of built-in converters for common units: =========== ================================================================ @@ -1383,9 +1576,8 @@ base units related units =========== ================================================================ C °C K, F °F, R °R K C °C, F °F, R °R -m km, m, cm, mm, um μm micron, nm, Å angstrom, mi mile miles, - in inch inches -g kg, mg, ug μg, ng, oz, lb lbs +m micron, Å angstrom, mi mile miles, ft feet, in inch inches +g oz, lb lbs s sec second seconds, min minute minutes, hour hours hr, day days b B BTC btc Ƀ ₿ sat sats ș @@ -1479,7 +1671,12 @@ Defining a conversion between the same pair of units acts to conceal an earlier definition, but the previous definition can be restored using *activate()*. -Parameterized Unit Converters +.. index:: + single: parametrized unit conversions + +.. _parameterized unit conversions: + +Parametrized Unit Converters ............................. Occasionally you might encounter conversion that requires one or more extra @@ -1510,6 +1707,113 @@ For more information on parametrized unit converters, see see :ref:`quantiphy bitcoin example`. +.. index:: + single: scale factor conversions + +.. _scale factor conversions: + +Scale Factor Conversions +------------------------ + +In the preceding sections it was shown that you can use the scaling features of +*QuantiPhy* to convert between units using only the name of the units. When +doing so the relationship between the units must be known, and +:class:`UnitConversion` is used to specify the relationship. However, it is +also possible to perform simple scale factor conversions without changing the +units. This case is specified in a manner similar to a unit conversion, but in +this case both the from-units and the to-units are the same, and it is not +necessary to define a :class:`UnitConversion`. For example, imagine printing +a table of bit-rates where the rates are held in bps but are expected to be +displayed in Mbps: + +.. code-block:: python + + >>> rates = [155.52e6, 622.08e6, 2.48832e9, 9.95328e9, 39.81312e9] + >>> rates = [Quantity(r, 'bps') for r in rates] + >>> for r in rates: + ... print(f"{r:>14,.2pMbps}") + 155.52 Mbps + 622.08 Mbps + 2,488.32 Mbps + 9,953.28 Mbps + 39,813.12 Mbps + +You can also do the inverse; convert simple numbers given in Mbps to quantities +in bps: + +.. code-block:: python + + >>> rates = [155.52, 622.08, 2488.32, 9953.28, 39813.12] + >>> rates = [Quantity(r, 'Mbps', scale='bps') for r in rates] + >>> for r in rates: + ... print(r.as_tuple()) + (155520000.0, 'bps') + (622080000.0, 'bps') + (2488320000.0, 'bps') + (9953280000.0, 'bps') + (39813120000.0, 'bps') + + +.. _quantity functions: + +Quantity Functions +------------------ + +It is sometimes handy to convert directly to and from real values rather than +converting to :class:`Quantity` objects and holding them. Generally it is +preferred to key a value and its units together, but as said before, the primary +use of *QuantiPhy* is inputting and outputting numbers. If you are not +inputting and outputting the same numbers, may not be worth even the small +overhead of a :class:`Quantity` object. In that case, you can use quantity +functions to convert directly to and from real values. If you wish to use +*QuantiPhy* to convert to a simple float, use :func:`as_real()`. It takes the +same arguments as a :class:`Quantity`, but returns a float rather than +a *Quantity*: + +.. code-block:: python + + >>> from quantiphy import as_real + >>> print(as_real('10 mL')) + 0.01 + +It is common to use :ref:`scale factor conversions` to scale the result to the +desired size: + +.. code-block:: python + + >>> print(as_real('10 mL', scale='uL')) + 10000.0 + +:func:`as_tuple()` is similar except it returns both the value and the units as +a tuple: + +.. code-block:: python + + >>> from quantiphy import as_tuple + >>> print(as_tuple('10 mL')) + (0.01, 'L') + + >>> print(as_tuple('10 mL', scale='uL')) + (10000.0, 'uL') + +Finally, you can use :func:`render()`, :func:`fixed()`, and :func:`binary()` to +convert a real value and units into a string. Besides the value and the units, +the these functions the same arguments as :meth:`Quantity.render()`, +:meth:`Quantity.fixed()`, and :meth:`Quantity.binary()`. + +.. code-block:: python + + >>> from quantiphy import render, fixed, binary + >>> print(render(1e-6, 'L')) + 1 uL + + >>> print(fixed(1e7, '$', show_commas=True, strip_zeros=False, prec=2)) + $10,000,000.00 + + >>> print(binary(2**32, 'B')) + 4 GiB + + .. _constants: Physical Constants @@ -1789,7 +2093,7 @@ example, consider creating a local module named *quantity.py*: minus = Quantity.minus_sign, show_units = True, ), - shinx = dict( + sphinx = dict( # assumes values are to be rendered with a variable-with font by Sphinx form = 'si', map_sf = Quantity.map_sf_to_sci_notation, @@ -1849,7 +2153,8 @@ radix The decimal point; generally ``.`` or ``,``. comma - The thousands separator; generally ``,``, ``.``, or the empty string. + The thousands separator; generally ``,``, ``.``, ``_`` or a narrow + non-breaking space. plus *QuantitPhy* does not use plus signs when rendering quantities either on the @@ -1944,149 +2249,6 @@ on the fly: €100,000,000.00 -.. index:: - single: Kelvin/kilo ambiguity - single: ambiguity between scale factors and units - -.. _ambiguity: - -Ambiguity of Scale Factors and Units ------------------------------------- - -By default, *QuantiPhy* treats both the scale factor and the units as being -optional. With the scale factor being optional, the meaning of some -specifications can be ambiguous. For example, '1m' may represent 1 milli or it -may represent 1 meter. Similarly, '1meter' my represent 1 meter or -1 milli-eter. In this case *QuantiPhy* gives preference to the scale factor, so -'1m' normally converts to 1e-3. To allow you to avoid this ambiguity, -*QuantiPhy* accepts '_' as the unity scale factor. In this way '1_m' is -unambiguously 1 meter. You can instruct *QuantiPhy* to output '_' as the unity -scale factor by specifying the *unity_sf* argument to -:meth:`Quantity.set_prefs()`: - -.. code-block:: python - - >>> Quantity.set_prefs(unity_sf='_', spacer='') - >>> l = Quantity(1, 'm') - >>> print(l) - 1_m - -This is often a good way to go if you are outputting numbers intended to be read -unambiguously or by both people and machines. - -If you need to interpret numbers that have units and are known not to have scale -factors, you can specify the *ignore_sf* preference: - -.. code-block:: python - - >>> Quantity.set_prefs(ignore_sf=True, unity_sf='', spacer=' ') - >>> l = Quantity('1000m') - >>> l.as_tuple() - (1000.0, 'm') - - >>> print(l) - 1 km - - >>> Quantity.set_prefs(ignore_sf=False) - >>> l = Quantity('1000m') - >>> l.as_tuple() - (1.0, '') - -If there are scale factors that you know you will never use, you can instruct -*QuantiPhy* to interpret a specific set and ignore the rest using the *input_sf* -preference. - -.. code-block:: python - - >>> Quantity.set_prefs(input_sf='GMk') - >>> l = Quantity('1000m') - >>> l.as_tuple() - (1000.0, 'm') - - >>> print(l) - 1 km - -Specifying *input_sf=None* causes *QuantiPhy* to again accept all known scale -factors. - -.. code-block:: python - - >>> Quantity.set_prefs(input_sf=None) - >>> l = Quantity('1000m') - >>> l.as_tuple() - (1.0, '') - -Alternatively, you can specify the units you wish to use whose leading character -is a scale factor. Once known, these units no longer confuse *QuantiPhy*. -These units can be specified as a list or as a string. If specified as a string -the string is split to form the list. Specifying the known units replaces any -existing known units. - -.. code-block:: python - - >>> d1 = Quantity('1 au') # astronomical unit - >>> d2 = Quantity('1000 pc') # parsec - >>> p = Quantity('138 Pa') # Pascal - >>> print(d1.render(form='eng'), d2, p, sep='\n') - 1e-18 u - 1 nc - 138e15 a - - >>> Quantity.set_prefs(known_units='au pc Pa') - >>> d1 = Quantity('1 au') - >>> d2 = Quantity('1000 pc') - >>> p = Quantity('138 Pa') - >>> print(d1.render(form='eng'), d2, p, sep='\n') - 1 au - 1 kpc - 138 Pa - -This same issue comes up for temperature quantities when given in Kelvin. There -are again several ways to handle this. First you can specify the acceptable -input scale factors leaving out 'K', ex. *input_sf* = 'TGMkmunpfa', or: - -.. code-block:: python - - >>> Quantity.set_prefs(input_sf = Quantity.get_pref('input_sf').replace('K', '')) - >>> temp = Quantity('100K') - >>> print(temp.as_tuple()) - (100.0, 'K') - - >>> temp = Quantity('100k') - >>> print(temp.as_tuple()) - (100000.0, '') - - >>> temp = Quantity('100k', 'K') - >>> print(temp.as_tuple()) - (100000.0, 'K') - -Alternatively, you can specify 'K' as one of the known units. Finally, if you -know exactly when you will be converting a temperature to a quantity, you can -specify *ignore_sf* for that specific conversion. The effect is the same either -way, 'K' is interpreted as a unit rather than a scale factor. - -Percentages are a special case. *QuantiPhy* can treat the % character as either -a unit or a scale factor (0.01). By default it is treated as a unit: - -.. code-block:: python - - >>> tolerance = Quantity('10%') - >>> change = Quantity('10%Δ') - >>> print(tolerance.as_tuple(), change.as_tuple(),) - (10.0, '%') (10.0, '%Δ') - -If, however, you add % as a known scale factor, it then acts as a scale factor. - - >>> with Quantity.prefs(input_sf = Quantity.get_pref('input_sf') + '%'): - ... tolerance = Quantity('10%') - ... change = Quantity('10%Δ') - ... print(tolerance.as_tuple(), change.as_tuple(),) - (0.1, '') (0.1, 'Δ') - -In general you cannot simply add to the list of known scale factors. The % -character is an exception as *QuantiPhy* knows about it but disables it by -default. - .. index:: single: tabular data @@ -2565,7 +2727,7 @@ cannot convert into a number. Now, a variety of *QuantiPhy* specific exceptions are used to indicate specific errors. However, these exceptions subclass the corresponding Python error for compatibility with existing code. It is recommended that new code catch the *QuantiPhy* specific exceptions rather than -the generic Python exceptions as their use may be deprecated in the future. +the generic Python exceptions as their use will be deprecated in the future. *QuantiPhy* employs the following exceptions: @@ -2614,15 +2776,11 @@ the generic Python exceptions as their use may be deprecated in the future. :class:`UnknownConversion`: Subclass of :class:`QuantiPhyError` and *KeyError*. - Used by :meth:`UnitConversion.convert()`. - - Raised when the given units are not supported by the underlying class. - - Used by :class:`Quantity()`, - :meth:`Quantity.scale()`, - :meth:`Quantity.render()`, - :meth:`Quantity.fixed()`, and - :meth:`Quantity.format()`. + Used by :meth:`UnitConversion.convert()`, :class:`Quantity()`, + :meth:`Quantity.scale()`, :meth:`Quantity.render()`, + :meth:`Quantity.fixed()`, :meth:`Quantity.format()`, + :meth:`Quantity.binary()`, :func:`as_real()`, :func:`as_tuple`, + :func:`render()`, :func:`fixed`, and :func:`binary`. Raised when a unit conversion was requested and there is no corresponding unit converter. diff --git a/quantiphy/__init__.py b/quantiphy/__init__.py index 97559a2..3109dff 100644 --- a/quantiphy/__init__.py +++ b/quantiphy/__init__.py @@ -1,4 +1,5 @@ from .quantiphy import ( + QuantiPhyError, # exceptions ExpectedQuantity, IncompatibleUnits, @@ -11,15 +12,20 @@ UnknownScaleFactor, UnknownUnitSystem, IncompatiblePreferences, + UnitConversion, # unit conversions set_unit_system, + add_constant, # constants + Quantity, # quantities + as_real, # quantity functions as_tuple, render, fixed, binary, + __version__, # version __released__, ) diff --git a/quantiphy/quantiphy.py b/quantiphy/quantiphy.py index 86ad70b..e3f8e79 100644 --- a/quantiphy/quantiphy.py +++ b/quantiphy/quantiphy.py @@ -71,7 +71,7 @@ def _scale(scale, unscaled): # if scale is string, it contains the units to convert to if isinstance(scale, str): - scaled = _convert_units(scale, unscaled.units, unscaled) + scaled = UnitConversion._convert_units(scale, unscaled.units, unscaled) to_units = scale else: # otherwise, it might be a function @@ -195,11 +195,7 @@ class UnknownConversion(QuantiPhyError, KeyError): The given units are not supported by the underlying class, or a unit conversion was requested and there is no corresponding unit converter. """ - _template = ( - "unable to convert between ‘{to_units}’ and ‘{from_units}’.", - "unable to convert to ‘{to_units}’.", - "unable to convert from ‘{from_units}’.", - ) + _template = "unable to convert between ‘{to_units}’ and ‘{from_units}’." # UnknownFormatKey {{{2 @@ -404,6 +400,7 @@ def add_constant(value, alias=None, unit_systems=None): 'r': 'e-27', # ronto 'q': 'e-30', # quecto } +ALL_SF = ''.join(MAPPINGS.keys()) BINARY_MAPPINGS = { 'Qi': 1024*1024*1024*1024*1024*1024*1024*1024*1024*1024, 'Ri': 1024*1024*1024*1024*1024*1024*1024*1024*1024, @@ -1454,8 +1451,8 @@ def fix_sign(num): # constructor {{{2 def __new__( - cls, value, model=None, *, units=None, scale=None, - name=None, desc=None, ignore_sf=None, binary=None, params=None + cls, value, model=None, *, units=None, scale=None, binary=None, + name=None, desc=None, ignore_sf=None, params=None ): # preliminaries {{{3 if ignore_sf is None: @@ -1712,8 +1709,8 @@ def scale(self, scale, cls=None): subclass. :arg class cls: Class to use for return value. If not given, the class of self is - used unless scale is a subclass of :class:`Quantity`, in which case - *scale* is used. + used it the units do not change, in which case :class:`Quantity` is + used. :type scale: real, pair, function, string, or quantity :raises UnknownConversion(QuantiPhyError, KeyError): @@ -3062,23 +3059,11 @@ def all_from_si_fmt(cls, text, **kwargs): # Unit Conversions {{{1 -_unit_conversions = {} - - -# _convert_units() {{{2 -def _convert_units(to_units, from_units, value): - # Not intended to be used by the user. If you want this functionality, - # simply use: Quantity(value, from_units).scale(to_units). - if to_units == from_units: - return value - try: - return _unit_conversions[(to_units, from_units)](value) - except KeyError: - raise UnknownConversion(to_units=to_units, from_units=from_units) - - # UnitConversion class {{{2 class UnitConversion(object): + _unit_conversions = {} + _known_units = set() + # description {{{3 """ Creates a unit converter. @@ -3219,6 +3204,9 @@ class UnitConversion(object): # constructor {{{3 def __init__(self, to_units, from_units, slope=1, intercept=0): + self.slope = slope + self.intercept = intercept + # convert units to lists # allow units to be a subclass of Quantity that has units try: @@ -3235,11 +3223,11 @@ def __init__(self, to_units, from_units, slope=1, intercept=0): if not self.from_units: self.from_units = [''] - # convert units to lists and save values - self.slope = slope - self.intercept = intercept + # save all units to set of known units + for units in self.to_units + self.from_units: + self._known_units.add(units) - # add to known converters + # add converter to set of known (aka active) converters self.activate() # activate() {{{3 @@ -3264,20 +3252,18 @@ def activate(self): # add to known unit conversion for to in self.to_units: for frm in self.from_units: - _unit_conversions[(to, frm)] = _forward - _unit_conversions[(frm, to)] = _reverse + self._unit_conversions[(to, frm)] = _forward + self._unit_conversions[(frm, to)] = _reverse # add no-op converters to allow a from-units to be converted to another for u1 in self.from_units: for u2 in self.from_units: - if u1 != u2: - _unit_conversions[(u1, u2)] = self._no_op + self._unit_conversions[(u1, u2)] = self._no_op # add no-op converters to allow a to-units to be converted to another for u1 in self.to_units: for u2 in self.to_units: - if u1 != u2: - _unit_conversions[(u1, u2)] = self._no_op + self._unit_conversions[(u1, u2)] = self._no_op # forward conversion {{{3 def _forward(self, value): @@ -3382,14 +3368,17 @@ def convert(self, value=1, from_units=None, to_units=None): else: from_units = self.to_units[0] - if to_units not in self.to_units + self.from_units: - raise UnknownConversion(to_units=to_units) - if from_units not in self.to_units + self.from_units: - raise UnknownConversion(from_units=from_units) + converted = self._convert_units(to_units, from_units, value) - converted = _convert_units(to_units, from_units, value) return Quantity(converted, units=to_units) + # clear_all() {{{3 + @classmethod + def clear_all(cls): + """Remove all known unit conversions.""" + cls._unit_conversions = {} + cls._known_units = set() + # fixture() {{{3 @staticmethod def fixture(converter_func): @@ -3487,6 +3476,81 @@ def wrapper(q): return converter_func(q) return wrapper + # _convert_units() {{{2 + @classmethod + def _convert_units(cls, to_units, from_units, value): + # Not intended to be used by the user. + # If you want this functionality, simply use: + # Quantity(value, from_units).scale(to_units) + + def get_converter(to_units, from_units): + # handle unity scale factor conversions + if ( + to_units == from_units or + (to_units, from_units) in cls._unit_conversions + ): + return to_units, from_units, 1, 1 + + # Split scale factors from units. + # There are a few cases to consider: + # 1. there is no scale factor and the units are known + # 2. there is a scale factor and the units are known + # 3. the to_ and from_units are the same + # a. there is no scale factor on the to_units + # b. there is no scale factor on the from_units + # c. there are scale factors on both the to_ and from_units + + # handle known-unit cases for to_units + to_sf = None + to_resolved = to_units in cls._known_units # case 1 + if not to_resolved: + to_prefix, to_suffix = to_units[:1], to_units[1:] + to_resolved = to_prefix in ALL_SF and to_suffix in cls._known_units + if to_resolved: + to_sf, to_units = to_prefix, to_suffix # case 2 + + # handle known-unit cases for from_units + from_sf = None + from_resolved = from_units in cls._known_units # case 1 + if not from_resolved: + from_prefix, from_suffix = from_units[:1], from_units[1:] + from_resolved = from_prefix in ALL_SF and from_suffix in cls._known_units + if from_resolved: + from_sf, from_units = from_prefix, from_suffix # case 2 + + # handle same-unit cases + if not to_resolved and not from_resolved: # case 3 + if to_units == from_suffix and from_prefix in ALL_SF: # case 3a + from_sf, from_units = from_prefix, from_suffix + elif from_units == to_suffix and to_prefix in ALL_SF: # case 3b + to_sf, to_units = to_prefix, to_suffix + elif from_prefix in ALL_SF and to_prefix in ALL_SF: # case 3c + to_sf, to_units = to_prefix, to_suffix + from_sf, from_units = from_prefix, from_suffix + + def get_sf(sf): + if sf is None: + return 1 + return float('1' + MAPPINGS[sf]) + + if to_sf or from_sf: + return to_units, from_units, get_sf(to_sf), get_sf(from_sf) + + raise UnknownConversion(to_units=to_units, from_units=from_units) + + to_units, from_units, to_sf, from_sf = get_converter(to_units, from_units) + + if not hasattr(value, 'units'): + value = Quantity(value, from_units) + if to_units == from_units: + return from_sf * value / to_sf + try: + converter = cls._unit_conversions[(to_units, from_units)] + return converter(value.scale(from_sf)) / to_sf + except KeyError: + raise UnknownConversion(to_units=to_units, from_units=from_units) + + # __str__ {{{3 def __str__(self): if callable(self.slope) or callable(self.intercept): @@ -3514,23 +3578,15 @@ def __str__(self): UnitConversion('K', 'R °R', 5/9, 0) # Length/Distance conversions {{{2 -UnitConversion('m', 'km', 1000) -UnitConversion('m', 'cm', 1/100) -UnitConversion('m', 'mm', 1/1000) -UnitConversion('m', 'um µm μm micron', 1/1000000) -UnitConversion('m', 'nm', 1/1000000000) +UnitConversion('m', 'micron', 1/1000000) UnitConversion('m', 'Å angstrom', 1/10000000000) UnitConversion('m', 'mi mile miles', 1609.344) UnitConversion('m', 'ft feet', 0.3048) UnitConversion('m', 'in inch inches', 0.0254) -# Mass conversions {{{2 +# Weight/Mass conversions {{{2 UnitConversion('g', 'lb lbs', 453.59237) UnitConversion('g', 'oz', 28.34952) -UnitConversion('g', 'kg', 1000) -UnitConversion('g', 'mg', 1/1000) -UnitConversion('g', 'ug µg μg', 1/1000000) -UnitConversion('g', 'ng', 1/1000000000) # Time conversions {{{2 UnitConversion('s', 'sec second seconds') @@ -3551,7 +3607,11 @@ def as_real(*args, **kwargs): """Convert to real. Takes the same arguments as :class:`Quantity`, but returns a float rather - than a Quantity. + than a Quantity. Takes one additional optional keyword argument ... + + :arg class cls: + Quantity subclass used to do the conversion. + If not given, :class:`Quantity` is used. Examples:: @@ -3563,14 +3623,19 @@ def as_real(*args, **kwargs): 1.6096579476861166e-05 """ - return Quantity(*args, **kwargs).real + cls = kwargs.pop('cls', Quantity) + return cls(*args, **kwargs).real # as_tuple() {{{2 def as_tuple(*args, **kwargs): """Convert to tuple (value, units). Takes the same arguments as :class:`Quantity`, but returns a tuple consisting - of the value and units. + of the value and units. Takes one additional optional keyword argument ... + + :arg class cls: + Quantity subclass used to do the conversion. + If not given, :class:`Quantity` is used. Examples:: @@ -3582,7 +3647,8 @@ def as_tuple(*args, **kwargs): (1.6096579476861166e-05, 'M') """ - return Quantity(*args, **kwargs).as_tuple() + cls = kwargs.pop('cls', Quantity) + return cls(*args, **kwargs).as_tuple() # render() {{{2 def render(value, units, params=None, *args, **kwargs): diff --git a/quantiphy/quantiphy.pyi b/quantiphy/quantiphy.pyi index 67cc64d..463bb67 100644 --- a/quantiphy/quantiphy.pyi +++ b/quantiphy/quantiphy.pyi @@ -67,7 +67,7 @@ class Quantity(float): desc: str = ..., ignore_sf: bool = ..., binary: bool = ..., - params: Any = ... + params: Any = ..., ) -> Quantity: ... @@ -217,6 +217,7 @@ class UnitConversion: value: float | str | Quantity = ..., from_units: str = ..., to_units: str = ..., + as_tuple: bool = ..., ): ... @@ -224,6 +225,9 @@ class UnitConversion: def fixture(converter_func): ... + def clear_all(self) -> None: + ... + def set_unit_system(unit_system: str) -> None: ... diff --git a/tests/test_doctests.py b/tests/test_doctests.py index 8a2f9d4..14211e3 100644 --- a/tests/test_doctests.py +++ b/tests/test_doctests.py @@ -33,7 +33,7 @@ def test_manual(): Quantity.reset_prefs() expected_test_count = { '../doc/index.rst': 31, - '../doc/user.rst': 424, + '../doc/user.rst': 448, '../doc/api.rst': 0, '../doc/examples.rst': 36, '../doc/accessories.rst': 12, diff --git a/tests/test_format.py b/tests/test_format.py index 96830d6..1928d82 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -25,6 +25,11 @@ def test_format(): assert '{:n}'.format(q) == 'f' assert '{:d}'.format(q) == 'frequency of hydrogen line' assert '{:p}'.format(q) == '1420405751.786 Hz' + assert '{:pHz}'.format(q) == '1420405751.786 Hz' + assert '{:pkHz}'.format(q) == '1420405.7518 kHz' + assert '{:pMHz}'.format(q) == '1420.4058 MHz' + assert '{:pGHz}'.format(q) == '1.4204 GHz' + assert '{:pTHz}'.format(q) == '0.0014 THz' assert '{:,p}'.format(q) == '1,420,405,751.786 Hz' assert '{:P}'.format(q) == 'f = 1420405751.786 Hz' assert '{:,P}'.format(q) == 'f = 1,420,405,751.786 Hz' @@ -46,7 +51,7 @@ def test_format(): def test_full_format(): Quantity.set_prefs(spacer=None, show_label=None, label_fmt=None, label_fmt_full=None, show_desc=False) Quantity.set_prefs(prec='full') - q = Quantity('f = 1420.405751786 MHz -- frequency of hydrogen line') + q = Quantity('f = 1420.405751786 MHz — frequency of hydrogen line') assert '{}'.format(q) == '1.420405751786 GHz' assert '{:.8}'.format(q) == '1.42040575 GHz' assert '{:.8s}'.format(q) == '1.42040575 GHz' @@ -66,6 +71,11 @@ def test_full_format(): assert '{:d}'.format(q) == 'frequency of hydrogen line' assert '{:.2p}'.format(q) == '1420405751.79 Hz' assert '{:,.2p}'.format(q) == '1,420,405,751.79 Hz' + assert '{:,.2pHz}'.format(q) == '1,420,405,751.79 Hz' + assert '{:,.2pkHz}'.format(q) == '1,420,405.75 kHz' + assert '{:,.2pMHz}'.format(q) == '1,420.41 MHz' + assert '{:,.2pGHz}'.format(q) == '1.42 GHz' + assert '{:,.2pTHz}'.format(q) == '0 THz' assert '{:.2P}'.format(q) == 'f = 1420405751.79 Hz' assert '{:,.2P}'.format(q) == 'f = 1,420,405,751.79 Hz' assert '{:#.3q}'.format(q) == '1.420 GHz' diff --git a/tests/test_quantity_functions.py b/tests/test_quantity_functions.py new file mode 100644 index 0000000..e8d0172 --- /dev/null +++ b/tests/test_quantity_functions.py @@ -0,0 +1,58 @@ +from quantiphy import ( + as_real, as_tuple, render, fixed, binary, + UnitConversion, QuantiPhyError, +) +from pytest import approx, fixture, raises + +def test_as_real(): + UnitConversion('g', 'lb lbs', 453.59237) + assert as_real('2 lbs') == approx(2) + assert as_real('2 lbs', scale='lb') == approx(2) + assert as_real('2 lbs', scale='g') == approx(907.18474) + assert as_real('2 lbs', scale='kg') == approx(0.90718474) + assert as_real('1000 g', scale='kg') == approx(1) + assert as_real('1 Mg', scale='g') == approx(1_000_000) + assert as_real('1 Mg', scale='kg') == approx(1_000) + +def test_as_tuple(): + UnitConversion('g', 'lb lbs', 453.59237) + assert as_tuple('2 lbs') == approx((2, 'lbs')) + assert as_tuple('2 lbs', scale='lb') == approx((2, 'lb')) + assert as_tuple('2 lbs', scale='g') == approx((907.18474, 'g')) + assert as_tuple('2 lbs', scale='kg') == approx((0.90718474, 'kg')) + assert as_tuple('1000 g', scale='kg') == approx((1, 'kg')) + assert as_tuple('1 Mg', scale='g') == approx((1_000_000, 'g')) + assert as_tuple('1 Mg', scale='kg') == approx((1_000, 'kg')) + +def test_render(): + UnitConversion('g', 'lb lbs', 453.59237) + assert render(2, 'lbs') == '2 lbs' + assert render(2, 'lbs', scale='lb') == '2 lb' + assert render(2, 'lbs', scale='g') == '907.18 g' + assert render(2, 'lbs', scale='kg') == '907.18 mkg' + assert render(1000, 'g', scale='kg') == '1 kg' + assert render(1e6, 'g') == '1 Mg' + assert render(1e6, 'g', scale='g') == '1 Mg' + assert render(1e6, 'g', scale='kg') == '1 kkg' + assert render(1, 'Mg', scale='g') == '1 Mg' + assert render(1, 'Mg', scale='kg') == '1 kkg' + +def test_fixed(): + UnitConversion('g', 'lb lbs', 453.59237) + assert fixed(2, 'lbs') == '2 lbs' + assert fixed(2, 'lbs', scale='lb') == '2 lb' + assert fixed(2, 'lbs', scale='g') == '907.1847 g' + assert fixed(2, 'lbs', scale='kg') == '0.9072 kg' + assert fixed(1000, 'g', scale='kg') == '1 kg' + assert fixed(1, 'Mg', scale='g', show_commas=True) == '1,000,000 g' + assert fixed(1, 'Mg', scale='kg', show_commas=True) == '1,000 kg' + assert fixed(1e6, 'g', scale='g', show_commas=True) == '1,000,000 g' + assert fixed(1e6, 'g', scale='kg', show_commas=True) == '1,000 kg' + +def test_fixed(): + UnitConversion('b', 'B', 8) + assert binary(2, 'B') == '2 B' + assert binary(2, 'B', scale='b') == '16 b' + assert binary(2, 'b', scale='B') == '0.25 B' + assert binary(2048, 'B') == '2 KiB' + assert binary(2048, 'B', scale='b') == '16 Kib' diff --git a/tests/test_unit_conversion.py b/tests/test_unit_conversion.py index 644ff1c..deea70b 100644 --- a/tests/test_unit_conversion.py +++ b/tests/test_unit_conversion.py @@ -369,35 +369,34 @@ def test_affine_conversion(): result = conversion.convert(32, from_units='F') assert str(result) == '0 C' - with pytest.raises(UnknownConversion) as exception: - result = conversion.convert(0, from_units='X', to_units='X') - assert str(exception.value) == "unable to convert to ‘X’." + result = conversion.convert(0, from_units='X', to_units='X') + assert str(result) == '0 X' with pytest.raises(UnknownConversion) as exception: result = conversion.convert(0, from_units='F', to_units='X') - assert str(exception.value) == "unable to convert to ‘X’." + assert str(exception.value) == "unable to convert between ‘X’ and ‘F’." with pytest.raises(UnknownConversion) as exception: result = conversion.convert(0, from_units='X', to_units='F') - assert str(exception.value) == "unable to convert from ‘X’." + assert str(exception.value) == "unable to convert between ‘F’ and ‘X’." assert isinstance(exception.value, UnknownConversion) assert isinstance(exception.value, QuantiPhyError) assert isinstance(exception.value, KeyError) assert exception.value.args == () - assert exception.value.kwargs == dict(from_units='X',) + assert exception.value.kwargs == dict(from_units='X', to_units='F') with pytest.raises(UnknownConversion) as exception: result = conversion.convert(0, to_units='X') - assert str(exception.value) == "unable to convert to ‘X’." + assert str(exception.value) == "unable to convert between ‘X’ and ‘F’." with pytest.raises(KeyError) as exception: result = conversion.convert(0, from_units='X') - assert str(exception.value) == "unable to convert from ‘X’." + assert str(exception.value) == "unable to convert between ‘C’ and ‘X’." assert isinstance(exception.value, UnknownConversion) assert isinstance(exception.value, QuantiPhyError) assert isinstance(exception.value, KeyError) assert exception.value.args == () - assert exception.value.kwargs == dict(from_units='X',) + assert exception.value.kwargs == dict(from_units='X', to_units='C') def test_func_converters(): Quantity.reset_prefs() diff --git a/tests/test_unit_conversion2.py b/tests/test_unit_conversion2.py new file mode 100644 index 0000000..01b084a --- /dev/null +++ b/tests/test_unit_conversion2.py @@ -0,0 +1,141 @@ +from quantiphy import ( + as_tuple, + Quantity, + QuantiPhyError, + UnitConversion, + UnknownConversion, +) +from pytest import approx, fixture, raises + +@fixture +def initialize_unit_conversions(): + UnitConversion.clear_all() + Quantity.reset_prefs() + +def test_2_lbs(initialize_unit_conversions): + UnitConversion('g', 'lb lbs', 453.59237) + assert as_tuple('2 lbs', scale='kg') == approx((0.90718474, 'kg')) + assert Quantity('2 lbs', scale='kg').as_tuple() == approx((0.90718474, 'kg')) + + +def test_39_in(initialize_unit_conversions): + UnitConversion('m', 'in inch inches', 0.0254) + assert as_tuple('39.37 in', scale='cm') == approx((99.9998, 'cm')) + assert Quantity('39.37 in', scale='cm').as_tuple() == approx((99.9998, 'cm')) + +@UnitConversion.fixture +def from_molarity(M, mw): + return M * mw + +@UnitConversion.fixture +def to_molarity(g_L, mw): + return g_L / mw + +def test_converter(initialize_unit_conversions): + cc_L = UnitConversion('cc', 'L', 1000) + assert str(cc_L) == 'cc ← 1000*L' + + volume = cc_L.convert(25, from_units='cc', to_units='uL').as_tuple() + assert volume == approx((25_000, 'uL')) + + volume = cc_L.convert(25, from_units='mL', to_units='uL').as_tuple() + assert volume == approx((25_000, 'uL')) + + volume = cc_L.convert(25, from_units='cc', to_units='mcc').as_tuple() + assert volume == approx((25_000, 'mcc')) + + volume = cc_L.convert(25, from_units='mL', to_units='mcc').as_tuple() + assert volume == approx((25_000, 'mcc')) + + with raises(UnknownConversion) as exception: + cc_L.convert(25, from_units='cc', to_units='gallons') + assert exception.value.args == () + assert exception.value.kwargs == dict(from_units='cc', to_units='gallons') + assert str(exception.value) == 'unable to convert between ‘gallons’ and ‘cc’.' + + with raises(UnknownConversion) as exception: + cc_L.convert(25, from_units='gallons', to_units='cc') + assert exception.value.args == () + assert exception.value.kwargs == dict(from_units='gallons', to_units='cc') + assert str(exception.value) == 'unable to convert between ‘cc’ and ‘gallons’.' + + with raises(UnknownConversion) as exception: + cc_L.convert(25, from_units='L', to_units='gallons') + assert exception.value.args == () + assert exception.value.kwargs == dict(from_units='L', to_units='gallons') + assert str(exception.value) == 'unable to convert between ‘gallons’ and ‘L’.' + + with raises(UnknownConversion) as exception: + cc_L.convert(25, from_units='gallons', to_units='L') + assert exception.value.args == () + assert exception.value.kwargs == dict(from_units='gallons', to_units='L') + assert str(exception.value) == 'unable to convert between ‘L’ and ‘gallons’.' + +def test_differing_known_units(initialize_unit_conversions): + Quantity.set_prefs(known_units='cc') + UnitConversion('cc', 'L', 1000) + + volume = as_tuple('100 cc', scale='L', ignore_sf=True) + assert volume == approx((0.1, 'L')) + + volume = as_tuple('100 cc', scale='uL', ignore_sf=True) + assert volume == approx((100000, 'uL')) + + volume = as_tuple('10 mL', scale='cc', ignore_sf=True) + assert volume == approx((10, 'cc')) + + volume = as_tuple('100 uL', scale='mcc', ignore_sf=True) + assert volume == approx((100, 'mcc')) + +def test_same_unknown_units(initialize_unit_conversions): + freq = as_tuple('100 Hz', scale='Hz', ignore_sf=True) + assert freq == approx((100, 'Hz')) + + freq = as_tuple('100 Hz', scale='kHz', ignore_sf=True) + assert freq == approx((0.1, 'kHz')) + + freq = as_tuple('10 kHz', scale='Hz', ignore_sf=True) + assert freq == approx((10000, 'Hz')) + + freq = as_tuple('100 kHz', scale='MHz', ignore_sf=True) + assert freq == approx((0.1, 'MHz')) + +def test_exceptions(initialize_unit_conversions): + # unknown units case + with raises(UnknownConversion) as exception: + Quantity('100 Hz', scale='V') + assert exception.value.args == () + assert exception.value.kwargs == dict(from_units='Hz', to_units='V') + assert str(exception.value) == 'unable to convert between ‘V’ and ‘Hz’.' + + # known units case + UnitConversion('m', 'in inch inches', 0.0254) + UnitConversion('g', 'lb lbs', 453.59237) + with raises(UnknownConversion) as exception: + Quantity('1 kg', scale='m') + assert exception.value.args == () + assert exception.value.kwargs == dict(from_units='g', to_units='m') + assert str(exception.value) == 'unable to convert between ‘m’ and ‘g’.' + + +def test_molarity(initialize_unit_conversions): + mol_conv = UnitConversion('g/L', 'M', from_molarity, to_molarity) + + assert as_tuple('1.2 mg/L', scale='uM', params=74.55) == approx((16.096579477, 'uM')) + assert as_tuple('1.2 mg/L', scale='µM', params=74.55) == approx((16.096579477, 'µM')) + +def test_cc(initialize_unit_conversions): + cc_L = UnitConversion('cc', 'L', 1000) + assert str(cc_L) == 'cc ← 1000*L' + assert cc_L.convert(25, from_units='cc', to_units='uL').as_tuple() == approx((25_000, 'uL')) + assert cc_L.convert(25, from_units='mL', to_units='uL').as_tuple() == approx((25_000, 'uL')) + assert cc_L.convert(25, from_units='cc', to_units='mcc').as_tuple() == approx((25_000, 'mcc')) + assert cc_L.convert(25, from_units='mL', to_units='mcc').as_tuple() == approx((25_000, 'mcc')) + + Quantity.set_prefs(known_units='cc') + assert as_tuple('0.25 L', scale='cc') == approx((250, 'cc')) + assert as_tuple('25 mL', scale='cc') == approx((25, 'cc')) + assert as_tuple('25 mcc', scale='uL', ignore_sf=True) == approx((25, 'uL')) + assert as_tuple('25 cc', scale='mL') == approx((25, 'mL')) + assert as_tuple('25 mcc', scale='L', ignore_sf=True) == approx((25e-6, 'L')) + assert as_tuple('25 cc', scale='L') == approx((0.025, 'L'))