AXLR / Catalog / research

chem_formula

Parse a chemical formula with parentheses, brackets, hydrates (CuSO4·5H2O or CuSO4*5H2O), leading coefficients and ionic charges, and return the element counts, the Hill formula, the molar mass and the mass percent of each element, summed exactly in decimal from the bundled IUPAC/CIAAW abridged standard atomic weights 2024. Accepts Unicode subscripts and superscripts and several formulas at once.

Install

/tools install chem_formula
/tools install chem_formula@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 · chem_formula 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 chem_formula; each run is approved as a local_exec.

Arguments

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

nametyperequireddefaultmeaning
formulastringnoCa(OH)2, K4[Fe(CN)6], CuSO4·5H2O, SO4^2-, NH4+. length ≤ 500
formulasarray of stringnoitems ≥ 1; items ≤ 100
decimalsintegerno2Digits of molar_mass_rounded and mass_percent. ≥ 0; ≤ 6
massesobject of ['number', 'string']noAtomic weights for elements without a standard atomic weight (Tc, Pm, Po...) or overrides, by symbol
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "formula": {
      "type": "string",
      "maxLength": 500,
      "description": "Ca(OH)2, K4[Fe(CN)6], CuSO4·5H2O, SO4^2-, NH4+"
    },
    "formulas": {
      "type": "array",
      "items": {
        "type": "string",
        "maxLength": 500
      },
      "minItems": 1,
      "maxItems": 100
    },
    "decimals": {
      "type": "integer",
      "minimum": 0,
      "maximum": 6,
      "default": 2,
      "description": "Digits of molar_mass_rounded and mass_percent"
    },
    "masses": {
      "type": "object",
      "additionalProperties": {
        "type": [
          "number",
          "string"
        ]
      },
      "description": "Atomic weights for elements without a standard atomic weight (Tc, Pm, Po...) or overrides, by symbol"
    }
  }
}

Example

Input

{
  "formula": "CuSO4·5H2O"
}

Output (stdout)

{
  "hill": "CuH10O9S",
  "counts": {
    "Cu": 1,
    "S": 1,
    "O": 9,
    "H": 10
  },
  "molar_mass": "249.6770",
  "molar_mass_rounded": "249.68",
  "unit": "g/mol"
}

Files

The command the install card registers is python3 chem_formula.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.

chem_formula.py · sha256 752e6b4030954562f4048a4f3574e3c0628649378fb45fe12fc7cad0d81b23bf
#!/usr/bin/env python3
"""Parse a chemical formula and compute element counts, molar mass and mass percent from the
bundled IUPAC/CIAAW abridged standard atomic weights (elements.json, written by
tools/catalog/data/chem_formula.py).

Input (JSON on stdin):
  formula    "Ca(OH)2", "K4[Fe(CN)6]", "CuSO4·5H2O" (also . * • as the hydrate dot),
             "SO4^2-", "NH4+", "Fe+++", "SO4 2-", "2H2O" (leading coefficient)
  formulas   a list of formulas instead of one (at most 100)
  decimals   digits of molar_mass_rounded and mass_percent, default 2 (0-6)
  masses     {"Tc": 98} atomic weights for elements without a standard atomic weight, or to override one
"""
import json
import pathlib
import re
import sys
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation

TABLE = pathlib.Path(__file__).resolve().parent / "elements.json"
DOTS = "·•∙⋅.*"
SUB = str.maketrans("₀₁₂₃₄₅₆₇₈₉", "0123456789")
SUP = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻", "0123456789+-")
SUP_CHARS = "⁰¹²³⁴⁵⁶⁷⁸⁹"
CLOSE = {"(": ")", "[": "]", "{": "}"}


class FormulaError(Exception):
    pass


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


