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