vibe-sketcherDocumentation
Source code for vibesketcher.chem.formula
"""Chemical formulas of solids that have no molecular graph (#142).
Oxides, salts, catalysts and scales are written as formulas, not drawn as
structures: Fe2O3, Pb3(CO3)2(OH)2, CuSO4·5H2O, a variable or doped
composition such as Fe3−xCrxO4 or CoAlxOy, a composite such as AlOx–Fe2O3,
with an optional polymorph (γ-Al2O3) and state ((s)). This module reads such
a formula into its composition, the canonical form that identifies it, and,
when every count is a number, its molar mass with the atomic weights RDKit
gives the species report.
Grammar, informally: ``[polymorph-]phase[–phase…][(state)]``, a phase being
groups and elements each with an optional count, hydrate parts after ``·``
(or ``.``/``*``) with a leading multiplier, and a count a number, a variable
(x, y, z or δ), or a number with a variable added or taken away (3−x).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from vibesketcher.elements import ATOMIC_NUMBER
__all__ = [
"Formula",
"FormulaError",
"is_solid_text",
"parse_formula",
]
#: Polymorph prefixes: Greek letters and their names.
_POLYMORPHS = {
"α": "α", "β": "β", "γ": "γ", "δ": "δ", "η": "η", "θ": "θ", "κ": "κ", "χ": "χ",
"alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "eta": "η", "theta": "θ",
"kappa": "κ", "chi": "χ",
} # fmt: skip
_STATES = ("s", "l", "g", "aq", "ads")
_MINUS = "−–\\-"
_COUNT = re.compile(
rf"\d+(?:\.\d+)?(?:[{_MINUS}+]\d*(?:\.\d+)?[xyzδ])?|\d*(?:\.\d+)?[xyzδ](?:[{_MINUS}+]\d+(?:\.\d+)?)?"
)
_HYDRATE = "·.*•"
#: Composite phases are joined by an en dash, a hyphen between phases, or a slash.
_COMPOSITE = re.compile(r"\s*[–/]\s*")
[docs]
class FormulaError(ValueError):
"""Text that is not a formula this module reads; the message says where."""
Count = float | str
def _count(text: str | None) -> Count:
if not text:
return 1.0
text = text.replace("−", "-").replace("–", "-")
try:
return float(text)
except ValueError:
return text
[docs]
@dataclass(frozen=True)
class Formula:
"""A formula read: its phases' compositions, polymorph and state."""
text: str
phases: tuple[dict[str, Count], ...]
polymorph: str | None = None
state: str | None = None
#: Each phase as written, for display.
written: tuple[str, ...] = field(default=())
[docs]
@property
def numeric(self) -> bool:
return all(isinstance(c, float) for phase in self.phases for c in phase.values())
[docs]
@property
def composition(self) -> dict[str, Count]:
"""The elements of all phases together; a variable count keeps its expression."""
total: dict[str, Count] = {}
for phase in self.phases:
for element, count in phase.items():
before = total.get(element)
if before is None:
total[element] = count
elif isinstance(before, float) and isinstance(count, float):
total[element] = before + count
else:
total[element] = f"{_show(before)}+{_show(count)}"
return total
[docs]
def canonical(self) -> str:
"""Each phase in Hill order, phases joined by a dash: what identifies the solid.
A variable count is kept in parentheses, Fe3−xCrxO4 as Cr(x)Fe(3-x)O4.
"""
return "–".join(_hill(phase) for phase in self.phases)
[docs]
def key(self) -> str:
"""The canonical formula with polymorph and state: γ-Al2O3(s)."""
prefix = f"{self.polymorph}-" if self.polymorph else ""
suffix = f"({self.state})" if self.state else ""
return f"{prefix}{self.canonical()}{suffix}"
[docs]
def molar_mass(self) -> float | None:
"""g/mol from RDKit's atomic weights, or ``None`` with a variable count."""
if not self.numeric:
return None
from rdkit import Chem
table = Chem.GetPeriodicTable()
return sum(
float(table.GetAtomicWeight(element)) * float(count)
for element, count in self.composition.items()
)
[docs]
def monoisotopic_mass(self) -> float | None:
"""g/mol from each element's most common isotope, or ``None`` with a variable count."""
if not self.numeric:
return None
from rdkit import Chem
table = Chem.GetPeriodicTable()
return sum(
float(table.GetMostCommonIsotopeMass(element)) * float(count)
for element, count in self.composition.items()
)
[docs]
def counts(self) -> dict[str, float] | None:
"""Atoms per formula unit, or ``None`` with a variable count."""
if not self.numeric:
return None
return {element: float(count) for element, count in self.composition.items()}
def _show(count: Count) -> str:
if isinstance(count, float):
return str(int(count)) if count == int(count) else f"{count:g}"
return count
def _hill(phase: dict[str, Count]) -> str:
present = set(phase)
if "C" in present:
order = ["C", *(["H"] if "H" in present else []), *sorted(present - {"C", "H"})]
else:
order = sorted(present)
parts = []
for element in order:
count = phase[element]
if isinstance(count, float):
parts.append(element if count == 1 else f"{element}{_show(count)}")
else:
parts.append(f"{element}({count})")
return "".join(parts)
def _add(target: dict[str, Count], element: str, count: Count, times: Count = 1.0) -> None:
if isinstance(count, float) and isinstance(times, float):
value: Count = count * times
elif isinstance(times, float) and times == 1:
value = count
else:
value = f"({_show(times)})*({_show(count)})"
before = target.get(element)
if before is None:
target[element] = value
elif isinstance(before, float) and isinstance(value, float):
target[element] = before + value
else:
target[element] = f"{_show(before)}+{_show(value)}"
def _group(text: str, start: int, closing: str | None) -> tuple[dict[str, Count], int]:
"""Elements and groups from ``start`` up to ``closing`` (or the end)."""
found: dict[str, Count] = {}
index = start
while index < len(text):
char = text[index]
if closing is not None and char == closing:
return found, index + 1
if char in "([":
inner, index = _group(text, index + 1, ")" if char == "(" else "]")
match = _COUNT.match(text, index)
times = _count(match.group(0) if match else None)
index = match.end() if match else index
for element, count in inner.items():
_add(found, element, count, times)
continue
symbol = None
if char.isupper():
two = text[index : index + 2]
if len(two) == 2 and two[1].islower() and two in ATOMIC_NUMBER:
symbol = two
elif char in ATOMIC_NUMBER:
symbol = char
if symbol is None:
raise FormulaError(f"{text!r}: no element at {text[index:]!r}")
index += len(symbol)
match = _COUNT.match(text, index)
_add(found, symbol, _count(match.group(0) if match else None))
index = match.end() if match else index
if closing is not None:
raise FormulaError(f"{text!r}: a bracket is not closed")
return found, index
def _phase(text: str) -> dict[str, Count]:
parts = re.split(rf"\s*[{_HYDRATE}]\s*", text)
total: dict[str, Count] = {}
for position, part in enumerate(parts):
if not part:
raise FormulaError(f"{text!r}: an empty part")
times: Count = 1.0
if position:
match = re.match(r"\d+(?:\.\d+)?|[xyzn]", part)
if match:
times = _count(match.group(0))
part = part[match.end() :]
found, _ = _group(part, 0, None)
for element, count in found.items():
_add(total, element, count, times)
if not total:
raise FormulaError(f"{text!r}: no elements")
return total
[docs]
def parse_formula(text: str) -> Formula:
"""Read a formula; raises :class:`FormulaError` for anything else."""
body = text.strip()
if not body or len(body) > 120:
raise FormulaError("a formula is 1 to 120 characters")
state = None
found = re.search(r"\(\s*(s|l|g|aq|ads)\s*\)\s*$", body)
if found:
state = found.group(1)
body = body[: found.start()].rstrip()
polymorph = None
prefix = re.match(r"^([A-Za-zα-ω]+)\s*[-‐]\s*", body)
if prefix and prefix.group(1) in _POLYMORPHS:
polymorph = _POLYMORPHS[prefix.group(1)]
body = body[prefix.end() :]
written = tuple(p for p in _COMPOSITE.split(body) if p)
if not written:
raise FormulaError(f"{text!r}: no formula")
return Formula(
text=text.strip(),
phases=tuple(_phase(p.replace(" ", "")) for p in written),
polymorph=polymorph,
state=state,
written=written,
)
[docs]
def is_solid_text(text: str) -> Formula | None:
"""The formula a canvas text writes for a solid, or ``None``.
Only a whole text that is a formula marked as a solid counts: with the
state (s) or a polymorph, so that conditions such as NaOH or H2O at an
arrow keep their meaning.
"""
if "\n" in text:
return None
try:
formula = parse_formula(text)
except FormulaError:
return None
if formula.state == "s" or formula.polymorph is not None:
return formula
return None