from __future__ import annotations
from dataclasses import dataclass
from fnmatch import fnmatch
from typing import ClassVar
from hofmann.model.colour import Colour, normalise_colour
[docs]
class BondSpec:
"""Declarative rule for bond detection between species pairs.
The *species* pair is stored in sorted order so that the data
structure is invariant under exchange of the two labels.
Species names support fnmatch-style wildcards (``*``, ``?``).
Only *species* and *max_length* are required. *radius* and
*colour* default to ``None``, meaning "use the class-level
default" (``BondSpec.default_radius`` and
``BondSpec.default_colour``). The resolved value is returned
by the corresponding property; ``repr()`` shows
``radius=<default 0.1>`` when unset so the intent is visible.
To change the defaults for all specs that have not been
explicitly set::
BondSpec.default_radius = 0.15
BondSpec.default_colour = "grey"
Attributes:
species: Sorted pair of species patterns.
max_length: Maximum bond length threshold.
min_length: Minimum bond length threshold. Defaults to
``0.0``.
complete: Controls single-pass bond completion across periodic
boundaries. A species string (e.g. ``"Zr"``) adds
missing partners around visible atoms of that species.
``"*"`` completes around both species in the pair.
``False`` (default) disables completion. Unlike
*recursive*, newly added atoms are **not** themselves
searched.
recursive: If ``True``, atoms bonded via this spec are
searched recursively across periodic boundaries. When
an image atom is materialised, its own bonded partners
are checked on the next iteration, extending the
expansion outward. Useful for molecules that span
periodic boundaries.
"""
default_radius: ClassVar[float] = 0.1
"""Class-level default for *radius* when not set explicitly."""
default_colour: ClassVar[Colour] = 0.5
"""Class-level default for *colour* when not set explicitly."""
@staticmethod
def _validate_max_length(value: float) -> None:
if value <= 0:
raise ValueError(
f"max_length must be positive, got {value}"
)
@staticmethod
def _validate_min_length(value: float) -> None:
if value < 0:
raise ValueError(
f"min_length must be non-negative, got {value}"
)
@staticmethod
def _validate_length_bounds(min_length: float, max_length: float) -> None:
if min_length > max_length:
raise ValueError(
f"min_length ({min_length}) must not exceed "
f"max_length ({max_length})"
)
@staticmethod
def _validate_radius(value: float | None) -> None:
if value is not None and value < 0:
raise ValueError(
f"radius must be non-negative, got {value}"
)
@staticmethod
def _validate_colour(value: Colour | None) -> None:
if value is not None:
# normalise_colour raises ValueError for invalid input.
normalise_colour(value)
@staticmethod
def _validate_complete(
value: bool | str, species: tuple[str, str],
) -> None:
if value is True:
raise ValueError(
"complete=True is not supported; use a species name "
"(e.g. complete='Zr') or complete='*' for both directions"
)
if value is not False and not isinstance(value, str):
raise ValueError(
"complete must be False, a species name string "
"(e.g. 'Zr'), or '*' for both directions"
)
if isinstance(value, str) and value != "*":
if value == "":
raise ValueError("complete must not be an empty string")
if not any(fnmatch(sp, value) for sp in species):
raise ValueError(
f"complete={value!r} does not match either "
f"species in the pair {species}"
)
[docs]
def __init__(
self,
species: tuple[str, str],
max_length: float,
min_length: float = 0.0,
radius: float | None = None,
colour: Colour | None = None,
complete: bool | str = False,
recursive: bool = False,
) -> None:
a, b = sorted(species)
self.species = (a, b)
self.max_length = max_length
self.min_length = min_length
self._radius = radius
self._colour = colour
self.complete = complete
self.recursive = recursive
self._validate()
def _validate(self) -> None:
self._validate_max_length(self.max_length)
self._validate_min_length(self.min_length)
self._validate_length_bounds(self.min_length, self.max_length)
self._validate_radius(self._radius)
self._validate_colour(self._colour)
self._validate_complete(self.complete, self.species)
@property
def radius(self) -> float:
"""Visual radius of the bond cylinder."""
return self.default_radius if self._radius is None else self._radius
@radius.setter
def radius(self, value: float | None) -> None:
self._validate_radius(value)
self._radius = value
@property
def colour(self) -> Colour:
"""Bond colour (resolved from class default when not set explicitly)."""
return self.default_colour if self._colour is None else self._colour
@colour.setter
def colour(self, value: Colour | None) -> None:
self._validate_colour(value)
self._colour = value
def __repr__(self) -> str:
radius_repr = (
f"<default {self.default_radius!r}>"
if self._radius is None
else repr(self._radius)
)
colour_repr = (
f"<default {self.default_colour!r}>"
if self._colour is None
else repr(self._colour)
)
parts = [
f"species={self.species!r}",
f"max_length={self.max_length!r}",
f"min_length={self.min_length!r}",
f"radius={radius_repr}",
f"colour={colour_repr}",
]
if self.complete is not False:
parts.append(f"complete={self.complete!r}")
if self.recursive:
parts.append(f"recursive={self.recursive!r}")
return f"BondSpec({', '.join(parts)})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, BondSpec):
return NotImplemented
return (
self.species == other.species
and self.max_length == other.max_length
and self.min_length == other.min_length
and self._radius == other._radius
and self._colour == other._colour
and self.complete == other.complete
and self.recursive == other.recursive
)
__hash__ = None # type: ignore[assignment]
[docs]
def matches(self, species_1: str, species_2: str) -> bool:
"""Check whether this spec matches a given species pair.
Matching is symmetric: ``BondSpec(("C", "H"), ...).matches("H", "C")``
returns ``True``.
Args:
species_1: First species label.
species_2: Second species label.
Returns:
``True`` if the pair matches in either order.
"""
a, b = self.species
forward = fnmatch(species_1, a) and fnmatch(species_2, b)
reverse = fnmatch(species_1, b) and fnmatch(species_2, a)
return forward or reverse
[docs]
def to_dict(self) -> dict:
"""Serialise to a JSON-compatible dictionary.
Fields at their default values are omitted (``min_length=0``,
``complete=False``, ``recursive=False``). When ``radius`` or
``colour`` use the class-level default (i.e. were not set
explicitly), they are omitted too.
"""
d: dict = {
"species": list(self.species),
"max_length": self.max_length,
}
if self.min_length != 0.0:
d["min_length"] = self.min_length
if self._radius is not None:
d["radius"] = self._radius
if self._colour is not None:
d["colour"] = list(normalise_colour(self._colour))
if self.complete is not False:
d["complete"] = self.complete
if self.recursive:
d["recursive"] = True
return d
[docs]
@classmethod
def from_dict(cls, d: dict) -> BondSpec:
"""Deserialise from a dictionary.
Missing optional fields use their defaults.
"""
return cls(
species=tuple(d["species"]),
max_length=d["max_length"],
min_length=d.get("min_length", 0.0),
radius=d.get("radius"),
colour=d.get("colour"),
complete=d.get("complete", False),
recursive=d.get("recursive", False),
)
[docs]
@dataclass(frozen=True)
class Bond:
"""A computed bond between two atoms.
Attributes:
index_a: Index of the first atom.
index_b: Index of the second atom.
length: Interatomic distance.
spec: The BondSpec rule that produced this bond.
image: Lattice translation applied to atom b to form the bond.
``(0, 0, 0)`` means a direct bond within the cell.
``(1, 0, 0)`` means atom b is shifted by +1 along lattice
vector **a**. Always ``(0, 0, 0)`` for non-periodic scenes.
"""
index_a: int
index_b: int
length: float
spec: BondSpec
image: tuple[int, int, int] = (0, 0, 0)
def __hash__(self) -> int:
# BondSpec is deliberately unhashable, so the auto-generated
# frozen-dataclass hash would fail. Hash on identity fields
# only; the equality contract is satisfied because equal bonds
# (all fields match) always produce equal hashes.
return hash((self.index_a, self.index_b, self.image))