Source code for httk.atomistic.models.formula.notation
"""The one home of httk's reduced/anonymous formula notation."""
import re
from collections.abc import Sequence
from fractions import Fraction
from functools import reduce
from math import gcd
from httk.atomistic.elements import SYMBOLS
_ELEMENT_TOKEN = re.compile(r"([A-Z][a-z]?)([0-9]*)")
_ANONYMOUS_TOKEN = re.compile(r"([A-Z][a-z]*)([0-9]*)")
_ELEMENTS = frozenset(SYMBOLS)
[docs]
def anonymous_symbol(index: int) -> str:
"""Return the unbounded OPTIMADE anonymous symbol for a zero-based index.
:param index: The non-negative zero-based symbol index.
:return: The generated anonymous symbol.
:raises ValueError: If ``index`` is not a non-negative integer.
"""
if not isinstance(index, int) or isinstance(index, bool) or index < 0:
raise ValueError("anonymous symbol index must be a non-negative integer")
head = chr(ord("A") + index % 26)
tail_number = index // 26
tail: list[str] = []
while tail_number:
tail_number -= 1
tail.append(chr(ord("a") + tail_number % 26))
tail_number //= 26
return head + "".join(reversed(tail))
[docs]
def reduced_coefficients(ratios: Sequence[Fraction]) -> tuple[int, ...] | None:
"""Return the least common integer coefficients for exact elemental ratios.
:param ratios: The exact elemental ratios in their desired output order.
:return: The reduced integer coefficients, or ``None`` for an empty sequence.
"""
if not ratios:
return None
denominator = 1
for ratio in ratios:
denominator = denominator * ratio.denominator // gcd(denominator, ratio.denominator)
values = tuple(int(ratio * denominator) for ratio in ratios)
common = reduce(gcd, values)
return tuple(value // common for value in values)
[docs]
def render_reduced(coefficients: Sequence[tuple[str, int]]) -> str:
"""Render element symbols and reduced integer coefficients in the given order.
:param coefficients: The element and coefficient pairs to render.
:return: The canonical reduced formula text.
"""
return "".join(element + (str(amount) if amount != 1 else "") for element, amount in coefficients)
[docs]
def render_anonymous(counts: Sequence[int]) -> str:
"""Render descending-sorted integer counts using OPTIMADE anonymous symbols.
:param counts: The coefficients in descending order.
:return: The canonical anonymous formula text.
"""
return "".join(anonymous_symbol(index) + (str(count) if count != 1 else "") for index, count in enumerate(counts))
[docs]
def parse_reduced_formula(text: str) -> tuple[tuple[str, int], ...]:
"""Parse a strictly canonical reduced OPTIMADE chemical formula.
The reduced and anonymous grammars are disjoint by construction: an anonymous
label may have an arbitrary lowercase tail, while an element symbol has at most one.
:param text: The formula text to parse.
:return: The canonical element and coefficient pairs.
:raises ValueError: If ``text`` is not a canonical reduced formula.
"""
if not isinstance(text, str) or not text:
raise ValueError("reduced formula must be a non-empty string")
result: list[tuple[str, int]] = []
position = 0
while position < len(text):
match = _ELEMENT_TOKEN.match(text, position)
if match is None:
raise ValueError(f"invalid reduced formula token at position {position}")
symbol, digits = match.groups()
if symbol not in _ELEMENTS:
raise ValueError(f"reduced formula contains unknown element symbol {symbol!r}")
if any(existing == symbol for existing, _ in result):
raise ValueError(f"reduced formula repeats element symbol {symbol!r}")
if result and symbol <= result[-1][0]:
raise ValueError("reduced formula element symbols must be strictly alphabetical")
if not digits:
count = 1
else:
count = int(digits)
if count < 2:
raise ValueError("reduced formula explicit counts must be at least 2; explicit 1 is invalid")
result.append((symbol, count))
position = match.end()
if gcd(*(count for _, count in result)) != 1:
raise ValueError("reduced formula coefficients must have greatest common divisor 1")
return tuple(result)
[docs]
def parse_anonymous_formula(text: str) -> tuple[tuple[str, int], ...]:
"""Parse a strictly canonical OPTIMADE anonymous chemical formula.
:param text: The formula text to parse.
:return: The canonical anonymous-label and coefficient pairs.
:raises ValueError: If ``text`` is not a canonical anonymous formula.
"""
if not isinstance(text, str) or not text:
raise ValueError("anonymous formula must be a non-empty string")
result: list[tuple[str, int]] = []
position = 0
while position < len(text):
match = _ANONYMOUS_TOKEN.match(text, position)
if match is None:
raise ValueError(f"invalid anonymous formula token at position {position}")
label, digits = match.groups()
expected = anonymous_symbol(len(result))
if label != expected:
raise ValueError(f"anonymous formula labels must be consecutive starting at {expected!r}")
if not digits:
count = 1
else:
count = int(digits)
if count < 2:
raise ValueError("anonymous formula explicit counts must be at least 2; explicit 1 is invalid")
if result and count > result[-1][1]:
raise ValueError("anonymous formula coefficients must be in non-increasing order")
result.append((label, count))
position = match.end()
if gcd(*(count for _, count in result)) != 1:
raise ValueError("anonymous formula coefficients must have greatest common divisor 1")
return tuple(result)
[docs]
def try_parse_reduced(text: str) -> tuple[tuple[str, int], ...] | None:
"""Return canonical reduced coefficients, or ``None`` when *text* is not one.
:param text: The formula text to test.
:return: The parsed coefficients, or ``None`` for invalid text.
"""
try:
return parse_reduced_formula(text)
except ValueError:
return None
[docs]
def try_parse_anonymous(text: str) -> tuple[tuple[str, int], ...] | None:
"""Return canonical anonymous coefficients, or ``None`` when *text* is not one.
:param text: The formula text to test.
:return: The parsed coefficients, or ``None`` for invalid text.
"""
try:
return parse_anonymous_formula(text)
except ValueError:
return None