11"""Private exact neutral-payload serializers used by :func:`httk.core.save`."""
22
33import fractions
4+ import math
45from collections .abc import Mapping
56from decimal import Decimal , localcontext
67from 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+
89107def _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
0 commit comments