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¶
- Normalize the input (lowercase, strip spaces).
- Handle parentheses recursively -- resolve innermost groups first via regex substitution.
- Tokenize on
*and/operators. - Convert each token using a lookup table, accumulating the multiplication factor (multiply for
*, divide for/). - 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.