Source code for vibesketch.document.stoichiometry

"""The stoichiometry table of a drawn reaction, as in a lab notebook (#99).

The chemist enters, per participant, an amount (mmol, a mass, or a volume
with a density or a concentration) or equivalents; the document stores what
was entered (:class:`~vibesketch.document.model.Amount`) and nothing is
inferred from the drawing. From it this module works out, with the molar
masses of the species report:

- n = m / M, n = V·ρ / M, or n = V·c, in mmol (mg, mL, g/mL, mol/L);
- the reference: the first reactant with an entered amount, and every
  participant's equivalents n / n(reference); equivalents entered alone give
  n = equivalents · n(reference);
- the limiting reactant, the smallest n / coefficient among the reactants
  (reagents above or below the arrow are not consumed);
- each product's theoretical amount, n(limiting) / coefficient · its own
  coefficient, and with an entered amount its isolated yield.

Units are in the column names; CSV keeps full precision and Markdown and
LaTeX show three significant figures.
"""

from __future__ import annotations

import csv
import io
import math
from collections.abc import Sequence
from typing import Any

from vibesketch.document.model import Amount, Document, Reaction
from vibesketch.document.tables import MEDIA_TYPES, TableFormat, _latex, _markdown

__all__ = [
    "MEDIA_TYPES",
    "StoichiometryRefused",
    "stoichiometry",
    "stoichiometry_table",
    "with_amounts",
]

_COLUMNS = (
    "role",
    "species",
    "label",
    "formula",
    "molar_mass_g_mol",
    "coefficient",
    "equivalents",
    "mmol",
    "mass_mg",
    "volume_ml",
    "density_g_ml",
    "concentration_mol_l",
    "theoretical_mmol",
    "theoretical_mass_mg",
    "yield_percent",
    "note",
)


