| """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 |
|
|
|
|
| @dataclass(frozen=True) |
| 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] |
|
|
|
|
| @dataclass(frozen=True) |
| 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] |
|
|
| @property |
| def site(self) -> int: |
| """Alias for callers that refer to the matched basis site as ``site``.""" |
|
|
| return self.site_id |
|
|
| @property |
| def inferred_target_cell_offset(self) -> tuple[int, int, int]: |
| return self.cell_offset |
|
|
| @property |
| 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 |
|
|
|
|
| @dataclass(frozen=True) |
| 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 |
|
|
|
|
| @dataclass(frozen=True) |
| 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) |
|
|
|
|
| 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, |
| ) |
| |
| |
| |
| |
| |
| 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", |
| ] |
|
|