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

1"""Utilities for mock objects.""" 

2 

3from math import ceil, floor, inf, isfinite, nextafter 

4from typing import Callable, Final, Sequence, cast # pylint: disable=W0611 

5 

6import numpy as np 

7from numpy.random import Generator 

8from pycommons.types import type_error 

9 

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))) 

16 

17 

18def _lb_int(lb: int | float) -> int: 

19 """ 

20 Convert a finite lower bound to an integer. 

21 

22 :param lb: the lower bound 

23 :retruns: the integer lower bound 

24 

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) 

35 

36 

37def _ub_int(ub: int | float) -> int: 

38 """ 

39 Convert a finite upper bound to an integer. 

40 

41 :param ub: the upper bound 

42 :retruns: the integer upper bound 

43 

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) 

54 

55 

56def _float_beautify(f: float) -> float: 

57 """ 

58 Get a slightly beautified float, if possible. 

59 

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)) 

66 

67 r2: float = 0.001 * (vb + 1) 

68 l2: int = len(str(r2)) 

69 if l2 < l1: 

70 l1 = l2 

71 r1 = r2 

72 

73 r2 = 0.001 * (vb - 1) 

74 l2 = len(str(r2)) 

75 if l2 < l1: 

76 return r2 

77 return r1 

78 

79 

80def _before_int(upper_bound: int | float, 

81 random: Generator) -> int | None: 

82 """ 

83 Get an `int` value before the given limit. 

84 

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 

103 

104 

105def _before_float(upper_bound: int | float, 

106 random: Generator) -> float | None: 

107 """ 

108 Get a `float` value before the given limit. 

109 

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)) 

126 

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 

133 

134 

135def _after_int(lower_bound: int | float, 

136 random: Generator) -> int | None: 

137 """ 

138 Get an `int` value after the given limit. 

139 

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 

158 

159 

160def _after_float(lower_bound: int | float, 

161 random: Generator) -> float | None: 

162 """ 

163 Get a `float` value after the given limit. 

164 

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 

187 

188 

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. 

194 

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)) 

211 

212 

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. 

218 

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)) 

244 

245 

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. 

251 

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. 

258 

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 

263 

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 [] 

282 

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) 

287 

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) 

304 

305 max_trials: int = 1000 

306 while max_trials > 0: 

307 max_trials -= 1 

308 result = list(definition) 

309 

310 failed: bool = False 

311 

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 

324 

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 

338 

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 

352 

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 

366 

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 

375 

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 

384 

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 

391 

392 if failed: 

393 continue 

394 

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 

407 

408 return None 

409 

410 

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. 

418 

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 

426 

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 

446 

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 

451 

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 

461 

462 raise ValueError(f"Failed to sample with lb={lb}, ub={ub}, " 

463 f"attractors={attractors}, is_int={is_int}.")