"""Utilities for mock objects."""
from math import ceil, floor, inf, isfinite, nextafter
from typing import Callable, Final, Sequence, cast
import numpy as np
from numpy.random import Generator
from pycommons.types import type_error
#: The default types to be used for testing.
DEFAULT_TEST_DTYPES: Final[tuple[np.dtype, ...]] = tuple(sorted({
np.dtype(bdt) for bdt in [
int, float, np.int8, np.int16, np.uint8, np.uint16, np.int32,
np.uint32, np.int64, np.uint64, np.float16, np.float32,
np.float64, np.float128]}, key=lambda dt: (dt.kind, dt.itemsize)))
def _lb_int(lb: int | float) -> int:
"""
Convert a finite lower bound to an integer.
:param lb: the lower bound
:retruns: the integer lower bound
>>> _lb_int(1)
1
>>> type(_lb_int(1))
<class 'int'>
>>> _lb_int(1.4)
2
>>> type(_lb_int(1.4))
<class 'int'>
"""
return lb if isinstance(lb, int) else int(ceil(lb))
def _ub_int(ub: int | float) -> int:
"""
Convert a finite upper bound to an integer.
:param ub: the upper bound
:retruns: the integer upper bound
>>> _ub_int(1)
1
>>> type(_ub_int(1))
<class 'int'>
>>> _ub_int(1.4)
1
>>> type(_ub_int(1.4))
<class 'int'>
"""
return ub if isinstance(ub, int) else int(floor(ub))
def _float_beautify(f: float) -> float:
"""
Get a slightly beautified float, if possible.
:param f: the float
:return: the beautified number
"""
vb: int = int(round(1000.0 * f))
r1: float = 0.001 * vb
l1: int = len(str(r1))
r2: float = 0.001 * (vb + 1)
l2: int = len(str(r2))
if l2 < l1:
l1 = l2
r1 = r2
r2 = 0.001 * (vb - 1)
l2 = len(str(r2))
if l2 < l1:
return r2
return r1
def _before_int(upper_bound: int | float,
random: Generator) -> int | None:
"""
Get an `int` value before the given limit.
:param upper_bound: the upper bound
:param random: the generator
:returns: the value, if it could be generated, `None` otherwise
"""
if upper_bound >= inf:
upper_bound = 10000
elif not isfinite(upper_bound):
return None
lov = min(int(0.6 * upper_bound), upper_bound - 22) \
if upper_bound > 0 else max(int(upper_bound / 0.6), upper_bound - 22)
lo: Final[int] = _lb_int(max(lov, -9223372036854775806))
up: Final[int] = _ub_int(upper_bound)
if lo >= up:
return None
res = int(random.integers(lo, up))
if res >= upper_bound:
return None
return res
def _before_float(upper_bound: int | float,
random: Generator) -> float | None:
"""
Get a `float` value before the given limit.
:param upper_bound: the upper bound
:param random: the generator
:returns: the value, if it could be generated, `None` otherwise
"""
if upper_bound >= inf:
upper_bound = 10000.0
elif not isfinite(upper_bound):
return None
ulp = 1E16 * (upper_bound - nextafter(upper_bound, -inf)) \
if (upper_bound < 0) else 1E-8 if upper_bound <= 0 \
else 1E16 * (nextafter(upper_bound, inf) - upper_bound)
lo = min(upper_bound * 0.6, upper_bound - ulp) \
if upper_bound > 0.0 else min(upper_bound / 0.6, upper_bound - ulp)
if (not isfinite(lo)) or (lo >= upper_bound):
return None
res = float(random.uniform(lo, upper_bound))
if (not isfinite(res)) or (res >= upper_bound):
return None
resb = _float_beautify(res)
if isfinite(resb) and (resb < upper_bound):
return resb
return res
def _after_int(lower_bound: int | float,
random: Generator) -> int | None:
"""
Get an `int` value after the given limit.
:param lower_bound: the upper bound
:param random: the generator
:returns: the value, if it could be generated, `None` otherwise
"""
if lower_bound <= -inf:
lower_bound = -10000
elif not isfinite(lower_bound):
return None
uv = max(int(lower_bound / 0.6), lower_bound + 22) \
if lower_bound > 0 else max(int(lower_bound * 0.6), lower_bound + 22)
ub: Final[int] = _ub_int(min(uv, 9223372036854775806))
lb: Final[int] = _lb_int(lower_bound)
if lb >= ub:
return None
res = int(random.integers(lb, ub))
if res <= lower_bound:
return None
return res
def _after_float(lower_bound: int | float,
random: Generator) -> float | None:
"""
Get a `float` value after the given limit.
:param lower_bound: the upper bound
:param random: the generator
:returns: the value, if it could be generated, `None` otherwise
"""
if lower_bound <= -inf:
lower_bound = -10000.0
elif not isfinite(lower_bound):
return None
ulp = 1E16 * (lower_bound - nextafter(lower_bound, -inf)) \
if (lower_bound < 0) else 1E-8 if lower_bound <= 0 \
else 1E16 * (nextafter(lower_bound, inf) - lower_bound)
hi = max(lower_bound / 0.6, lower_bound + ulp) \
if lower_bound > 0.0 else max(lower_bound * 0.6, lower_bound + ulp)
if (not isfinite(hi)) or (hi <= lower_bound):
return None
res = float(random.uniform(lower_bound, hi))
if (not isfinite(res)) or (res <= lower_bound):
return None
resb = _float_beautify(res)
if isfinite(resb) and (resb > lower_bound):
return resb
return res
def _between_int(lower_bound: int | float,
upper_bound: int | float,
random: Generator) -> int | None:
"""
Compute a number between two others.
:param lower_bound: the minimum
:param upper_bound: the maximum
:param random: the generator
:returns: the value, if it could be generated, `None` otherwise
"""
if isfinite(lower_bound):
if isfinite(upper_bound):
lb: Final[int] = _lb_int(lower_bound) + 1
ub: Final[int] = _ub_int(upper_bound)
if lb < ub:
return int(random.integers(lb, ub))
return None
return _after_int(lower_bound, random)
if isfinite(upper_bound):
return _before_int(upper_bound, random)
return int(random.normal(0, 1000.0))
def _between_float(lower_bound: int | float,
upper_bound: int | float,
random: Generator) -> float | None:
"""
Compute a number between two others.
:param lower_bound: the minimum
:param upper_bound: the maximum
:param random: the generator
:returns: the value, if it could be generated, `None` otherwise
"""
if isfinite(lower_bound):
if isfinite(upper_bound):
a = lower_bound
b = upper_bound
for _ in range(5):
a = nextafter(a, inf)
b = nextafter(b, -inf)
if a < b:
res = max(a, min(b, float(random.uniform(a, b))))
if not isfinite(res) or not (lower_bound < res < upper_bound):
return None
resb = _float_beautify(res)
if isfinite(resb) and (lower_bound < resb < upper_bound):
return resb
return res
return None
return _after_float(lower_bound, random)
if isfinite(upper_bound):
return _before_float(upper_bound, random)
return float(random.normal(0, 1000.0))
[docs]
def make_ordered_list(definition: Sequence[int | float | None],
is_int: bool, random: Generator) \
-> list[int | float] | None:
"""
Make an ordered list of elements, filling in gaps.
This function takes a list template where some values may be defined
and some may be left `None`.
The `None` values are then replaced such that an overall ordered list
is created where each value is larger than its predecessor.
The original non-`None` elements are kept in place.
Of course, this process may fail, in which case `None` is returned.
:param definition: a template with `None` for gaps to be filled
:param is_int: should all generated values be integers?
:param random: the generator
:returns: the refined tuple with all values filled in
>>> from numpy.random import default_rng
>>> rg = default_rng(11)
>>> make_ordered_list([None, 10, None, None, 50], True, rg)
[-10, 10, 42, 47, 50]
>>> make_ordered_list([None, 10, None, None, 50, None, None], False, rg)
[-5.136, 10, 13.228, 15.19, 50, 115.953, 125.961]
>>> print(make_ordered_list([9, None, 10, None, None, 50], True, rg))
None
>>> make_ordered_list([8, None, 10, None, None, 50], True, rg)
[8, 9, 10, 45, 47, 50]
>>> make_ordered_list([9, None, 10, None, None, 50], False, rg)
[9, 9.568, 10, 47.576, 49.482, 50]
"""
if not isinstance(definition, Sequence):
raise type_error(definition, "definition", Sequence)
total: Final[int] = len(definition)
if total <= 0:
return []
if not isinstance(random, Generator):
raise type_error(random, "random", Generator)
if not isinstance(is_int, bool):
raise type_error(is_int, "is_int", bool)
if is_int:
_before = cast(Callable[[int | float, Generator],
int | float | None], _before_int)
_after = cast(Callable[[int | float, Generator],
int | float | None], _after_int)
_between = cast(Callable[[int | float, int | float,
Generator], int | float | None],
_between_int)
else:
_before = cast(Callable[[int | float, Generator],
int | float | None], _before_float)
_after = cast(Callable[[int | float, Generator],
int | float | None], _after_float)
_between = cast(Callable[[int | float, int | float,
Generator], int | float | None],
_between_float)
max_trials: int = 1000
while max_trials > 0:
max_trials = max_trials - 1
result = list(definition)
failed: bool = False
# create one random midpoint if necessary
has_defined: bool = False
for i in range(total):
if result[i] is not None:
has_defined = True
break
if not has_defined:
val = _between(-inf, inf, random)
result[random.integers(total)] = val
failed = val is None
if failed:
continue
# fill front backwards
for i in range(total):
ub = result[i]
if ub is not None:
for j in range(i - 1, -1, -1):
ub = _before(ub, random)
if ub is None:
failed = True
break
result[j] = ub
break
if failed:
continue
# fill end forward
for i in range(total - 1, -1, -1):
lb = result[i]
if lb is not None:
for j in range(i + 1, total):
lb = _after(lb, random)
if lb is None:
failed = True
break
result[j] = lb
break
if failed:
continue
# fill all the gaps in between
while not failed:
# find random gap
has_missing: bool = False
ofs: int = random.integers(total)
missing: int = 0
for i in range(total):
missing = (ofs + i) % total
if result[missing] is None:
has_missing = True
break
if not has_missing:
break
# find start of gap and lower bound
prev_idx: int = missing
prev: int | float | None = None
for i in range(missing - 1, -1, -1):
prev = result[i]
if prev is not None:
prev_idx = i
break
# find end of gap and upper bound
nxt_idx: int = missing
nxt: int | float | None = None
for i in range(missing + 1, total):
nxt = result[i]
if nxt is not None:
nxt_idx = i
break
# generate new value and store at random position in gap
val = _between(prev, nxt, random)
if val is None:
failed = True
break
result[random.integers(prev_idx + 1, nxt_idx)] = val
if failed:
continue
# now check and return result
prev = result[0]
if prev is None:
continue
for i in range(1, total):
nxt = result[i]
if (nxt is None) or (nxt <= prev):
failed = True
break
prev = nxt
if not failed:
return result
return None
[docs]
def sample_from_attractors(random: Generator,
attractors: Sequence[int | float],
is_int: bool = False,
lb: int | float = -inf,
ub: int | float = inf) -> int | float:
"""
Sample from a given range using the specified attractors.
:param random: the random number generator
:param attractors: the attractor points
:param lb: the lower bound
:param ub: the upper bound
:param is_int: shall we sample integer values?
:return: the value
:raises ValueError: if the sampling failed
>>> from numpy.random import default_rng
>>> rg = default_rng(11)
>>> sample_from_attractors(rg, [5, 20])
15.198106552324713
>>> sample_from_attractors(rg, [2], lb=0, ub=10, is_int=True)
3
>>> sample_from_attractors(rg, [5, 20], lb=4)
4.7448464616061665
>>> sample_from_attractors(rg, [5, 20], ub=22)
1.044618552249311
>>> sample_from_attractors(rg, [5, 20], lb=0, ub=30, is_int=True)
6
>>> sample_from_attractors(rg, [5, 20], lb=4, ub=22, is_int=True)
20
"""
max_trials: int = 1000
al: Final[int] = len(attractors)
while max_trials > 0:
max_trials -= 1
chosen_idx = random.integers(al)
chosen = attractors[chosen_idx]
lo = attractors[chosen_idx - 1] if (chosen_idx > 0) else lb
hi = attractors[chosen_idx + 1] if (chosen_idx < (al - 1)) else ub
sd = 0.5 * min(hi - chosen, chosen - lo)
if not isfinite(sd):
sd = max(1.0, 0.05 * abs(chosen))
sample = random.normal(chosen, sd)
if not isfinite(sample):
continue
sample = int(sample) if is_int else float(sample)
if lb <= sample <= ub:
return sample
raise ValueError(f"Failed to sample with lb={lb}, ub={ub}, "
f"attractors={attractors}, is_int={is_int}.")