Composable Unit Expression Parser

A Flask microservice that converts compound unit expressions (like degree/minute) to SI units with the correct multiplication factor -- parsing nested expressions with operator precedence.

Problem

Given a unit string that may contain multiplication (*), division (/), and parentheses, convert each unit to its SI (International System of Units) equivalent and compute the overall multiplication factor.

Conversion table:

Unit SI Unit Factor
min s 60
h s 3600
d s 86400
degree rad pi/180
ha m^2 10000
L m^3 0.001
t kg 1000

Example: degree/minute becomes (rad/s) with factor 0.000291.

Approach

  1. Normalize the input (lowercase, strip spaces).
  2. Handle parentheses recursively -- resolve innermost groups first via regex substitution.
  3. Tokenize on * and / operators.
  4. Convert each token using a lookup table, accumulating the multiplication factor (multiply for *, divide for /).
  5. Expose via Flask at GET /units/si?units=degree/minute.

The recursive parenthesis handling means expressions like (degree/minute)*ha work correctly.

Implementation

from flask import Flask, request, jsonify
import re
from math import pi
import logging

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
app = Flask(__name__)

UNIT_CONVERSIONS = {
    "min": ("s", 60), "minute": ("s", 60),
    "h": ("s", 3600), "hr": ("s", 3600), "hour": ("s", 3600),
    "d": ("s", 86400), "day": ("s", 86400),
    "degree": ("rad", pi / 180),
    "arcminute": ("rad", pi / 10800),
    "arcsecond": ("rad", pi / 648000),
    "ha": ("m^2", 10000), "hectare": ("m^2", 10000),
    "L": ("m^3", 0.001), "litre": ("m^3", 0.001), "liter": ("m^3", 0.001),
    "t": ("kg", 1000), "tonne": ("kg", 1000),
    "rad": ("rad", 1),
}


def parse_and_convert(expression, is_recursive=False):
    original_expression = expression.lower().replace(" ", "")
    multiplier = 1.0

    # Resolve innermost parentheses first
    def _resolve_parens(m):
        nonlocal multiplier
        si_expr, factor = parse_and_convert(m.group(1), True)
        multiplier *= factor
        return si_expr
    while '(' in expression:
        expression = re.sub(r'\(([^()]*)\)', _resolve_parens, expression)

    tokens = re.split(r'([*/])', expression)
    result_si = ""

    for i in range(0, len(tokens), 2):
        unit = tokens[i].strip()
        if unit in UNIT_CONVERSIONS:
            si_unit, factor = UNIT_CONVERSIONS[unit]
        else:
            # Already-converted SI unit from parenthesized sub-expression
            si_unit, factor = unit, 1.0

        if i == 0 or tokens[i - 1] == '*':
            result_si = f"{result_si}*{si_unit}" if result_si else si_unit
            multiplier *= factor
        elif tokens[i - 1] == '/':
            result_si = f"{result_si}/{si_unit}" if result_si else si_unit
            multiplier /= factor

    # Clean up formatting
    result_si = re.sub(r'^\*|\*$', '', result_si)
    result_si = re.sub(r'\*{2,}', '*', result_si)
    result_si = re.sub(r'/{2,}', '/', result_si)
    if not result_si.startswith('(') and not result_si.endswith(')'):
        result_si = f"({result_si})"

    return result_si, multiplier


@app.route('/units/si', methods=['GET'])
def convert_units_endpoint():
    units = request.args.get('units')
    unit_name, multiplication_factor = parse_and_convert(units)
    return jsonify({
        "unit_name": unit_name,
        "multiplication_factor": multiplication_factor
    })


if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0')

Test script:

curl -s "http://localhost:5000/units/si?units=degree/minute"
# {"multiplication_factor": 0.0002908882086657216, "unit_name": "(rad/s)"}

Takeaway

Unit conversion bugs are sneaky -- they compile, they pass tests, and they silently corrupt your data. A composable parser that handles compound expressions beats the alternative of ad-hoc conversions scattered across your codebase.


Back to Software Design