Download src/induced_exchange/model.py from aibergman/minducer: direct link, hf CLI and curl.
- Browser
- Download file 9.76 kB
-
https://huggingface.co/spaces/aibergman/minducer/resolve/main/src/induced_exchange/model.py
- Command line
-
hf download hf://spaces/aibergman/minducer/src/induced_exchange/model.py
-
curl -L -o model.py https://huggingface.co/spaces/aibergman/minducer/resolve/main/src/induced_exchange/model.py
9.76 kB
| """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 | |
| class UnitMetadata: | |
| """Units attached to input values; no unit conversion is done here.""" | |
| energy: str = "unspecified" | |
| length: str = "unspecified" | |
| moment: str = "mu_B" | |
| 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 | |
| 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 | |
| def distance(self) -> float: | |
| return float(np.linalg.norm(self.displacement)) | |
| 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, | |
| } | |
| 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)) | |
| def warnings(self) -> list[ValidationIssue]: | |
| return [issue for issue in self.issues if issue.level == "warning"] | |
| def errors(self) -> list[ValidationIssue]: | |
| return [issue for issue in self.issues if issue.level == "error"] | |
| 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 | |
| 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 | |
| def site_indices(self) -> set[int]: | |
| return {site.index for site in self.sites} | |
| def site_by_index(self) -> dict[int, MagneticSite]: | |
| return {site.index: site for site in self.sites} | |
| def bonds(self) -> list[ExchangeBond]: | |
| """Short alias for callers that use the generic term ``bonds``.""" | |
| return self.exchange_bonds | |
| def cell_volume(self) -> float: | |
| return float(abs(np.linalg.det(self.cell))) | |
| 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 | |