"""Validated, physics-neutral data model for atomistic exchange input. This module deliberately stops at the input/model boundary. It does not infer chemical elements, exchange conventions, or induced-moment susceptibilities. """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any, Hashable, Iterable import numpy as np @dataclass(frozen=True) class UnitMetadata: """Units attached to input values; no unit conversion is done here.""" energy: str = "unspecified" length: str = "unspecified" moment: str = "mu_B" @dataclass(frozen=True) class MagneticSite: """One basis site with a Cartesian position and reference moment.""" index: int atom_type: Hashable position: tuple[float, float, float] moment: float | None spin_direction: tuple[float, float, float] | None = None @dataclass(frozen=True) class ExchangeBond: """An isotropic scalar exchange entry from the Jij file. ``displacement`` is authoritative. In particular, it is not rebuilt from the basis positions or the cell matrix. """ i: int j: int displacement: tuple[float, float, float] jij: float supplied_distance: float | None = None source_line: int | None = None @property def distance(self) -> float: return float(np.linalg.norm(self.displacement)) @dataclass class ValidationIssue: level: str code: str message: str source: str | None = None line: int | None = None def as_dict(self) -> dict[str, Any]: return { "level": self.level, "code": self.code, "message": self.message, "source": self.source, "line": self.line, } @dataclass class ValidationReport: """Structured diagnostics emitted while loading and validating a model.""" issues: list[ValidationIssue] = field(default_factory=list) reciprocal_missing: list[ExchangeBond] = field(default_factory=list) reciprocal_mismatched: list[tuple[ExchangeBond, ExchangeBond]] = field(default_factory=list) duplicate_bonds: list[ExchangeBond] = field(default_factory=list) pair_complete: bool | None = None real_space_hermitian: bool | None = None def add_warning(self, code: str, message: str, *, source: str | None = None, line: int | None = None) -> None: self.issues.append(ValidationIssue("warning", code, message, source, line)) def add_error(self, code: str, message: str, *, source: str | None = None, line: int | None = None) -> None: self.issues.append(ValidationIssue("error", code, message, source, line)) @property def warnings(self) -> list[ValidationIssue]: return [issue for issue in self.issues if issue.level == "warning"] @property def errors(self) -> list[ValidationIssue]: return [issue for issue in self.issues if issue.level == "error"] @property def ok(self) -> bool: return not self.errors def as_dict(self) -> dict[str, Any]: return { "ok": self.ok, "issues": [issue.as_dict() for issue in self.issues], "reciprocal_missing": len(self.reciprocal_missing), "reciprocal_mismatched": len(self.reciprocal_mismatched), "duplicate_bonds": len(self.duplicate_bonds), "pair_complete": self.pair_complete, "real_space_hermitian": self.real_space_hermitian, } to_dict = as_dict @dataclass class MagneticCrystal: """The complete validated input-level magnetic crystal.""" cell: np.ndarray sites: list[MagneticSite] exchange_bonds: list[ExchangeBond] units: UnitMetadata = field(default_factory=UnitMetadata) source_files: dict[str, Path] = field(default_factory=dict) def __post_init__(self) -> None: cell = np.asarray(self.cell, dtype=float) if cell.shape != (3, 3): raise ValueError(f"cell must have shape (3, 3), got {cell.shape}") self.cell = cell @property def site_indices(self) -> set[int]: return {site.index for site in self.sites} @property def site_by_index(self) -> dict[int, MagneticSite]: return {site.index: site for site in self.sites} @property def bonds(self) -> list[ExchangeBond]: """Short alias for callers that use the generic term ``bonds``.""" return self.exchange_bonds @property def cell_volume(self) -> float: return float(abs(np.linalg.det(self.cell))) @property def bond_distances(self) -> np.ndarray: return np.asarray([bond.distance for bond in self.exchange_bonds], dtype=float) def exchange_energy(self, spin_directions: Any) -> float: """Return the native UppASD ordered-pair exchange energy.""" from .conventions import uppasd_exchange_energy return uppasd_exchange_energy(self, spin_directions) def hamiltonian_energy(self, spin_directions: Any) -> float: """Alias for :meth:`exchange_energy`.""" return self.exchange_energy(spin_directions) def local_exchange_field(self, spin_directions: Any, *, moment_magnitudes: Any = None) -> np.ndarray: """Return the ordered-pair exchange field coefficient.""" from .conventions import local_exchange_field return local_exchange_field(self, spin_directions, moment_magnitudes=moment_magnitudes) def _cell_is_degenerate(cell: np.ndarray) -> bool: """Return whether a cell is singular relative to its own length scale.""" scale = float(np.max(np.linalg.norm(cell, axis=1), initial=0.0)) if scale <= 0.0: return True return abs(float(np.linalg.det(cell))) <= 1e-14 * scale**3 def _bond_key( bond: ExchangeBond, *, decimals: int = 10, length_scale: float = 1.0, ) -> tuple[int, int, tuple[float, float, float]]: normalized = np.asarray(bond.displacement, dtype=float) / length_scale return (bond.i, bond.j, tuple(round(float(value), decimals) for value in normalized)) def validate_model(model: MagneticCrystal, report: ValidationReport | None = None) -> ValidationReport: """Validate model invariants and reciprocal real-space exchange entries.""" report = report or ValidationReport() if not np.isfinite(model.cell).all(): report.add_error("nonfinite_cell", "cell contains a non-finite value") if _cell_is_degenerate(model.cell): report.add_error("zero_cell_volume", "cell has zero (or numerically zero) volume") cell_scale = float(np.max(np.linalg.norm(model.cell, axis=1), initial=0.0)) if not np.isfinite(cell_scale) or cell_scale <= 0.0: cell_scale = 1.0 indices = model.site_indices if len(indices) != len(model.sites): report.add_error("duplicate_site_index", "basis contains duplicate site indices") for site in model.sites: if not np.isfinite(site.position).all(): report.add_error("nonfinite_position", f"site {site.index} has a non-finite position") if site.moment is None: report.add_error("missing_moment", f"site {site.index} has no moment data") elif not np.isfinite(site.moment): report.add_error("nonfinite_moment", f"site {site.index} has a non-finite moment") if site.spin_direction is not None: direction = np.asarray(site.spin_direction) if not np.isfinite(direction).all(): report.add_error("nonfinite_spin", f"site {site.index} has a non-finite spin direction") elif np.linalg.norm(direction) <= 1e-14: report.add_warning("zero_spin", f"site {site.index} has a zero initial spin direction") seen: dict[tuple[int, int, tuple[float, float, float]], ExchangeBond] = {} for bond in model.exchange_bonds: if bond.i not in indices or bond.j not in indices: report.add_error("invalid_site_index", f"exchange bond ({bond.i}, {bond.j}) references an unknown site") if not np.isfinite(bond.displacement).all() or not np.isfinite(bond.jij): report.add_error("nonfinite_exchange", f"exchange bond on line {bond.source_line or '?'} is non-finite") if bond.i == bond.j and bond.distance <= 1e-14 * cell_scale: report.add_warning("self_interaction", f"zero-displacement self interaction for site {bond.i}", line=bond.source_line) key = _bond_key(bond, length_scale=cell_scale) if key in seen: report.duplicate_bonds.append(bond) report.add_warning("duplicate_exchange", f"duplicate exchange entry for ({bond.i}, {bond.j}, {bond.displacement})", line=bond.source_line) else: seen[key] = bond missing: list[ExchangeBond] = [] mismatched: list[tuple[ExchangeBond, ExchangeBond]] = [] for bond in model.exchange_bonds: reciprocal_key = ( bond.j, bond.i, tuple(round(float(-value / cell_scale), 10) for value in bond.displacement), ) reciprocal = seen.get(reciprocal_key) if reciprocal is None: missing.append(bond) elif not np.isclose(bond.jij, reciprocal.jij, rtol=1e-8, atol=1e-12): mismatched.append((bond, reciprocal)) report.reciprocal_missing = missing report.reciprocal_mismatched = mismatched report.pair_complete = not missing report.real_space_hermitian = not missing and not mismatched if missing: report.add_warning("missing_reciprocal", f"{len(missing)} exchange bond(s) have no likely +/-R partner") if mismatched: report.add_warning("asymmetric_reciprocal", f"{len(mismatched)} reciprocal bond pair(s) have different Jij values") return report