Source code for httk.core.precision
"""How precisely a number was written down.
A data file states its numbers to a definite number of digits, and that is a claim about
precision: ``0.3333`` says the value is known to about ``1e-4``, while ``0.33`` says only
``1e-2``. Recovering that claim is what lets a matching tolerance — or an spglib
``symprec`` — be *derived from the data* rather than guessed at, which is otherwise a
recurring source of arbitrary constants.
The value returned is an **absolute** bound, in whatever units the number was written in.
It is not a relative precision and not a significant-figure count: ``0.0001`` and
``9999.9999`` both report ``1e-4``.
This is deliberately a separate concept from the *arithmetic* precision in
:mod:`~httk.core.exactmath`, which governs how far an inexact computation is
carried. This module is about what the source said; that one is about what we compute.
"""
import fractions
import re
from collections.abc import Iterable
__all__ = ["combined_precision", "decimal_precision"]
#: A decimal literal: optional sign, then digits with an optional point in any of the
#: usual arrangements, then an optional exponent. Anything else is not a decimal and gets
#: no precision claim rather than a guessed one.
_DECIMAL = re.compile(
r"""
^
[+-]?
(?:
(?P<int_only>\d+) # 10
| (?P<int_part>\d*) \. (?P<frac>\d*) # 0.123, .25, 5.
)
(?: [eE] (?P<exponent>[+-]?\d+) )?
$
""",
re.VERBOSE,
)
#: Values a CIF uses for "unknown" and "inapplicable". Neither states a precision.
_NON_VALUES = frozenset({"?", "."})
[docs]
def decimal_precision(text: object) -> fractions.Fraction | None:
"""The absolute precision implied by how a number was written, or ``None``.
``None`` means the literal makes no precision claim, which is a different thing from
claiming perfect precision. It is returned for an empty or missing value, for CIF's
``?`` and ``.`` placeholders, for anything that is not a decimal literal, and — this
one matters — for an exact rational such as ``"1/3"``, which states a value rather
than a measurement and so should not drag a structure's precision down to
the width of its last digit.
Examples, all exact:
* ``"0.123"`` -> ``1/1000`` — three digits after the point
* ``"-0.5"`` -> ``1/10`` — the sign is not part of the claim
* ``".25"`` -> ``1/100`` — a leading point is still two digits
* ``"5."`` -> ``1`` — a trailing point states no fraction
* ``"10"`` -> ``1`` — an integer literal is precise to the unit
* ``"1.2e-3"`` -> ``1/10000`` — one digit, then scaled by the exponent
* ``"1/3"`` -> ``None`` — exact, not measured
* ``"?"`` -> ``None`` — no value at all
"""
if text is None:
return None
literal = str(text).strip()
if not literal or literal in _NON_VALUES:
return None
if "/" in literal:
# A rational literal is an exact statement, not a rounded measurement.
return None
match = _DECIMAL.match(literal)
if match is None:
return None
if match.group("int_only") is not None:
digits_after_point = 0
else:
# A point with nothing on either side ("." alone) is not a number; the regex
# allows both parts to be empty, so reject that here rather than reporting a
# precision for it.
if not match.group("int_part") and not match.group("frac"):
return None
digits_after_point = len(match.group("frac"))
exponent = int(match.group("exponent") or 0)
return fractions.Fraction(10) ** (exponent - digits_after_point)
[docs]
def combined_precision(values: Iterable[object]) -> fractions.Fraction | None:
"""The precision of a set of values taken together: the **coarsest** of them.
A structure is only as precisely stated as its least precisely stated number, so one
sloppy ``0.5`` among a table of six-decimal coordinates really does mean the table is
good to ``1e-1``. Taking the coarsest is the conservative reading and the one that
keeps a derived tolerance from being too tight to match anything.
Each value may be a literal to be interpreted by :func:`decimal_precision`, or an
already-computed precision as a :class:`~fractions.Fraction`, :class:`int`, or
:class:`float` — a standard uncertainty read from a file, say. Floats convert through
their decimal spelling, so ``0.005`` lands on ``1/200`` rather than a binary
approximation of it. Values that state no precision are skipped, and ``None`` is
returned when none of them state one.
"""
coarsest: fractions.Fraction | None = None
for value in values:
precision = _as_precision(value)
if precision is None:
continue
if coarsest is None or precision > coarsest:
coarsest = precision
return coarsest
def _as_precision(value: object) -> fractions.Fraction | None:
"""One entry of :func:`combined_precision`, as a precision or ``None``."""
if value is None:
return None
if isinstance(value, fractions.Fraction):
return value if value > 0 else None
if isinstance(value, bool):
# bool is an int subclass, and "True" is not a precision.
return None
if isinstance(value, (int, float)):
exact = fractions.Fraction(str(value))
return exact if exact > 0 else None
return decimal_precision(value)