conversion.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
  2. from django.db.models import BigIntegerField, Case, DecimalField, F, Value, When
  3. from django.db.models.functions import Cast, Floor
  4. from django.utils.translation import gettext as _
  5. from dcim.choices import CableLengthUnitChoices
  6. from netbox.choices import (
  7. DiameterUnitChoices,
  8. FlowRateUnitChoices,
  9. WeightUnitChoices,
  10. )
  11. __all__ = (
  12. 'DIAMETER_TO_MILLIMETERS',
  13. 'FLOW_RATE_TO_LITERS_PER_MINUTE',
  14. 'LENGTH_TO_METERS',
  15. 'WEIGHT_TO_GRAMS',
  16. 'normalized_unit_expression',
  17. 'to_grams',
  18. 'to_liters_per_minute',
  19. 'to_meters',
  20. 'to_millimeters',
  21. )
  22. #
  23. # Conversion factors
  24. #
  25. # Each mapping gives the multiplier from a unit to its base unit as a Decimal literal. The to_*() helpers and the
  26. # GeneratedField expressions built by normalized_unit_expression() both read these, so changing a factor (or adding
  27. # a unit) requires a migration to drop and re-add the affected generated columns.
  28. WEIGHT_TO_GRAMS = {
  29. WeightUnitChoices.UNIT_KILOGRAM: Decimal('1000'),
  30. WeightUnitChoices.UNIT_GRAM: Decimal('1'),
  31. WeightUnitChoices.UNIT_POUND: Decimal('453.592'),
  32. WeightUnitChoices.UNIT_OUNCE: Decimal('28.3495'),
  33. }
  34. # Shared by cable lengths and distances; DistanceUnitChoices is a subset of CableLengthUnitChoices
  35. LENGTH_TO_METERS = {
  36. CableLengthUnitChoices.UNIT_KILOMETER: Decimal('1000'),
  37. CableLengthUnitChoices.UNIT_METER: Decimal('1'),
  38. CableLengthUnitChoices.UNIT_CENTIMETER: Decimal('0.01'),
  39. CableLengthUnitChoices.UNIT_MILE: Decimal('1609.344'),
  40. CableLengthUnitChoices.UNIT_FOOT: Decimal('0.3048'),
  41. CableLengthUnitChoices.UNIT_INCH: Decimal('0.0254'),
  42. }
  43. DIAMETER_TO_MILLIMETERS = {
  44. DiameterUnitChoices.UNIT_MILLIMETER: Decimal('1'),
  45. DiameterUnitChoices.UNIT_CENTIMETER: Decimal('10'),
  46. DiameterUnitChoices.UNIT_INCH: Decimal('25.4'),
  47. }
  48. FLOW_RATE_TO_LITERS_PER_MINUTE = {
  49. FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE: Decimal('1'),
  50. FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR: Decimal('16.66666666666666666666666667'), # 1000 / 60
  51. FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE: Decimal('3.785411784'),
  52. }
  53. #
  54. # Database expressions
  55. #
  56. def normalized_unit_expression(value_field, unit_field, factors, integer=False):
  57. """
  58. Return a database expression converting `value_field` to its base unit according to the value of `unit_field`,
  59. per the given mapping of unit to factor. Evaluates to NULL if either field is NULL or the unit is unrecognized.
  60. With `integer=True`, the result is truncated to a whole number (as to_grams() does) and cast to a bigint.
  61. """
  62. # Cast to numeric so that an integer source column cannot overflow integer arithmetic
  63. value = Cast(F(value_field), output_field=DecimalField())
  64. expression = Case(*[
  65. When(**{unit_field: unit}, then=value * Value(factor))
  66. for unit, factor in factors.items()
  67. ])
  68. if integer:
  69. expression = Cast(Floor(expression), output_field=BigIntegerField())
  70. return expression
  71. #
  72. # Python conversions
  73. #
  74. def _normalized_measurement(value, unit, factors, quantity, precision=4) -> Decimal:
  75. """
  76. Shared implementation for the unit-normalization helpers below. Coerce `value` to a non-negative
  77. Decimal, multiply by the factor for `unit`, and round the result to `precision` decimal places.
  78. `factors` maps each valid unit to its multiplier; `quantity` labels the value in error messages.
  79. Pass `precision=None` to return the unrounded result. Rounding is half away from zero, matching how
  80. PostgreSQL stores the same result in a generated numeric column.
  81. """
  82. try:
  83. value = Decimal(value)
  84. except (InvalidOperation, TypeError, ValueError):
  85. raise TypeError(
  86. _("Invalid value '{value}' for {quantity} (must be a number)").format(value=value, quantity=quantity)
  87. )
  88. if value < 0:
  89. raise ValueError(_("Invalid value for {quantity}: must be a positive number").format(quantity=quantity))
  90. if unit not in factors:
  91. raise ValueError(
  92. _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
  93. unit=unit,
  94. valid_units=', '.join(factors)
  95. )
  96. )
  97. result = value * factors[unit]
  98. if precision is None:
  99. return result
  100. return result.quantize(Decimal(1).scaleb(-precision), rounding=ROUND_HALF_UP)
  101. def to_grams(weight, unit) -> int:
  102. """
  103. Convert the given weight to integer grams.
  104. """
  105. # Rounding is suppressed so that the result is truncated exactly as the caller's int() expects; rounding
  106. # first could nudge a value across an integer boundary.
  107. return int(_normalized_measurement(weight, unit, WEIGHT_TO_GRAMS, _('weight'), precision=None))
  108. def to_meters(length, unit) -> Decimal:
  109. """
  110. Convert the given length to meters, returning a Decimal value.
  111. """
  112. return _normalized_measurement(length, unit, LENGTH_TO_METERS, _('length'))
  113. def to_millimeters(diameter, unit) -> Decimal:
  114. """
  115. Convert the given diameter to millimeters, returning a Decimal value.
  116. """
  117. return _normalized_measurement(diameter, unit, DIAMETER_TO_MILLIMETERS, _('diameter'))
  118. def to_liters_per_minute(flow_rate, unit) -> Decimal:
  119. """
  120. Convert the given flow rate to liters per minute, returning a Decimal value.
  121. """
  122. return _normalized_measurement(flow_rate, unit, FLOW_RATE_TO_LITERS_PER_MINUTE, _('flow rate'))