Source code for vibesketch.document.electrochem

"""Electrochemical half-reactions: electrons, states, coefficients and potentials (#141).

An electrode reaction is drawn like any reaction, with what SMILES cannot say
written as text, and read from the drawing like the rest of it:

* electrons, ``2e⁻`` (also ``2 e-`` or ``2e−``), drawn on a side among the
  participants, or at the arrow as ``+ 2e⁻`` (taken up, a reactant) or
  ``− 2e⁻`` (given off, a product);
* a state, ``(s)``, ``(l)``, ``(g)``, ``(aq)`` or ``(ads)``, or the asterisk
  of an adsorbed species, just right of a participant;
* a coefficient, such as the 2 of 2H2O, just left of a participant;
* conditions at the arrow that read as a value and a unit: a potential with
  its reference electrode (``−1.2 V vs Ag/AgCl (3 M KCl)``, SHE, NHE, SCE,
  RHE), ``pH 7.4``, a temperature in °C or K, a time.

The texts stay in the drawing, so figures print them as drawn. The balance
check counts atoms (implicit hydrogens included) and charge, with each
participant's coefficient and the electrons' charge of −1 each, and reports
what does not match; it never changes the reaction.
"""

from __future__ import annotations

import math
import re
from collections import Counter
from collections.abc import Sequence
from typing import Any

from vibesketch.document.ket import KetArrow, KetScene
from vibesketch.document.model import Document, Quantity, Reaction

__all__ = [
    "FARADAY",
    "BalanceReport",
    "HalfReactionRefused",
    "balance",
    "describe",
    "electron_text",
    "half_reaction_canvas",
    "quantities",
    "read_half_reaction",
    "widened",
    "with_half_reaction",
]

#: The Faraday constant, C/mol (CODATA 2018: exact, from e and N_A).
FARADAY = 96485.33212

_MINUS = "-−–⁻"
#: ``2e⁻``, ``+ 2 e-``, ``− e−``: an optional sign, a count, e, and a minus.
_ELECTRONS = re.compile(rf"^\s*([+\-−–])?\s*(\d{{1,3}})?\s*e\s*(?:\^)?[{_MINUS}]\s*$")
_STATE = re.compile(r"^\s*(?:\(\s*(s|l|g|aq|ads)\s*\)|(\*))\s*$")
_COEFFICIENT = re.compile(r"^\s*(\d{1,3}(?:\.\d+)?|½)\s*$")
_NUMBER = r"([+\-−–]?\d+(?:[.,]\d+)?)"
_POTENTIAL = re.compile(
    rf"(?:E\s*=\s*)?{_NUMBER}\s*(mV|V)\s*(?:vs\.?|versus)\s*"
    r"(SHE|NHE|SCE|RHE|Ag\s*/\s*AgCl)(?:\s*\(\s*(\d+(?:[.,]\d+)?|sat\.?)\s*M?\s*KCl\s*\))?",
    re.IGNORECASE,
)
_PH = re.compile(rf"\bpH\s*=?\s*{_NUMBER}")
_TEMPERATURE = re.compile(rf"{_NUMBER}\s*(°C|℃|K)\b")
_TIME = re.compile(rf"{_NUMBER}\s*(h|min|s|d)\b")
#: How far, in bond lengths, a state or coefficient text may be from its participant's
#: atoms: a lone ion's label (Pb²⁺) reaches about a bond length past its atom.
_NEAR = 1.6
#: Saturated KCl, mol/L at 25 °C, for an Ag/AgCl reference written "(sat. KCl)".
_SATURATED_KCL = 4.2


def _float(text: str) -> float:
    return float(text.replace(",", ".").replace("−", "-").replace("–", "-"))


