Skip to content

Commit ea12bdb

Browse files
committed
Fix cif writing issues
1 parent 61fd66d commit ea12bdb

3 files changed

Lines changed: 57 additions & 13 deletions

File tree

‎src/httk/atomistic/_writing.py‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Private exact neutral-payload serializers used by :func:`httk.core.save`."""
22

33
import fractions
4+
import math
45
from collections.abc import Mapping
56
from decimal import Decimal, localcontext
67
from typing import Any
@@ -86,6 +87,23 @@ def _snap_to_precision(value: fractions.Fraction, precision: Any) -> fractions.F
8687
return value.limit_denominator(round(1 / precision))
8788

8889

90+
def _coordinate_decimals(precision: Any) -> int | None:
91+
"""Decimal places whose CIF digit-count precision claim (``10**-places``) matches ``precision``.
92+
93+
A relaxed coordinate is only good to its stated precision. Padding it to the full 16 places
94+
would tell the reader (via :func:`httk.core.decimal_precision`, which reads precision off the
95+
digit count) that ``0.855`` is exact to ``1e-16``. Emitting ``round(-log10(precision))`` places
96+
instead makes the written token claim the precision the structure actually has, so a CIF
97+
round-trip recovers it. Returns ``None`` for exact structures (``precision is None``), which
98+
keep the full-width padding.
99+
"""
100+
if precision is None or precision <= 0:
101+
return None
102+
# floor, not round: a written token must never claim finer precision than the data has
103+
# (httk reads precision as the coarsest of its numbers), so round the digit count down.
104+
return max(1, min(16, math.floor(-math.log10(float(precision)))))
105+
106+
89107
def _poscar_token(value: Any) -> str:
90108
if isinstance(value, fractions.Fraction):
91109
rational = value
@@ -317,14 +335,20 @@ def append_row(species: Any, index: int, source_label: str, position: FracVector
317335
# Snap float-noise coordinates onto the short rational their precision resolves (0.355
318336
# rather than 563492063538/1587301587431). Only for the relaxed unit-cell path (snap=True
319337
# with a finite precision); the ASU path passes exact Wyckoff reps that must not be rounded.
320-
coordinate_precision = structure.coordinate_precision if snap else None
338+
# The digit-width claim follows the structure's precision unconditionally (so a relaxed cell
339+
# survives repeated CIF round-trips), while value-snapping is only for the relaxed unit-cell
340+
# path -- the ASU path passes exact Wyckoff reps that must not be rounded.
341+
snap_precision = structure.coordinate_precision if snap else None
321342
return {
322343
"format": "cif",
323344
"approximate": cell_is_approximate,
345+
# A finite precision (relaxed data) writes only as many coordinate decimals as it resolves,
346+
# so the reader recovers that precision instead of reading full padding as machine-exact.
347+
"coordinate_decimals": _coordinate_decimals(structure.coordinate_precision),
324348
"cell_parameters_exact": cell_parameters,
325349
"positions_exact": [
326350
tuple(
327-
_render(_snap_to_precision(value, coordinate_precision), field="fractional coordinate")
351+
_render(_snap_to_precision(value, snap_precision), field="fractional coordinate")
328352
for value in row.to_fractions()
329353
)
330354
for row in written_positions
@@ -348,8 +372,10 @@ def _cif_payload_from_structure(obj: Any) -> Mapping[str, object]:
348372
349373
Exact channels retain rational tokens (including ``1/3``); the CIF writer emits
350374
valid standard decimals and adds ``_httk_*_exact`` companions when those decimals
351-
are lossy. Lossy standard values use 16 significant digits; finite coordinate
352-
values are padded to 16 decimal places to keep recognition precision stable.
375+
are lossy. Lossy standard values use 16 significant digits. An exact structure pads
376+
its coordinate decimals to 16 places (reading back as machine-exact); a relaxed one
377+
writes only the places its precision resolves (:func:`_coordinate_decimals`), so the
378+
digit count round-trips as the precision the data actually has rather than 1e-16.
353379
354380
A relaxed unit-cell structure carries a finite coordinate/cell precision but stores
355381
coordinates and cell lengths as full-precision rationalizations of floating-point input

‎src/httk/atomistic/io/cif/cif_writer.py‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -301,26 +301,30 @@ def _dual_cif_value(value: object) -> tuple[object, str | None]:
301301

302302

303303
def _dual_cif_column(
304-
values: Iterable[object], *, pad_coordinates: bool = False
304+
values: Iterable[object], *, pad_coordinates: bool = False, decimals: int = _HTTK_CIF_DECIMAL_DIGITS
305305
) -> tuple[list[object], list[str | None], bool]:
306306
standard: list[object] = []
307307
exact: list[str | None] = []
308308
for value in values:
309309
visible, companion = _dual_cif_value(value)
310310
if pad_coordinates and visible is not None and companion is None:
311-
visible = _pad_coordinate_decimal(visible)
311+
visible = _pad_coordinate_decimal(visible, decimals)
312312
standard.append(visible)
313313
exact.append(companion)
314314
return standard, exact, any(value is not None for value in exact)
315315

316316

317-
def _pad_coordinate_decimal(value: object) -> object:
318-
"""Give standard coordinate decimals a fixed 16-place precision claim."""
317+
def _pad_coordinate_decimal(value: object, decimals: int = _HTTK_CIF_DECIMAL_DIGITS) -> object:
318+
"""Give a standard coordinate decimal a fixed ``decimals``-place precision claim.
319+
320+
``decimals`` defaults to 16 (an exact structure's coordinate reads as machine-exact); a relaxed
321+
structure passes its precision-derived width so the digit count states the precision it has.
322+
"""
319323
text = str(value)
320324
whole, dot, fraction = text.partition(".")
321325
if not dot:
322-
return text + "." + "0" * _HTTK_CIF_DECIMAL_DIGITS
323-
return whole + "." + fraction.ljust(_HTTK_CIF_DECIMAL_DIGITS, "0")
326+
return text + "." + "0" * decimals
327+
return whole + "." + fraction.ljust(decimals, "0")
324328

325329

326330
def _neutral_cif_block(block: Mapping[str, object]) -> dict[str, object]:
@@ -365,8 +369,13 @@ def _neutral_cif_block(block: Mapping[str, object]) -> dict[str, object]:
365369
raw["loop_atoms"] = loop_atoms
366370
raw["atom_site_label"] = labels
367371
raw["atom_site_type_symbol"] = symbols
372+
# A relaxed block states how many coordinate decimals its precision resolves; exact blocks
373+
# (and reader-round-tripped blocks that omit the key) keep the full 16-place claim.
374+
coordinate_decimals = cast("int | None", block.get("coordinate_decimals")) or _HTTK_CIF_DECIMAL_DIGITS
368375
for index, tag in enumerate(("atom_site_fract_x", "atom_site_fract_y", "atom_site_fract_z")):
369-
values, exact_values, has_exact = _dual_cif_column((row[index] for row in positions), pad_coordinates=True)
376+
values, exact_values, has_exact = _dual_cif_column(
377+
(row[index] for row in positions), pad_coordinates=True, decimals=coordinate_decimals
378+
)
370379
raw[tag] = values
371380
if has_exact:
372381
raw[f"httk_{tag}_exact"] = exact_values

‎tests/test_save.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,18 @@ def test_relaxed_cif_snaps_float_noise_to_clean_decimals(tmp_path):
159159
assert "/" not in "".join(text.splitlines()[12:]) # no fraction tokens in the atom/loop body
160160
# Clean, snapped values rather than the raw float noise.
161161
assert "_cell_length_a 5.568" in text
162-
assert "0.3550000000000000" in text
163-
assert "0.7590000000000000" in text
164162
assert "0.3549999999999969" not in text
163+
# Coordinates are written only to the precision they resolve (~1e-4 -> 4 decimals), not padded
164+
# to 16 places, so the digit count does not claim machine precision the data lacks.
165+
assert "V V 0.0000 0.3550 0.2500" in text
166+
assert "0.3550000000000000" not in text
167+
# A CIF round-trip therefore recovers a realistic precision, not 1e-16 -- and stays there on a
168+
# second round-trip (the width follows the structure's precision, not just the first-write path).
169+
reloaded = load(destination)
170+
assert reloaded.coordinate_precision == Fraction(1, 10000)
171+
resaved = tmp_path / "relaxed2.cif"
172+
save(reloaded, resaved)
173+
assert load(resaved).coordinate_precision == Fraction(1, 10000)
165174

166175

167176
def test_relaxed_cif_strict_mode_refuses_snapped_cell(tmp_path):

0 commit comments

Comments
 (0)