Functional Weave
Code in Python

units.convert@1.0.0

impl/python.py

3,452 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re
from math import gcd

from .units_convert_data import UNITS, UnitDefinition  ← this capability’s own data, compiled from data/units.json into the same file by fune build

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