def split_charge(text):
    """Return (formula without charge, charge as int)."""
    patterns = [
        (r"\^\{?(\d*)([+-])\}?$", lambda m: (m.group(1), m.group(2))),
        (r"\^\{?([+-])(\d*)\}?$", lambda m: (m.group(2), m.group(1))),
        (r"\s+(\d*)([+-])$", lambda m: (m.group(1), m.group(2))),
    ]
    for pattern, parts in patterns:
        m = re.search(pattern, text)
        if m:
            digits, sign = parts(m)
            size = int(digits) if digits else 1
            return text[:m.start()], size if sign == "+" else -size
    m = re.search(f"([{SUP_CHARS}]*)([⁺⁻])$", text)
    if m:
        digits = m.group(1).translate(SUP)
        size = int(digits) if digits else 1
        return text[:m.start()], size if m.group(2) == "⁺" else -size
    m = re.search(r"(\++|-+)$", text)
    if m:
        size = len(m.group(1))
        return text[:m.start()], size if m.group(1)[0] == "+" else -size
    return text, 0


class Parser:
    def __init__(self, text, known):
        self.text, self.pos, self.known = text, 0, known

    def peek(self):
        return self.text[self.pos] if self.pos < len(self.text) else ""

    def number(self):
        start = self.pos
        while self.peek().isdigit():
            self.pos += 1
        return int(self.text[start:self.pos]) if self.pos > start else None

    def group(self, closing=None):
        counts = {}
        while True:
            ch = self.peek()
            if ch == "":
                if closing:
                    raise FormulaError(f"missing '{closing}'")
                break
            if ch == closing:
                break
            if ch in CLOSE:
                self.pos += 1
                inner = self.group(CLOSE[ch])
                if self.peek() != CLOSE[ch]:
                    raise FormulaError(f"missing '{CLOSE[ch]}'")
                self.pos += 1
                n = self.number()
                if n == 0:
                    raise FormulaError(f"zero count after '{CLOSE[ch]}' at position {self.pos}")
                for symbol, c in inner.items():
                    counts[symbol] = counts.get(symbol, 0) + c * (n or 1)
            elif ch.isupper():
                start = self.pos
                self.pos += 1
                if self.peek().islower():
                    self.pos += 1
                symbol = self.text[start:self.pos]
                if symbol not in self.known:
                    raise FormulaError(f"unknown element '{symbol}' at position {start + 1}")
                n = self.number()
                if n == 0:
                    raise FormulaError(f"zero count after {symbol}")
                counts[symbol] = counts.get(symbol, 0) + (n or 1)
            elif ch in ")]}":
                raise FormulaError(f"unexpected '{ch}' at position {self.pos + 1}")
            elif ch.islower():
                raise FormulaError(f"element symbols start with a capital letter: '{ch}' at position {self.pos + 1}")
            elif ch.isdigit():
                raise FormulaError(f"a count must follow an element or a bracket (position {self.pos + 1})")
            else:
                raise FormulaError(f"unexpected character '{ch}' at position {self.pos + 1}")
        return counts


def parse(formula, known):
    text = formula.strip().translate(SUB)
    if not text:
        raise FormulaError("empty formula")
    text, charge = split_charge(text)
    text = text.replace(" ", "")
    pieces = re.split(f"[{re.escape(DOTS)}]", text)
    total, parts = {}, []
    for piece in pieces:
        if not piece:
            raise FormulaError("empty part around a hydrate dot")
        m = re.match(r"\d+", piece)
        coefficient = int(m.group()) if m else 1
        body = piece[m.end():] if m else piece
        if coefficient == 0 or not body:
            raise FormulaError(f"bad part '{piece}'")
        parser = Parser(body, known)
        counts = parser.group()
        if parser.pos != len(body):
            raise FormulaError(f"unexpected '{body[parser.pos]}'")
        parts.append({"part": piece, "coefficient": coefficient})
        for symbol, c in counts.items():
            total[symbol] = total.get(symbol, 0) + c * coefficient
    return total, charge, parts


