AXLR / Catalog / personal

cooking_unit_convert

Kitchen unit conversion: cups, tablespoons, teaspoons, fluid ounces, pints and millilitres under a stated cup definition (US customary 236.588 mL, US legal 240 mL, metric 250 mL, UK 284 mL); grams, ounces and pounds; volume to weight by a given density in g/mL (or the bundled ingredient table: water now, USDA FoodData Central rows when the generator's fetch succeeds); oven temperatures in °C, °F and gas marks (UK table and exact scale).

Install

/tools install cooking_unit_convert
/tools install cooking_unit_convert@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · cooking_unit_convert 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool cooking_unit_convert; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
valuenumber | stringno2, "1.5", "3/4", "1 1/2", "½"
fromstringnoml, l, dl, cl, cup, tbsp, tsp, fl_oz, pint (in system); us_cup, legal_cup, metric_cup, uk_cup, us_tbsp, us_tsp, au_tbsp, us_fl_oz, uk_fl_oz, us_pint, uk_pint; g, kg, mg, oz, lb; C, F, gas_mark
tostringno
systemstringno"us_customary"Meaning of cup, tbsp, tsp, fl_oz and pint. one of "us_customary", "us_legal", "metric", "uk"
ingredientstringnoBundled ingredient for volume <-> weight (list: true lists them)
densitynumber | stringnoGrams per millilitre, instead of ingredient
digitsintegerno6≥ 1; ≤ 30
listbooleannoList the ingredients, cup systems and gas marks
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "value": {
      "type": [
        "number",
        "string"
      ],
      "description": "2, \"1.5\", \"3/4\", \"1 1/2\", \"½\""
    },
    "from": {
      "type": "string",
      "description": "ml, l, dl, cl, cup, tbsp, tsp, fl_oz, pint (in system); us_cup, legal_cup, metric_cup, uk_cup, us_tbsp, us_tsp, au_tbsp, us_fl_oz, uk_fl_oz, us_pint, uk_pint; g, kg, mg, oz, lb; C, F, gas_mark"
    },
    "to": {
      "type": "string"
    },
    "system": {
      "type": "string",
      "enum": [
        "us_customary",
        "us_legal",
        "metric",
        "uk"
      ],
      "default": "us_customary",
      "description": "Meaning of cup, tbsp, tsp, fl_oz and pint"
    },
    "ingredient": {
      "type": "string",
      "description": "Bundled ingredient for volume <-> weight (list: true lists them)"
    },
    "density": {
      "type": [
        "number",
        "string"
      ],
      "description": "Grams per millilitre, instead of ingredient"
    },
    "digits": {
      "type": "integer",
      "minimum": 1,
      "maximum": 30,
      "default": 6
    },
    "list": {
      "type": "boolean",
      "description": "List the ingredients, cup systems and gas marks"
    }
  }
}

Example

Input

{
  "value": 1,
  "from": "tbsp",
  "to": "ml"
}

Output (stdout)

{
  "result": "14.7868",
  "exact": "473176473/32000000"
}

Files

The command the install card registers is python3 cooking_unit_convert.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

