Repository: hgrecco/pint

Issue:
Converting a Decimal number results in a loss of precision
### Discussed in https://github.com/hgrecco/pint/discussions/2105

Converting a `Decimal` number from `inch` to `thou` results in a loss of precision. I would expect that this conversion does the equivalent of `Decimal("1.0") * 1000`, i.e. the conversion returns `Decimal('1000.0')`.

<div type='discussions-op-text'>

<sup>Originally posted by **cdwilson** December 31, 2024</sup>

```python
>>> from decimal import Decimal
>>> from pint import UnitRegistry
>>> ureg = UnitRegistry(non_int_type=Decimal)
>>> d2 = ureg.Quantity(Decimal("1.0"), ureg.inch)
>>> d2.to(ureg.thou).magnitude
Decimal('999.9999999999999999999999996')
```
</div>


Discussion:
Hi

I experience the same issue with different units.

```python
import decimal, pint
ureg = pint.UnitRegistry(non_int_type=decimal.Decimal)
flow = decimal.Decimal('2')*ureg.L/ureg.h
print(flow.to(ureg.L/ureg.day)) 
```
gives `48.00000000000000000000000002 liter / day` not `48 liter / day`.

I read through https://github.com/hgrecco/pint/issues/967 and assumed this was fixed. Maybe this is a new issue?
The trouble appears to have it's roots in fractions. For my own case the conversion required computation of `1/60/60` which cannot be represented exactly as a decimal no matter the precision.

`decimal` actually has facilities to circumvent this. However, `pint` uses exponentiation to divide which causes `decimal` to take the non-clever path.

To fix this the following change is required to `facets/plain/registry.py`:

```python
    def _get_root_units_recurse(
        self, ref: UnitsContainer, exp: Scalar, accumulators: dict[str | None, int]
    ) -> None:
        """

        accumulators None keeps the scalar prefactor not associated with a specific unit.

        """
        for key in ref:
            exp2 = exp * ref[key]
            key = self.get_name(key)
            reg = self._units[key]
            if reg.is_base:
                accumulators[key] += exp2
            else:
                # Use division operator for division
                if exp2 < 0:
                    accumulators[None] /= reg.converter.scale**abs(exp2)
                else:
                    accumulators[None] *= reg.converter.scale**abs(exp2)
                if reg.reference is not None:
                    self._get_root_units_recurse(reg.reference, exp2, accumulators)
```

With this change
```python
import decimal, pint
ureg = pint.UnitRegistry(non_int_type=decimal.Decimal)
flow = decimal.Decimal('2')*ureg.L/ureg.h
print(flow.to(ureg.L/ureg.day).magnitude)
```
gives `48.00000000000000000000000000`.