[docs] class StoichiometryRefused(ValueError): """Amounts that cannot be entered, or a table that cannot be worked out."""
def _reaction(document: Document, identifier: str) -> Reaction: for reaction in document.reactions: if reaction.id == identifier: return reaction raise StoichiometryRefused(f"the document has no reaction {identifier!r}")
[docs] def with_amounts( document: Document, reaction: str, amounts: Sequence[Amount | dict[str, Any]] ) -> Document: """``document`` with ``reaction``'s entered amounts replaced; an empty list clears them.""" target = _reaction(document, reaction) try: entered = [a if isinstance(a, Amount) else Amount.model_validate(a) for a in amounts] updated = target.model_validate( {**target.model_dump(by_alias=True), "amounts": [a.model_dump() for a in entered]} ) except ValueError as error: raise StoichiometryRefused(str(error)) from None reactions = [updated if r.id == reaction else r for r in document.reactions] return document.model_copy(update={"reactions": reactions})
def _molar_masses(document: Document) -> dict[str, tuple[str | None, float | None]]: from vibesketch.chem.species import properties from vibesketch.document.formats import species_mols found: dict[str, tuple[str | None, float | None]] = {} for species, mol in species_mols(document).items(): if mol is None: continue props = properties(mol) found[species] = (props.formula, props.average_mass) return found def _entered_mmol(amount: Amount, molar_mass: float | None) -> float | None: """n in mmol from what was entered, or ``None`` when only equivalents were.""" if amount.mmol is not None: return amount.mmol if amount.volume_ml is not None and amount.concentration_mol_l is not None: return amount.volume_ml * amount.concentration_mol_l # mL · mol/L = mmol mass = amount.mass_mg if amount.volume_ml is not None and amount.density_g_ml is not None: mass = amount.volume_ml * amount.density_g_ml * 1000 # mL · g/mL → mg if mass is None: return None if molar_mass is None: raise StoichiometryRefused(f"{amount.species} has no structure, so no molar mass") return mass / molar_mass # mg / (g/mol) = mmol
[docs] def stoichiometry(document: Document, reaction: str) -> dict[str, Any]: """The worked-out table of ``reaction``: one row per participant, reactants first.""" target = _reaction(document, reaction) masses = _molar_masses(document) entered = {a.species: a for a in target.amounts} roles = [ *(("reactant", s) for s in target.reactants), *(("reagent", s) for s in target.reagents), *(("product", s) for s in target.products), ] mmol: dict[str, float] = {} for species, amount in entered.items(): value = _entered_mmol(amount, masses.get(species, (None, None))[1]) if value is not None: mmol[species] = value reference = next((s for s in target.reactants if s in mmol), None) if reference is None and any(a.equivalents is not None for a in target.amounts): raise StoichiometryRefused("give one reactant an amount; equivalents are relative to it") for species, amount in entered.items(): if amount.equivalents is None: continue if species in target.products: raise StoichiometryRefused(f"{species} is a product: give its amount, not equivalents") assert reference is not None mmol[species] = amount.equivalents * mmol[reference] coefficient = {s: (entered[s].coefficient if s in entered else 1.0) for _, s in roles} consumed = [s for s in target.reactants if s in mmol] limiting = min(consumed, key=lambda s: mmol[s] / coefficient[s]) if consumed else None rows: list[dict[str, Any]] = [] for role, species in roles: given = entered.get(species) formula, molar_mass = masses.get(species, (None, None)) label = document.species_by_id(species).label n = mmol.get(species) density = given.density_g_ml if given else None concentration = given.concentration_mol_l if given else None mass = n * molar_mass if n is not None and molar_mass is not None else None volume = None if n is not None and concentration is not None: volume = n / concentration elif mass is not None and density is not None: volume = mass / density / 1000 row: dict[str, Any] = { "role": role, "species": species, "label": label, "formula": formula, "molar_mass_g_mol": molar_mass, "coefficient": coefficient[species], "equivalents": n / mmol[reference] if n is not None and reference else None, "mmol": n, "mass_mg": mass, "volume_ml": volume, "density_g_ml": density, "concentration_mol_l": concentration, "theoretical_mmol": None, "theoretical_mass_mg": None, "yield_percent": None, "note": "", } notes = [] if species == reference: notes.append("reference") if species == limiting: notes.append("limiting") if role == "product": row["equivalents"] = None if limiting is not None: theory = mmol[limiting] / coefficient[limiting] * coefficient[species] row["theoretical_mmol"] = theory if molar_mass is not None: row["theoretical_mass_mg"] = theory * molar_mass if n is not None: row["yield_percent"] = 100 * n / theory notes.append("isolated") row["note"] = ", ".join(notes) rows.append(row) return {"reaction": reaction, "reference": reference, "limiting": limiting, "rows": rows}
def _sig(value: float | None, figures: int = 3) -> str: """``value`` to ``figures`` significant figures, without an exponent.""" if value is None: return "" if value == 0: return "0" decimals = max(0, figures - 1 - math.floor(math.log10(abs(value)))) return f"{value:.{decimals}f}"
[docs] def stoichiometry_table(document: Document, reaction: str, format: TableFormat = "csv") -> bytes: """The table as CSV (full precision), Markdown or a booktabs LaTeX fragment.""" if format not in MEDIA_TYPES: raise StoichiometryRefused("table format must be csv, markdown or latex") table = stoichiometry(document, reaction) if format == "csv": stream = io.StringIO(newline="") writer = csv.writer(stream, lineterminator="\n") writer.writerow(_COLUMNS) for row in table["rows"]: writer.writerow(["" if row[c] is None else row[c] for c in _COLUMNS]) return stream.getvalue().encode("utf-8") headers = [ "Role", "Compound", "M (g/mol)", "Equiv.", "n (mmol)", "m (mg)", "V (mL)", "Note", ] values = [headers] for row in table["rows"]: name = (f"{row['label']} " if row["label"] else "") + (row["formula"] or row["species"]) product = row["role"] == "product" mmol = row["mmol"] if not product or row["mmol"] is not None else row["theoretical_mmol"] mass = row["mass_mg"] if not product or row["mmol"] is not None else row[ "theoretical_mass_mg" ] # fmt: skip note = row["note"] if product and row["theoretical_mmol"] is not None: note = ", ".join( filter( None, [ note, "theoretical" if row["mmol"] is None else "", f"{row['yield_percent']:.1f}% yield" if row["yield_percent"] else "", ], ) ) values.append( [ row["role"], name, "" if row["molar_mass_g_mol"] is None else f"{row['molar_mass_g_mol']:.2f}", "" if row["equivalents"] is None else f"{row['equivalents']:.2f}", _sig(mmol), _sig(mass), _sig(row["volume_ml"]), note, ] ) caption = f"Reaction {reaction}. Equivalents relative to the reference; blank: not entered." if format == "markdown": lines = ["| " + " | ".join(_markdown(c) for c in line) + " |" for line in values] lines.insert(1, "| " + " | ".join(["---"] * len(headers)) + " |") return ("\n".join(lines) + "\n\n" + caption + "\n").encode("utf-8") lines = [ r"% Requires \usepackage{booktabs}.", r"\begin{tabular}{@{}llrrrrrl@{}}", r"\toprule", ] for index, line in enumerate(values): lines.append(" & ".join(_latex(c) for c in line) + r" \\") if index == 0: lines.append(r"\midrule") lines += [r"\bottomrule", r"\end{tabular}", r"\par\smallskip " + _latex(caption), ""] return "\n".join(lines).encode("utf-8")