cooking.json · sha256 c0cbf5e1e22df51561608023b0d5b45e2cb7236e87e507f3bf8a960cb37fb632
{
 "generated": "2026-10-18",
 "generator": "tools/catalog/data/cooking_unit_convert.py",
 "volume_systems": {
  "us_customary": {
   "cup": "236.5882365",
   "tbsp": "14.78676478125",
   "tsp": "4.92892159375",
   "fl_oz": "29.5735295625",
   "pint": "473.176473",
   "source": "NIST Handbook 44 (2024), Appendix C (US gallon = 231 in^3)"
  },
  "us_legal": {
   "cup": "240",
   "tbsp": "15",
   "tsp": "5",
   "fl_oz": "30",
   "pint": "480",
   "source": "21 CFR 101.9(b)(5)(viii) (US nutrition labelling)"
  },
  "metric": {
   "cup": "250",
   "tbsp": "15",
   "tsp": "5",
   "fl_oz": "29.5735295625",
   "pint": "500",
   "source": "metric cup of Australia, Canada, New Zealand and South Africa; fl oz is the US one, pint 500 mL"
  },
  "uk": {
   "cup": "284.130625",
   "tbsp": "15",
   "tsp": "5",
   "fl_oz": "28.4130625",
   "pint": "568.26125",
   "source": "half imperial pint; imperial gallon 4.54609 L (NIST SP 811 App. B.8, Weights and Measures Act 1985); metric spoons"
  }
 },
 "gas_marks": [
  {
   "gas_mark": "1/4",
   "fahrenheit": 225,
   "celsius": 110,
   "description": "very slow / very cool"
  },
  {
   "gas_mark": "1/2",
   "fahrenheit": 250,
   "celsius": 120,
   "description": "very slow / very cool"
  },
  {
   "gas_mark": "1",
   "fahrenheit": 275,
   "celsius": 140,
   "description": "slow / cool"
  },
  {
   "gas_mark": "2",
   "fahrenheit": 300,
   "celsius": 150,
   "description": "slow / cool"
  },
  {
   "gas_mark": "3",
   "fahrenheit": 325,
   "celsius": 170,
   "description": "moderately slow / warm"
  },
  {
   "gas_mark": "4",
   "fahrenheit": 350,
   "celsius": 180,
   "description": "moderate"
  },
  {
   "gas_mark": "5",
   "fahrenheit": 375,
   "celsius": 190,
   "description": "moderately hot"
  },
  {
   "gas_mark": "6",
   "fahrenheit": 400,
   "celsius": 200,
   "description": "moderately hot"
  },
  {
   "gas_mark": "7",
   "fahrenheit": 425,
   "celsius": 220,
   "description": "hot"
  },
  {
   "gas_mark": "8",
   "fahrenheit": 450,
   "celsius": 230,
   "description": "very hot"
  },
  {
   "gas_mark": "9",
   "fahrenheit": 475,
   "celsius": 240,
   "description": "very hot"
  },
  {
   "gas_mark": "10",
   "fahrenheit": 500,
   "celsius": 260,
   "description": "extremely hot"
  }
 ],
 "gas_mark_source": "conventional UK table (BBC Good Food; The Guardian, 2007) as reproduced in Wikipedia, 'Gas mark', retrieved 2026-10-18",
 "ingredients_source": "USDA FoodData Central, SR Legacy (2018), household-measure portions; public domain (CC0 1.0); fetched with the FDC API",
 "licence": "NIST and CFR: US Government works, public domain; USDA FoodData Central: CC0 1.0; gas marks: factual values",
 "ingredients": {
  "water": {
   "aliases": [
    "tap water"
   ],
   "fdc_id": null,
   "description": "water (by definition, about 1 g/mL)",
   "grams_per_us_cup": "236.5882365",
   "density_g_per_ml": "1.000000",
   "density_basis": "1 mL = 1 g (water near 4 degC)"
  }
 },
 "not_fetched": {
  "all-purpose flour": "FDC 168894: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "bread flour": "FDC 168896: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "whole wheat flour": "FDC 168944: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "granulated sugar": "FDC 169655: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "brown sugar": "FDC 168833: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "powdered sugar": "FDC 169656: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "butter": "FDC 173410: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "whole milk": "FDC 171265: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "heavy cream": "FDC 170859: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "honey": "FDC 169640: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "maple syrup": "FDC 169661: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "olive oil": "FDC 171413: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "vegetable oil": "FDC 172336: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "table salt": "FDC 173468: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "baking soda": "FDC 175040: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "baking powder": "FDC 172805: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "cocoa powder": "FDC 169593: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "rolled oats": "FDC 173904: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "white rice": "FDC 169756: API request failed (HTTPError: HTTP Error 429: Too Many Requests)",
  "cornstarch": "FDC 169698: API request failed (HTTPError: HTTP Error 429: Too Many Requests)"
 }
}
cooking_unit_convert.py · sha256 e14125bd10150aa6ba792f70c3c48438b3ee965f0b11d17f2645531c83465de4
#!/usr/bin/env python3
"""Kitchen conversions: cups, tablespoons, teaspoons, fluid ounces, pints and millilitres under a
stated cup definition (US customary, US legal, metric, UK); grams, ounces and pounds; volume to
weight and back through an ingredient's density (bundled USDA table or given); oven temperatures in
degrees Celsius, Fahrenheit and gas marks.

Input (JSON on stdin):
  value       number or string: 2, "1.5", "3/4", "1 1/2", "½"
  from, to    units: ml, l, dl, cl, cup, tbsp, tsp, fl_oz, pint (in `system`), or explicitly
              us_cup, legal_cup, metric_cup, uk_cup, us_tbsp, us_tsp, au_tbsp, us_fl_oz, uk_fl_oz,
              us_pint, uk_pint; g, kg, mg, oz, lb; C, F, gas_mark
  system      "us_customary" (default) | "us_legal" | "metric" | "uk"
  ingredient  name in the bundled table (volume <-> weight)    or
  density     grams per millilitre (volume <-> weight)
  digits      significant digits of the decimal result (default 6)
  list        true: list the ingredients, cup systems and gas marks
"""
import json
import re
import sys
from decimal import Decimal, getcontext
from fractions import Fraction

