Download src/induced_exchange/vector_mapping.py from aibergman/minducer: direct link, hf CLI and curl.
- Browser
- Download file 22.3 kB
-
https://huggingface.co/spaces/aibergman/minducer/resolve/main/src/induced_exchange/vector_mapping.py
- Command line
-
hf download hf://spaces/aibergman/minducer/src/induced_exchange/vector_mapping.py
-
curl -L -o vector_mapping.py https://huggingface.co/spaces/aibergman/minducer/resolve/main/src/induced_exchange/vector_mapping.py
22.3 kB
| """UppASD ``jfile`` vector and target-cell mapping utilities. | |
| The functions in this module keep the input vector convention explicit. Cell | |
| vectors are rows, so a direct/fractional vector ``v`` is converted with | |
| ``v @ cell``. Maptype 1 treats the jfile vector as a bond vector; maptypes 2 | |
| and 3 treat it as lattice-vector coefficients and add the basis-site | |
| separation using folded or raw positions, respectively. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Iterable, Mapping, Sequence | |
| import numpy as np | |
| import numpy.typing as npt | |
| ArrayLike = npt.ArrayLike | |
| class VectorMappingError(ValueError): | |
| """Raised when an UppASD vector or target-cell row cannot be mapped.""" | |
| def __init__( | |
| self, | |
| message: str, | |
| *, | |
| code: str = "vector_mapping_error", | |
| source: str | Path | None = None, | |
| line: int | None = None, | |
| ) -> None: | |
| super().__init__(message) | |
| self.code = code | |
| self.source = None if source is None else str(source) | |
| self.line = line | |
| class PreparedPositions: | |
| """Converted and folded basis positions used by the vector mapper.""" | |
| cell: np.ndarray | |
| positions_raw: dict[int, np.ndarray] | |
| positions_folded: dict[int, np.ndarray] | |
| wrap_shifts: dict[int, np.ndarray] | |
| atom_types: dict[int, object] | |
| class TargetCellMatch: | |
| """Best basis-site match for a Cartesian target position.""" | |
| site_id: int | |
| cell_offset: tuple[int, int, int] | |
| residual: tuple[float, float, float] | |
| def site(self) -> int: | |
| """Alias for callers that refer to the matched basis site as ``site``.""" | |
| return self.site_id | |
| def inferred_target_cell_offset(self) -> tuple[int, int, int]: | |
| return self.cell_offset | |
| def match_residual(self) -> tuple[float, float, float]: | |
| return self.residual | |
| def __iter__(self): | |
| yield self.site_id | |
| yield self.cell_offset | |
| yield self.residual | |
| class JFileRow: | |
| """One parsed non-alloy or random-alloy jfile row.""" | |
| site_i: int | |
| site_j: int | |
| input_Rij: tuple[float, float, float] | |
| Jij: float | |
| supplied_distance: float | None = None | |
| chemical_i: object | None = None | |
| chemical_j: object | None = None | |
| source_line: int | None = None | |
| class MappedExchangeRecord: | |
| """One jfile row after vector conversion and target-cell inference.""" | |
| site_i: int | |
| site_j: int | |
| atom_type_i: object | |
| atom_type_j: object | |
| input_Rij: tuple[float, float, float] | |
| Jij: float | |
| rij_cart: tuple[float, float, float] | |
| distance: float | |
| inferred_target_cell_offset: tuple[int, int, int] | |
| match_residual: tuple[float, float, float] | |
| supplied_distance: float | None = None | |
| chemical_i: object | None = None | |
| chemical_j: object | None = None | |
| source_line: int | None = None | |
| def _normalise_posfiletype(posfiletype: str) -> str: | |
| value = str(posfiletype).upper() | |
| if value not in {"C", "D"}: | |
| raise VectorMappingError( | |
| "posfiletype must be either C or D", | |
| code="unsupported_position_format", | |
| ) | |
| return value | |
| def _as_cell(cell: ArrayLike) -> np.ndarray: | |
| try: | |
| result = np.asarray(cell, dtype=float) | |
| except (TypeError, ValueError) as exc: | |
| raise VectorMappingError("cell must be a finite 3x3 array", code="malformed_cell") from exc | |
| if result.shape != (3, 3) or not np.isfinite(result).all(): | |
| raise VectorMappingError("cell must be a finite 3x3 array", code="malformed_cell") | |
| if np.linalg.matrix_rank(result) < 3: | |
| raise VectorMappingError("cell must be non-singular", code="malformed_cell") | |
| return result.copy() | |
| def _as_vector(vector: ArrayLike, *, field: str) -> np.ndarray: | |
| try: | |
| result = np.asarray(vector, dtype=float) | |
| except (TypeError, ValueError) as exc: | |
| raise VectorMappingError(f"{field} must be a finite 3-vector", code="malformed_vector") from exc | |
| if result.shape != (3,) or not np.isfinite(result).all(): | |
| raise VectorMappingError(f"{field} must be a finite 3-vector", code="malformed_vector") | |
| return result | |
| def _identifier(value: object) -> object: | |
| try: | |
| return int(str(value)) | |
| except (TypeError, ValueError): | |
| return value | |
| def _position_items(positions: Mapping[int, object] | Iterable[Sequence[object]]): | |
| if isinstance(positions, Mapping): | |
| for site, record in positions.items(): | |
| atom_type = getattr(record, "atom_type", None) | |
| coordinates = getattr(record, "position", record) | |
| if isinstance(record, Mapping): | |
| atom_type = record.get("atom_type", atom_type) | |
| coordinates = record.get("position", coordinates) | |
| try: | |
| site_id = int(site) | |
| except (TypeError, ValueError) as exc: | |
| raise VectorMappingError(f"invalid site ID {site!r}", code="missing_site_id") from exc | |
| yield site_id, atom_type, coordinates | |
| return | |
| for row in positions: | |
| values = list(row) | |
| if len(values) != 5: | |
| raise VectorMappingError( | |
| "position rows must contain site_id atom_type x y z", | |
| code="malformed_position_row", | |
| ) | |
| try: | |
| site_id = int(values[0]) | |
| except (TypeError, ValueError) as exc: | |
| raise VectorMappingError(f"invalid site ID {values[0]!r}", code="missing_site_id") from exc | |
| yield site_id, values[1], values[2:5] | |
| def prepare_positions( | |
| positions: Mapping[int, object] | Iterable[Sequence[object]], | |
| cell: ArrayLike, | |
| *, | |
| posfiletype: str = "C", | |
| ) -> PreparedPositions: | |
| """Convert and UppASD-fold basis positions into the first cell. | |
| ``positions`` may be a mapping of site IDs to position records/3-vectors | |
| or an iterable of ``site_id atom_type x y z`` rows. ``positions_raw`` is | |
| retained after optional direct-coordinate conversion; ``positions_folded`` | |
| applies ``floor(fractional + 1e-5)`` to each basis position. | |
| """ | |
| direct_cell = _as_cell(cell) | |
| normalized_type = _normalise_posfiletype(posfiletype) | |
| inverse_cell = np.linalg.inv(direct_cell) | |
| raw: dict[int, np.ndarray] = {} | |
| atom_types: dict[int, object] = {} | |
| for site, atom_type, coordinates in _position_items(positions): | |
| if site in raw: | |
| raise VectorMappingError(f"duplicate site ID {site}", code="duplicate_site_id") | |
| position = _as_vector(coordinates, field="position").copy() | |
| if normalized_type == "D": | |
| position = position @ direct_cell | |
| raw[site] = position | |
| atom_types[site] = atom_type | |
| if not raw: | |
| raise VectorMappingError("positions must contain at least one site", code="missing_positions") | |
| folded: dict[int, np.ndarray] = {} | |
| shifts: dict[int, np.ndarray] = {} | |
| for site, position in raw.items(): | |
| fractional = position @ inverse_cell | |
| shift = np.floor(fractional + 1e-5).astype(int) | |
| shifts[site] = shift | |
| folded[site] = position - shift @ direct_cell | |
| return PreparedPositions(direct_cell, raw, folded, shifts, atom_types) | |
| def _prepared_positions( | |
| positions: PreparedPositions | Mapping[int, object] | Iterable[Sequence[object]], | |
| cell: ArrayLike | None, | |
| *, | |
| posfiletype: str, | |
| ) -> PreparedPositions: | |
| _normalise_posfiletype(posfiletype) | |
| if isinstance(positions, PreparedPositions): | |
| if cell is not None and not np.allclose(positions.cell, _as_cell(cell)): | |
| raise VectorMappingError("prepared positions and cell do not match", code="malformed_cell") | |
| return positions | |
| if cell is None: | |
| raise VectorMappingError("cell is required when positions are not prepared", code="malformed_cell") | |
| return prepare_positions(positions, cell, posfiletype=posfiletype) | |
| def map_exchange_vector( | |
| site_i: int, | |
| site_j: int, | |
| input_Rij: ArrayLike, | |
| *, | |
| cell: ArrayLike | None = None, | |
| positions: PreparedPositions | Mapping[int, object] | Iterable[Sequence[object]], | |
| maptype: int = 1, | |
| posfiletype: str = "C", | |
| ) -> np.ndarray: | |
| """Map one jfile vector to Cartesian coordinates. | |
| Maptype 1 uses the vector directly for Cartesian positions, or converts | |
| it with ``R @ cell`` for direct positions. Maptypes 2 and 3 always treat | |
| ``R`` as lattice-vector coefficients and add the folded/raw basis-site | |
| separation, respectively. | |
| """ | |
| if maptype not in {1, 2, 3}: | |
| raise VectorMappingError("maptype must be 1, 2, or 3", code="invalid_maptype") | |
| normalized_type = _normalise_posfiletype(posfiletype) | |
| prepared = _prepared_positions(positions, cell, posfiletype=posfiletype) | |
| if site_i not in prepared.positions_raw or site_j not in prepared.positions_raw: | |
| missing = site_i if site_i not in prepared.positions_raw else site_j | |
| raise VectorMappingError(f"site ID {missing} is missing from positions", code="missing_site_id") | |
| vector = _as_vector(input_Rij, field="input Rij") | |
| if maptype == 1: | |
| if normalized_type == "C": | |
| return vector.copy() | |
| return vector @ prepared.cell | |
| translation = vector @ prepared.cell | |
| if maptype == 2: | |
| return prepared.positions_folded[site_j] - prepared.positions_folded[site_i] + translation | |
| return prepared.positions_raw[site_j] - prepared.positions_raw[site_i] + translation | |
| def infer_target_site( | |
| target: ArrayLike, | |
| positions: PreparedPositions | Mapping[int, object] | Iterable[Sequence[object]], | |
| *, | |
| cell: ArrayLike | None = None, | |
| expected_site: int | None = None, | |
| posfiletype: str = "C", | |
| tolerance: float = 1e-5, | |
| ) -> TargetCellMatch: | |
| """Infer the basis site and integer cell offset for a Cartesian target. | |
| The residual is returned in fractional coordinates. ``tolerance`` is a | |
| squared residual threshold, matching the scale used by UppASD's vector | |
| comparisons. | |
| """ | |
| prepared = _prepared_positions(positions, cell, posfiletype=posfiletype) | |
| target_vector = _as_vector(target, field="target position") | |
| inverse_cell = np.linalg.inv(prepared.cell) | |
| candidates: list[tuple[float, int, np.ndarray, np.ndarray]] = [] | |
| for site, basis_position in prepared.positions_folded.items(): | |
| relative = (target_vector - basis_position) @ inverse_cell | |
| offset = np.rint(relative).astype(int) | |
| residual = relative - offset | |
| squared_residual = float(residual @ residual) | |
| candidates.append((squared_residual, site, offset, residual)) | |
| squared_residual, site, offset, residual = min(candidates, key=lambda item: item[0]) | |
| if squared_residual >= tolerance: | |
| raise VectorMappingError( | |
| f"target position does not match a basis site within tolerance; residual={residual.tolist()}", | |
| code="unmatched_target_position", | |
| ) | |
| if expected_site is not None and site != expected_site: | |
| raise VectorMappingError( | |
| f"target position matches site {site}, not requested site {expected_site}", | |
| code="unmatched_target_position", | |
| ) | |
| return TargetCellMatch(site, tuple(int(value) for value in offset), tuple(float(value) for value in residual)) | |
| def _normalise_ncell(ncell: Sequence[int] | None) -> tuple[int, int, int] | None: | |
| if ncell is None: | |
| return None | |
| try: | |
| values = tuple(int(value) for value in ncell) | |
| except (TypeError, ValueError) as exc: | |
| raise VectorMappingError("ncell must contain three positive integers", code="malformed_ncell") from exc | |
| if len(values) != 3 or any(value <= 0 for value in values): | |
| raise VectorMappingError("ncell must contain three positive integers", code="malformed_ncell") | |
| return values | |
| def _normalise_bc(bc: Sequence[str] | None) -> tuple[str, str, str] | None: | |
| if bc is None: | |
| return None | |
| values = tuple(str(value).upper() for value in bc) | |
| aliases = {"P": "P", "PERIODIC": "P", "F": "F", "FREE": "F", "O": "F", "OPEN": "F"} | |
| if len(values) != 3 or any(value not in aliases for value in values): | |
| raise VectorMappingError("boundary conditions must be P or F in each direction", code="malformed_bc") | |
| return tuple(aliases[value] for value in values) # type: ignore[return-value] | |
| def _periodic_offset( | |
| offset: tuple[int, int, int], | |
| *, | |
| ncell: tuple[int, int, int] | None, | |
| bc: tuple[str, str, str] | None, | |
| ) -> tuple[int, int, int]: | |
| if ncell is None: | |
| return offset | |
| conditions = bc or ("P", "P", "P") | |
| result = list(offset) | |
| for axis, (value, size, condition) in enumerate(zip(offset, ncell, conditions)): | |
| if condition == "P": | |
| result[axis] = value % size | |
| elif not 0 <= value < size: | |
| raise VectorMappingError( | |
| f"target cell offset {offset} is outside the non-periodic supercell", | |
| code="target_out_of_bounds", | |
| ) | |
| return tuple(result) | |
| def _parse_float(token: str, *, source: Path, line: int, field: str) -> float: | |
| try: | |
| value = float(token) | |
| except ValueError as exc: | |
| raise VectorMappingError( | |
| f"{source}:{line}: invalid {field} value {token!r}", | |
| code="malformed_jfile_row", | |
| source=source, | |
| line=line, | |
| ) from exc | |
| if not np.isfinite(value): | |
| raise VectorMappingError( | |
| f"{source}:{line}: non-finite {field} value {token!r}", | |
| code="malformed_jfile_row", | |
| source=source, | |
| line=line, | |
| ) | |
| return value | |
| def _is_finite_float(token: str) -> bool: | |
| try: | |
| value = float(token) | |
| except ValueError: | |
| return False | |
| return bool(np.isfinite(value)) | |
| def _parse_int(token: str, *, source: Path, line: int, field: str) -> int: | |
| try: | |
| return int(token) | |
| except ValueError as exc: | |
| raise VectorMappingError( | |
| f"{source}:{line}: invalid {field} value {token!r}", | |
| code="malformed_jfile_row", | |
| source=source, | |
| line=line, | |
| ) from exc | |
| def read_jfile( | |
| path: str | Path, | |
| *, | |
| strict: bool = True, | |
| errors: list[VectorMappingError] | None = None, | |
| ) -> list[JFileRow]: | |
| """Read non-alloy and random-alloy UppASD jfile rows. | |
| Supported layouts are ``i j r1 r2 r3 Jij [distance]`` and | |
| ``i j chemical_i chemical_j r1 r2 r3 Jij [distance]``. Chemical fields | |
| are retained as metadata and do not replace the basis atom types. | |
| """ | |
| source = Path(path).expanduser().resolve() | |
| if not source.is_file(): | |
| raise FileNotFoundError(source) | |
| rows: list[JFileRow] = [] | |
| for line_number, raw in enumerate(source.read_text(encoding="utf-8").splitlines(), start=1): | |
| line = raw.split("#", 1)[0].split("!", 1)[0].strip() | |
| if not line: | |
| continue | |
| tokens = line.split() | |
| try: | |
| if len(tokens) < 6: | |
| raise VectorMappingError( | |
| f"{source}:{line_number}: expected at least 6 columns", | |
| code="malformed_jfile_row", | |
| source=source, | |
| line=line_number, | |
| ) | |
| # A normal jfile row is six required fields, optionally followed | |
| # by a distance and/or arbitrary trailing data. Random-alloy | |
| # rows are recognized by their two non-numeric chemical fields; | |
| # this avoids mistaking trailing text on a normal row for alloy | |
| # metadata. | |
| random_alloy = len(tokens) >= 8 and ( | |
| not _is_finite_float(tokens[2]) or not _is_finite_float(tokens[3]) | |
| ) | |
| if random_alloy: | |
| site_i = _parse_int(tokens[0], source=source, line=line_number, field="site_i") | |
| site_j = _parse_int(tokens[1], source=source, line=line_number, field="site_j") | |
| chemical_i = _identifier(tokens[2]) | |
| chemical_j = _identifier(tokens[3]) | |
| vector_start = 4 | |
| else: | |
| site_i = _parse_int(tokens[0], source=source, line=line_number, field="site_i") | |
| site_j = _parse_int(tokens[1], source=source, line=line_number, field="site_j") | |
| chemical_i = None | |
| chemical_j = None | |
| vector_start = 2 | |
| if len(tokens) < vector_start + 4: | |
| raise VectorMappingError( | |
| f"{source}:{line_number}: incomplete exchange row", | |
| code="malformed_jfile_row", | |
| source=source, | |
| line=line_number, | |
| ) | |
| input_vector = tuple( | |
| _parse_float(tokens[index], source=source, line=line_number, field="Rij") | |
| for index in range(vector_start, vector_start + 3) | |
| ) | |
| Jij = _parse_float(tokens[vector_start + 3], source=source, line=line_number, field="Jij") | |
| supplied_distance = ( | |
| _parse_float(tokens[vector_start + 4], source=source, line=line_number, field="distance") | |
| if len(tokens) > vector_start + 4 and _is_finite_float(tokens[vector_start + 4]) | |
| else None | |
| ) | |
| rows.append(JFileRow(site_i, site_j, input_vector, Jij, supplied_distance, chemical_i, chemical_j, line_number)) | |
| except VectorMappingError as exc: | |
| if strict: | |
| raise | |
| if errors is not None: | |
| errors.append(exc) | |
| return rows | |
| def _replace_duplicate_vectors( | |
| records: list[MappedExchangeRecord], | |
| *, | |
| threshold: float = 1e-5, | |
| ) -> list[MappedExchangeRecord]: | |
| grouped: dict[object, list[MappedExchangeRecord]] = {} | |
| result: list[MappedExchangeRecord] = [] | |
| for record in records: | |
| group = grouped.setdefault(record.atom_type_i, []) | |
| duplicate_index = next( | |
| ( | |
| index | |
| for index, previous in enumerate(group) | |
| if float(np.sum((np.asarray(record.rij_cart) - np.asarray(previous.rij_cart)) ** 2)) < threshold | |
| ), | |
| None, | |
| ) | |
| if duplicate_index is None: | |
| group.append(record) | |
| result.append(record) | |
| else: | |
| previous = group[duplicate_index] | |
| result[result.index(previous)] = record | |
| group[duplicate_index] = record | |
| return result | |
| def map_exchange_file( | |
| path: str | Path, | |
| *, | |
| cell: ArrayLike | None = None, | |
| positions: PreparedPositions | Mapping[int, object] | Iterable[Sequence[object]], | |
| maptype: int = 1, | |
| posfiletype: str = "C", | |
| ncell: Sequence[int] | None = None, | |
| bc: Sequence[str] | None = None, | |
| boundary_conditions: Sequence[str] | None = None, | |
| strict: bool = True, | |
| deduplicate: bool = True, | |
| duplicate_threshold: float = 1e-5, | |
| target_tolerance: float = 1e-5, | |
| errors: list[VectorMappingError] | None = None, | |
| ) -> list[MappedExchangeRecord]: | |
| """Read and map a complete jfile into structured Cartesian records. | |
| Periodic target offsets are reduced modulo ``ncell``. Free-boundary | |
| offsets must lie in ``[0, ncell)``. Duplicate Cartesian vectors for the | |
| same central atom type are replaced by the later row by default, matching | |
| UppASD's last-value-wins behavior. | |
| """ | |
| if boundary_conditions is not None: | |
| if bc is not None and tuple(bc) != tuple(boundary_conditions): | |
| raise VectorMappingError("bc and boundary_conditions disagree", code="malformed_bc") | |
| bc = boundary_conditions | |
| normalized_ncell = _normalise_ncell(ncell) | |
| normalized_bc = _normalise_bc(bc) | |
| prepared = _prepared_positions(positions, cell, posfiletype=posfiletype) | |
| rows = read_jfile(path, strict=strict, errors=errors) | |
| mapped: list[MappedExchangeRecord] = [] | |
| for row in rows: | |
| try: | |
| rij_cart = map_exchange_vector( | |
| row.site_i, | |
| row.site_j, | |
| row.input_Rij, | |
| positions=prepared, | |
| maptype=maptype, | |
| posfiletype=posfiletype, | |
| ) | |
| target = prepared.positions_folded[row.site_i] + rij_cart | |
| match = infer_target_site( | |
| target, | |
| prepared, | |
| expected_site=row.site_j, | |
| tolerance=target_tolerance, | |
| ) | |
| offset = _periodic_offset(match.cell_offset, ncell=normalized_ncell, bc=normalized_bc) | |
| mapped.append( | |
| MappedExchangeRecord( | |
| site_i=row.site_i, | |
| site_j=row.site_j, | |
| atom_type_i=prepared.atom_types[row.site_i], | |
| atom_type_j=prepared.atom_types[row.site_j], | |
| input_Rij=row.input_Rij, | |
| Jij=row.Jij, | |
| rij_cart=tuple(float(value) for value in rij_cart), | |
| distance=float(np.linalg.norm(rij_cart)), | |
| inferred_target_cell_offset=offset, | |
| match_residual=match.residual, | |
| supplied_distance=row.supplied_distance, | |
| chemical_i=row.chemical_i, | |
| chemical_j=row.chemical_j, | |
| source_line=row.source_line, | |
| ) | |
| ) | |
| except (KeyError, VectorMappingError) as exc: | |
| if isinstance(exc, KeyError): | |
| error = VectorMappingError( | |
| f"site ID {exc.args[0]} is missing from positions", | |
| code="missing_site_id", | |
| line=row.source_line, | |
| ) | |
| else: | |
| error = exc | |
| if strict: | |
| raise error | |
| if errors is not None: | |
| errors.append(error) | |
| return _replace_duplicate_vectors(mapped, threshold=duplicate_threshold) if deduplicate else mapped | |
| __all__ = [ | |
| "JFileRow", | |
| "MappedExchangeRecord", | |
| "PreparedPositions", | |
| "TargetCellMatch", | |
| "VectorMappingError", | |
| "infer_target_site", | |
| "map_exchange_file", | |
| "map_exchange_vector", | |
| "prepare_positions", | |
| "read_jfile", | |
| ] | |