"""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")