getcontext().prec = 40
UNICODE = {"½": Fraction(1, 2), "⅓": Fraction(1, 3), "⅔": Fraction(2, 3), "¼": Fraction(1, 4), "¾": Fraction(3, 4),
           "⅛": Fraction(1, 8), "⅜": Fraction(3, 8), "⅝": Fraction(5, 8), "⅞": Fraction(7, 8)}
MASS = {"g": Fraction(1), "kg": Fraction(1000), "mg": Fraction(1, 1000), "oz": Fraction("28.349523125"),
        "lb": Fraction("453.59237")}
MASS_ALIASES = {"gram": "g", "grams": "g", "kilogram": "kg", "kilograms": "kg", "kilo": "kg", "milligram": "mg",
                "ounce": "oz", "ounces": "oz", "pound": "lb", "pounds": "lb", "lbs": "lb"}
METRIC_VOLUME = {"ml": Fraction(1), "cl": Fraction(10), "dl": Fraction(100), "l": Fraction(1000)}
VOLUME_ALIASES = {"millilitre": "ml", "milliliter": "ml", "millilitres": "ml", "milliliters": "ml", "litre": "l", "liter": "l",
                  "litres": "l", "liters": "l", "cups": "cup", "c": "cup", "tablespoon": "tbsp", "tablespoons": "tbsp",
                  "tbs": "tbsp", "teaspoon": "tsp", "teaspoons": "tsp", "fl oz": "fl_oz", "floz": "fl_oz",
                  "fluid ounce": "fl_oz", "fluid ounces": "fl_oz", "pints": "pint", "pt": "pint"}
TEMPS = {"c": "C", "°c": "C", "degc": "C", "celsius": "C", "f": "F", "°f": "F", "degf": "F", "fahrenheit": "F",
         "gas_mark": "gas_mark", "gas mark": "gas_mark", "gas": "gas_mark", "gasmark": "gas_mark", "regulo": "gas_mark"}


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def load():
    try:
        with open("cooking.json", encoding="utf-8") as handle:
            return json.load(handle)
    except (OSError, ValueError) as error:
        fail(f"cannot read the bundled cooking.json: {error}")


def number(value, field):
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number or a string such as \"1 1/2\"")
    text = str(value).strip()
    try:
        m = re.fullmatch(r"(-?\d+)\s+(\d+)/(\d+)", text)
        if m:
            return int(m.group(1)) + Fraction(int(m.group(2)), int(m.group(3)))
        m = re.fullmatch(r"(\d*)\s*([½⅓⅔¼¾⅛⅜⅝⅞])", text)
        if m:
            return (int(m.group(1)) if m.group(1) else 0) + UNICODE[m.group(2)]
        return Fraction(text)
    except (ValueError, ZeroDivisionError):
        fail(f"{field}: cannot read {value!r} as a number")


def dec(x, digits):
    d = Decimal(x.numerator) / Decimal(x.denominator)
    if d == 0:
        return "0"
    text = format(d, f".{digits}g")
    if "e" in text:
        text = format(Decimal(text), "f")
    if "." in text:
        text = text.rstrip("0").rstrip(".")
    return text


def frac(x):
    return str(x.numerator) if x.denominator == 1 else f"{x.numerator}/{x.denominator}"


