Add Q
, R
, C
, and GF2
fields
- add `lalib.fields.base.Field`, a blueprint for all concrete fields, providing a unified interface to be used outside of the `lalib.fields` sub-package - implement `lalib.fields.complex_.ComplexField`, or `C` for short, the field over the complex numbers (modeled as `complex` numbers) - implement `lalib.fields.galois.GaloisField2`, or `GF2` for short, the (finite) field over the two elements `one` and `zero` + adapt `lalib.elements.galois.GF2Element.__eq__()` to return `NotImplemented` instead of `False` for non-castable `other`s => this fixes a minor issue with `pytest.approx()` - implement `lalib.fields.rational.RationalField`, or `Q` for short, the field over the rational numbers (modeled as `fractions.Fraction`s) - implement `lalib.fields.real.RealField`, or `R` for short, the field over the real numbers (modeled as `float`s) - organize top-level imports for `lalib.fields`, making `Q`, `R`, `C`, and `GF2` importable with `from lalib.fields import *` - provide extensive unit and integration tests for the new objects: + test generic and common behavior in `tests.fields.test_base` + test specific behavior is other modules + test the well-known math axioms for all fields (integration tests) + test the new objects' docstrings + add "pytest-repeat" to run randomized tests many times
This commit is contained in:
parent
cbc1f8fd3a
commit
153094eef5
19 changed files with 1302 additions and 2 deletions
|
@ -231,6 +231,7 @@ TEST_DEPENDENCIES = (
|
|||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-randomly",
|
||||
"pytest-repeat",
|
||||
"semver",
|
||||
'typing-extensions; python_version < "3.11"', # to support Python 3.9 & 3.10
|
||||
"xdoctest",
|
||||
|
@ -290,6 +291,7 @@ def test_coverage_run(session: nox.Session) -> None:
|
|||
session.install(".")
|
||||
install_pinned(session, "coverage", *TEST_DEPENDENCIES)
|
||||
|
||||
session.env["N_RANDOM_DRAWS"] = "10"
|
||||
session.env["NO_CROSS_REFERENCE"] = "true"
|
||||
session.run(
|
||||
"python",
|
||||
|
|
16
poetry.lock
generated
16
poetry.lock
generated
|
@ -1234,6 +1234,20 @@ files = [
|
|||
importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""}
|
||||
pytest = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-repeat"
|
||||
version = "0.9.3"
|
||||
description = "pytest plugin for repeating tests"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "pytest_repeat-0.9.3-py3-none-any.whl", hash = "sha256:26ab2df18226af9d5ce441c858f273121e92ff55f5bb311d25755b8d7abdd8ed"},
|
||||
{file = "pytest_repeat-0.9.3.tar.gz", hash = "sha256:ffd3836dfcd67bb270bec648b330e20be37d2966448c4148c4092d1e8aba8185"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
|
@ -1714,4 +1728,4 @@ type = ["pytest-mypy"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "31a7b1ad8dd8949814d1f397022e400cd37643c6f5e9e438054de472091bcd69"
|
||||
content-hash = "a10f3c13ed0c67e6ea65500335281cd269e3efdd0fae544ad667d07637ef118f"
|
||||
|
|
|
@ -78,6 +78,7 @@ packaging = "^24.1" # to test the version identifier
|
|||
pytest = "^8.3"
|
||||
pytest-cov = "^5.0"
|
||||
pytest-randomly = "^3.15"
|
||||
pytest-repeat = "^0.9"
|
||||
semver = "^3.0" # to test the version identifier
|
||||
tomli = [ { python = "<3.11", version = "^2.0" } ]
|
||||
xdoctest = { extras = ["colors"], version = "^1.2" }
|
||||
|
@ -381,6 +382,8 @@ extend-ignore = [ # never check the following codes
|
|||
|
||||
]
|
||||
|
||||
allowed-confusables = ["ℂ", "ℝ", "ℚ"]
|
||||
|
||||
|
||||
[tool.ruff.lint.flake8-pytest-style] # aligned with [tool.flake8] above
|
||||
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
"""Library-wide default settings."""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
NDIGITS = 12
|
||||
|
||||
THRESHOLD = 1 / (10**NDIGITS)
|
||||
|
||||
MAX_DENOMINATOR = math.trunc(1 / THRESHOLD)
|
||||
|
||||
|
||||
del math
|
||||
|
|
|
@ -314,7 +314,7 @@ class GF2Element(metaclass=GF2Meta):
|
|||
try:
|
||||
other = GF2Element(other)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return NotImplemented
|
||||
else:
|
||||
# TODO(webartifex): investigate the below issue
|
||||
# https://github.com/webartifex/lalib/issues/1
|
||||
|
|
|
@ -1 +1,32 @@
|
|||
"""A collection of common fields used in linear algebra."""
|
||||
|
||||
from lalib.fields import base
|
||||
from lalib.fields import complex_
|
||||
from lalib.fields import galois
|
||||
from lalib.fields import rational
|
||||
from lalib.fields import real
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
Field = base.Field
|
||||
|
||||
Q = rational.Q
|
||||
R = real.R
|
||||
C = complex_.C
|
||||
GF2 = galois.GF2
|
||||
|
||||
|
||||
del base
|
||||
del complex_
|
||||
del galois
|
||||
del rational
|
||||
del real
|
||||
del utils # `import`ed and `del`eted to not be in the final namespace
|
||||
|
||||
|
||||
__all__ = (
|
||||
"Q",
|
||||
"R",
|
||||
"C",
|
||||
"GF2",
|
||||
)
|
||||
|
|
227
src/lalib/fields/base.py
Normal file
227
src/lalib/fields/base.py
Normal file
|
@ -0,0 +1,227 @@
|
|||
"""The abstract blueprint of a `Field`."""
|
||||
|
||||
import abc
|
||||
import numbers
|
||||
from typing import Any, Callable, Generic, TypeVar
|
||||
|
||||
|
||||
T = TypeVar("T", bound=numbers.Real)
|
||||
|
||||
|
||||
class Field(abc.ABC, Generic[T]):
|
||||
"""The abstract blueprint of a mathematical field."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _math_name(self) -> str:
|
||||
"""The common abbreviation used in math notation."""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def _dtype(value: Any, /) -> T:
|
||||
"""Data type to store the `Field` elements."""
|
||||
|
||||
def _cast_func(self, value: Any, /, **kwargs: Any) -> T:
|
||||
"""Function to cast `value`s as field elements."""
|
||||
return self._dtype(value, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _post_cast_filter(possible_element: T, /) -> bool:
|
||||
"""Function to filter out castable `value`s.
|
||||
|
||||
Called after a successfull call of the `._cast_func()`.
|
||||
|
||||
For example, if one wants to avoid non-finite `Field` elements,
|
||||
an overwriting `._post_cast_filter()` could return `False` for
|
||||
non-finite `value`s like `float("NaN")`.
|
||||
|
||||
By default, all castable `value`s may become `Field` elements.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _additive_identity(self) -> T:
|
||||
"""The field's additive identity."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def _multiplicative_identity(self) -> T:
|
||||
"""The field's multiplicative identity."""
|
||||
|
||||
def cast(
|
||||
self,
|
||||
value: object,
|
||||
/,
|
||||
**cast_kwargs: Any,
|
||||
) -> T:
|
||||
"""Cast a (numeric) `value` as an element of the field.
|
||||
|
||||
Args:
|
||||
value: to be cast as the "right" data type
|
||||
as defined in the concrete `Field` sub-class
|
||||
**cast_kwargs: extra `kwargs` to the `._cast_func()`
|
||||
|
||||
Returns:
|
||||
element: of the concrete `Field`
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not an element of the field
|
||||
"""
|
||||
try:
|
||||
element = self._cast_func(value, **cast_kwargs) # type: ignore[arg-type]
|
||||
except (ArithmeticError, TypeError, ValueError):
|
||||
msg = "`value` is not an element of the field"
|
||||
raise ValueError(msg) from None
|
||||
|
||||
if not self._post_cast_filter(element):
|
||||
msg = "`value` is not an element of the field"
|
||||
raise ValueError(msg)
|
||||
|
||||
return element
|
||||
|
||||
def validate(
|
||||
self,
|
||||
value: object,
|
||||
/,
|
||||
*,
|
||||
silent: bool = True,
|
||||
**cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if a (numeric) `value` is an element of the `Field`.
|
||||
|
||||
Wraps `.cast()`, catches the documented `ValueError`,
|
||||
and returns a `bool`ean indicating field membership.
|
||||
|
||||
Args:
|
||||
value: see docstring for `.cast()`
|
||||
silent: suppress the `ValueError`
|
||||
**cast_kwargs: extra `kwargs` to `.cast()` the `value`
|
||||
|
||||
Returns:
|
||||
is_element
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
(suppressed by default)
|
||||
"""
|
||||
try:
|
||||
self.cast(value, **cast_kwargs)
|
||||
except ValueError:
|
||||
if not silent:
|
||||
raise
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def dtype(self) -> Callable[[Any], T]:
|
||||
"""Data type to store the `Field` elements."""
|
||||
return self._dtype
|
||||
|
||||
@property
|
||||
def zero(self) -> T:
|
||||
"""The field's additive identity."""
|
||||
return self._additive_identity
|
||||
|
||||
def is_zero(self, value: T, /, **cast_kwargs: Any) -> bool:
|
||||
"""Check if `value` equals the `.zero`-like field element.
|
||||
|
||||
This method, together with `.is_one()` below, provides a unified
|
||||
way across the different `Field`s to check if a given `value`
|
||||
equals the field's additive or multiplicative identity.
|
||||
|
||||
Concrete `Field`s may use a different logic. For example, some
|
||||
compare the absolute difference between the `value` and the
|
||||
`.zero`-or-`.one`-like field element to a small `threshold`.
|
||||
|
||||
Overwriting methods should
|
||||
- check the `value` for field membership first, and
|
||||
- accept arbitrary keyword-only arguments
|
||||
that they may simply ignore
|
||||
|
||||
Args:
|
||||
value: see docstring for `.cast()`
|
||||
**cast_kwargs: extra `kwargs` to `.cast()` the `value`
|
||||
|
||||
Returns:
|
||||
is_zero
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return self.cast(value, **cast_kwargs) == self._additive_identity
|
||||
|
||||
@property
|
||||
def one(self) -> T:
|
||||
"""The field's multiplicative identity."""
|
||||
return self._multiplicative_identity
|
||||
|
||||
def is_one(self, value: T, /, **cast_kwargs: Any) -> bool:
|
||||
"""Check if `value` equals the `.one`-like field element.
|
||||
|
||||
See docstring for `.is_zero()` above for more details.
|
||||
|
||||
Args:
|
||||
value: see docstring for `.cast()`
|
||||
**cast_kwargs: extra `kwargs` to `.cast()` the `value`
|
||||
|
||||
Returns:
|
||||
is_one
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return self.cast(value, **cast_kwargs) == self._multiplicative_identity
|
||||
|
||||
@abc.abstractmethod
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: T,
|
||||
upper: T,
|
||||
**cast_kwargs: Any,
|
||||
) -> T:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
Overwriting methods should sort them if necessary.
|
||||
|
||||
Extra keyword arguments should be passed through to `.cast()`.
|
||||
"""
|
||||
|
||||
def _get_bounds(
|
||||
self,
|
||||
lower: T,
|
||||
upper: T,
|
||||
/,
|
||||
**cast_kwargs: Any,
|
||||
) -> tuple[T, T]:
|
||||
"""Get the `lower` & `upper` bounds for `Field.random()`.
|
||||
|
||||
Utility method to either
|
||||
- resolve the given `lower` and `upper` bounds into a
|
||||
`.cast()`ed element of the `Field`, or
|
||||
- obtain their default `value`s, which are
|
||||
+ the `.additive_identity` for `lower`, and
|
||||
+ the `.multiplicative_identity` for `upper`
|
||||
|
||||
Extra keyword arguments are passed through to `.cast()`.
|
||||
"""
|
||||
lower = lower if lower is not None else self._additive_identity
|
||||
upper = upper if upper is not None else self._multiplicative_identity
|
||||
|
||||
return (self.cast(lower, **cast_kwargs), self.cast(upper, **cast_kwargs))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Text representations: `repr(...)` and `str(...)`.
|
||||
|
||||
The `.math_name` should be a valid name within `lalib`.
|
||||
If not, use the "<cls ...>" convention; in other words,
|
||||
the text representation is no valid code on its own.
|
||||
|
||||
See: https://docs.python.org/3/reference/datamodel.html#object.__repr__
|
||||
"""
|
||||
return self._math_name
|
||||
|
||||
__str__ = __repr__
|
110
src/lalib/fields/complex_.py
Normal file
110
src/lalib/fields/complex_.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
"""The concrete `ComplexField`."""
|
||||
|
||||
import cmath
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib import config
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class ComplexField(utils.SingletonMixin, base.Field):
|
||||
"""The `Field` over ℂ, the complex numbers."""
|
||||
|
||||
_math_name = "ℂ"
|
||||
_dtype = complex
|
||||
_post_cast_filter = cmath.isfinite
|
||||
_additive_identity = 0 + 0j
|
||||
_multiplicative_identity = 1 + 0j
|
||||
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: complex = _additive_identity,
|
||||
upper: complex = _multiplicative_identity,
|
||||
ndigits: int = config.NDIGITS,
|
||||
**_cast_kwargs: Any,
|
||||
) -> complex:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
The `.real` and `.imag`inary parts of the `lower` and `upper`
|
||||
bounds evaluated separately; i.e., the `random_element` is drawn
|
||||
from a rectangle with opposing corners `lower` and `upper`.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
ndigits: no. of significant digits to the right of the ".";
|
||||
both the `.real` and the `.imag`inary parts are rounded
|
||||
|
||||
Returns:
|
||||
random_element
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper)
|
||||
|
||||
random_real, random_imag = (
|
||||
# `random.uniform()` can handle `upper < lower`
|
||||
round(random.uniform(lower.real, upper.real), ndigits), # noqa: S311
|
||||
round(random.uniform(lower.imag, upper.imag), ndigits), # noqa: S311
|
||||
)
|
||||
|
||||
return complex(random_real, random_imag)
|
||||
|
||||
def is_zero(
|
||||
self,
|
||||
value: complex,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `0.0 + 0.0j`.
|
||||
|
||||
To be precise: Check if `value` deviates by less than the
|
||||
`threshold` from `0.0 + 0.0j` in absolute terms.
|
||||
|
||||
Args:
|
||||
value: to be compared to `0.0 + 0.0j`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_zero
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value)) < threshold
|
||||
|
||||
def is_one(
|
||||
self,
|
||||
value: complex,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `1.0 + 0.0j`.
|
||||
|
||||
To be precise: Check if `value` deviates by less than the
|
||||
`threshold` from `1.0 + 0.0j` in absolute terms.
|
||||
|
||||
Args:
|
||||
value: to be compared to `1.0 + 0.0j`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_one
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value) - (1.0 + 0j)) < threshold
|
||||
|
||||
|
||||
C = ComplexField()
|
45
src/lalib/fields/galois.py
Normal file
45
src/lalib/fields/galois.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
"""The concrete `GaloisField2`."""
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib.elements import galois as gf2_elements
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class GaloisField2(utils.SingletonMixin, base.Field):
|
||||
"""The Galois `Field` of 2 elements."""
|
||||
|
||||
_math_name = "GF2"
|
||||
_dtype = gf2_elements.GF2Element
|
||||
_additive_identity = gf2_elements.zero
|
||||
_multiplicative_identity = gf2_elements.one
|
||||
|
||||
def random(
|
||||
self,
|
||||
lower: gf2_elements.GF2Element = gf2_elements.zero,
|
||||
upper: gf2_elements.GF2Element = gf2_elements.one,
|
||||
**cast_kwargs: Any,
|
||||
) -> gf2_elements.GF2Element:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
**cast_kwargs: extra `kwargs` to `.cast()`
|
||||
the `lower` and `upper` bounds
|
||||
|
||||
Returns:
|
||||
random_element: either `one` or `zero`
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper, **cast_kwargs)
|
||||
|
||||
# `random.choice()` can handle `upper < lower`
|
||||
return random.choice((lower, upper)) # noqa: S311
|
||||
|
||||
|
||||
GF2 = GaloisField2()
|
90
src/lalib/fields/rational.py
Normal file
90
src/lalib/fields/rational.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
"""The concrete `RationalField`."""
|
||||
|
||||
import fractions
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib import config
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class RationalField(utils.SingletonMixin, base.Field):
|
||||
"""The `Field` over ℚ, the rational numbers.
|
||||
|
||||
Although `Q.cast()` accepts `float`s as possible field elements,
|
||||
do so only with care as `float`s are inherently imprecise numbers:
|
||||
|
||||
>>> 0.1 + 0.2
|
||||
0.30000000000000004
|
||||
|
||||
To mitigate this, `Q.cast()` cuts off the decimals, as configured
|
||||
with the `MAX_DENOMINATOR` setting. So, `float`s with just a couple
|
||||
of digits return the possibly desired field element. For example:
|
||||
|
||||
>>> Q.cast(0.1)
|
||||
Fraction(1, 10)
|
||||
|
||||
Yet, with the hidden `max_denominator` argument, we can easily
|
||||
see how `float`s may result in "weird" `Fraction`s.
|
||||
|
||||
>>> Q.cast(0.1, max_denominator=1_000_000_000_000)
|
||||
Fraction(1, 10)
|
||||
>>> Q.cast(0.1, max_denominator=1_000_000_000_000_000_000)
|
||||
Fraction(3602879701896397, 36028797018963968)
|
||||
|
||||
It is recommended to use `str`ings instead:
|
||||
|
||||
>>> Q.cast("0.1")
|
||||
Fraction(1, 10)
|
||||
>>> Q.cast("1/10")
|
||||
Fraction(1, 10)
|
||||
"""
|
||||
|
||||
_math_name = "ℚ"
|
||||
_dtype = fractions.Fraction
|
||||
|
||||
def _cast_func(
|
||||
self,
|
||||
value: Any,
|
||||
/,
|
||||
max_denominator: int = config.MAX_DENOMINATOR,
|
||||
**_kwargs: Any,
|
||||
) -> fractions.Fraction:
|
||||
return fractions.Fraction(value).limit_denominator(max_denominator)
|
||||
|
||||
_additive_identity = fractions.Fraction(0, 1)
|
||||
_multiplicative_identity = fractions.Fraction(1, 1)
|
||||
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: fractions.Fraction = _additive_identity,
|
||||
upper: fractions.Fraction = _multiplicative_identity,
|
||||
max_denominator: int = config.MAX_DENOMINATOR,
|
||||
**_cast_kwargs: Any,
|
||||
) -> fractions.Fraction:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
max_denominator: maximum for `random_element.denominator`
|
||||
|
||||
Returns:
|
||||
random_element
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper)
|
||||
|
||||
# `random.uniform()` can handle `upper < lower`
|
||||
random_value = random.uniform(float(lower), float(upper)) # noqa: S311
|
||||
|
||||
return self._cast_func(random_value).limit_denominator(max_denominator)
|
||||
|
||||
|
||||
Q = RationalField()
|
97
src/lalib/fields/real.py
Normal file
97
src/lalib/fields/real.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
"""The concrete `RealField`."""
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from lalib import config
|
||||
from lalib.fields import base
|
||||
from lalib.fields import utils
|
||||
|
||||
|
||||
class RealField(utils.SingletonMixin, base.Field):
|
||||
"""The `Field` over ℝ, the real numbers."""
|
||||
|
||||
_math_name = "ℝ"
|
||||
_dtype = float
|
||||
_post_cast_filter = math.isfinite
|
||||
_additive_identity = 0.0
|
||||
_multiplicative_identity = 1.0
|
||||
|
||||
def random(
|
||||
self,
|
||||
*,
|
||||
lower: float = _additive_identity,
|
||||
upper: float = _multiplicative_identity,
|
||||
ndigits: int = config.NDIGITS,
|
||||
**_cast_kwargs: Any,
|
||||
) -> float:
|
||||
"""Draw a uniformly distributed random element from the field.
|
||||
|
||||
`lower` and `upper` may come in reversed order.
|
||||
|
||||
Args:
|
||||
lower: bound of the random interval
|
||||
upper: bound of the random interval
|
||||
ndigits: no. of significant digits to the right of the "."
|
||||
|
||||
Returns:
|
||||
random_element
|
||||
|
||||
Raises:
|
||||
ValueError: `lower` and `upper` are not `.cast()`able
|
||||
"""
|
||||
lower, upper = self._get_bounds(lower, upper)
|
||||
|
||||
# `random.uniform()` can handle `upper < lower`
|
||||
lower, upper = float(lower), float(upper)
|
||||
rand_value = random.uniform(lower, upper) # noqa: S311
|
||||
|
||||
return round(rand_value, ndigits)
|
||||
|
||||
def is_zero(
|
||||
self,
|
||||
value: float,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `0.0`.
|
||||
|
||||
Args:
|
||||
value: to be compared to `0.0`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_zero (boolean)
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value)) < threshold
|
||||
|
||||
def is_one(
|
||||
self,
|
||||
value: float,
|
||||
/,
|
||||
*,
|
||||
threshold: float = config.THRESHOLD,
|
||||
**_cast_kwargs: Any,
|
||||
) -> bool:
|
||||
"""Check if `value` equals `1.0`.
|
||||
|
||||
Args:
|
||||
value: to be compared to `1.0`
|
||||
threshold: for the equality check
|
||||
|
||||
Returns:
|
||||
is_one
|
||||
|
||||
Raises:
|
||||
ValueError: `value` is not `.cast()`able
|
||||
"""
|
||||
return abs(self.cast(value) - 1.0) < threshold
|
||||
|
||||
|
||||
R = RealField()
|
22
src/lalib/fields/utils.py
Normal file
22
src/lalib/fields/utils.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
"""Generic utilities for the library."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
try:
|
||||
from typing import Self
|
||||
except ImportError: # pragma: no cover to support Python 3.9 & 3.10
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class SingletonMixin:
|
||||
"""Utility class to provide singleton pattern implementation."""
|
||||
|
||||
_instance: Self
|
||||
|
||||
@staticmethod
|
||||
def __new__(cls: type[Self], *args: Any, **kwargs: Any) -> Self:
|
||||
"""Check if the `_instance` already exists."""
|
||||
if getattr(cls, "_instance", None) is None:
|
||||
cls._instance = super().__new__(cls, *args, **kwargs)
|
||||
return cls._instance
|
92
tests/fields/test_axioms.py
Normal file
92
tests/fields/test_axioms.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
"""Ensure all `Field`s fulfill the axioms from math.
|
||||
|
||||
Source: https://en.wikipedia.org/wiki/Field_(mathematics)#Classic_definition
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import operator
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
class TestAllFieldsManyTimes:
|
||||
"""Run the tests many times for all `field`s."""
|
||||
|
||||
@pytest.mark.parametrize("opr", [operator.add, operator.mul])
|
||||
def test_associativity(self, field, opr):
|
||||
"""`a + (b + c) == (a + b) + c` ...
|
||||
|
||||
... and `a * (b * c) == (a * b) * c`.
|
||||
"""
|
||||
a, b, c = field.random(), field.random(), field.random()
|
||||
|
||||
left = opr(a, opr(b, c))
|
||||
right = opr(opr(a, b), c)
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
@pytest.mark.parametrize("opr", [operator.add, operator.mul])
|
||||
def test_commutativity(self, field, opr):
|
||||
"""`a + b == b + a` ...
|
||||
|
||||
... and `a * b == b * a`.
|
||||
"""
|
||||
a, b = field.random(), field.random()
|
||||
|
||||
left = opr(a, b)
|
||||
right = opr(b, a)
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_additive_identity(self, field):
|
||||
"""`a + 0 == a`."""
|
||||
a = field.random()
|
||||
|
||||
left = a + field.zero
|
||||
right = a
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_multiplicative_identity(self, field):
|
||||
"""`a * 1 == a`."""
|
||||
a = field.random()
|
||||
|
||||
left = a * field.one
|
||||
right = a
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_additive_inverse(self, field):
|
||||
"""`a + (-a) == 0`."""
|
||||
a = field.random()
|
||||
|
||||
left = a + (-a)
|
||||
right = field.zero
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_multiplicative_inverse(self, field):
|
||||
"""`a * (1 / a) == 1`."""
|
||||
a = field.random()
|
||||
|
||||
# Realistically, `ZeroDivisionError` only occurs for `GF2`
|
||||
# => With a high enough `utils.N_RANDOM_DRAWS`
|
||||
# this test case is also `assert`ed for `GF2`
|
||||
with contextlib.suppress(ZeroDivisionError):
|
||||
left = a * (field.one / a)
|
||||
right = field.one
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
||||
|
||||
def test_distributivity(self, field):
|
||||
"""`a * (b + c) == (a * b) + (a * c)`."""
|
||||
a, b, c = field.random(), field.random(), field.random()
|
||||
|
||||
left = a * (b + c)
|
||||
right = (a * b) + (a * c)
|
||||
|
||||
assert left == pytest.approx(right, abs=utils.DEFAULT_THRESHOLD)
|
253
tests/fields/test_base.py
Normal file
253
tests/fields/test_base.py
Normal file
|
@ -0,0 +1,253 @@
|
|||
"""Generic tests for all `lalib.fields.*.Field`s.
|
||||
|
||||
The abstract base class `lalib.fields.base.Field`
|
||||
defines generic behavior that all concrete `Field`s
|
||||
in the `lalib.fields` sub-package must implement.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
class TestGenericClassBehavior:
|
||||
"""Generic `Field` behavior."""
|
||||
|
||||
def test_create_singletons(self, field):
|
||||
"""All `field`s so far are singletons."""
|
||||
cls = type(field)
|
||||
new_field = cls()
|
||||
|
||||
assert new_field is field
|
||||
|
||||
@pytest.mark.parametrize("func", [repr, str])
|
||||
def test_text_repr(self, field, func):
|
||||
"""The text representations behave like Python literals."""
|
||||
new_field = eval(func(field), fields.__dict__) # noqa: S307
|
||||
|
||||
assert new_field is field
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test `Field.cast()` and `Field.validate()`.
|
||||
|
||||
Every `field` must be able to tell if a given `value` is
|
||||
an element of the `field`, and, if so, `.cast()` it as such.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.NUMBERS)
|
||||
def test_number_is_field_element(self, field, value):
|
||||
"""Common numbers are typically `field` elements.
|
||||
|
||||
This is not true for `GF2`, which, by default,
|
||||
only accepts `1`-like and `0`-like numbers.
|
||||
"""
|
||||
utils.is_field_element(field, value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ONES_N_ZEROS)
|
||||
def test_one_and_zero_number_is_field_element(self, field, value):
|
||||
"""`1`-like and `0`-like numbers are always `field` elements."""
|
||||
utils.is_field_element(field, value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", ["abc", (1, 2, 3)])
|
||||
def test_non_numeric_value_is_not_field_element(self, field, value):
|
||||
"""Values of non-numeric data types are typically not `field` elements."""
|
||||
utils.is_not_field_element(field, value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"])
|
||||
def test_non_finite_number_is_not_field_element(self, field, pre_value):
|
||||
"""For now, we only allow finite numbers as `field` elements.
|
||||
|
||||
Notes:
|
||||
- `Q._cast_func()` cannot handle non-finite `value`s
|
||||
and raises an `OverflowError` or `ValueError`
|
||||
=> `Field.cast()` catches these errors
|
||||
and (re-)raises a `ValueError` instead
|
||||
=> no need to define a specific `._post_cast_filter()`
|
||||
- `R._cast_func()` and `C._cast_func()`
|
||||
handle non-finite `value`s without any complaints
|
||||
=> using a `._post_cast_filter()`, we don't allow
|
||||
non-finite but castable `value`s to be `field` elements
|
||||
- `GF2._cast_func()` handles non-finite `value`s
|
||||
by raising a `ValueError` already
|
||||
=> `Field.cast()` re-raises it with an adapted message
|
||||
=> no need to define a specific `._post_cast_filter()`
|
||||
"""
|
||||
value = float(pre_value)
|
||||
utils.is_not_field_element(field, value)
|
||||
|
||||
|
||||
class TestDTypes:
|
||||
"""Test the `Field.dtype` property."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_field_dtype(self, field):
|
||||
"""`field.dtype` must be a `type`."""
|
||||
assert isinstance(field.dtype, type)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_element_is_instance_of_field_dtype(self, field):
|
||||
"""Elements are an instance of `field.dtype`."""
|
||||
element = field.random()
|
||||
|
||||
assert isinstance(element, field.dtype)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_element_dtype_is_subclass_of_field_dtype(self, field):
|
||||
"""Elements may have a more specific `.dtype` than their `field.dtype`."""
|
||||
element = field.random()
|
||||
dtype = type(element)
|
||||
|
||||
assert issubclass(dtype, field.dtype)
|
||||
|
||||
|
||||
class TestIsZero:
|
||||
"""Test `Field.zero` & `Field.is_zero()`."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ZEROS)
|
||||
def test_is_exactly_zero(self, field, value):
|
||||
"""`value` is equal to `field.zero`."""
|
||||
assert field.zero == value
|
||||
assert field.is_zero(value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_is_almost_zero(self, field):
|
||||
"""`value` is within an acceptable threshold of `field.zero`."""
|
||||
value = 0.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.zero, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert field.is_zero(value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
def test_is_slightly_not_zero(self, field):
|
||||
"""`value` is not within an acceptable threshold of `field.zero`."""
|
||||
value = 0.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.zero, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not field.is_zero(value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ONES)
|
||||
def test_is_not_zero(self, field, value):
|
||||
"""`value` is not equal to `field.zero`."""
|
||||
assert field.zero != value
|
||||
assert not field.is_zero(value)
|
||||
|
||||
|
||||
class TestIsOne:
|
||||
"""Test `Field.one` & `Field.is_one()`."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ONES)
|
||||
def test_is_exactly_one(self, field, value):
|
||||
"""`value` is equal to `field.one`."""
|
||||
assert field.one == value
|
||||
assert field.is_one(value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_is_almost_one(self, field):
|
||||
"""`value` is within an acceptable threshold of `field.one`."""
|
||||
value = 1.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.one, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert field.is_one(value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
def test_is_slightly_not_one(self, field):
|
||||
"""`value` is not within an acceptable threshold of `field.one`."""
|
||||
value = 1.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(field.one, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not field.is_one(value)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
@pytest.mark.parametrize("value", utils.ZEROS)
|
||||
def test_is_not_one(self, field, value):
|
||||
"""`value` is not equal to `field.one`."""
|
||||
assert field.one != value
|
||||
assert not field.is_one(value)
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
class TestDrawRandomFieldElement:
|
||||
"""Test `Field.random()`."""
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_draw_element_with_default_bounds(self, field):
|
||||
"""Draw a random element from the `field`, ...
|
||||
|
||||
... within the `field`'s default bounds.
|
||||
|
||||
Here, the default bounds come from the default arguments.
|
||||
"""
|
||||
element = field.random()
|
||||
|
||||
assert field.validate(element)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.ALL_FIELDS)
|
||||
def test_draw_element_with_default_bounds_set_to_none(self, field):
|
||||
"""Draw a random element from the `field`, ...
|
||||
|
||||
... within the `field`'s default bounds.
|
||||
|
||||
If no default arguments are defined in `field.random()`,
|
||||
the internal `Field._get_bounds()` method provides them.
|
||||
"""
|
||||
element = field.random(lower=None, upper=None)
|
||||
|
||||
assert field.validate(element)
|
||||
|
||||
@pytest.mark.parametrize("field", utils.NON_10_FIELDS)
|
||||
def test_draw_element_with_custom_bounds(self, field):
|
||||
"""Draw a random element from the `field` ...
|
||||
|
||||
... within the bounds passed in as arguments.
|
||||
|
||||
For `GF2`, this only works in non-`strict` mode.
|
||||
"""
|
||||
lower = 200 * random.random() - 100 # noqa: S311
|
||||
upper = 200 * random.random() - 100 # noqa: S311
|
||||
|
||||
# `field.random()` sorts the bounds internally
|
||||
# => test both directions
|
||||
element1 = field.random(lower=lower, upper=upper)
|
||||
element2 = field.random(lower=upper, upper=lower)
|
||||
|
||||
assert field.validate(element1)
|
||||
assert field.validate(element2)
|
||||
|
||||
# Done implicitly in `field.random()` above
|
||||
lower, upper = field.cast(lower), field.cast(upper)
|
||||
|
||||
# Not all data types behind the `Field._cast_func()`
|
||||
# support sorting the numbers (e.g., `complex`)
|
||||
try:
|
||||
swap = upper < lower
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
if swap:
|
||||
lower, upper = upper, lower
|
||||
|
||||
assert lower <= element1 <= upper
|
||||
assert lower <= element2 <= upper
|
||||
|
||||
|
||||
def test_numbers():
|
||||
"""We use `0`, `1`, `+42`, and `-42` in different data types."""
|
||||
unique_one_and_zero = {int(n) for n in utils.ONES_N_ZEROS}
|
||||
unique_non_one_and_zero = {int(n) for n in utils.NON_ONES_N_ZEROS}
|
||||
unique_numbers = {int(n) for n in utils.NUMBERS}
|
||||
|
||||
assert unique_one_and_zero == {0, 1}
|
||||
assert unique_non_one_and_zero == {+42, -42}
|
||||
assert unique_numbers == {0, 1, +42, -42}
|
96
tests/fields/test_complex.py
Normal file
96
tests/fields/test_complex.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
"""Tests for the `lalib.fields.complex_.ComplexField` only."""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
C = fields.C
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test specifics for `C.cast()` and `C.validate()`."""
|
||||
|
||||
@pytest.mark.parametrize("pre_value", [1, 0, +42, -42])
|
||||
def test_complex_number_is_field_element(self, pre_value):
|
||||
"""`C` must be able to process `complex` numbers."""
|
||||
value = complex(pre_value, 0)
|
||||
utils.is_field_element(C, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"])
|
||||
def test_non_finite_complex_number_is_not_field_element(self, pre_value):
|
||||
"""For now, we only allow finite numbers as field elements.
|
||||
|
||||
This also holds true for `complex` numbers
|
||||
with a non-finite `.real` part.
|
||||
"""
|
||||
value = complex(pre_value)
|
||||
utils.is_not_field_element(C, value)
|
||||
|
||||
|
||||
class TestIsZero:
|
||||
"""Test specifics for `C.zero` and `C.is_zero()`."""
|
||||
|
||||
def test_is_almost_zero(self):
|
||||
"""`value` is within an acceptable threshold of `C.zero`."""
|
||||
value = 0.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.zero, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert C.is_zero(value)
|
||||
|
||||
def test_is_slightly_not_zero(self):
|
||||
"""`value` is not within an acceptable threshold of `C.zero`."""
|
||||
value = 0.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.zero, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not C.is_zero(value)
|
||||
|
||||
|
||||
class TestIsOne:
|
||||
"""Test specifics for `C.one` and `C.is_one()`."""
|
||||
|
||||
def test_is_almost_one(self):
|
||||
"""`value` is within an acceptable threshold of `C.one`."""
|
||||
value = 1.0 + utils.WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.one, abs=utils.DEFAULT_THRESHOLD) == value
|
||||
assert C.is_one(value)
|
||||
|
||||
def test_is_slightly_not_one(self):
|
||||
"""`value` is not within an acceptable threshold of `C.one`."""
|
||||
value = 1.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert pytest.approx(C.one, abs=utils.DEFAULT_THRESHOLD) != value
|
||||
assert not C.is_one(value)
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
class TestDrawRandomFieldElement:
|
||||
"""Test specifics for `C.random()`."""
|
||||
|
||||
def test_draw_elements_with_custom_bounds(self):
|
||||
"""Draw a random element from `C` ...
|
||||
|
||||
... within the bounds passed in as arguments.
|
||||
|
||||
For `C`, the bounds are interpreted in a 2D fashion.
|
||||
"""
|
||||
lower = complex(
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
)
|
||||
upper = complex(
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
200 * random.random() - 100, # noqa: S311
|
||||
)
|
||||
|
||||
element = C.random(lower=lower, upper=upper)
|
||||
|
||||
l_r, u_r = min(lower.real, upper.real), max(lower.real, upper.real)
|
||||
l_i, u_i = min(lower.imag, upper.imag), max(lower.imag, upper.imag)
|
||||
|
||||
assert l_r <= element.real <= u_r
|
||||
assert l_i <= element.imag <= u_i
|
100
tests/fields/test_galois.py
Normal file
100
tests/fields/test_galois.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
"""Tests for the `lalib.fields.galois.GaloisField2` only."""
|
||||
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
from tests.fields import utils
|
||||
|
||||
|
||||
GF2 = fields.GF2
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test specifics for `GF2.cast()` and `GF2.validate()`."""
|
||||
|
||||
@pytest.mark.parametrize("value", utils.NUMBERS)
|
||||
def test_number_is_field_element(self, value):
|
||||
"""Common numbers are always `GF2` elements in non-`strict` mode."""
|
||||
left = GF2.cast(value, strict=False)
|
||||
right = bool(value)
|
||||
|
||||
assert left == right
|
||||
assert GF2.validate(value, strict=False)
|
||||
|
||||
@pytest.mark.parametrize("value", utils.ONES_N_ZEROS)
|
||||
def test_one_and_zero_number_is_field_element(self, value):
|
||||
"""`1`-like and `0`-like `value`s are `GF2` elements."""
|
||||
utils.is_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", [1, 0])
|
||||
def test_complex_number_is_field_element(self, pre_value):
|
||||
"""By design, `GF2` can process `complex` numbers."""
|
||||
value = complex(pre_value, 0)
|
||||
utils.is_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", [+42, -42])
|
||||
def test_complex_number_is_not_field_element(self, pre_value):
|
||||
"""By design, `GF2` can process `complex` numbers ...
|
||||
|
||||
... but they must be `one`-like or `zero`-like
|
||||
to become a `GF2` element.
|
||||
"""
|
||||
value = complex(pre_value, 0)
|
||||
utils.is_not_field_element(GF2, value)
|
||||
|
||||
@pytest.mark.parametrize("pre_value", ["NaN", "+inf", "-inf"])
|
||||
def test_non_finite_complex_number_is_not_field_element(self, pre_value):
|
||||
"""For now, we only allow finite numbers as field elements.
|
||||
|
||||
This also holds true for `complex` numbers
|
||||
with a non-finite `.real` part.
|
||||
"""
|
||||
value = complex(pre_value)
|
||||
utils.is_not_field_element(GF2, value)
|
||||
|
||||
|
||||
class TestIsZero:
|
||||
"""Test specifics for `GF2.zero` and `GF2.is_zero()`."""
|
||||
|
||||
def test_is_slightly_not_zero(self):
|
||||
"""`value` is not within an acceptable threshold of `GF2.zero`."""
|
||||
value = 0.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert GF2.zero != value
|
||||
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
GF2.is_zero(value)
|
||||
|
||||
|
||||
class TestIsOne:
|
||||
"""Test specifics for `GF2.one` and `GF2.is_one()`."""
|
||||
|
||||
def test_is_slightly_not_one(self):
|
||||
"""`value` is not within an acceptable threshold of `GF2.one`."""
|
||||
value = 1.0 + utils.NOT_WITHIN_THRESHOLD
|
||||
|
||||
assert GF2.one != value
|
||||
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
GF2.is_one(value)
|
||||
|
||||
|
||||
@pytest.mark.repeat(utils.N_RANDOM_DRAWS)
|
||||
class TestDrawRandomFieldElement:
|
||||
"""Test specifics for `GF2.random()`."""
|
||||
|
||||
@pytest.mark.parametrize("bounds", itertools.product([0, 1], repeat=2))
|
||||
def test_draw_element_with_custom_bounds(self, bounds):
|
||||
"""Draw a random element from `GF2` in non-`strict` mode ...
|
||||
|
||||
... within the bounds passed in as arguments.
|
||||
"""
|
||||
lower, upper = bounds
|
||||
element = GF2.random(lower=lower, upper=upper)
|
||||
|
||||
if upper < lower:
|
||||
lower, upper = upper, lower
|
||||
|
||||
assert lower <= element <= upper
|
29
tests/fields/test_rational.py
Normal file
29
tests/fields/test_rational.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
"""Tests for the `lalib.fields.rational.RationalField` only."""
|
||||
|
||||
import fractions
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import fields
|
||||
|
||||
|
||||
Q = fields.Q
|
||||
|
||||
|
||||
class TestCastAndValidateFieldElements:
|
||||
"""Test specifics for `Q.cast()` and `Q.validate()`."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["1", "0", "1/1", "0/1", "+42", "-42", "+42/1", "-42/1"],
|
||||
)
|
||||
def test_str_is_field_element(self, value):
|
||||
"""`fractions.Fraction()` also accepts `str`ings.
|
||||
|
||||
Source: https://docs.python.org/3/library/fractions.html#fractions.Fraction
|
||||
"""
|
||||
left = Q.cast(value)
|
||||
right = fractions.Fraction(value)
|
||||
|
||||
assert left == right
|
||||
assert Q.validate(value)
|
75
tests/fields/utils.py
Normal file
75
tests/fields/utils.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
"""Utilities to test the `lalib.fields` sub-package."""
|
||||
|
||||
import decimal
|
||||
import fractions
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from lalib import config
|
||||
from lalib import elements
|
||||
from lalib import fields
|
||||
|
||||
|
||||
ALL_FIELDS = (fields.Q, fields.R, fields.C, fields.GF2)
|
||||
NON_10_FIELDS = (fields.Q, fields.R, fields.C)
|
||||
|
||||
ONES = (
|
||||
1,
|
||||
1.0,
|
||||
fractions.Fraction(1, 1),
|
||||
decimal.Decimal("1.0"),
|
||||
elements.one,
|
||||
True,
|
||||
)
|
||||
|
||||
ZEROS = (
|
||||
0,
|
||||
0.0,
|
||||
fractions.Fraction(0, 1),
|
||||
decimal.Decimal("+0.0"),
|
||||
decimal.Decimal("-0.0"),
|
||||
elements.zero,
|
||||
False,
|
||||
)
|
||||
|
||||
ONES_N_ZEROS = ONES + ZEROS
|
||||
|
||||
NON_ONES_N_ZEROS = (
|
||||
+42,
|
||||
+42.0,
|
||||
fractions.Fraction(+42, 1),
|
||||
decimal.Decimal("+42.0"),
|
||||
-42,
|
||||
-42.0,
|
||||
fractions.Fraction(-42, 1),
|
||||
decimal.Decimal("-42.0"),
|
||||
)
|
||||
|
||||
NUMBERS = ONES_N_ZEROS + NON_ONES_N_ZEROS
|
||||
|
||||
DEFAULT_THRESHOLD = config.THRESHOLD
|
||||
WITHIN_THRESHOLD = config.THRESHOLD / 10
|
||||
NOT_WITHIN_THRESHOLD = config.THRESHOLD * 10
|
||||
|
||||
N_RANDOM_DRAWS = os.environ.get("N_RANDOM_DRAWS") or 1
|
||||
|
||||
|
||||
def is_field_element(field, value):
|
||||
"""Utility method to avoid redundant logic in tests."""
|
||||
element = field.cast(value)
|
||||
|
||||
assert element == value
|
||||
assert field.validate(value)
|
||||
|
||||
|
||||
def is_not_field_element(field, value):
|
||||
"""Utility method to avoid redundant logic in tests."""
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
field.cast(value)
|
||||
|
||||
assert not field.validate(value)
|
||||
assert not field.validate(value, silent=True)
|
||||
|
||||
with pytest.raises(ValueError, match="not an element of the field"):
|
||||
field.validate(value, silent=False)
|
|
@ -16,6 +16,12 @@ import xdoctest
|
|||
"lalib",
|
||||
"lalib.elements",
|
||||
"lalib.elements.galois",
|
||||
"lalib.fields",
|
||||
"lalib.fields.base",
|
||||
"lalib.fields.complex_",
|
||||
"lalib.fields.galois",
|
||||
"lalib.fields.rational",
|
||||
"lalib.fields.real",
|
||||
],
|
||||
)
|
||||
def test_docstrings(module):
|
||||
|
|
Loading…
Reference in a new issue