Improve unit conversion (#5658)

- In previous implementation, could not have a value "10k" for a unit of "m"
- Now, correct result is obtained (10k [m] = 10[km])
- Still passes all previous unit tests
- Simpler code, too
This commit is contained in:
Oliver 2023-10-04 17:28:20 +11:00 committed by GitHub
parent c18bc4e1d1
commit ffda700244
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 71 additions and 58 deletions

View File

@ -85,65 +85,66 @@ def convert_physical_value(value: str, unit: str = None, strip_units=True):
The converted quantity, in the specified units
"""
original = str(value).strip()
# Ensure that the value is a string
value = str(value).strip()
value = str(value).strip() if value else ''
unit = str(unit).strip() if unit else ''
# Error on blank values
if not value:
raise ValidationError(_('No value provided'))
# Create a "backup" value which be tried if the first value fails
# e.g. value = "10k" and unit = "ohm" -> "10kohm"
# e.g. value = "10m" and unit = "F" -> "10mF"
if unit:
backup_value = value + unit
else:
backup_value = None
ureg = get_unit_registry()
error = ''
try:
# Convert to a quantity
val = ureg.Quantity(value)
value = ureg.Quantity(value)
if unit:
if is_dimensionless(val):
# If the provided value is dimensionless, assume that the unit is correct
val = ureg.Quantity(value, unit)
if is_dimensionless(value):
magnitude = value.to_base_units().magnitude
value = ureg.Quantity(magnitude, unit)
else:
# Convert to the provided unit (may raise an exception)
val = val.to(ureg.Unit(unit))
value = value.to(unit)
# At this point we *should* have a valid pint value
# To double check, look at the maginitude
float(ureg.Quantity(val.magnitude).magnitude)
except (TypeError, ValueError, AttributeError):
error = _('Provided value is not a valid number')
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError):
error = _('Provided value has an invalid unit')
except Exception:
if backup_value:
try:
value = ureg.Quantity(backup_value)
except Exception:
value = None
else:
value = None
if value is None:
if unit:
error += f' ({unit})'
except pint.errors.DimensionalityError:
error = _('Provided value could not be converted to the specified unit')
if unit:
error += f' ({unit})'
except Exception as e:
error = _('Error') + ': ' + str(e)
if error:
raise ValidationError(error)
raise ValidationError(_(f'Could not convert {original} to {unit}'))
else:
raise ValidationError(_("Invalid quantity supplied"))
# Calculate the "magnitude" of the value, as a float
# If the value is specified strangely (e.g. as a fraction or a dozen), this can cause isuses
# If the value is specified strangely (e.g. as a fraction or a dozen), this can cause issues
# So, we ensure that it is converted to a floating point value
# If we wish to return a "raw" value, some trickery is required
if unit:
magnitude = ureg.Quantity(val.to(ureg.Unit(unit))).magnitude
magnitude = ureg.Quantity(value.to(ureg.Unit(unit))).magnitude
else:
magnitude = ureg.Quantity(val.to_base_units()).magnitude
magnitude = ureg.Quantity(value.to_base_units()).magnitude
magnitude = float(ureg.Quantity(magnitude).to_base_units().magnitude)
if strip_units:
return magnitude
elif unit or val.units:
return ureg.Quantity(magnitude, unit or val.units)
elif unit or value.units:
return ureg.Quantity(magnitude, unit or value.units)
else:
return ureg.Quantity(magnitude)

View File

@ -42,6 +42,22 @@ from .validators import validate_overage
class ConversionTest(TestCase):
"""Tests for conversion of physical units"""
def test_prefixes(self):
"""Test inputs where prefixes are used"""
tests = {
"3": 3,
"3m": 3,
"3mm": 0.003,
"3k": 3000,
"3u": 0.000003,
"3 inch": 0.0762,
}
for val, expected in tests.items():
q = InvenTree.conversion.convert_physical_value(val, 'm')
self.assertAlmostEqual(q, expected, 3)
def test_base_units(self):
"""Test conversion to specified base units"""
tests = {
@ -56,14 +72,12 @@ class ConversionTest(TestCase):
for val, expected in tests.items():
q = InvenTree.conversion.convert_physical_value(val, 'W')
self.assertAlmostEqual(q, expected, 0.01)
self.assertAlmostEqual(q, expected, places=2)
q = InvenTree.conversion.convert_physical_value(val, 'W', strip_units=False)
self.assertAlmostEqual(float(q.magnitude), expected, 0.01)
self.assertAlmostEqual(float(q.magnitude), expected, places=2)
def test_dimensionless_units(self):
"""Tests for 'dimensonless' unit quantities"""
"""Tests for 'dimensionless' unit quantities"""
# Test some dimensionless units
tests = {
@ -84,25 +98,20 @@ class ConversionTest(TestCase):
for val, expected in tests.items():
# Convert, and leave units
q = InvenTree.conversion.convert_physical_value(val, strip_units=False)
self.assertAlmostEqual(float(q.magnitude), expected, 0.01)
self.assertAlmostEqual(float(q.magnitude), expected, 3)
# Convert, and strip units
q = InvenTree.conversion.convert_physical_value(val)
self.assertAlmostEqual(q, expected, 0.01)
self.assertAlmostEqual(q, expected, 3)
def test_invalid_values(self):
"""Test conversion of invalid inputs"""
inputs = [
'-',
';;',
'-x',
'?',
'--',
'+',
'++',
'1/0',
'1/-',
'xyz',
'12B45C'
]
for val in inputs:
@ -112,8 +121,7 @@ class ConversionTest(TestCase):
# Test dimensionless
with self.assertRaises(ValidationError):
result = InvenTree.conversion.convert_physical_value(val)
print("Testing invalid value:", val, result)
InvenTree.conversion.convert_physical_value(val)
def test_custom_units(self):
"""Tests for custom unit conversion"""
@ -154,11 +162,11 @@ class ConversionTest(TestCase):
for val, expected in tests.items():
# Convert, and leave units
q = InvenTree.conversion.convert_physical_value(val, 'henry / km', strip_units=False)
self.assertAlmostEqual(float(q.magnitude), expected, 0.01)
self.assertAlmostEqual(float(q.magnitude), expected, 2)
# Convert and strip units
q = InvenTree.conversion.convert_physical_value(val, 'henry / km')
self.assertAlmostEqual(q, expected, 0.01)
self.assertAlmostEqual(q, expected, 2)
class ValidatorTest(TestCase):
@ -857,7 +865,7 @@ class CurrencyTests(TestCase):
class TestStatus(TestCase):
"""Unit tests for status functions."""
def test_check_system_healt(self):
def test_check_system_health(self):
"""Test that the system health check is false in testing -> background worker not running."""
self.assertEqual(status.check_system_health(), False)
@ -953,7 +961,7 @@ class TestSettings(InvenTreeTestCase):
InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user)
registry.reload_plugins(full_reload=True)
# Check that there was anotehr run
# Check that there was another run
response = registry.install_plugin_file()
self.assertEqual(response, True)
@ -1147,7 +1155,7 @@ class BarcodeMixinTest(InvenTreeTestCase):
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
self.assertEqual(StockLocation.barcode_model_type(), 'stocklocation')
def test_bacode_hash(self):
def test_barcode_hash(self):
"""Test that the barcode hashing function provides correct results"""
# Test multiple values for the hashing function
@ -1176,7 +1184,7 @@ class SanitizerTest(TestCase):
# Test that valid string
self.assertEqual(valid_string, sanitize_svg(valid_string))
# Test that invalid string is cleanded
# Test that invalid string is cleaned
self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string))

View File

@ -484,7 +484,7 @@ class PluginsRegistry:
t_start = time.time()
plg_i: InvenTreePlugin = plg()
dt = time.time() - t_start
logger.info('Loaded plugin `%s` in %.3fs', plg_name, dt)
logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt)
except Exception as error:
handle_error(error, log_name='init') # log error and raise it -> disable plugin
logger.warning("Plugin `%s` could not be loaded", plg_name)
@ -554,7 +554,7 @@ class PluginsRegistry:
if hasattr(mixin, '_deactivate_mixin'):
mixin._deactivate_mixin(self, force_reload=force_reload)
logger.info('Done deactivating')
logger.debug('Finished deactivating plugins')
# endregion
# region mixin specific loading ...

View File

@ -257,6 +257,10 @@ function showApiError(xhr, url) {
title = '{% trans "Error 408: Timeout" %}';
message = '{% trans "Connection timeout while requesting data from server" %}';
break;
case 503:
title = '{% trans "Error 503: Service Unavailable" %}';
message = '{% trans "The server is currently unavailable" %}';
break;
default:
title = '{% trans "Unhandled Error Code" %}';
message = `{% trans "Error code" %}: ${xhr.status}`;