Patch:
diff --git a/CHANGES b/CHANGES
index 66478998c..743811f8a 100644
--- a/CHANGES
+++ b/CHANGES
@@ -14,6 +14,7 @@ Pint Changelog
 - Fix return type of `PlainQuantity.to` (#2088)
 - Update constants to CODATA 2022 recommended values. (#2049)
 - Fixed issue with `.to_compact` and Magnitudes with uncertainties / Quantities with units (PR #2069, issue #2044)
+- Fixed issue in unit conversion which led to loss of precision when using `decimal`. (#2107)
 - Add conductivity dimension. (#2112)
 - Add absorbance unit and dimension. (#2114)
 - Add membrane filtration flux and permeability dimensionality, and shorthand "LMH". (#2116)
diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py
index 43f28f8b9..0219a089e 100644
--- a/pint/facets/plain/registry.py
+++ b/pint/facets/plain/registry.py
@@ -898,10 +898,49 @@ def _get_root_units(
             pass
 
         accumulators: dict[str | None, int] = defaultdict(int)
-        accumulators[None] = 1
-        self._get_root_units_recurse(input_units, 1, accumulators)
+        fraction: dict[str, dict[Decimal | float, Decimal | int]] = dict(
+            numerator=dict(), denominator=dict()
+        )
+        self._get_root_units_recurse(input_units, 1, accumulators, fraction)
+
+        # Identify if terms appear in both numerator and denominator
+        def terms_are_unique(fraction):
+            for n_factor, n_exp in fraction["numerator"].items():
+                if n_factor in fraction["denominator"]:
+                    return False
+            return True
+
+        # Cancel out terms where factor matches
+        while not terms_are_unique(fraction):
+            for n_factor, n_exponent in fraction["numerator"].items():
+                if n_factor in fraction["denominator"]:
+                    if n_exponent >= fraction["denominator"][n_factor]:
+                        fraction["numerator"][n_factor] -= fraction["denominator"][
+                            n_factor
+                        ]
+                        del fraction["denominator"][n_factor]
+                        continue
+            for d_factor, d_exponent in fraction["denominator"].items():
+                if d_factor in fraction["numerator"]:
+                    if d_exponent >= fraction["numerator"][d_factor]:
+                        fraction["denominator"][d_factor] -= fraction["numerator"][
+                            d_factor
+                        ]
+                        del fraction["numerator"][d_factor]
+                        continue
+
+        factor = 1
+        for n_factor, n_exponent in fraction["numerator"].copy().items():
+            if n_exponent == 0:
+                del fraction["numerator"][n_factor]
+            else:
+                factor *= n_factor**n_exponent
+        for d_factor, d_exponent in fraction["denominator"].copy().items():
+            if d_exponent == 0:
+                del fraction["denominator"][d_factor]
+            else:
+                factor *= d_factor**-d_exponent
 
-        factor = accumulators[None]
         units = self.UnitsContainer(
             {k: v for k, v in accumulators.items() if k is not None and v != 0}
         )
@@ -948,7 +987,11 @@ def get_base_units(
     # TODO: accumulators breaks typing list[int, dict[str, int]]
     # So we have changed the behavior here
     def _get_root_units_recurse(
-        self, ref: UnitsContainer, exp: Scalar, accumulators: dict[str | None, int]
+        self,
+        ref: UnitsContainer,
+        exp: Scalar,
+        accumulators: dict[str | None, int],
+        fraction: dict[str, dict[Decimal | float, Decimal | int]],
     ) -> None:
         """
 
@@ -962,9 +1005,19 @@ def _get_root_units_recurse(
             if reg.is_base:
                 accumulators[key] += exp2
             else:
-                accumulators[None] *= reg.converter.scale**exp2
+                # Build numerator and denominator
+                if exp2 < 0:
+                    fraction["denominator"][reg.converter.scale] = (
+                        fraction["denominator"].get(reg.converter.scale, 0) - exp2
+                    )
+                else:
+                    fraction["numerator"][reg.converter.scale] = (
+                        fraction["numerator"].get(reg.converter.scale, 0) + exp2
+                    )
                 if reg.reference is not None:
-                    self._get_root_units_recurse(reg.reference, exp2, accumulators)
+                    self._get_root_units_recurse(
+                        reg.reference, exp2, accumulators, fraction
+                    )
 
     def get_compatible_units(self, input_units: QuantityOrUnitLike) -> frozenset[UnitT]:
         """ """


Test patch:
diff --git a/pint/testsuite/test_issues.py b/pint/testsuite/test_issues.py
index d270d9c19..c6bed595e 100644
--- a/pint/testsuite/test_issues.py
+++ b/pint/testsuite/test_issues.py
@@ -1327,6 +1327,26 @@ def test_issue2044():
     assert q.u == "kilometer"
 
 
+def test_issue2107():
+    # Use decimal
+    ureg = UnitRegistry(non_int_type=decimal.Decimal)
+    # 2 L/h is equal to 48 L/day
+    flow = decimal.Decimal("2") * ureg.L / ureg.h
+    assert flow.to(ureg.L / ureg.day).magnitude == 48.0
+    # 1 inch is equal to 1000 thou
+    distance = ureg.Quantity(decimal.Decimal("1.0"), ureg.inch)
+    assert distance.to(ureg.thou).magnitude == 1000.0
+
+    # Perform the same conversions without decimal
+    ureg = UnitRegistry()
+    # 2 L/h is equal to 48 L/day
+    flow = 2 * ureg.L / ureg.h
+    assert flow.to(ureg.L / ureg.day).magnitude == 48.0
+    # 1 inch is equal to 1000 thou
+    distance = ureg.Quantity(1, ureg.inch)
+    assert distance.to(ureg.thou).magnitude == 1000.0
+
+
 def test_issue2172():
     ureg = UnitRegistry()