Coverage for moptipy / mock / utils.py: 76%
237 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
1"""Utilities for mock objects."""
3from math import ceil, floor, inf, isfinite, nextafter
4from typing import Callable, Final, Sequence, cast # pylint: disable=W0611
6import numpy as np
7from numpy.random import Generator
8from pycommons.types import type_error
10#: The default types to be used for testing.
11DEFAULT_TEST_DTYPES: Final[tuple[np.dtype, ...]] = tuple(sorted({
12 np.dtype(bdt) for bdt in [
13 int, float, np.int8, np.int16, np.uint8, np.uint16, np.int32,
14 np.uint32, np.int64, np.uint64, np.float16, np.float32,
15 np.float64, np.float128]}, key=lambda dt: (dt.kind, dt.itemsize)))
18def _lb_int(lb: int | float) -> int:
19 """
20 Convert a finite lower bound to an integer.
22 :param lb: the lower bound
23 :retruns: the integer lower bound
25 >>> _lb_int(1)
26 1
27 >>> type(_lb_int(1))
28 <class 'int'>
29 >>> _lb_int(1.4)
30 2
31 >>> type(_lb_int(1.4))
32 <class 'int'>
33 """
34 return lb if isinstance(lb, int) else ceil(lb)
37def _ub_int(ub: int | float) -> int:
38 """
39 Convert a finite upper bound to an integer.
41 :param ub: the upper bound
42 :retruns: the integer upper bound
44 >>> _ub_int(1)
45 1
46 >>> type(_ub_int(1))
47 <class 'int'>
48 >>> _ub_int(1.4)
49 1
50 >>> type(_ub_int(1.4))
51 <class 'int'>
52 """
53 return ub if isinstance(ub, int) else floor(ub)
56def _float_beautify(f: float) -> float:
57 """
58 Get a slightly beautified float, if possible.
60 :param f: the float
61 :return: the beautified number
62 """
63 vb: int = round(1000.0 * f)
64 r1: float = 0.001 * vb
65 l1: int = len(str(r1))
67 r2: float = 0.001 * (vb + 1)
68 l2: int = len(str(r2))
69 if l2 < l1:
70 l1 = l2
71 r1 = r2
73 r2 = 0.001 * (vb - 1)
74 l2 = len(str(r2))
75 if l2 < l1:
76 return r2
77 return r1
80def _before_int(upper_bound: int | float,
81 random: Generator) -> int | None:
82 """
83 Get an `int` value before the given limit.
85 :param upper_bound: the upper bound
86 :param random: the generator
87 :returns: the value, if it could be generated, `None` otherwise
88 """
89 if upper_bound >= inf:
90 upper_bound = 10000
91 elif not isfinite(upper_bound):
92 return None
93 lov = min(int(0.6 * upper_bound), upper_bound - 22) \
94 if upper_bound > 0 else max(int(upper_bound / 0.6), upper_bound - 22)
95 lo: Final[int] = _lb_int(max(lov, -9223372036854775806))
96 up: Final[int] = _ub_int(upper_bound)
97 if lo >= up:
98 return None
99 res = int(random.integers(lo, up))
100 if res >= upper_bound:
101 return None
102 return res
105def _before_float(upper_bound: int | float,
106 random: Generator) -> float | None:
107 """
108 Get a `float` value before the given limit.
110 :param upper_bound: the upper bound
111 :param random: the generator
112 :returns: the value, if it could be generated, `None` otherwise
113 """
114 if upper_bound >= inf:
115 upper_bound = 10000.0
116 elif not isfinite(upper_bound):
117 return None
118 ulp = 1E16 * (upper_bound - nextafter(upper_bound, -inf)) \
119 if (upper_bound < 0) else 1E-8 if upper_bound <= 0 \
120 else 1E16 * (nextafter(upper_bound, inf) - upper_bound)
121 lo = min(upper_bound * 0.6, upper_bound - ulp) \
122 if upper_bound > 0.0 else min(upper_bound / 0.6, upper_bound - ulp)
123 if (not isfinite(lo)) or (lo >= upper_bound):
124 return None
125 res = float(random.uniform(lo, upper_bound))
127 if (not isfinite(res)) or (res >= upper_bound):
128 return None
129 resb = _float_beautify(res)
130 if isfinite(resb) and (resb < upper_bound):
131 return resb
132 return res
135def _after_int(lower_bound: int | float,
136 random: Generator) -> int | None:
137 """
138 Get an `int` value after the given limit.
140 :param lower_bound: the upper bound
141 :param random: the generator
142 :returns: the value, if it could be generated, `None` otherwise
143 """
144 if lower_bound <= -inf:
145 lower_bound = -10000
146 elif not isfinite(lower_bound):
147 return None
148 uv = max(int(lower_bound / 0.6), lower_bound + 22) \
149 if lower_bound > 0 else max(int(lower_bound * 0.6), lower_bound + 22)
150 ub: Final[int] = _ub_int(min(uv, 9223372036854775806))
151 lb: Final[int] = _lb_int(lower_bound)
152 if lb >= ub:
153 return None
154 res = int(random.integers(lb, ub))
155 if res <= lower_bound:
156 return None
157 return res
160def _after_float(lower_bound: int | float,
161 random: Generator) -> float | None:
162 """
163 Get a `float` value after the given limit.
165 :param lower_bound: the upper bound
166 :param random: the generator
167 :returns: the value, if it could be generated, `None` otherwise
168 """
169 if lower_bound <= -inf:
170 lower_bound = -10000.0
171 elif not isfinite(lower_bound):
172 return None
173 ulp = 1E16 * (lower_bound - nextafter(lower_bound, -inf)) \
174 if (lower_bound < 0) else 1E-8 if lower_bound <= 0 \
175 else 1E16 * (nextafter(lower_bound, inf) - lower_bound)
176 hi = max(lower_bound / 0.6, lower_bound + ulp) \
177 if lower_bound > 0.0 else max(lower_bound * 0.6, lower_bound + ulp)
178 if (not isfinite(hi)) or (hi <= lower_bound):
179 return None
180 res = float(random.uniform(lower_bound, hi))
181 if (not isfinite(res)) or (res <= lower_bound):
182 return None
183 resb = _float_beautify(res)
184 if isfinite(resb) and (resb > lower_bound):
185 return resb
186 return res
189def _between_int(lower_bound: int | float,
190 upper_bound: int | float,
191 random: Generator) -> int | None:
192 """
193 Compute a number between two others.
195 :param lower_bound: the minimum
196 :param upper_bound: the maximum
197 :param random: the generator
198 :returns: the value, if it could be generated, `None` otherwise
199 """
200 if isfinite(lower_bound):
201 if isfinite(upper_bound):
202 lb: Final[int] = _lb_int(lower_bound) + 1
203 ub: Final[int] = _ub_int(upper_bound)
204 if lb < ub:
205 return int(random.integers(lb, ub))
206 return None
207 return _after_int(lower_bound, random)
208 if isfinite(upper_bound):
209 return _before_int(upper_bound, random)
210 return int(random.normal(0, 1000.0))
213def _between_float(lower_bound: int | float,
214 upper_bound: int | float,
215 random: Generator) -> float | None:
216 """
217 Compute a number between two others.
219 :param lower_bound: the minimum
220 :param upper_bound: the maximum
221 :param random: the generator
222 :returns: the value, if it could be generated, `None` otherwise
223 """
224 if isfinite(lower_bound):
225 if isfinite(upper_bound):
226 a = lower_bound
227 b = upper_bound
228 for _ in range(5):
229 a = nextafter(a, inf)
230 b = nextafter(b, -inf)
231 if a < b:
232 res = max(a, min(b, float(random.uniform(a, b))))
233 if not isfinite(res) or not (lower_bound < res < upper_bound):
234 return None
235 resb = _float_beautify(res)
236 if isfinite(resb) and (lower_bound < resb < upper_bound):
237 return resb
238 return res
239 return None
240 return _after_float(lower_bound, random)
241 if isfinite(upper_bound):
242 return _before_float(upper_bound, random)
243 return float(random.normal(0, 1000.0))
246def make_ordered_list(definition: Sequence[int | float | None],
247 is_int: bool, random: Generator) \
248 -> list[int | float] | None:
249 """
250 Make an ordered list of elements, filling in gaps.
252 This function takes a list template where some values may be defined
253 and some may be left `None`.
254 The `None` values are then replaced such that an overall ordered list
255 is created where each value is larger than its predecessor.
256 The original non-`None` elements are kept in place.
257 Of course, this process may fail, in which case `None` is returned.
259 :param definition: a template with `None` for gaps to be filled
260 :param is_int: should all generated values be integers?
261 :param random: the generator
262 :returns: the refined tuple with all values filled in
264 >>> from numpy.random import default_rng
265 >>> rg = default_rng(11)
266 >>> make_ordered_list([None, 10, None, None, 50], True, rg)
267 [-10, 10, 42, 47, 50]
268 >>> make_ordered_list([None, 10, None, None, 50, None, None], False, rg)
269 [-5.136, 10, 13.228, 15.19, 50, 115.953, 125.961]
270 >>> print(make_ordered_list([9, None, 10, None, None, 50], True, rg))
271 None
272 >>> make_ordered_list([8, None, 10, None, None, 50], True, rg)
273 [8, 9, 10, 45, 47, 50]
274 >>> make_ordered_list([9, None, 10, None, None, 50], False, rg)
275 [9, 9.568, 10, 47.576, 49.482, 50]
276 """
277 if not isinstance(definition, Sequence):
278 raise type_error(definition, "definition", Sequence)
279 total: Final[int] = len(definition)
280 if total <= 0:
281 return []
283 if not isinstance(random, Generator):
284 raise type_error(random, "random", Generator)
285 if not isinstance(is_int, bool):
286 raise type_error(is_int, "is_int", bool)
288 if is_int:
289 lbefore = cast("Callable[[int | float, Generator], "
290 "int | float | None]", _before_int)
291 lafter = cast("Callable[[int | float, Generator], "
292 "int | float | None]", _after_int)
293 lbetween = cast("Callable[[int | float, int | float, "
294 "Generator], int | float | None]",
295 _between_int)
296 else:
297 lbefore = cast("Callable[[int | float, Generator], "
298 "int | float | None]", _before_float)
299 lafter = cast("Callable[[int | float, Generator], "
300 "int | float | None]", _after_float)
301 lbetween = cast("Callable[[int | float, "
302 "int | float, Generator], int | float | None]",
303 _between_float)
305 max_trials: int = 1000
306 while max_trials > 0:
307 max_trials -= 1
308 result = list(definition)
310 failed: bool = False
312 # create one random midpoint if necessary
313 has_defined: bool = False
314 for i in range(total):
315 if result[i] is not None:
316 has_defined = True
317 break
318 if not has_defined:
319 val = lbetween(-inf, inf, random)
320 result[int(random.integers(total))] = val
321 failed = val is None
322 if failed:
323 continue
325 # fill front backwards
326 for i in range(total):
327 ub = result[i]
328 if ub is not None:
329 for j in range(i - 1, -1, -1):
330 ub = lbefore(ub, random)
331 if ub is None:
332 failed = True
333 break
334 result[j] = ub
335 break
336 if failed:
337 continue
339 # fill end forward
340 for i in range(total - 1, -1, -1):
341 lb = result[i]
342 if lb is not None:
343 for j in range(i + 1, total):
344 lb = lafter(lb, random)
345 if lb is None:
346 failed = True
347 break
348 result[j] = lb
349 break
350 if failed:
351 continue
353 # fill all the gaps in between
354 while not failed:
355 # find random gap
356 has_missing: bool = False
357 ofs: int = int(random.integers(total))
358 missing: int = 0
359 for i in range(total):
360 missing = (ofs + i) % total
361 if result[missing] is None:
362 has_missing = True
363 break
364 if not has_missing:
365 break
367 # find start of gap and lower bound
368 prev_idx: int = missing
369 prev: int | float | None = None
370 for i in range(missing - 1, -1, -1):
371 prev = result[i]
372 if prev is not None:
373 prev_idx = i
374 break
376 # find end of gap and upper bound
377 nxt_idx: int = missing
378 nxt: int | float | None = None
379 for i in range(missing + 1, total):
380 nxt = result[i]
381 if nxt is not None:
382 nxt_idx = i
383 break
385 # generate new value and store at random position in gap
386 val = lbetween(prev, nxt, random)
387 if val is None:
388 failed = True
389 break
390 result[int(random.integers(prev_idx + 1, nxt_idx))] = val
392 if failed:
393 continue
395 # now check and return result
396 prev = result[0]
397 if prev is None:
398 continue
399 for i in range(1, total):
400 nxt = result[i]
401 if (nxt is None) or (nxt <= prev):
402 failed = True
403 break
404 prev = nxt
405 if not failed:
406 return result
408 return None
411def sample_from_attractors(random: Generator,
412 attractors: Sequence[int | float],
413 is_int: bool = False,
414 lb: int | float = -inf,
415 ub: int | float = inf) -> int | float:
416 """
417 Sample from a given range using the specified attractors.
419 :param random: the random number generator
420 :param attractors: the attractor points
421 :param lb: the lower bound
422 :param ub: the upper bound
423 :param is_int: shall we sample integer values?
424 :return: the value
425 :raises ValueError: if the sampling failed
427 >>> from numpy.random import default_rng
428 >>> rg = default_rng(11)
429 >>> sample_from_attractors(rg, [5, 20])
430 15.198106552324713
431 >>> sample_from_attractors(rg, [2], lb=0, ub=10, is_int=True)
432 3
433 >>> sample_from_attractors(rg, [5, 20], lb=4)
434 4.7448464616061665
435 >>> sample_from_attractors(rg, [5, 20], ub=22)
436 1.044618552249311
437 >>> sample_from_attractors(rg, [5, 20], lb=0, ub=30, is_int=True)
438 6
439 >>> sample_from_attractors(rg, [5, 20], lb=4, ub=22, is_int=True)
440 20
441 """
442 max_trials: int = 1000
443 al: Final[int] = len(attractors)
444 while max_trials > 0:
445 max_trials -= 1
447 chosen_idx = int(random.integers(al))
448 chosen = attractors[chosen_idx]
449 lo = attractors[chosen_idx - 1] if (chosen_idx > 0) else lb
450 hi = attractors[chosen_idx + 1] if (chosen_idx < (al - 1)) else ub
452 sd = 0.5 * min(hi - chosen, chosen - lo)
453 if not isfinite(sd):
454 sd = max(1.0, 0.05 * abs(chosen))
455 sample = random.normal(chosen, sd)
456 if not isfinite(sample):
457 continue
458 sample = int(sample) if is_int else float(sample)
459 if lb <= sample <= ub:
460 return sample
462 raise ValueError(f"Failed to sample with lb={lb}, ub={ub}, "
463 f"attractors={attractors}, is_int={is_int}.")