1212"""
1313
1414import fractions
15+ import math
1516from collections .abc import Mapping
1617from typing import Any , Callable
1718
1819from httk .core import SurdScalar , SurdVector , load
1920from httk .core .vectors import exactmath
2021
22+ from ._vector_guards import to_surdscalar
2123from .cell import Cell
24+ from .sites import Sites
2225from .species import Species
2326from .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
111117def _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 ,
0 commit comments