Skip to content

Commit 088bc37

Browse files
committed
Warn only on ambiguous CIF uncertainty
1 parent e8109b8 commit 088bc37

4 files changed

Lines changed: 202 additions & 24 deletions

File tree

‎docs/details/cif.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,11 @@ known only to ±0.05. Signed spellings such as `-0.5` follow the same rule. Extr
9393
digits remain significant: `0.50` claims a decimal step of `0.01`.
9494

9595
Coordinate precision is converted to a Cartesian distance using the cell. A projected
96-
positional uncertainty of one ångström or more is a hard safety error because it can make
97-
many unrelated Wyckoff positions plausible. A caller who has inspected the source may opt
98-
in explicitly:
96+
positional uncertainty from 0.1 up to (but not including) one ångström is reported at DEBUG
97+
when the Wyckoff match is unambiguous or trusted from the CIF. It is reported at WARNING
98+
when the Wyckoff letter is missing or untrusted and multiple compatible Wyckoff positions
99+
remain. A projected uncertainty of one ångström or more is a hard safety error because it can
100+
make many unrelated positions plausible. A caller who has inspected the source may opt in explicitly:
99101

100102
```python
101103
structure = load("coarse.cif", allow_large_cif_uncertainty=True)

‎src/httk/atomistic/cif_structures.py‎

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,8 @@ def asu_structure_from_cif(
505505
species_by_name: dict[str, Species] = {}
506506
wyckoff_sites: list[WyckoffSite] = []
507507
warning_uncertainties: list[Any] = []
508+
ambiguous_warning_count = 0
509+
debug_uncertainties: list[Any] = []
508510
warned_type_symbols: set[str] = set()
509511
for index, coordinate in enumerate(coordinates):
510512
if occupancies_exact is not None and occupancies_exact[index] is not None:
@@ -560,6 +562,11 @@ def asu_structure_from_cif(
560562
declared_position, declaration, declaration_error, declared_positions = _declared_wyckoff_position(
561563
declared_wyckoff, declared_multiplicities, declared_site_symmetry_orders, index, setting, standard
562564
)
565+
coarse_uncertainty = uncertainty is not None and uncertainty[0] >= CIF_POSITIONAL_UNCERTAINTY_WARNING**2
566+
classifiable_uncertainty = (
567+
uncertainty is not None and uncertainty[0] < CIF_POSITIONAL_UNCERTAINTY_ERROR**2 and coarse_uncertainty
568+
)
569+
matched_letters: set[str] | None = set() if classifiable_uncertainty and declared_position is None else None
563570
orbit_screen: list[tuple[tuple[float, float, float], Any, FracVector]] | None = (
564571
[] if declaration is not None or repair else None
565572
)
@@ -625,6 +632,7 @@ def asu_structure_from_cif(
625632
allow_large_cif_uncertainty=allow_large_cif_uncertainty,
626633
positions=declared_positions,
627634
orbit_screen=orbit_screen,
635+
matched_letters=matched_letters,
628636
)
629637
if match is None:
630638
declaration_error = f"does not lie on any position allowed by declared {declaration}"
@@ -639,6 +647,7 @@ def asu_structure_from_cif(
639647
assert declaration is not None # an error always describes a present declaration
640648
ignored_declaration = declaration, declaration_error
641649
declaration = None
650+
matched_letters = set() if classifiable_uncertainty else None
642651
match = _snap(
643652
standard,
644653
standard_point,
@@ -651,6 +660,7 @@ def asu_structure_from_cif(
651660
allow_large_cif_uncertainty=allow_large_cif_uncertainty,
652661
orbit_screen=orbit_screen,
653662
general_screen=general_screen,
663+
matched_letters=matched_letters,
654664
)
655665
elif declaration is None:
656666
match = _snap(
@@ -665,6 +675,7 @@ def asu_structure_from_cif(
665675
allow_large_cif_uncertainty=allow_large_cif_uncertainty,
666676
orbit_screen=orbit_screen,
667677
general_screen=general_screen,
678+
matched_letters=matched_letters,
668679
)
669680
if repair and declaration is None and match is not None:
670681
assert orbit_screen is not None
@@ -679,6 +690,7 @@ def asu_structure_from_cif(
679690
coordinate_bounds=coordinate_bounds,
680691
allow_large_cif_uncertainty=allow_large_cif_uncertainty,
681692
most_specific=True,
693+
matched_letters=matched_letters,
682694
)
683695
if corrected_match != match:
684696
assert corrected_match is not None
@@ -718,13 +730,26 @@ def asu_structure_from_cif(
718730
and matched_position.free_count != 3
719731
and matched_position.parameters_of(standard_point) is not None
720732
)
721-
if not exact_special and uncertainty is not None and uncertainty[0] >= CIF_POSITIONAL_UNCERTAINTY_WARNING**2:
722-
warning_uncertainties.append(uncertainty[0])
733+
if not exact_special and uncertainty is not None:
734+
if uncertainty[0] >= CIF_POSITIONAL_UNCERTAINTY_ERROR**2:
735+
warning_uncertainties.append(uncertainty[0])
736+
elif classifiable_uncertainty and matched_letters is not None and len(matched_letters) > 1:
737+
warning_uncertainties.append(uncertainty[0])
738+
ambiguous_warning_count += 1
739+
elif classifiable_uncertainty:
740+
debug_uncertainties.append(uncertainty[0])
723741

724742
if warning_uncertainties:
725743
maximum = max(warning_uncertainties)
726744
_cif_warning(
727-
f"CIF block has {len(warning_uncertainties)} site(s) with projected positional uncertainty; "
745+
f"CIF block has {len(warning_uncertainties)} site(s) with projected positional uncertainty"
746+
f"{f'; {ambiguous_warning_count} had ambiguous Wyckoff matches' if ambiguous_warning_count else ''}; "
747+
f"maximum is {math.sqrt(maximum.to_float()):.6g} Å"
748+
)
749+
if debug_uncertainties:
750+
maximum = max(debug_uncertainties)
751+
_cif_debug(
752+
f"CIF block has {len(debug_uncertainties)} site(s) with projected positional uncertainty; "
728753
f"maximum is {math.sqrt(maximum.to_float()):.6g} Å"
729754
)
730755

@@ -1337,6 +1362,11 @@ def _cif_warning(message: str) -> None:
13371362
logging.getLogger(__name__).warning(message, extra={"context": "cif"})
13381363

13391364

1365+
def _cif_debug(message: str) -> None:
1366+
"""Send one low-confidence but unambiguous CIF diagnostic through the report channel."""
1367+
logging.getLogger(__name__).debug(message, extra={"context": "cif"})
1368+
1369+
13401370
def _block_name(data: Mapping[str, Any]) -> str:
13411371
"""The CIF data-block name retained by the repair reader bridge."""
13421372
return str(data.get("_httk_atomistic_block_name", "<unnamed>"))
@@ -1780,12 +1810,14 @@ def _snap(
17801810
positions: Sequence[Any] | None = None,
17811811
orbit_screen: list[tuple[tuple[float, float, float], Any, FracVector]] | None = None,
17821812
general_screen: _GeneralPositionScreen | None = None,
1813+
matched_letters: set[str] | None = None,
17831814
) -> tuple[str, FracVector] | None:
17841815
"""The most specific Wyckoff position within ``tolerance``, and its free parameters.
17851816
17861817
Floating point screens branch candidates at twice the tolerance, but exact distance and
17871818
per-component coordinate bounds still decide every result. ``positions`` limits the search
17881819
to authoritative CIF declarations; otherwise every standard position is tried in its established order.
1820+
``matched_letters`` collects every compatible Wyckoff letter and forces the complete candidate traversal.
17891821
"""
17901822
from httk.atomistic.symmetry.recognition import _cartesian_distance_squared
17911823

@@ -1799,7 +1831,7 @@ def inside_coordinate_bounds(candidate: FracVector) -> bool:
17991831
for own, snapped, bound in zip(own_point.to_fractions(), candidate.to_fractions(), coordinate_bounds)
18001832
)
18011833

1802-
def accepted_parameters(branch: Any, parameters: FracVector, *, general: bool) -> FracVector | None:
1834+
def accepted_parameters(branch: Any, parameters: FracVector, *, general: bool, letter: str) -> FracVector | None:
18031835
candidate = branch.coordinate(parameters)
18041836
if not identity:
18051837
candidate = transform.to_setting(candidate)
@@ -1815,6 +1847,8 @@ def accepted_parameters(branch: Any, parameters: FracVector, *, general: bool) -
18151847
return None
18161848
if general or candidate.normalize() != own_point.normalize():
18171849
reject_large_uncertainty()
1850+
if matched_letters is not None:
1851+
matched_letters.add(letter)
18181852
return parameters
18191853

18201854
def finish(letter: str, parameters: FracVector) -> tuple[str, FracVector]:
@@ -1872,6 +1906,8 @@ def reject_large_uncertainty() -> None:
18721906
general = standard.wyckoff[-1]
18731907
parameters = general.representative.parameters_of(standard_point)
18741908
assert general.free_count == 3 and parameters is not None
1909+
if matched_letters is not None:
1910+
matched_letters.add(general.letter)
18751911
return finish(general.letter, parameters)
18761912

18771913
limit = tolerance * tolerance
@@ -1901,14 +1937,22 @@ def reject_large_uncertainty() -> None:
19011937
)
19021938
deferred: list[tuple[Any, Any]] = []
19031939
matches: list[tuple[int, str, FracVector]] = []
1940+
collecting = matched_letters is not None
1941+
preferred_match: tuple[str, FracVector] | None = None
19041942
for position in candidates:
19051943
if exact_first and position.free_count == 3:
19061944
reject_large_uncertainty()
19071945
for deferred_position, deferred_branch in deferred:
19081946
parameters = deferred_branch.nearest_parameters(standard_point)
1909-
parameters = accepted_parameters(deferred_branch, parameters, general=False)
1947+
parameters = accepted_parameters(
1948+
deferred_branch, parameters, general=False, letter=deferred_position.letter
1949+
)
19101950
if parameters is not None:
1911-
return finish(deferred_position.letter, parameters)
1951+
if not collecting:
1952+
return finish(deferred_position.letter, parameters)
1953+
if not most_specific and preferred_match is None:
1954+
preferred_match = deferred_position.letter, parameters
1955+
matches.append((deferred_position.multiplicity, deferred_position.letter, parameters))
19121956
exact_first = False
19131957
for branch in position.branches:
19141958
projection = (
@@ -1925,7 +1969,14 @@ def reject_large_uncertainty() -> None:
19251969
if exact_first and position.free_count != 3 and exact_candidate:
19261970
parameters = branch.parameters_of(standard_point)
19271971
if parameters is not None:
1928-
return finish(position.letter, parameters)
1972+
if not collecting:
1973+
return finish(position.letter, parameters)
1974+
assert matched_letters is not None
1975+
matched_letters.add(position.letter)
1976+
matches.append((position.multiplicity, position.letter, parameters))
1977+
if not most_specific and preferred_match is None:
1978+
preferred_match = position.letter, parameters
1979+
continue
19291980
if screen is not None and projected is not None:
19301981
assert float_geometry is not None
19311982
basis, point, matrix, vector = float_geometry
@@ -1948,22 +1999,32 @@ def reject_large_uncertainty() -> None:
19481999
deferred.append((position, branch))
19492000
continue
19502001
parameters = branch.nearest_parameters(standard_point)
1951-
parameters = accepted_parameters(branch, parameters, general=position.free_count == 3)
2002+
parameters = accepted_parameters(
2003+
branch, parameters, general=position.free_count == 3, letter=position.letter
2004+
)
19522005
if parameters is not None:
1953-
if not most_specific:
2006+
if not most_specific and not collecting:
19542007
return finish(position.letter, parameters)
2008+
if not most_specific and preferred_match is None:
2009+
preferred_match = position.letter, parameters
19552010
matches.append((position.multiplicity, position.letter, parameters))
19562011
break
19572012
if exact_first:
19582013
reject_large_uncertainty()
19592014
for position, branch in deferred:
19602015
parameters = branch.nearest_parameters(standard_point)
1961-
parameters = accepted_parameters(branch, parameters, general=False)
2016+
parameters = accepted_parameters(branch, parameters, general=False, letter=position.letter)
19622017
if parameters is not None:
1963-
return finish(position.letter, parameters)
2018+
if not collecting:
2019+
return finish(position.letter, parameters)
2020+
if not most_specific and preferred_match is None:
2021+
preferred_match = position.letter, parameters
2022+
matches.append((position.multiplicity, position.letter, parameters))
19642023
if not matches:
19652024
return None
1966-
_, letter, parameters = min(matches)
2025+
if preferred_match is not None:
2026+
return finish(*preferred_match)
2027+
_, letter, parameters = min(matches, key=lambda match: (match[0], match[1]))
19672028
return finish(letter, parameters)
19682029

19692030

‎tests/test_cif_structures.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,11 +750,13 @@ def test_precision_stays_with_valid_block_after_unparsed_block(tmp_path: Path) -
750750
path = tmp_path / "mixed.cif"
751751
path.write_text(invalid + valid, encoding="utf-8")
752752

753-
with collect_reports(level="warning") as collection:
753+
with collect_reports(level="debug") as collection:
754754
structure = load(str(path))
755755

756756
assert len(structure.sites) == 1
757+
assert not any(record.levelno >= logging.WARNING for record in collection.records)
757758
assert len(collection.records) == 1
759+
assert collection.records[0].levelno == logging.DEBUG
758760
assert "maximum is 0.1 Å" in collection.records[0].getMessage()
759761

760762

@@ -1231,6 +1233,36 @@ def test_exact_special_position_beats_an_earlier_nearby_position() -> None:
12311233
cell = Cell([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
12321234

12331235
assert _snap(spacegroup, point, point, cell, spacegroup.transform_from_standard, 0.02) == ("h", parameters)
1236+
matched_letters: set[str] = set()
1237+
assert _snap(
1238+
spacegroup,
1239+
point,
1240+
point,
1241+
cell,
1242+
spacegroup.transform_from_standard,
1243+
0.02,
1244+
matched_letters=matched_letters,
1245+
) == ("h", parameters)
1246+
assert matched_letters == {"d", "h", "k", "l"}
1247+
1248+
1249+
def test_snap_collection_keeps_the_first_branch_for_a_letter() -> None:
1250+
spacegroup = Spacegroup.standard(10)
1251+
point = FracVector([F(7, 10), F(19, 20), F(1, 2)])
1252+
matched_letters: set[str] = set()
1253+
1254+
match = _snap(
1255+
spacegroup,
1256+
point,
1257+
point,
1258+
Cell([[1, 0, 0], [0, 1, 0], [0, 0, 1]]),
1259+
spacegroup.transform_from_standard,
1260+
0.2,
1261+
matched_letters=matched_letters,
1262+
)
1263+
1264+
assert match == ("l", FracVector([F(19, 20)]))
1265+
assert matched_letters == {"l", "m", "o"}
12341266

12351267

12361268
def test_unwrapped_large_coordinate_falls_through_the_float_screen(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)