def hill(counts, charge):
    symbols = sorted(counts)
    if "C" in counts:
        symbols = ["C"] + (["H"] if "H" in counts else []) + [s for s in symbols if s not in ("C", "H")]
    text = "".join(s + (str(counts[s]) if counts[s] > 1 else "") for s in symbols)
    if charge:
        text += f"^{abs(charge) if abs(charge) > 1 else ''}{'+' if charge > 0 else '-'}"
    return text, symbols


def compute(formula, table, overrides, decimals):
    known = {e["symbol"]: e for e in table}
    counts, charge, parts = parse(formula, known)
    text, order = hill(counts, charge)
    q = Decimal(1).scaleb(-decimals)
    total, unc, rows = Decimal(0), Decimal(0), []
    for symbol in order:
        e = known[symbol]
        if symbol in overrides:
            weight, u, origin = overrides[symbol], None, "input"
        elif e["weight"] is None:
            raise FormulaError(f"{e['name']} ({symbol}) has no standard atomic weight; give it in masses")
        else:
            weight, u, origin = Decimal(e["weight"]), Decimal(e["uncertainty"]), "CIAAW 2024"
        mass = weight * counts[symbol]
        total += mass
        if u is not None:
            unc += u * counts[symbol]
        rows.append({"symbol": symbol, "name": e["name"], "count": counts[symbol], "atomic_weight": str(weight),
                     "weight_source": origin, "mass": str(mass)})
    for row in rows:
        row["mass_percent"] = str((Decimal(row["mass"]) * 100 / total).quantize(q, ROUND_HALF_UP))
    out = {"formula": formula, "hill": text, "charge": charge, "parts": parts,
           "counts": counts, "atoms": sum(counts.values()), "elements": rows,
           "molar_mass": str(total), "molar_mass_rounded": str(total.quantize(q, ROUND_HALF_UP)), "unit": "g/mol",
           "abridged_uncertainty_sum": str(unc)}
    if charge:
        out["note"] = "molar mass of the ion ignores the mass of the electrons gained or lost"
    return out


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) - {"formula", "formulas", "decimals", "masses"}
    if extra:
        fail(f"unknown field(s): {', '.join(sorted(extra))}")
    try:
        data = json.loads(TABLE.read_text(encoding="utf-8"))
    except (OSError, ValueError) as error:
        fail(f"cannot read the bundled table: {error}")
    table = data["elements"]
    decimals = request.get("decimals", 2)
    if isinstance(decimals, bool) or not isinstance(decimals, int) or not 0 <= decimals <= 6:
        fail("decimals must be an integer from 0 to 6")
    overrides = {}
    masses = request.get("masses", {})
    if not isinstance(masses, dict):
        fail("masses must be an object of element symbol to atomic weight")
    symbols = {e["symbol"] for e in table}
    for symbol, value in masses.items():
        if symbol not in symbols:
            fail(f"masses: unknown element '{symbol}'")
        if isinstance(value, bool) or not isinstance(value, (int, float, str)):
            fail(f"masses.{symbol} must be a number")
        try:
            weight = Decimal(str(value).strip())
        except InvalidOperation:
            fail(f"masses.{symbol} must be a number")
        if not weight.is_finite() or weight <= 0:
            fail(f"masses.{symbol} must be positive")
        overrides[symbol] = weight
    source = {"table": data["title"], "basis": data["basis"], "url": data["source_url"], "fetched": data["fetched"]}
    if ("formula" in request) == ("formulas" in request):
        fail("give formula or formulas")
    if "formula" in request:
        formula = request["formula"]
        if not isinstance(formula, str) or len(formula) > 500:
            fail("formula must be a string of at most 500 characters")
        try:
            result = compute(formula, table, overrides, decimals)
        except FormulaError as error:
            fail(f"formula {formula!r}: {error}")
        result["source"] = source
        print(json.dumps(result, ensure_ascii=False))
        return
    formulas = request["formulas"]
    if not isinstance(formulas, list) or not 1 <= len(formulas) <= 100:
        fail("formulas must be a list of 1 to 100 strings")
    results = []
    for i, formula in enumerate(formulas):
        if not isinstance(formula, str) or len(formula) > 500:
            fail(f"formulas[{i}] must be a string of at most 500 characters")
        try:
            results.append(compute(formula, table, overrides, decimals))
        except FormulaError as error:
            results.append({"formula": formula, "error": str(error)})
    print(json.dumps({"results": results, "source": source}, ensure_ascii=False))