def unit(name, systems, system, field):
    """('volume', mL per unit, label) | ('mass', g per unit, label) | ('temp', kind, label)."""
    if not isinstance(name, str) or not name.strip():
        fail(f"{field} must be a unit name")
    key = name.strip()
    low = key.lower()
    if low in TEMPS or key in ("°C", "°F"):
        return "temp", TEMPS.get(low, key[-1]), TEMPS.get(low, key[-1])
    low = MASS_ALIASES.get(low, low)
    if low in MASS:
        return "mass", MASS[low], low
    low = VOLUME_ALIASES.get(low, low)
    if low in METRIC_VOLUME:
        return "volume", METRIC_VOLUME[low], low
    explicit = {"us_cup": ("us_customary", "cup"), "legal_cup": ("us_legal", "cup"), "metric_cup": ("metric", "cup"),
                "uk_cup": ("uk", "cup"), "us_tbsp": ("us_customary", "tbsp"), "us_tsp": ("us_customary", "tsp"),
                "us_fl_oz": ("us_customary", "fl_oz"), "uk_fl_oz": ("uk", "fl_oz"), "us_pint": ("us_customary", "pint"),
                "uk_pint": ("uk", "pint"), "legal_tbsp": ("us_legal", "tbsp"), "legal_tsp": ("us_legal", "tsp")}
    if low == "au_tbsp":
        return "volume", Fraction(20), "au_tbsp (20 mL)"
    if low in explicit:
        sysname, base = explicit[low]
        return "volume", Fraction(systems[sysname][base]), f"{base} ({sysname})"
    if low in ("cup", "tbsp", "tsp", "fl_oz", "pint"):
        return "volume", Fraction(systems[system][low]), f"{low} ({system})"
    fail(f"{field}: unknown unit {name!r}")


def find_ingredient(table, name):
    if not isinstance(name, str) or not name.strip():
        fail("ingredient must be a name")
    key = name.strip().lower()
    for k, info in table["ingredients"].items():
        if key == k or key in info.get("aliases", []):
            return k, info
    for k, info in table["ingredients"].items():
        if key in k:
            return k, info
    if any(key in k for k in table.get("not_fetched", {})):
        fail(f"ingredient {name!r} is listed for the table but its USDA data were not fetched in this version; give density in g/mL")
    fail(f"ingredient {name!r} is not in the bundled table ({', '.join(sorted(table['ingredients']))}); give density in g/mL instead")


def gas_rows(table):
    return [(Fraction(r["gas_mark"]), r) for r in table["gas_marks"]]


def to_fahrenheit_from_gas(g):
    if g >= 1:
        return 250 + 25 * g
    # below gas mark 1 the scale halves for each 25 degF step: 1/2 = 250 F, 1/4 = 225 F
    steps = 0
    x = g
    while x < 1:
        x *= 2
        steps += 1
    if x != 1:
        fail("gas marks below 1 are 1/2 and 1/4 (and 1/8)")
    return 275 - 25 * steps


