Skip to content

Commit a60e044

Browse files
committed
Attach stated precision when building structures from CIF and POSCAR
1 parent e3e0903 commit a60e044

6 files changed

Lines changed: 188 additions & 9 deletions

File tree

‎src/httk/atomistic/asu_recognition.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,10 @@ def _group_into_orbits(
243243
consumed[other] = True
244244
asu_sites.append(ASUSite(position.letter, parameters, species))
245245

246-
return ASUStructure(cell, standard, asu_sites, view.species, transform)
246+
# The recognized ASU inherits the precision of the structure it came from: nothing
247+
# about recognition sharpens the data, and dropping it here would mean the value had
248+
# to be guessed again by everything downstream.
249+
return ASUStructure(cell, standard, asu_sites, view.species, transform, view.sites.precision)
247250

248251

249252
def _lies_in_orbit(point: FracVector, orbit: Sequence[FracVector], cell: Any, tolerance: float) -> bool:

‎src/httk/atomistic/asu_structure.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from httk.core import FracVector
2929

3030
from . import data
31+
from ._vector_guards import to_precision
3132
from .cell import Cell
3233
from .cell_class_view import CellClassView
3334
from .cell_like import CellLike
@@ -86,6 +87,7 @@ class ASUStructure:
8687
_transform: SettingTransform
8788
_asu_sites: tuple[ASUSite, ...]
8889
_species: tuple[Species, ...]
90+
_coordinate_precision: fractions.Fraction | None
8991

9092
def __init__(
9193
self,
@@ -94,6 +96,7 @@ def __init__(
9496
asu_sites: Sequence[ASUSite],
9597
species: Sequence[SpeciesLike],
9698
transform: SettingTransform | None = None,
99+
coordinate_precision: Any = None,
97100
) -> None:
98101
self._cell = cell if isinstance(cell, Cell) else CellClassView(cell)
99102
self._spacegroup = spacegroup if isinstance(spacegroup, Spacegroup) else Spacegroup.standard(spacegroup)
@@ -104,6 +107,7 @@ def __init__(
104107
f"express the difference as the transform instead"
105108
)
106109
self._transform = SettingTransform.identity() if transform is None else transform
110+
self._coordinate_precision = to_precision(coordinate_precision)
107111
self._asu_sites = tuple(asu_sites)
108112
self._species = tuple(item if isinstance(item, Species) else SpeciesClassView(item) for item in species)
109113

@@ -149,6 +153,20 @@ def species(self) -> tuple[Species, ...]:
149153
"""The species referenced by the sites."""
150154
return self._species
151155

156+
@property
157+
def coordinate_precision(self) -> fractions.Fraction | None:
158+
"""How precisely the coordinates behind this structure were stated, or ``None``.
159+
160+
Fractional, and expressed in **this structure's own setting** — the frame the data
161+
arrived in — so it needs no transforming on the way to the expanded sites. Recording
162+
it here is what lets an asymmetric unit say how good the data behind it was, rather
163+
than leaving that to be guessed again downstream.
164+
165+
It is provenance, never an operating parameter: expansion remains exact and uses no
166+
tolerance at all.
167+
"""
168+
return self._coordinate_precision
169+
152170
@property
153171
def asu(self) -> "ASUStructure":
154172
"""Self, so a view can recognize a backend that already holds an ASU and pass it through."""
@@ -239,7 +257,7 @@ def expand_sites(self) -> Sites:
239257
The opposite case, a transform onto a larger cell, is covered by
240258
:meth:`~httk.atomistic.SettingTransform.lattice_cosets`.
241259
"""
242-
return Sites(self._expansion[0])
260+
return Sites(self._expansion[0], self._coordinate_precision)
243261

244262
def expand_species_at_sites(self) -> tuple[str, ...]:
245263
"""The species name occupying each site produced by :meth:`expand_sites`, in order."""

‎src/httk/atomistic/asu_structure_view.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __new__(
6565
asu.asu_sites,
6666
asu.species,
6767
asu.transform,
68+
asu.coordinate_precision,
6869
)
6970
instance._backend = backend
7071
return instance

‎src/httk/atomistic/cif_structures.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,14 @@ def asu_structure_from_cif(
121121
)
122122
asu_sites.append(ASUSite(letter, parameters, name))
123123

124-
return ASUStructure(cell, standard, asu_sites, list(species_by_name.values()), transform)
124+
return ASUStructure(
125+
cell,
126+
standard,
127+
asu_sites,
128+
list(species_by_name.values()),
129+
transform,
130+
data.get("coordinate_precision"),
131+
)
125132

126133

127134
def cif_setting(data: Mapping[str, Any]) -> Spacegroup:
@@ -182,10 +189,16 @@ def _candidate_settings(data: Mapping[str, Any]) -> list[dict[str, Any]]:
182189

183190
def _cell_from_cif(data: Mapping[str, Any]) -> Cell:
184191
"""The cell, built exactly from the lattice parameters where the file gives them."""
192+
precision = data.get("basis_precision")
193+
exact = data.get("cell_parameters_exact")
194+
if exact is not None and all(value is not None for value in exact):
195+
# The text the file wrote, so 5.6402 becomes 56402/10000 rather than the binary
196+
# value of float("5.6402").
197+
return Cell(CellParams([fractions.Fraction(value) for value in exact]).basis, 1, precision)
185198
parameters = data.get("cell_parameters")
186199
if parameters is not None:
187-
return Cell(CellParams([fractions.Fraction(str(value)) for value in parameters]).basis)
188-
return Cell(data["basis"])
200+
return Cell(CellParams([fractions.Fraction(str(value)) for value in parameters]).basis, 1, precision)
201+
return Cell(data["basis"], 1, precision)
189202

190203

191204
def _exact_positions(data: Mapping[str, Any]) -> list[FracVector]:

‎src/httk/atomistic/vasp_structures.py‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
"""
1313

1414
import fractions
15+
import math
1516
from collections.abc import Mapping
1617
from typing import Any, Callable
1718

1819
from httk.core import SurdScalar, SurdVector, load
1920
from httk.core.vectors import exactmath
2021

22+
from ._vector_guards import to_surdscalar
2123
from .cell import Cell
24+
from .sites import Sites
2225
from .species import Species
2326
from .structure import Structure
2427

@@ -72,21 +75,24 @@ def structure_from_poscar(data: Mapping[str, Any]) -> Structure:
7275
scale_str = data.get("scale")
7376
volume_str = data.get("volume")
7477
if scale_str is not None:
75-
cell = Cell(cell_rows, scale_str)
78+
scale: Any = scale_str
7679
elif volume_str is not None:
7780
abs_det = abs(_to_fraction(raw_basis.det()))
7881
if abs_det == 0:
7982
raise ValueError("Cannot volume-scale a degenerate cell (zero determinant).")
8083
target_volume = _to_fraction(SurdVector.create(volume_str)._as_scalar())
81-
cell = Cell(cell_rows, _cube_root(target_volume / abs_det))
84+
scale = _cube_root(target_volume / abs_det)
8285
else:
83-
cell = Cell(cell_rows)
86+
scale = 1
87+
88+
cell = Cell(cell_rows, scale, _basis_precision(data, raw_basis, scale))
8489

8590
if data["cartesian"]:
8691
# reduced = cart * basis^-1 (row-vector convention); the universal scale cancels.
8792
reduced: Any = SurdVector.create(data["coords"]) * raw_basis.inv()
8893
else:
8994
reduced = data["coords"]
95+
sites = Sites(reduced, _coordinate_precision(data, raw_basis))
9096

9197
symbols = data.get("symbols")
9298
if symbols is None:
@@ -105,7 +111,7 @@ def structure_from_poscar(data: Mapping[str, Any]) -> Structure:
105111
seen.add(symbol)
106112
species.append(Species(name=symbol, chemical_symbols=(symbol,), concentration=(1.0,)))
107113

108-
return Structure(cell, reduced, species, species_at_sites)
114+
return Structure(cell, sites, species, species_at_sites)
109115

110116

111117
def _structure_from_cif(data: Mapping[str, Any]) -> Structure:
@@ -122,6 +128,55 @@ def _structure_from_cif(data: Mapping[str, Any]) -> Structure:
122128
return StructureSimpleView(structures[0])
123129

124130

131+
def _basis_precision(data: Mapping[str, Any], raw_basis: SurdVector, scale: Any) -> fractions.Fraction | None:
132+
"""How precisely the POSCAR states its cell, as an absolute length.
133+
134+
The reader reports the precision of the cell-vector tokens *as written*, before the
135+
universal scaling factor multiplies them, so the conversion happens here where the
136+
scale is known: ``scale * cell_precision``.
137+
138+
The scale's **own** written precision is deliberately not folded in, even though the
139+
reader reports it. The scaling factor is a definition of units — a choice about whether
140+
to carry the lattice constant in the factor or in the rows — and not a quantity measured
141+
independently of them. Charging its digits as well would double-count the same
142+
measurement, and would give a nonsensical answer in the ordinary case: a POSCAR that
143+
writes ``1.0`` would be charged 0.1 of an uncertainty it plainly does not have, and the
144+
cell of a 5.64 A structure would be declared good to only half an angstrom.
145+
146+
For the negative, volume-scaling form the scale is derived from a target volume rather
147+
than written; the volume's precision is likewise not propagated through the cube root.
148+
"""
149+
cell_precision = data.get("cell_precision")
150+
if cell_precision is None:
151+
return None
152+
scale_value = abs(_to_fraction(to_surdscalar(scale)))
153+
return scale_value * fractions.Fraction(cell_precision)
154+
155+
156+
def _coordinate_precision(data: Mapping[str, Any], raw_basis: SurdVector) -> fractions.Fraction | None:
157+
"""How precisely the POSCAR states its coordinates, in fractional units.
158+
159+
Direct coordinates are already fractional and pass straight through — and the universal
160+
scaling factor cancels for them, exactly as it does for the coordinates themselves.
161+
162+
Cartesian coordinates are a length, so they are divided by the *shortest* cell edge to
163+
become fractional. Shortest because that yields the largest fractional uncertainty,
164+
which is the conservative choice; it is an approximation for a strongly oblique cell,
165+
where a rigorous bound would need the norm of the inverse basis. The scale cancels here
166+
too, since it multiplies the coordinates and the edge lengths alike.
167+
"""
168+
precision = data.get("coordinate_precision")
169+
if precision is None:
170+
return None
171+
if not data["cartesian"]:
172+
return fractions.Fraction(precision)
173+
174+
shortest = min(math.dist((0.0, 0.0, 0.0), row) for row in raw_basis.to_floats())
175+
if shortest <= 0:
176+
return None
177+
return fractions.Fraction(precision) / fractions.Fraction(str(shortest)).limit_denominator(10**12)
178+
179+
125180
#: Maps a loaded payload's ``"format"`` tag to the structure builder for it.
126181
_STRUCTURE_ADAPTERS: dict[str, Callable[[Mapping[str, Any]], Structure]] = {
127182
"vasp-poscar": structure_from_poscar,

‎tests/test_precision.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import fractions
1010

1111
import pytest
12+
from httk.core import FracVector
1213

1314
from httk.atomistic import (
1415
Cell,
@@ -24,6 +25,20 @@
2425
same_crystal,
2526
)
2627

28+
pytest.importorskip("httk.io", reason="the readers live in httk-io")
29+
30+
import io # noqa: E402
31+
32+
from httk.io.vasp import read_poscar # noqa: E402
33+
34+
from httk.atomistic import ( # noqa: E402
35+
ASUSite,
36+
ASUStructure,
37+
Spacegroup,
38+
recognize_asu,
39+
structure_from_poscar,
40+
)
41+
2742
F = fractions.Fraction
2843

2944
CUBIC = [[5.0, 0, 0], [0, 5.0, 0], [0, 0, 5.0]]
@@ -191,3 +206,77 @@ def test_precision_does_not_affect_equality() -> None:
191206
vague = Structure(Cell(CUBIC), Sites([[0, 0, 0], [F(1, 2), F(1, 2), F(1, 2)]]), _species(), ["Na", "Na"])
192207
assert precise == vague
193208
assert same_crystal(precise, vague)
209+
210+
211+
# --- precision arriving from a file ---
212+
213+
214+
def _poscar(*, scale: str = "1.0", mode: str = "Direct", coords: str = "0.0000 0.0000 0.0000") -> str:
215+
return (
216+
"NaCl\n"
217+
f"{scale}\n"
218+
"5.6400 0.0000 0.0000\n0.0000 5.6400 0.0000\n0.0000 0.0000 5.6400\n"
219+
"Na\n1\n"
220+
f"{mode}\n{coords}\n"
221+
)
222+
223+
224+
def test_poscar_direct_coordinates_pass_their_precision_through() -> None:
225+
"""Direct coordinates are already fractional, and the scale cancels for them."""
226+
structure = structure_from_poscar(read_poscar(io.StringIO(_poscar(coords="0.5000 0.5000 0.5000"))))
227+
assert structure.coordinate_precision == F(1, 10000)
228+
assert structure.basis_precision == F(1, 10000)
229+
230+
231+
def test_poscar_cartesian_coordinates_are_converted_to_fractional() -> None:
232+
"""A Cartesian precision is a length; dividing by the shortest edge makes it fractional."""
233+
structure = structure_from_poscar(
234+
read_poscar(io.StringIO(_poscar(mode="Cartesian", coords="2.8200 2.8200 2.8200")))
235+
)
236+
assert float(structure.coordinate_precision) == pytest.approx(1e-4 / 5.64)
237+
238+
239+
def test_the_poscar_scale_multiplies_the_basis_precision() -> None:
240+
"""The cell entries are scaled, so their absolute precision scales with them."""
241+
assert structure_from_poscar(read_poscar(io.StringIO(_poscar(scale="1.0")))).basis_precision == F(1, 10000)
242+
assert structure_from_poscar(read_poscar(io.StringIO(_poscar(scale="2.0")))).basis_precision == F(1, 5000)
243+
244+
245+
def test_the_scales_own_digits_are_not_charged_as_uncertainty() -> None:
246+
"""The scaling factor defines units; it is not measured apart from the rows it scales.
247+
248+
Charging its digits would double-count the same measurement, and would declare the cell
249+
of a 5.64 A structure good to only half an angstrom because the file wrote ``1.0``.
250+
"""
251+
structure = structure_from_poscar(read_poscar(io.StringIO(_poscar(scale="1.0"))))
252+
assert float(structure.basis_precision) == pytest.approx(1e-4)
253+
254+
255+
def test_an_asu_structure_carries_and_propagates_its_precision() -> None:
256+
"""Recorded in the structure's own setting, so it needs no transforming on the way out."""
257+
asu = ASUStructure(
258+
Cell(CUBIC, 1, BASIS_PRECISION),
259+
225,
260+
[ASUSite("a", FracVector.create(()), "Na")],
261+
_species(),
262+
coordinate_precision=COORD_PRECISION,
263+
)
264+
assert asu.coordinate_precision == COORD_PRECISION
265+
assert asu.expand_sites().precision == COORD_PRECISION
266+
267+
expanded = StructureSimpleView(asu)
268+
assert expanded.coordinate_precision == COORD_PRECISION
269+
assert expanded.basis_precision == BASIS_PRECISION
270+
271+
272+
def test_recognition_carries_the_precision_onto_the_asu() -> None:
273+
"""Nothing about recognizing symmetry sharpens the data, so the claim is inherited."""
274+
structure = Structure(
275+
Cell(CUBIC, 1, BASIS_PRECISION),
276+
Sites([[0, 0, 0], [F(1, 2), F(1, 2), F(1, 2)]], COORD_PRECISION),
277+
_species() + [Species(name="Cl", chemical_symbols=("Cl",), concentration=(1.0,))],
278+
["Na", "Cl"],
279+
)
280+
recovered = recognize_asu(structure, setting=Spacegroup.standard(221))
281+
assert recovered.coordinate_precision == COORD_PRECISION
282+
assert recovered.cell.precision == BASIS_PRECISION

0 commit comments

Comments
 (0)