import re from math import gcd from .units_convert_data import UNITS, UnitDefinition DECIMAL = re.compile(r"^-?[0-9]+(\.[0-9]+)?$") MAX_DIGITS = 15 def _unit(symbol: str) -> UnitDefinition: for u in UNITS: if u.symbol == symbol: return u raise ValueError('unknown unit "%s"' % (symbol,)) def convert_units(value: str, from_unit: str, to_unit: str, decimals: int) -> str: """Convert a quantity between units of the same dimension, exactly. The value travels as decimal text and every factor is an exact fraction (1 in = 254/10000 m, 1 lb = 45359237/100000000 kg), so the only rounding is the one at the end, to ``decimals`` places. Multiplying floats instead gets 0.75 in = 19.049999999999997 mm, which rounds to 19.0 rather than 19.1. """ if not isinstance(value, str) or not DECIMAL.fullmatch(value): raise ValueError('value must be a plain decimal like "12.5", received "%s"' % (value,)) negative = value.startswith("-") body = value[1:] if negative else value whole, _, raw_fraction = body.partition(".") fraction = raw_fraction.rstrip("0") digits = (whole + fraction).lstrip("0") if len(digits) > MAX_DIGITS or len(fraction) > MAX_DIGITS: raise ValueError( 'value "%s" has too many digits: at most 15 significant digits and 15 decimal places' % (value,) ) src = _unit(from_unit) dst = _unit(to_unit) if src.dimension != dst.dimension: raise ValueError( "cannot convert %s (%s) to %s (%s)" % (from_unit, src.dimension, to_unit, dst.dimension) ) if isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0 or decimals > 12: raise ValueError("decimals must be a whole number from 0 to 12, received %s" % (decimals,)) # base = v * fa + oa, result = (base - ob) / fb # = v * P/Q + R/S, with P/Q = fa/fb and R/S = (oa - ob)/fb, each reduced. p = src.factor_numerator * dst.factor_denominator q = src.factor_denominator * dst.factor_numerator g = gcd(p, q) p //= g q //= g r = (src.offset_numerator * dst.offset_denominator - dst.offset_numerator * src.offset_denominator) * dst.factor_denominator s = src.offset_denominator * dst.offset_denominator * dst.factor_numerator g = gcd(r, s) if g != 0: r //= g s //= g scale = 10 ** len(fraction) magnitude = int(digits) if digits else 0 numerator = (-magnitude if negative else magnitude) * p * s + r * scale * q denominator = scale * q * s # Long division one digit at a time keeps every intermediate below 10 x the # denominator, which is what lets the Rust port do the same in i128. result_negative = numerator < 0 absolute = -numerator if result_negative else numerator int_part, remainder = divmod(absolute, denominator) frac_part = 0 for _ in range(decimals): remainder *= 10 frac_part = frac_part * 10 + remainder // denominator remainder %= denominator if remainder * 2 >= denominator: frac_part += 1 if frac_part == 10 ** decimals: frac_part = 0 int_part += 1 text = str(int_part) if decimals > 0: shown = str(frac_part).rjust(decimals, "0").rstrip("0") if shown: text += "." + shown if result_negative and (int_part != 0 or frac_part != 0): text = "-" + text return text