def temperature(value, src, dst, table, digits):
    result = {"from": src, "to": dst, "value": dec(value, digits)}
    if src == "gas_mark":
        f = Fraction(to_fahrenheit_from_gas(value))
        row = next((r for g, r in gas_rows(table) if g == value), None)
        c_exact = (f - 32) * Fraction(5, 9)
        if dst == "F":
            result.update({"result": dec(f, digits), "exact": frac(f)})
        elif dst == "C":
            if row:
                result.update({"result": str(row["celsius"]), "convention": "UK cookery table (rounded to 10 degC)",
                               "description": row["description"]})
            else:
                result["result"] = dec(c_exact, 4)
            result["exact_celsius_from_fahrenheit"] = dec(c_exact, digits)
        else:
            result.update({"result": dec(value, digits)})
        result["fahrenheit"] = dec(f, digits)
        result["formula"] = "degF = 250 + 25 x gas mark (gas mark >= 1); 1/2 = 250 degF, 1/4 = 225 degF"
        result["table_source"] = table["gas_mark_source"]
        return result
    c = value if src == "C" else (value - 32) * Fraction(5, 9)
    f = value if src == "F" else value * Fraction(9, 5) + 32
    if dst in ("C", "F"):
        out = c if dst == "C" else f
        result.update({"result": dec(out, digits), "exact": frac(out), "formula": "degF = degC x 9/5 + 32"})
        return result
    # to gas mark
    if f >= 275:
        exact = (f - 250) / 25
    else:  # below gas mark 1 each 25 degF step halves the mark
        steps = (f - 275) / 25
        exact = Fraction(2) ** int(steps) if steps.denominator == 1 else Fraction(2 ** float(steps))
    rows = gas_rows(table)
    if src == "C":
        g, row = min(rows, key=lambda gr: (abs(gr[1]["celsius"] - c), gr[0]))
        basis = "nearest by the UK table's Celsius values"
    else:
        g, row = min(rows, key=lambda gr: (abs(gr[1]["fahrenheit"] - f), gr[0]))
        basis = "nearest by degF"
    result.update({"result": row["gas_mark"], "basis": basis, "description": row["description"],
                   "fahrenheit": dec(f, digits), "celsius": dec(c, digits),
                   "exact_gas_mark": dec(exact, 4) if exact is not None else None,
                   "table_source": table["gas_mark_source"]})
    if f < 200 or f > 525:
        result["warning"] = "outside the gas mark scale (1/4 to 10)"
    return result


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    extra = set(request) - {"value", "from", "to", "system", "ingredient", "density", "digits", "list"}
    if extra:
        fail(f"unknown fields: {sorted(extra)}")
    table = load()
    systems = table["volume_systems"]
    if request.get("list"):
        print(json.dumps({"volume_systems": systems, "gas_marks": table["gas_marks"],
                          "ingredients": {k: {"aliases": v.get("aliases", []), "grams_per_us_cup": v["grams_per_us_cup"],
                                              "density_g_per_ml": v["density_g_per_ml"], "fdc_id": v.get("fdc_id"),
                                              "usda_description": v.get("description")} for k, v in table["ingredients"].items()},
                          "ingredients_source": table["ingredients_source"], "generated": table["generated"]}, ensure_ascii=False))
        return
    system = request.get("system", "us_customary")
    if system not in systems:
        fail(f"system must be one of {', '.join(systems)}")
    digits = request.get("digits", 6)
    if isinstance(digits, bool) or not isinstance(digits, int) or not 1 <= digits <= 30:
        fail("digits must be an integer from 1 to 30")
    if "value" not in request or "from" not in request or "to" not in request:
        fail("give value, from and to (or list: true)")
    value = number(request["value"], "value")
    kind_a, fa, la = unit(request["from"], systems, system, "from")
    kind_b, fb, lb = unit(request["to"], systems, system, "to")
    if (kind_a == "temp") != (kind_b == "temp"):
        fail("temperatures convert only to temperatures")
    if kind_a == "temp":
        if fa == "gas_mark" and value <= 0:
            fail("gas mark must be positive")
        print(json.dumps(temperature(value, fa, fb, table, digits), ensure_ascii=False))
        return
    if value < 0:
        fail("value must not be negative")
    result = {"value": dec(value, digits), "from": la, "to": lb, "system": system}
    definitions = {}
    for label, kind, factor in ((la, kind_a, fa), (lb, kind_b, fb)):
        definitions[label] = f"{dec(factor, 15)} {'mL' if kind == 'volume' else 'g'}"
    result["definitions"] = definitions
    if any(k == "volume" and "(" + system + ")" in lab for k, lab in ((kind_a, la), (kind_b, lb))):
        result["system_source"] = systems[system]["source"]
    if kind_a == kind_b:
        out = value * fa / fb
        result.update({"result": dec(out, digits), "exact": frac(out), "factor": frac(fa / fb)})
        print(json.dumps(result, ensure_ascii=False))
        return
    # volume <-> mass through a density in g/mL
    if "density" in request and "ingredient" in request:
        fail("give ingredient or density, not both")
    if "density" in request:
        density = number(request["density"], "density")
        if density <= 0:
            fail("density must be positive")
        result["density_g_per_ml"] = dec(density, digits)
        result["density_source"] = "given in the input"
    elif "ingredient" in request:
        name, info = find_ingredient(table, request["ingredient"])
        density = Fraction(info["grams_per_us_cup"]) / Fraction(systems["us_customary"]["cup"])
        result["ingredient"] = name
        result["density_g_per_ml"] = info["density_g_per_ml"]
        result["grams_per_us_cup"] = info["grams_per_us_cup"]
        source = {"basis": info["density_basis"]}
        if info.get("fdc_id"):
            source.update({"usda": info["description"], "fdc_id": info["fdc_id"], "dataset": table["ingredients_source"]})
        else:
            source["note"] = info["description"]
        result["density_source"] = source
        result["note"] = "household measures vary with how the cup is filled (sifted, spooned, packed): treat as approximate"
    else:
        fail("converting between volume and weight needs ingredient or density (g/mL)")
    ml = value * fa if kind_a == "volume" else value * fa / density
    out = ml * density / fb if kind_b == "mass" else ml / fb
    result.update({"result": dec(out, digits), "exact": None, "millilitres": dec(ml, digits)})
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/cooking_unit_convert/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/cooking_unit_convert/1.0.0/cooking.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/cooking_unit_convert/1.0.0/cooking_unit_convert.py