diff --git a/src/fixate/core/common.py b/src/fixate/core/common.py index 1e31bdc2..aa3a06c8 100644 --- a/src/fixate/core/common.py +++ b/src/fixate/core/common.py @@ -1,8 +1,6 @@ import re import sys import threading -import inspect -import ctypes import logging import warnings from functools import wraps @@ -21,17 +19,23 @@ "%", "Hertz", "Volts", + "Amps", + "A", "Percent", "PC", - "deg", "Deg", "C", "H", + "F", + "Ohm", + "Ohms", } UNIT_SCALE = { - "m": 10**-3, - "u": 10**-6, + "p": 10**-12, "n": 10**-9, + "u": 10**-6, + "m": 10**-3, + "": 1, "k": 10**3, "M": 10**6, "G": 10**9, @@ -134,7 +138,12 @@ def mode_builder(search_dict, repl_kwargs, *args, **kwargs): return ret_string -def unit_convert(value, min_primary_number, max_primary_number, as_int=False): +def unit_convert( + value: int or float, + min_primary_number: int or float, + max_primary_number=None, + as_int=False, +) -> str: """ :param value: An int or float to convert into a scaled unit @@ -151,21 +160,38 @@ def unit_convert(value, min_primary_number, max_primary_number, as_int=False): >>>unit_convert(100e6, 1, 999, as_int=True) '100M' """ + # Previous implementation had lots of holes: + # i.e. unit_convert(99.9e6, 0.1, 99) would fall through + # NOTE: since we are using eng.notation - hardcode range as 1e3 + max_primary_number = min_primary_number * 1e3 + # TODO: should we enforce min_primary_number in (1e-3, 1) + # otherwise can get some odd display issues + for unit, scale in UNIT_SCALE.items(): - if min_primary_number * scale <= value <= max_primary_number * scale: + if min_primary_number * scale <= abs(value) < max_primary_number * scale: new_val = value / scale if as_int: new_val = int(new_val) - return "{}{}".format(new_val, unit) + return f"{new_val:.3g}{unit}" + # TODO - thinking I should change this to .6g? + # Just need to prevent displaying whole float + + logger.error("Could not convert to units: %f", value) + # Should only get here now if there doesn't exist appropriate UNIT_SCALE entry? + # Best to return the entry rather than throw exception? + return f"{value}" -def unit_scale(str_value, accepted_units=UNITS): +def unit_scale(str_value, accepted_units=UNITS) -> int or float: """ :param str_value: A Value to search for a number and the acceptable units to then scale the original number :param accepted_units: Restricts the units to this sequence or if not parsed will use defaults specified in the UNITS set :return: + Value of input with embedded unit converted to respectivescaling + :raises: + InvalidScalarQuantityError - unable to process input string """ # If type is a number, no scaling required if type(str_value) in [int, float]: @@ -182,12 +208,12 @@ def unit_scale(str_value, accepted_units=UNITS): ) ) # Match Decimal and Integer Values - p = re.compile("\d+(\.\d+)?") + p = re.compile("-?\d+(\.\d+)?") num_match = p.search(str_value) if num_match: num = float(num_match.group()) - comp = "^ ?({unit_scale})(?=($|{units}))".format( + comp = "^ *({unit_scale}) ?(?i)(?=($|{units}))".format( units="|".join(accepted_units), unit_scale="|".join(UNIT_SCALE.keys()) ) p = re.compile(comp) diff --git a/test/core/test_lib_common_unitscale.py b/test/core/test_lib_common_unitscale.py index 09d0eed9..54db298d 100644 --- a/test/core/test_lib_common_unitscale.py +++ b/test/core/test_lib_common_unitscale.py @@ -1,5 +1,5 @@ import unittest -from fixate.core.common import unit_scale +from fixate.core.common import unit_scale, unit_convert, UNITS from fixate.core.exceptions import InvalidScalarQuantityError @@ -19,12 +19,18 @@ def test_micro_scale(self): def test_nano_scale(self): self.assertAlmostEqual(unit_scale("10nV", ["V"]), 10e-9) + def test_pico_scale(self): + self.assertAlmostEqual(unit_scale("10pV", ["V"]), 10e-12) + def test_giga_scale(self): self.assertAlmostEqual(unit_scale("10GV", ["V"]), 10e9) def test_no_scale(self): self.assertEqual(unit_scale("10V", ["V"]), 10) + def test_negative_value(self): + self.assertAlmostEqual(unit_scale("-12mV", UNITS), -12e-3) + def test_milli_scale_no_units(self): self.assertAlmostEqual(unit_scale("10m", ["V"]), 10e-3) @@ -40,12 +46,33 @@ def test_micro_scale_no_units(self): def test_nano_scale_no_units(self): self.assertAlmostEqual(unit_scale("10n", ["V"]), 10e-9) + def test_pico_scale_no_units(self): + self.assertAlmostEqual(unit_scale("10p", ["V"]), 10e-12) + def test_giga_scale_no_units(self): self.assertAlmostEqual(unit_scale("10G", ["V"]), 10e9) def test_no_scale_no_units(self): self.assertEqual(unit_scale("10", ["V"]), 10e0) + def test_negative_no_scale(self): + self.assertAlmostEqual(unit_scale("-10V", UNITS), -10) + + def test_negative_no_units(self): + self.assertAlmostEqual(unit_scale("-10m", UNITS), -10e-3) + + def test_space_before_units(self): + self.assertAlmostEqual(unit_scale("-10 mHz", UNITS), -10e-3) + + def test_multiple_spaces_before_units(self): + self.assertAlmostEqual(unit_scale("-10 mV", UNITS), -10e-3) + + def test_space_between_units(self): + self.assertAlmostEqual(unit_scale("-10 k V", UNITS), -10e3) + + def test_leading_spaces(self): + self.assertAlmostEqual(unit_scale(" -10 mV ", UNITS), -10e-3) + def test_number_invalid_suffix(self): with self.assertRaises(InvalidScalarQuantityError): self.assertEqual(unit_scale("10 abcd", ["V"]), 10e0) @@ -63,3 +90,65 @@ def test_invalid_string(self): def test_none(self): self.assertEqual(unit_scale(None), None) + + +class TestUnitConvert(unittest.TestCase): + """Use of unit_convert: unit_convert(100e6, 1, 999)""" + + def test_no_scale(self): + self.assertEqual(unit_convert(10.1, 1, 999), "10.1") + + def test_no_scale_int(self): + self.assertEqual(unit_convert(10.9, 1, 999, as_int=True), "10") + + def test_round_down(self): + self.assertEqual(unit_convert(10.83, 1, 999), "10.8") + + # NOTE: unpredictable behaviour with rounding of floats, i.e.: + # unit_convert(10.95, 1) = '10.9' + # unit_convert(10.05, 1) = '10.1' + + def test_milli_scale(self): + self.assertEqual(unit_convert(10e-3, 1, 999), "10m") + + def test_mega_scale(self): + self.assertEqual(unit_convert(10e6, 1, 999), "10M") + + def test_kilo_scale(self): + self.assertEqual(unit_convert(10e3, 1, 999), "10k") + + def test_micro_scale(self): + self.assertEqual(unit_convert(10e-6, 1, 999), "10u") + + def test_nano_scale(self): + self.assertEqual(unit_convert(10e-9, 1, 999), "10n") + + def test_pico_scale(self): + self.assertEqual(unit_convert(10e-12, 1, 999), "10p") + + def test_giga_scale(self): + self.assertEqual(unit_convert(10e9, 1, 999), "10G") + + def test_smaller_base(self): + self.assertEqual(unit_convert(100e-6, 0.1, 99), "0.1m") + + def test_smaller_base2(self): + self.assertEqual(unit_convert(19.8e-6, 0.01, 99), "0.0198m") + + def test_smaller_base3(self): + self.assertEqual(unit_convert(9.8e-6, 0.001, 9), "0.0098m") + + def test_negative_value(self): + self.assertEqual(unit_convert(-10e-3, 1, 999), "-10m") + + def test_out_of_range(self): + """Change in future if exception raised instead""" + self.assertEqual(unit_convert(10e12, 1, 999), "10000000000000.0") + + def test_none(self): + with self.assertRaises(TypeError): + unit_convert(None, None) + + def test_string_input_invalid(self): + with self.assertRaises(TypeError): + unit_convert("10", 1, 99)