[docs] def electron_text(text: str) -> tuple[str | None, int] | None: """A text that writes electrons as its sign and count, or ``None``.""" match = _ELECTRONS.match(text) if match is None: return None sign = match.group(1) return ("+" if sign == "+" else "-" if sign else None), int(match.group(2) or 1)
[docs] def quantities(lines: Sequence[str]) -> list[Quantity]: """The conditions among ``lines`` that read as a value with its unit.""" found: list[Quantity] = [] for line in lines: text = line.strip()[:500] if not text: continue potential = _POTENTIAL.search(text) if potential is not None: value = _float(potential.group(1)) / (1000 if potential.group(2).lower() == "mv" else 1) reference = re.sub(r"\s+", "", potential.group(3)) reference = "Ag/AgCl" if reference.lower() == "ag/agcl" else reference.upper() kcl = potential.group(4) molar = None if kcl is not None: molar = _SATURATED_KCL if kcl.lower().startswith("sat") else _float(kcl) found.append( Quantity( kind="potential", value=value, unit="V", reference=reference, kcl_mol_l=molar if reference == "Ag/AgCl" else None, text=text, ) ) rest = _POTENTIAL.sub(" ", text) if (ph := _PH.search(rest)) is not None: found.append(Quantity(kind="ph", value=_float(ph.group(1)), unit="pH", text=text)) if (temperature := _TEMPERATURE.search(rest)) is not None: unit = "K" if temperature.group(2) == "K" else "°C" found.append( Quantity( kind="temperature", value=_float(temperature.group(1)), unit=unit, text=text ) # fmt: skip ) if (time := _TIME.search(rest)) is not None: found.append( Quantity( kind="time", value=_float(time.group(1)), unit=time.group(2), text=text, ) # fmt: skip ) return found
def _along(arrow: KetArrow, point: tuple[float, float]) -> tuple[float, float]: from vibesketch.document.derive import _along as along return along(arrow, point) def _width(text: str) -> float: """A text's approximate width in bond lengths, for nearness only.""" return 0.3 * len(text.strip())
[docs] def read_half_reaction( scene: KetScene, arrow: KetArrow, sides: tuple[list[int], list[int], list[int]], species_of: dict[int, str], used: set[int], ) -> tuple[dict[str, Any], list[str]]: """The electrons, states and coefficients drawn for one step, and warnings. ``sides`` are the step's reactant, reagent and product fragments; ``species_of`` maps a fragment to its species; texts read here are added to ``used`` so they do not become condition lines. """ from vibesketch.document.derive import ( _ARROW_REACH, _SINGLE_SIDE_GAP, _box_distance, _box_gap, ) reactants, reagents, products = sides warnings: list[str] = [] electrons = {"reactants": 0, "products": 0} for index, text in enumerate(scene.texts): if index in used or (found := electron_text(text.text)) is None: continue sign, count = found t, distance = _along(arrow, text.centre) side: str | None = None if -0.15 <= t <= 1.15 and distance <= _ARROW_REACH: side = "products" if sign == "-" else "reactants" else: half = _width(text.text) / 2 box = (text.centre[0] - half, text.centre[1] - 0.2, text.centre[0] + half, text.centre[1] + 0.2) # fmt: skip for name, members, end in ( ("reactants", reactants, arrow.tail), ("products", products, arrow.head), ): near = [_box_gap(scene.fragments[m].bounds, box) for m in members] # A plus sign joining the text to the side's molecules, past a state label. near += [ 0.0 for plus in scene.pluses if _box_distance(box, plus) <= 2.0 and min( (_box_distance(scene.fragments[m].bounds, plus) for m in members), default=math.inf, ) <= _SINGLE_SIDE_GAP ] # As close to the side as its molecules may be to each other, or to the arrow. if (t < 0) == (name == "reactants") and ( min(near, default=math.inf) <= _SINGLE_SIDE_GAP or _box_distance(box, end) <= 3.0 ): side = name if side is not None: electrons[side] += count used.add(index) fields: dict[str, Any] = {} taken, given = electrons["reactants"], electrons["products"] if taken and given: warnings.append( f"electrons are drawn on both sides ({taken} and {given}); the difference is kept" ) if taken != given: fields["electrons"] = { "side": "reactants" if taken > given else "products", "count": abs(taken - given), } states: dict[str, str] = {} coefficients: dict[str, float] = {} for position in [*reactants, *reagents, *products]: species = species_of.get(position) if species is None: continue xmin, ymin, xmax, ymax = scene.fragments[position].bounds middle = (ymin + ymax) / 2 for index, text in enumerate(scene.texts): if index in used: continue x, y = text.centre reach = max(0.7, (ymax - ymin) / 2 + 0.3) if abs(y - middle) > reach and not ymin - 0.6 <= y <= ymax: continue half = _width(text.text) / 2 state = _STATE.match(text.text) if state is not None and 0 <= x - half - xmax <= _NEAR and species not in states: states[species] = state.group(1) or "ads" used.add(index) continue number = _COEFFICIENT.match(text.text) if ( number is not None and 0 <= xmin - (x + half) <= _NEAR and species not in coefficients ): value = 0.5 if number.group(1) == "½" else _float(number.group(1)) if value > 0: coefficients[species] = value used.add(index) if states: fields["states"] = [{"species": s, "state": v} for s, v in states.items()] if coefficients: fields["coefficients"] = [{"species": s, "value": v} for s, v in coefficients.items()] return fields, warnings
[docs] def widened(scene: KetScene) -> KetScene: """``scene`` with each molecule's box taken out over its coefficient and state texts. So a plus sign between ``2 H⁺(aq)`` and its neighbour joins them as it joins bare molecules; only the boxes change, never which texts there are. """ import dataclasses fragments = [] for fragment in scene.fragments: xmin, ymin, xmax, ymax = fragment.bounds middle = (ymin + ymax) / 2 reach = max(0.7, (ymax - ymin) / 2 + 0.3) left, right = xmin, xmax for text in scene.texts: x, y = text.centre if abs(y - middle) > reach: continue half = _width(text.text) / 2 if _STATE.match(text.text) and 0 <= x - half - xmax <= _NEAR: right = max(right, x + half) elif _COEFFICIENT.match(text.text) and 0 <= xmin - (x + half) <= _NEAR: left = min(left, x - half) if (left, right) != (xmin, xmax): fragment = dataclasses.replace(fragment, bounds=(left, ymin, right, ymax)) fragments.append(fragment) return dataclasses.replace(scene, fragments=fragments)
# --- balance ---
[docs] class BalanceReport(dict[str, Any]): """A reaction's atom and charge balance: what each side holds and what does not match."""
def _atoms(document: Document, species_id: str) -> Counter[str] | None: from rdkit import Chem from vibesketch.chem.species import mol_from_smiles species = document.species_by_id(species_id) if species.solid is not None: from vibesketch.chem.formula import parse_formula counts = parse_formula(species.solid.text).counts() return None if counts is None else Counter(counts) if species.smiles is not None: mol = Chem.AddHs(mol_from_smiles(species.smiles)) return Counter(atom.GetSymbol() for atom in mol.GetAtoms()) if species.conformers: return Counter(species.conformers[0].symbols) return None
[docs] def balance(document: Document, reaction_id: str) -> BalanceReport: """Atoms and charge on each side of a reaction, with the electrons, and the imbalance. Reagents drawn at the arrow are not counted. A side's count is the sum of coefficient times atoms, and of coefficient times charge, less one per electron on that side. ``balanced`` is true only when every element and the charge match. Raises ``KeyError`` for an unknown reaction. """ reaction: Reaction | None = next((r for r in document.reactions if r.id == reaction_id), None) if reaction is None: raise KeyError(reaction_id) sides: dict[str, dict[str, Any]] = {} unknown: list[str] = [] for name, members in (("reactants", reaction.reactants), ("products", reaction.products)): atoms: dict[str, float] = {} charge = 0.0 for species_id in members: coefficient = reaction.coefficient(species_id) found = _atoms(document, species_id) if found is None: unknown.append(species_id) continue for symbol, count in found.items(): atoms[symbol] = atoms.get(symbol, 0.0) + coefficient * count charge += coefficient * document.species_by_id(species_id).charge electrons = ( reaction.electrons.count if reaction.electrons and reaction.electrons.side == name else 0 ) sides[name] = { "atoms": {k: _clean(v) for k, v in sorted(atoms.items())}, "charge": _clean(charge - electrons), "electrons": electrons, } elements = sorted(set(sides["reactants"]["atoms"]) | set(sides["products"]["atoms"])) atom_imbalance = { element: _clean( sides["products"]["atoms"].get(element, 0) - sides["reactants"]["atoms"].get(element, 0) ) for element in elements } atom_imbalance = {k: v for k, v in atom_imbalance.items() if v} charge_imbalance = _clean(sides["products"]["charge"] - sides["reactants"]["charge"]) report = BalanceReport( reaction=reaction.id, reactants=sides["reactants"], products=sides["products"], electrons=reaction.electrons.model_dump() if reaction.electrons else None, states={s.species: s.state for s in reaction.states}, atom_imbalance=atom_imbalance, charge_imbalance=charge_imbalance, balanced=not atom_imbalance and not charge_imbalance and not unknown, unknown=unknown, ) return report
def _clean(value: float) -> float | int: """A count as an integer when it is one, to read as 2, not 2.0.""" rounded = round(value, 6) return int(rounded) if rounded == int(rounded) else rounded
[docs] def describe(report: BalanceReport) -> str: """One line saying whether a reaction balances, and if not, by how much.""" if report["unknown"]: return f"{report['reaction']}: cannot count {', '.join(report['unknown'])}" if report["balanced"]: return f"{report['reaction']}: balanced" parts = [f"{element} {value:+g}" for element, value in report["atom_imbalance"].items()] if report["charge_imbalance"]: parts.append(f"charge {report['charge_imbalance']:+g}") return f"{report['reaction']}: unbalanced, products minus reactants: {', '.join(parts)}"
# --- drawing a half-reaction ---
[docs] class HalfReactionRefused(ValueError): """A half-reaction that cannot be drawn as asked; the message says why."""
def _solid_text(entry: dict[str, Any], where: str) -> str: """The text a solid participant ``{formula, state}`` is drawn as: Fe2O3(s) (#142).""" from vibesketch.chem.formula import is_solid_text formula = str(entry["formula"]).strip() state = entry.get("state", "s") text = formula if formula.endswith(")") and is_solid_text(formula) else f"{formula}({state})" if is_solid_text(text) is None: raise HalfReactionRefused( f"{where}: {text!r} is not a solid's formula; give a formula such as Fe2O3 with the " "state s, or a polymorph such as γ-Al2O3" ) return text def _participant_spec(entry: Any, where: str) -> tuple[str, float, str | None]: if isinstance(entry, str): return entry, 1.0, None if isinstance(entry, dict) and isinstance(entry.get("formula"), str): entry = {**entry, "smiles": ""} if not isinstance(entry, dict) or not isinstance(entry.get("smiles"), str): raise HalfReactionRefused( f"{where}: give a SMILES, {{smiles, coefficient, state}} or {{formula, coefficient, " "state}}" ) coefficient = entry.get("coefficient", 1) state = entry.get("state") if ( isinstance(coefficient, bool) or not isinstance(coefficient, int | float) or not 0 < coefficient <= 999 ): raise HalfReactionRefused(f"{where}: the coefficient is a number from 0 to 999") if state is not None and state not in ("s", "l", "g", "aq", "ads"): raise HalfReactionRefused(f"{where}: the state is s, l, g, aq or ads, not {state!r}") return entry["smiles"], float(coefficient), state def _label_room(mol: Any) -> float: """How far an atom label reaches past its atom, in bond lengths: Pb²⁺ more than C.""" heavy = [a for a in mol.GetAtoms() if a.GetAtomicNum() > 1] if len(heavy) != 1: return 0.55 atom = heavy[0] charge = abs(int(atom.GetFormalCharge())) hydrogens = int(atom.GetTotalNumHs()) return ( 0.2 + 0.26 * len(atom.GetSymbol()) + (0.3 if hydrogens else 0.0) + (0.15 if hydrogens > 1 else 0.0) + (0.2 + (0.12 if charge > 1 else 0.0) if charge else 0.0) ) def _number(value: float) -> str: return str(int(value)) if value == int(value) else f"{value:g}"
[docs] def half_reaction_canvas( reactants: Sequence[Any], products: Sequence[Any], *, electrons: dict[str, Any] | None = None, conditions: Sequence[str] = (), arrow: str = "forward", over: Sequence[Any] = (), ) -> dict[str, Any]: """A KET drawing of a half-reaction, laid out in a row as the derivation reads it. A participant is a SMILES or ``{smiles, coefficient, state}``; coefficients are drawn before it and states after it, the electrons ``{count, side, at_arrow}`` as ``2e⁻`` among the participants of their side or, with ``at_arrow``, as ``+ 2e⁻`` or ``− 2e⁻`` above the arrow, and the conditions below it. A participant may also be a solid ``{formula, coefficient, state}`` (#142), drawn as its formula. ``over`` are drawn above the arrow as reagents: a solid catalyst ``{formula}`` or a SMILES. Raises :class:`HalfReactionRefused`. """ from vibesketch.chem.species import mol_from_smiles from vibesketch.document.ket import ARROW_MODES, GAP, _ket_molecule, text_node if not reactants or not products: raise HalfReactionRefused("a half-reaction needs a reactant and a product") if arrow not in ARROW_MODES: raise HalfReactionRefused(f"the arrow is one of {', '.join(ARROW_MODES)}") count = side = None at_arrow = False if electrons is not None: count, side = electrons.get("count"), electrons.get("side") at_arrow = bool(electrons.get("at_arrow", False)) if isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= 100: raise HalfReactionRefused("the electron count is a whole number from 1 to 100") if side not in ("reactants", "products"): raise HalfReactionRefused("the electrons' side is reactants or products") document: dict[str, Any] = { "ket_version": "2.0.0", "root": {"nodes": [], "connections": [], "templates": []}, } nodes: list[dict[str, Any]] = document["root"]["nodes"] counter = 0 def text(content: str, x: float, y: float = 0.0) -> float: """A text starting at ``x``; returns where it ends.""" width = max(0.4, 0.22 * len(content)) nodes.append(text_node(content, (x + width / 2, y))) return x + width def row(entries: Sequence[Any], x: float, name: str, electron_side: bool) -> float: nonlocal counter items: list[tuple[str, Any]] = [("molecule", e) for e in entries] if electron_side: items.append(("electrons", count)) for index, (kind, entry) in enumerate(items): if index: nodes.append({"type": "plus", "location": [x + GAP, 0.0, 0.0]}) x += GAP * 2 if kind == "electrons": x = text(f"{'' if entry == 1 else entry}e⁻", x) continue smiles, coefficient, state = _participant_spec(entry, f"{name} {index + 1}") if isinstance(entry, dict) and "formula" in entry: # A solid is drawn as its formula; its state is part of the text. solid = _solid_text(entry, f"{name} {index + 1}") if coefficient != 1: x = text(_number(coefficient), x) + 0.25 x = text(solid, x) continue try: mol = mol_from_smiles(smiles) except ValueError as error: raise HalfReactionRefused(f"{name} {index + 1}: {error}") from None if mol is None: raise HalfReactionRefused(f"{name} {index + 1}: {smiles!r} is not a SMILES") room = _label_room(mol) if coefficient != 1: x = text(_number(coefficient), x) + room molecule, width = _ket_molecule(mol, (x, 0.0)) key = f"mol{counter}" counter += 1 document[key] = molecule nodes.append({"$ref": key}) x += width if state is not None: x = text(f"({state})", x + room) return x tail = row(reactants, 0.0, "reactant", side == "reactants" and not at_arrow) + GAP longest = max((len(line) for line in conditions), default=0) * 0.22 above = [ _solid_text(e, f"reagent {i + 1}") if isinstance(e, dict) else str(e) for i, e in enumerate(over) ] widest = max((len(t) for t in above), default=0) * 0.3 head = tail + max(3.0, longest + 1.0, widest + 1.0, 2.5 if at_arrow else 0.0) nodes.append( { "type": "arrow", "data": { "mode": ARROW_MODES[arrow], "pos": [{"x": tail, "y": 0.0, "z": 0.0}, {"x": head, "y": 0.0, "z": 0.0}], }, } ) if at_arrow and count is not None: sign = "+" if side == "reactants" else "−" nodes.append(text_node(f"{sign} {'' if count == 1 else count}e⁻", ((tail + head) / 2, 0.6))) for number, line in enumerate(conditions): nodes.append(text_node(line, ((tail + head) / 2, -0.65 - 0.6 * number))) for number, item in enumerate(above): y = 0.6 + 0.6 * (number + (1 if at_arrow else 0)) if isinstance(over[number], dict): nodes.append(text_node(item, ((tail + head) / 2, y))) else: try: mol = mol_from_smiles(item) except ValueError as error: raise HalfReactionRefused(f"reagent {number + 1}: {error}") from None molecule, width = _ket_molecule(mol, ((tail + head) / 2, 0.0)) for atom in molecule["atoms"]: atom["location"][0] -= width / 2 atom["location"][1] += y + 0.4 key = f"mol{counter}" counter += 1 document[key] = molecule nodes.append({"$ref": key}) row(products, head + GAP, "product", side == "products" and not at_arrow) return document
[docs] def with_half_reaction( document: Document, reactants: Sequence[Any], products: Sequence[Any], *, electrons: dict[str, Any] | None = None, conditions: Sequence[str] = (), arrow: str = "forward", over: Sequence[Any] = (), ) -> tuple[Document, list[str]]: """``document`` with a half-reaction drawn beside its drawing, derived, and the warnings. See :func:`half_reaction_canvas`. Raises :class:`HalfReactionRefused`. """ from vibesketch.document.derive import derive from vibesketch.document.formats import _merged from vibesketch.document.model import Canvas drawing = half_reaction_canvas( reactants, products, electrons=electrons, conditions=conditions, arrow=arrow, over=over ) merged = _merged(dict(document.canvas.data), drawing) derived = derive(document.model_copy(update={"canvas": Canvas(format="ket", data=merged)})) return derived.document, derived.warnings