if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception as error:  # never a traceback
        fail(f"{type(error).__name__}: {error}")
elements.json · sha256 c1ac4b63fb2f6a9131930e23fbd6a11b5217a23b49106ec4483e1984ffb58cb9
{
 "title": "IUPAC/CIAAW Abridged Standard Atomic Weights 2024",
 "basis": "Atomic Weights 2021 report (Prohaska et al., Pure Appl. Chem. 94 (2022) 573-600) with the 2024 revisions of Gd, Lu and Zr",
 "source_url": "https://www.ciaaw.org/abridged-atomic-weights.htm",
 "licence": "official table of the IUPAC commission (page (c) CIAAW); numerical values reproduced as facts, with attribution",
 "fetched": "2026-10-18",
 "generator": "tools/catalog/data/chem_formula.py",
 "notes_legend": {
  "g": "geological materials with an isotopic composition outside normal limits are known",
  "m": "modified isotopic compositions may be found in commercial material",
  "r": "range in isotopic composition of normal terrestrial material limits precision"
 },
 "elements": [
  {
   "z": 1,
   "symbol": "H",
   "name": "hydrogen",
   "weight": "1.0080",
   "uncertainty": "0.0002",
   "notes": [
    "m"
   ]
  },
  {
   "z": 2,
   "symbol": "He",
   "name": "helium",
   "weight": "4.0026",
   "uncertainty": "0.0001",
   "notes": [
    "g",
    "r"
   ]
  },
  {
   "z": 3,
   "symbol": "Li",
   "name": "lithium",
   "weight": "6.94",
   "uncertainty": "0.06",
   "notes": [
    "m"
   ]
  },
  {
   "z": 4,
   "symbol": "Be",
   "name": "beryllium",
   "weight": "9.0122",
   "uncertainty": "0.0001",
   "notes": []
  },
  {
   "z": 5,
   "symbol": "B",
   "name": "boron",
   "weight": "10.81",
   "uncertainty": "0.02",
   "notes": [
    "m"
   ]
  },
  {
   "z": 6,
   "symbol": "C",
   "name": "carbon",
   "weight": "12.011",
   "uncertainty": "0.002",
   "notes": []
  },
  {
   "z": 7,
   "symbol": "N",
   "name": "nitrogen",
   "weight": "14.007",
   "uncertainty": "0.001",
   "notes": [
    "m"
   ]
  },
  {
   "z": 8,
   "symbol": "O",
   "name": "oxygen",
   "weight": "15.999",
   "uncertainty": "0.001",
   "notes": [
    "m"
   ]
  },
  {
   "z": 9,
   "symbol": "F",
   "name": "fluorine",
   "weight": "18.998",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 10,
   "symbol": "Ne",
   "name": "neon",
   "weight": "20.180",
   "uncertainty": "0.001",
   "notes": [
    "g",
    "m"
   ]
  },
  {
   "z": 11,
   "symbol": "Na",
   "name": "sodium",
   "weight": "22.990",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 12,
   "symbol": "Mg",
   "name": "magnesium",
   "weight": "24.305",
   "uncertainty": "0.002",
   "notes": []
  },
  {
   "z": 13,
   "symbol": "Al",
   "name": "aluminium",
   "weight": "26.982",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 14,
   "symbol": "Si",
   "name": "silicon",
   "weight": "28.085",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 15,
   "symbol": "P",
   "name": "phosphorus",
   "weight": "30.974",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 16,
   "symbol": "S",
   "name": "sulfur",
   "weight": "32.06",
   "uncertainty": "0.02",
   "notes": []
  },
  {
   "z": 17,
   "symbol": "Cl",
   "name": "chlorine",
   "weight": "35.45",
   "uncertainty": "0.01",
   "notes": [
    "m"
   ]
  },
  {
   "z": 18,
   "symbol": "Ar",
   "name": "argon",
   "weight": "39.95",
   "uncertainty": "0.16",
   "notes": []
  },
  {
   "z": 19,
   "symbol": "K",
   "name": "potassium",
   "weight": "39.098",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 20,
   "symbol": "Ca",
   "name": "calcium",
   "weight": "40.078",
   "uncertainty": "0.004",
   "notes": [
    "g"
   ]
  },
  {
   "z": 21,
   "symbol": "Sc",
   "name": "scandium",
   "weight": "44.956",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 22,
   "symbol": "Ti",
   "name": "titanium",
   "weight": "47.867",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 23,
   "symbol": "V",
   "name": "vanadium",
   "weight": "50.942",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 24,
   "symbol": "Cr",
   "name": "chromium",
   "weight": "51.996",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 25,
   "symbol": "Mn",
   "name": "manganese",
   "weight": "54.938",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 26,
   "symbol": "Fe",
   "name": "iron",
   "weight": "55.845",
   "uncertainty": "0.002",
   "notes": []
  },
  {
   "z": 27,
   "symbol": "Co",
   "name": "cobalt",
   "weight": "58.933",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 28,
   "symbol": "Ni",
   "name": "nickel",
   "weight": "58.693",
   "uncertainty": "0.001",
   "notes": [
    "r"
   ]
  },
  {
   "z": 29,
   "symbol": "Cu",
   "name": "copper",
   "weight": "63.546",
   "uncertainty": "0.003",
   "notes": [
    "r"
   ]
  },
  {
   "z": 30,
   "symbol": "Zn",
   "name": "zinc",
   "weight": "65.38",
   "uncertainty": "0.02",
   "notes": [
    "r"
   ]
  },
  {
   "z": 31,
   "symbol": "Ga",
   "name": "gallium",
   "weight": "69.723",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 32,
   "symbol": "Ge",
   "name": "germanium",
   "weight": "72.630",
   "uncertainty": "0.008",
   "notes": []
  },
  {
   "z": 33,
   "symbol": "As",
   "name": "arsenic",
   "weight": "74.922",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 34,
   "symbol": "Se",
   "name": "selenium",
   "weight": "78.971",
   "uncertainty": "0.008",
   "notes": [
    "r"
   ]
  },
  {
   "z": 35,
   "symbol": "Br",
   "name": "bromine",
   "weight": "79.904",
   "uncertainty": "0.003",
   "notes": []
  },
  {
   "z": 36,
   "symbol": "Kr",
   "name": "krypton",
   "weight": "83.798",
   "uncertainty": "0.002",
   "notes": [
    "g",
    "m"
   ]
  },
  {
   "z": 37,
   "symbol": "Rb",
   "name": "rubidium",
   "weight": "85.468",
   "uncertainty": "0.001",
   "notes": [
    "g"
   ]
  },
  {
   "z": 38,
   "symbol": "Sr",
   "name": "strontium",
   "weight": "87.62",
   "uncertainty": "0.01",
   "notes": [
    "g",
    "r"
   ]
  },
  {
   "z": 39,
   "symbol": "Y",
   "name": "yttrium",
   "weight": "88.906",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 40,
   "symbol": "Zr",
   "name": "zirconium",
   "weight": "91.222",
   "uncertainty": "0.003",
   "notes": [
    "g"
   ]
  },
  {
   "z": 41,
   "symbol": "Nb",
   "name": "niobium",
   "weight": "92.906",
   "uncertainty": "0.001",
   "notes": []
  },
  {
   "z": 42,
   "symbol": "Mo",
   "name": "molybdenum",
   "weight": "95.95",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 43,
   "symbol": "Tc",
   "name": "technetium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 44,
   "symbol": "Ru",
   "name": "ruthenium",
   "weight": "101.07",
   "uncertainty": "0.02",
   "notes": [
    "g"
   ]
  },
  {
   "z": 45,
   "symbol": "Rh",
   "name": "rhodium",
   "weight": "102.91",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 46,
   "symbol": "Pd",
   "name": "palladium",
   "weight": "106.42",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 47,
   "symbol": "Ag",
   "name": "silver",
   "weight": "107.87",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 48,
   "symbol": "Cd",
   "name": "cadmium",
   "weight": "112.41",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 49,
   "symbol": "In",
   "name": "indium",
   "weight": "114.82",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 50,
   "symbol": "Sn",
   "name": "tin",
   "weight": "118.71",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 51,
   "symbol": "Sb",
   "name": "antimony",
   "weight": "121.76",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 52,
   "symbol": "Te",
   "name": "tellurium",
   "weight": "127.60",
   "uncertainty": "0.03",
   "notes": [
    "g"
   ]
  },
  {
   "z": 53,
   "symbol": "I",
   "name": "iodine",
   "weight": "126.90",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 54,
   "symbol": "Xe",
   "name": "xenon",
   "weight": "131.29",
   "uncertainty": "0.01",
   "notes": [
    "g",
    "m"
   ]
  },
  {
   "z": 55,
   "symbol": "Cs",
   "name": "caesium",
   "weight": "132.91",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 56,
   "symbol": "Ba",
   "name": "barium",
   "weight": "137.33",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 57,
   "symbol": "La",
   "name": "lanthanum",
   "weight": "138.91",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 58,
   "symbol": "Ce",
   "name": "cerium",
   "weight": "140.12",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 59,
   "symbol": "Pr",
   "name": "praseodymium",
   "weight": "140.91",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 60,
   "symbol": "Nd",
   "name": "neodymium",
   "weight": "144.24",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 61,
   "symbol": "Pm",
   "name": "promethium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 62,
   "symbol": "Sm",
   "name": "samarium",
   "weight": "150.36",
   "uncertainty": "0.02",
   "notes": [
    "g"
   ]
  },
  {
   "z": 63,
   "symbol": "Eu",
   "name": "europium",
   "weight": "151.96",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 64,
   "symbol": "Gd",
   "name": "gadolinium",
   "weight": "157.25",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 65,
   "symbol": "Tb",
   "name": "terbium",
   "weight": "158.93",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 66,
   "symbol": "Dy",
   "name": "dysprosium",
   "weight": "162.50",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 67,
   "symbol": "Ho",
   "name": "holmium",
   "weight": "164.93",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 68,
   "symbol": "Er",
   "name": "erbium",
   "weight": "167.26",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 69,
   "symbol": "Tm",
   "name": "thulium",
   "weight": "168.93",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 70,
   "symbol": "Yb",
   "name": "ytterbium",
   "weight": "173.05",
   "uncertainty": "0.02",
   "notes": [
    "g"
   ]
  },
  {
   "z": 71,
   "symbol": "Lu",
   "name": "lutetium",
   "weight": "174.97",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 72,
   "symbol": "Hf",
   "name": "hafnium",
   "weight": "178.49",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 73,
   "symbol": "Ta",
   "name": "tantalum",
   "weight": "180.95",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 74,
   "symbol": "W",
   "name": "tungsten",
   "weight": "183.84",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 75,
   "symbol": "Re",
   "name": "rhenium",
   "weight": "186.21",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 76,
   "symbol": "Os",
   "name": "osmium",
   "weight": "190.23",
   "uncertainty": "0.03",
   "notes": [
    "g"
   ]
  },
  {
   "z": 77,
   "symbol": "Ir",
   "name": "iridium",
   "weight": "192.22",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 78,
   "symbol": "Pt",
   "name": "platinum",
   "weight": "195.08",
   "uncertainty": "0.02",
   "notes": []
  },
  {
   "z": 79,
   "symbol": "Au",
   "name": "gold",
   "weight": "196.97",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 80,
   "symbol": "Hg",
   "name": "mercury",
   "weight": "200.59",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 81,
   "symbol": "Tl",
   "name": "thallium",
   "weight": "204.38",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 82,
   "symbol": "Pb",
   "name": "lead",
   "weight": "207.2",
   "uncertainty": "1.1",
   "notes": []
  },
  {
   "z": 83,
   "symbol": "Bi",
   "name": "bismuth",
   "weight": "208.98",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 84,
   "symbol": "Po",
   "name": "polonium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 85,
   "symbol": "At",
   "name": "astatine",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 86,
   "symbol": "Rn",
   "name": "radon",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 87,
   "symbol": "Fr",
   "name": "francium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 88,
   "symbol": "Ra",
   "name": "radium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 89,
   "symbol": "Ac",
   "name": "actinium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 90,
   "symbol": "Th",
   "name": "thorium",
   "weight": "232.04",
   "uncertainty": "0.01",
   "notes": [
    "g"
   ]
  },
  {
   "z": 91,
   "symbol": "Pa",
   "name": "protactinium",
   "weight": "231.04",
   "uncertainty": "0.01",
   "notes": []
  },
  {
   "z": 92,
   "symbol": "U",
   "name": "uranium",
   "weight": "238.03",
   "uncertainty": "0.01",
   "notes": [
    "g",
    "m"
   ]
  },
  {
   "z": 93,
   "symbol": "Np",
   "name": "neptunium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 94,
   "symbol": "Pu",
   "name": "plutonium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 95,
   "symbol": "Am",
   "name": "americium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 96,
   "symbol": "Cm",
   "name": "curium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 97,
   "symbol": "Bk",
   "name": "berkelium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 98,
   "symbol": "Cf",
   "name": "californium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 99,
   "symbol": "Es",
   "name": "einsteinium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 100,
   "symbol": "Fm",
   "name": "fermium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 101,
   "symbol": "Md",
   "name": "mendelevium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 102,
   "symbol": "No",
   "name": "nobelium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 103,
   "symbol": "Lr",
   "name": "lawrencium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 104,
   "symbol": "Rf",
   "name": "rutherfordium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 105,
   "symbol": "Db",
   "name": "dubnium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 106,
   "symbol": "Sg",
   "name": "seaborgium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 107,
   "symbol": "Bh",
   "name": "bohrium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 108,
   "symbol": "Hs",
   "name": "hassium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 109,
   "symbol": "Mt",
   "name": "meitnerium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 110,
   "symbol": "Ds",
   "name": "darmstadtium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 111,
   "symbol": "Rg",
   "name": "roentgenium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 112,
   "symbol": "Cn",
   "name": "copernicium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 113,
   "symbol": "Nh",
   "name": "nihonium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 114,
   "symbol": "Fl",
   "name": "flerovium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 115,
   "symbol": "Mc",
   "name": "moscovium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 116,
   "symbol": "Lv",
   "name": "livermorium",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 117,
   "symbol": "Ts",
   "name": "tennessine",
   "weight": null,
   "uncertainty": null,
   "notes": []
  },
  {
   "z": 118,
   "symbol": "Og",
   "name": "oganesson",
   "weight": null,
   "uncertainty": null,
   "notes": []
  }
 ]
}

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/chem_formula/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/chem_formula/1.0.0/chem_formula.py
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/chem_formula/1.0.0/elements.json