Source code for e3nn_core.irreps

"""Immutable O(3) irrep metadata and parsing."""

from __future__ import annotations

from dataclasses import dataclass
import re
from typing import Callable, Iterable, Iterator

from .typing import Parity

_IRREP_RE = re.compile(r"^(?:(?P<mul>\d+)x)?(?P<l>\d+)(?P<parity>[eoy])$")


def _parse_parity_token(token: str, l: int) -> Parity:
    if token == "e":
        return 1
    if token == "o":
        return -1
    if token == "y":
        return 1 if l % 2 == 0 else -1
    raise ValueError(f"invalid parity token: {token!r}")


[docs] @dataclass(frozen=True, slots=True, order=True, init=False) class Irrep: l: int p: Parity
[docs] def __init__(self, l: int | str | tuple[int, int] | Irrep, p: int | None = None) -> None: if isinstance(l, Irrep): if p is not None: raise ValueError("p must not be provided when copying an Irrep") parsed_l, parsed_p = l.l, l.p elif isinstance(l, str): if p is not None: raise ValueError("p must not be provided with a string irrep") match = _IRREP_RE.match(l.replace(" ", "")) if match is None or match.group("mul") is not None: raise ValueError(f"invalid irrep spec: {l!r}") parsed_l = int(match.group("l")) parsed_p = _parse_parity_token(match.group("parity"), parsed_l) elif isinstance(l, tuple): if p is not None or len(l) != 2: raise ValueError("tuple irrep must be (l, p)") parsed_l, parsed_p = int(l[0]), int(l[1]) else: if p is None: raise ValueError("parity p is required when l is an integer") parsed_l, parsed_p = int(l), int(p) object.__setattr__(self, "l", parsed_l) object.__setattr__(self, "p", parsed_p) self.__post_init__()
def __post_init__(self) -> None: if self.l < 0: raise ValueError("l must be >= 0") if self.p not in (-1, 1): raise ValueError("parity must be -1 or 1") @property def dim(self) -> int: return 2 * self.l + 1 @property def parity_token(self) -> str: return "e" if self.p == 1 else "o" @classmethod def parse(cls, spec: str | tuple[int, int] | Irrep) -> Irrep: if isinstance(spec, cls): return spec return cls(spec) def is_scalar(self) -> bool: return self.l == 0 and self.p == 1 def selection_rule(self, other: Irrep | str) -> tuple[Irrep, ...]: other = Irrep.parse(other) parity = self.p * other.p return tuple(Irrep(l_out, parity) for l_out in range(abs(self.l - other.l), self.l + other.l + 1)) def __mul__(self, other: Irrep | str | int) -> tuple[Irrep, ...] | Irreps: if isinstance(other, int): if other < 0: raise ValueError("multiplicity must be non-negative") return Irreps(((other, self),)) return self.selection_rule(other) def __rmul__(self, other: int) -> Irreps: result = self * other if not isinstance(result, Irreps): raise TypeError("Irrep can only be left-multiplied by an integer") return result def __add__(self, other: Irrep | str) -> Irreps: return Irreps(((1, self), (1, Irrep.parse(other)))) def __str__(self) -> str: return f"{self.l}{self.parity_token}" def __repr__(self) -> str: return str(self) def __iter__(self): return iter((self.l, self.p)) @staticmethod def iterator(lmax: int | None = None) -> Iterator[Irrep]: if lmax is not None and lmax < 0: raise ValueError("lmax must be non-negative") l = 0 while lmax is None or l <= lmax: yield Irrep(l, 1) yield Irrep(l, -1) l += 1
[docs] @dataclass(frozen=True, slots=True, order=True) class MulIrrep: mul: int ir: Irrep def __post_init__(self) -> None: if self.mul < 0: raise ValueError("mul must be >= 0") @property def dim(self) -> int: return self.mul * self.ir.dim @classmethod def parse(cls, spec: str | Irrep | MulIrrep | tuple[int, Irrep | str]) -> MulIrrep: if isinstance(spec, cls): return spec if isinstance(spec, Irrep): return cls(1, spec) if isinstance(spec, tuple): mul, ir = spec return cls(mul=int(mul), ir=Irrep.parse(ir)) token = spec.replace(" ", "") match = _IRREP_RE.match(token) if match is None: raise ValueError(f"invalid mul irrep spec: {spec!r}") mul = int(match.group("mul") or 1) l = int(match.group("l")) return cls(mul=mul, ir=Irrep(l, _parse_parity_token(match.group("parity"), l))) def __str__(self) -> str: if self.mul == 1: return str(self.ir) return f"{self.mul}x{self.ir}"
@dataclass(frozen=True, slots=True) class SortResult: irreps: Irreps p: tuple[int, ...] inv: tuple[int, ...] def __iter__(self): return iter((self.irreps, self.p, self.inv))
[docs] @dataclass(frozen=True, slots=True) class Irreps: parts: tuple[MulIrrep, ...]
[docs] def __init__(self, spec: str | Irrep | Irreps | Iterable[MulIrrep | Irrep | str | tuple[int, Irrep | str]] = ()) -> None: if isinstance(spec, Irreps): parts = spec.parts elif isinstance(spec, Irrep): parts = (MulIrrep(1, spec),) elif isinstance(spec, str): token = spec.strip() if not token: parts = () else: parts = tuple(MulIrrep.parse(piece.strip()) for piece in token.split("+")) else: parts = tuple(MulIrrep.parse(part) for part in spec) object.__setattr__(self, "parts", parts)
@property def dim(self) -> int: return sum(part.dim for part in self.parts) @property def num_irreps(self) -> int: return sum(part.mul for part in self.parts) @property def lmax(self) -> int: nonzero = [part.ir.l for part in self.parts if part.mul > 0] if not nonzero: return -1 return max(nonzero) @property def ls(self) -> tuple[int, ...]: return tuple(part.ir.l for part in self.parts for _ in range(part.mul)) def slices(self) -> tuple[slice, ...]: start = 0 out: list[slice] = [] for part in self.parts: stop = start + part.dim out.append(slice(start, stop)) start = stop return tuple(out) def simplify(self) -> Irreps: simplified: list[MulIrrep] = [] for part in self.parts: if part.mul == 0: continue if simplified and simplified[-1].ir == part.ir: previous = simplified[-1] simplified[-1] = MulIrrep(previous.mul + part.mul, part.ir) else: simplified.append(part) return Irreps(simplified) def remove_zero_multiplicities(self) -> Irreps: return Irreps(part for part in self.parts if part.mul > 0) def regroup(self) -> Irreps: return self.sort().irreps.simplify() def sort(self) -> SortResult: inv = tuple(sorted(range(len(self.parts)), key=lambda index: self.parts[index].ir)) p_list = [0] * len(inv) for new_index, old_index in enumerate(inv): p_list[old_index] = new_index return SortResult(Irreps(self.parts[index] for index in inv), tuple(p_list), inv) def filter( self, keep: Irreps | str | Iterable[Irrep | str] | Callable[[MulIrrep], bool] | None = None, *, drop: Irreps | str | Iterable[Irrep | str] | Callable[[MulIrrep], bool] | None = None, lmax: int | None = None, ) -> Irreps: if keep is not None and drop is not None: raise ValueError("keep and drop are mutually exclusive") def predicate(value, default: bool) -> Callable[[MulIrrep], bool]: if value is None: return lambda _part: default if callable(value): return value if isinstance(value, (str, Irreps)): allowed = {part.ir for part in Irreps(value)} else: allowed = {Irrep.parse(ir) for ir in value} return lambda part: part.ir in allowed keep_predicate = predicate(keep, True) drop_predicate = predicate(drop, False) return Irreps( part for part in self.parts if keep_predicate(part) and not drop_predicate(part) and (lmax is None or part.ir.l <= lmax) ) @staticmethod def spherical_harmonics(lmax: int, p: int = -1) -> Irreps: if lmax < 0: return Irreps() if p not in (-1, 1): raise ValueError("p must be -1 or 1") return Irreps((1, Irrep(l, p**l)) for l in range(lmax + 1)) def extend(self, other: str | Irreps | Iterable[MulIrrep | str | tuple[int, Irrep | str]]) -> Irreps: return Irreps((*self.parts, *Irreps(other).parts)) def count(self, irrep: str | Irrep) -> int: target = Irrep.parse(irrep) return sum(part.mul for part in self.parts if part.ir == target) def __iter__(self) -> Iterator[MulIrrep]: return iter(self.parts) def __len__(self) -> int: return len(self.parts) def __contains__(self, irrep: object) -> bool: try: target = Irrep.parse(irrep) # type: ignore[arg-type] except (TypeError, ValueError): return False return any(part.ir == target and part.mul > 0 for part in self.parts) def __getitem__(self, item: int | slice) -> MulIrrep | Irreps: if isinstance(item, slice): return Irreps(self.parts[item]) return self.parts[item] def __add__(self, other: str | Irreps | Iterable[MulIrrep | str | tuple[int, Irrep | str]]) -> Irreps: return self.extend(other) def __mul__(self, multiplicity: int) -> Irreps: if not isinstance(multiplicity, int): return NotImplemented if multiplicity < 0: raise ValueError("multiplicity must be non-negative") return Irreps(self.parts * multiplicity) def __rmul__(self, multiplicity: int) -> Irreps: return self * multiplicity @property def slice_by_mul(self) -> _MulIndexSlice: return _MulIndexSlice(self) def __str__(self) -> str: return "+".join(str(part) for part in self.parts) def __repr__(self) -> str: return f"Irreps('{self}')"
class _MulIndexSlice: """Multiplicity-indexed slicing helper matching upstream ``Irreps``.""" def __init__(self, irreps: Irreps) -> None: self.irreps = irreps def __getitem__(self, item: slice) -> Irreps: if not isinstance(item, slice): raise TypeError("slice_by_mul only supports slices") if item.step not in (None, 1): raise ValueError("slice_by_mul does not support a step") total = self.irreps.num_irreps start, stop, _ = item.indices(total) if stop <= start: return Irreps() parts: list[MulIrrep] = [] cursor = 0 for part in self.irreps: part_start, part_stop = cursor, cursor + part.mul overlap = max(0, min(stop, part_stop) - max(start, part_start)) if overlap: parts.append(MulIrrep(overlap, part.ir)) cursor = part_stop if cursor >= stop: break return Irreps(parts)