Coverage for moptipy / evaluation / axis_ranger.py: 64%

242 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-24 08:49 +0000

1"""A utility to specify axis ranges.""" 

2import sys 

3from math import inf, isfinite 

4from typing import Callable, Final 

5 

6import numpy as np 

7from matplotlib.axes import Axes # type: ignore 

8from pycommons.types import type_error 

9 

10from moptipy.api.logging import ( 

11 KEY_BEST_F, 

12 KEY_LAST_IMPROVEMENT_FE, 

13 KEY_LAST_IMPROVEMENT_TIME_MILLIS, 

14 KEY_TOTAL_FES, 

15 KEY_TOTAL_TIME_MILLIS, 

16) 

17from moptipy.evaluation.base import ( 

18 F_NAME_NORMALIZED, 

19 F_NAME_RAW, 

20 F_NAME_SCALED, 

21 TIME_UNIT_FES, 

22 TIME_UNIT_MILLIS, 

23) 

24from moptipy.evaluation.end_statistics import KEY_ERT_FES, KEY_ERT_TIME_MILLIS 

25 

26#: The internal minimum float value for log-scaled axes. 

27_MIN_LOG_FLOAT: Final[float] = sys.float_info.min 

28 

29 

30class AxisRanger: 

31 """An object for simplifying axis range computations.""" 

32 

33 def __init__(self, 

34 chosen_min: float | None = None, 

35 chosen_max: float | None = None, 

36 use_data_min: bool = True, 

37 use_data_max: bool = True, 

38 log_scale: bool = False, 

39 log_base: float | None = None): 

40 """ 

41 Initialize the axis ranger. 

42 

43 :param chosen_min: the chosen minimum 

44 :param chosen_max: the chosen maximum 

45 :param use_data_min: use the minimum found in the data? 

46 :param use_data_max: use the maximum found in the data? 

47 :param log_scale: should the axis be log-scaled? 

48 :param log_base: the base to be used for the logarithm 

49 """ 

50 if not isinstance(log_scale, bool): 

51 raise type_error(log_scale, "log_scale", bool) 

52 #: Should the axis be log-scaled? 

53 self.log_scale: Final[bool] = log_scale 

54 

55 self.__log_base: Final[float | None] = \ 

56 log_base if self.log_scale else None 

57 if self.__log_base is not None: 

58 if not isinstance(log_base, float): 

59 raise type_error(log_base, "log_base", float) 

60 if log_base <= 1.0: 

61 raise ValueError(f"log_base must be > 1, but is {log_base}.") 

62 

63 if chosen_min is not None: 

64 if not isinstance(chosen_min, float | int): 

65 raise type_error(chosen_min, "chosen_min", (int, float)) 

66 chosen_min = float(chosen_min) 

67 if not isfinite(chosen_min): 

68 raise ValueError(f"chosen_min cannot be {chosen_min}.") 

69 if self.log_scale and (chosen_min <= 0): 

70 raise ValueError( 

71 f"if log_scale={self.log_scale}, then chosen_min must " 

72 f"be > 0, but is {chosen_min}.") 

73 

74 #: The pre-defined, chosen minimum axis value. 

75 self.__chosen_min: Final[float | None] = chosen_min 

76 

77 if chosen_max is not None: 

78 if not isinstance(chosen_max, float | int): 

79 raise type_error(chosen_max, "chosen_max", (int, float)) 

80 chosen_max = float(chosen_max) 

81 if not isfinite(chosen_max): 

82 raise ValueError(f"chosen_max cannot be {chosen_max}.") 

83 if (self.__chosen_min is not None) and \ 

84 (chosen_max <= self.__chosen_min): 

85 raise ValueError(f"If chosen_min is {self.__chosen_min}, then" 

86 f" chosen_max cannot be {chosen_max}.") 

87 

88 #: The pre-defined, chosen maximum axis value. 

89 self.__chosen_max: Final[float | None] = chosen_max 

90 

91 if not isinstance(use_data_min, bool): 

92 raise type_error(use_data_min, "use_data_min", bool) 

93 #: Should we use the data min value? 

94 self.__use_data_min: Final[bool] = use_data_min 

95 

96 if not isinstance(use_data_max, bool): 

97 raise type_error(use_data_max, "use_data_max", bool) 

98 #: Should we use the data max value? 

99 self.__use_data_max: Final[bool] = use_data_max 

100 

101 #: The minimum detected from the data. 

102 self.__detected_min: float = inf 

103 

104 #: The maximum detected from the data. 

105 self.__detected_max: float = _MIN_LOG_FLOAT if self.log_scale \ 

106 else -inf 

107 

108 #: Did we detect a minimum? 

109 self.__has_detected_min = False 

110 #: Did we detect a maximum? 

111 self.__has_detected_max = False 

112 

113 def register_array(self, data: np.ndarray) -> None: 

114 """ 

115 Register a data array. 

116 

117 :param data: the data to register 

118 """ 

119 if self.__use_data_min or self.__use_data_max: 

120 d = data[np.isfinite(data)] 

121 if self.__use_data_min: 

122 self.register_value(float(d.min())) 

123 if self.__use_data_max: 

124 self.register_value(float(d.max())) 

125 

126 def register_value(self, value: float) -> None: 

127 """ 

128 Register a single value. 

129 

130 :param value: the data to register 

131 """ 

132 if isfinite(value): 

133 if self.__use_data_min and ( 

134 (value < self.__detected_min) 

135 and ((value > 0.0) or (not self.log_scale))): 

136 self.__detected_min = value 

137 self.__has_detected_min = True 

138 if self.__use_data_max and (value > self.__detected_max): 

139 self.__detected_max = value 

140 self.__has_detected_max = True 

141 

142 def pad_detected_range(self, pad_min: bool = False, 

143 pad_max: bool = False) -> None: 

144 """ 

145 Add some padding to the current detected range. 

146 

147 This function increases the current detected or chosen maximum value 

148 and/or decreases the current detected minimum by a small amount. This 

149 can be useful when we want to plot stuff that otherwise would become 

150 invisible because it would be directly located at the boundary of a 

151 plot. 

152 

153 This function works by computing a slightly smaller/larger value than 

154 the current detected minimum/maximum and then passing it to 

155 :meth:`register_value`. It can only work if the end(s) chosen for 

156 padding are in "detect" mode and the other end is either in "detect" 

157 or "chosen" mode. 

158 

159 This method should be called *only* once and *only* after all data has 

160 been registered (via :meth:`register_value` :meth:`register_array`) 

161 and before calling :meth:`apply`. 

162 

163 :param pad_min: should we pad the minimum? 

164 :param pad_max: should we pad the maximum? 

165 

166 :raises ValueError: if this axis ranger is not configured to use a 

167 detected minimum/maximum or does not have a detected 

168 minimum/maximum or any other invalid situation occurs 

169 """ 

170 if not isinstance(pad_min, bool): 

171 raise type_error(pad_min, "pad_min", bool) 

172 if not isinstance(pad_max, bool): 

173 raise type_error(pad_max, "pad_max", bool) 

174 if not (pad_min or pad_max): 

175 return 

176 

177 max_value: float 

178 min_value: float 

179 if self.__use_data_min: 

180 if not self.__has_detected_min: 

181 raise ValueError("No minimum detected so far.") 

182 min_value = self.__detected_min 

183 else: 

184 if pad_min: 

185 raise ValueError("Can only pad minimum if use_data_min.") 

186 if self.__chosen_min is None: 

187 raise ValueError("Chosen min is None!") 

188 min_value = self.__chosen_min 

189 

190 if self.__use_data_max: 

191 if not self.__has_detected_max: 

192 raise ValueError("No maximum detected so far.") 

193 max_value = self.__detected_max 

194 else: 

195 if pad_max: 

196 raise ValueError("Can only pad maximum if use_data_max.") 

197 if self.__chosen_max is None: 

198 raise ValueError("Chosen max is None!") 

199 max_value = self.__chosen_max 

200 

201 if min_value >= max_value: 

202 raise ValueError( 

203 f"minimum={min_value} while maximum={max_value}.") 

204 

205 new_max: float 

206 if pad_max: 

207 if max_value >= inf: 

208 return 

209 new_max = max_value + (3.0 * (max_value - min_value)) / 100.0 

210 if not isfinite(new_max) or (new_max <= max_value): 

211 raise ValueError(f"invalid padded max={new_max} at min=" 

212 f"{min_value} and max={max_value}.") 

213 self.register_value(new_max) 

214 else: 

215 new_max = max_value 

216 

217 new_min: float 

218 if pad_min: 

219 if min_value <= -inf: 

220 return 

221 new_min = min_value - (3.0 * (max_value - min_value)) / 100.0 

222 if self.log_scale and (new_min <= 0.0 < min_value): 

223 new_min = 0.5 * min_value 

224 if not isfinite(new_min) or (new_min >= min_value): 

225 raise ValueError(f"invalid padded min={new_min} at min=" 

226 f"{min_value} and max={max_value}.") 

227 self.register_value(new_min) 

228 else: 

229 new_min = min_value 

230 

231 if new_min > new_max: 

232 raise ValueError(f"new_min={new_min}, new_max={new_max}??") 

233 

234 def apply(self, axes: Axes, which_axis: str) -> None: 

235 """ 

236 Apply this axis ranger to the given axis. 

237 

238 :param axes: the axes object to which the ranger shall be applied 

239 :param which_axis: the axis to which it should be applied, either 

240 `"x"` or `"y"` or both (`"xy"`) 

241 """ 

242 if not isinstance(which_axis, str): 

243 raise type_error(which_axis, "which_axis", str) 

244 

245 for is_x_axis in (True, False): 

246 if ("x" if is_x_axis else "y") not in which_axis: 

247 continue 

248 

249 use_min, use_max = \ 

250 axes.get_xlim() if is_x_axis else axes.get_ylim() 

251 

252 if not isfinite(use_min): 

253 raise ValueError(f"Minimum data interval cannot be {use_min}.") 

254 if not isfinite(use_max): 

255 raise ValueError(f"Maximum data interval cannot be {use_max}.") 

256 if use_max <= use_min: 

257 raise ValueError(f"Invalid axis range[{use_min},{use_max}].") 

258 

259 replace_range = False 

260 

261 if self.__chosen_min is not None: 

262 use_min = self.__chosen_min 

263 replace_range = True 

264 elif self.__use_data_min: 

265 if not self.__has_detected_min: 

266 raise ValueError("No minimum in data detected.") 

267 use_min = self.__detected_min 

268 replace_range = True 

269 

270 if self.__chosen_max is not None: 

271 use_max = self.__chosen_max 

272 replace_range = True 

273 elif self.__use_data_max: 

274 if not self.__has_detected_max: 

275 raise ValueError("No maximum in data detected.") 

276 use_max = self.__detected_max 

277 replace_range = True 

278 

279 if replace_range: 

280 if use_min >= use_max: 

281 raise ValueError( 

282 f"Invalid computed range [{use_min},{use_max}].") 

283 if is_x_axis: 

284 axes.set_xlim(use_min, use_max) 

285 else: 

286 axes.set_ylim(use_min, use_max) 

287 

288 if self.log_scale: 

289 if use_min <= 0: 

290 raise ValueError("minimum must be positive if log scale " 

291 f"is defined, but found {use_min}.") 

292 if is_x_axis: 

293 if self.__log_base is None: 

294 axes.semilogx() 

295 else: 

296 axes.semilogx(base=self.__log_base) 

297 elif self.__log_base is None: 

298 axes.semilogy() 

299 else: 

300 axes.semilogy(base=self.__log_base) 

301 

302 def get_pinf_replacement(self) -> float: 

303 """ 

304 Get a reasonable finite value that can replace positive infinity. 

305 

306 :return: a reasonable finite value that can be used to replace 

307 positive infinity 

308 """ 

309 data_max: float = 0.0 

310 if self.__chosen_max is not None: 

311 data_max = self.__chosen_max 

312 elif self.__has_detected_max: 

313 data_max = self.__detected_max 

314 return min(1e100, max(1e70, 1e5 * data_max)) 

315 

316 def get_0_replacement(self) -> float: 

317 """ 

318 Get a reasonable positive finite value that can replace `0`. 

319 

320 :return: a reasonable finite value that can be used to replace 

321 `0` 

322 """ 

323 data_min: float = 1e-100 

324 if self.__chosen_min is not None: 

325 data_min = self.__chosen_min 

326 elif self.__has_detected_min: 

327 data_min = self.__detected_min 

328 return max(1e-100, min(1e-70, 1e-5 * data_min)) 

329 

330 @staticmethod 

331 def for_axis(name: str, 

332 chosen_min: float | None = None, 

333 chosen_max: float | None = None, 

334 use_data_min: bool | None = None, 

335 use_data_max: bool | None = None, 

336 log_scale: bool | None = None, 

337 log_base: float | None = None) -> "AxisRanger": 

338 """ 

339 Create a default axis ranger based on the axis type. 

340 

341 The axis ranger will use the minimal values and log scaling options 

342 that usually make sense for the dimension, unless overridden by the 

343 optional arguments. 

344 

345 :param name: the axis type name, supporting `"ms"`, `"FEs"`, 

346 `"plainF"`, `"scaledF"`, and `"normalizedF"` 

347 :param chosen_min: the chosen minimum 

348 :param chosen_max: the chosen maximum 

349 :param use_data_min: should the data minimum be used 

350 :param use_data_max: should the data maximum be used 

351 :param log_scale: the log scale indicator 

352 :param log_base: the log base 

353 :return: the `AxisRanger` 

354 """ 

355 if not isinstance(name, str): 

356 raise type_error(name, "axis name", str) 

357 

358 l_log: bool = False 

359 l_min: float | None = None 

360 l_max: float | None = None 

361 l_data_min: bool = chosen_min is None 

362 l_data_max: bool = chosen_max is None 

363 

364 if name in {TIME_UNIT_MILLIS, KEY_LAST_IMPROVEMENT_TIME_MILLIS, 

365 KEY_TOTAL_TIME_MILLIS, KEY_ERT_TIME_MILLIS}: 

366 if chosen_min is not None: 

367 if (chosen_min < 0) or (not isfinite(chosen_min)): 

368 raise ValueError("chosen_min must be >= 0 for axis " 

369 f"type {name}, but is {chosen_min}.") 

370 l_log = (chosen_min > 0) 

371 if log_scale is not None: 

372 if log_scale and (not l_log): 

373 raise ValueError(f"Cannot set log_scale={log_scale} " 

374 f"and chosen_min={chosen_min} for " 

375 f"axis type {name}.") 

376 l_log = log_scale 

377 elif log_scale is None: 

378 l_log = True 

379 else: 

380 l_log = log_scale 

381 

382 l_min = (1 if l_log else 0) if chosen_min is None else chosen_min 

383 

384 if use_data_max is not None: 

385 l_data_max = use_data_max 

386 

387 l_data_min = False if use_data_min is None else use_data_min 

388 

389 if chosen_max is not None: 

390 l_max = chosen_max 

391 

392 return AxisRanger(l_min, l_max, l_data_min, l_data_max, 

393 l_log, log_base if l_log else None) 

394 

395 if name in {TIME_UNIT_FES, KEY_LAST_IMPROVEMENT_FE, KEY_TOTAL_FES, 

396 KEY_ERT_FES}: 

397 if chosen_min is None: 

398 l_min = 1 

399 else: 

400 if (chosen_min < 1) or (not isfinite(chosen_min)): 

401 raise ValueError("chosen_min must be >= 1 for axis " 

402 f"type {name}, but is {chosen_min}.") 

403 l_min = chosen_min 

404 l_log = True if (log_scale is None) else log_scale 

405 

406 if use_data_max is not None: 

407 l_data_max = use_data_max 

408 

409 l_data_min = False if use_data_min is None else use_data_min 

410 

411 return AxisRanger(l_min, chosen_max, l_data_min, l_data_max, 

412 l_log, log_base if l_log else None) 

413 

414 if name in {F_NAME_RAW, KEY_BEST_F}: 

415 if use_data_max is not None: 

416 l_data_max = use_data_max 

417 if use_data_min is not None: 

418 l_data_min = use_data_min 

419 if log_scale is not None: 

420 l_log = log_scale 

421 return AxisRanger(chosen_min, chosen_max, l_data_min, l_data_max, 

422 l_log, log_base if l_log else None) 

423 

424 if name == F_NAME_SCALED: 

425 l_min = 1 

426 elif name == F_NAME_NORMALIZED: 

427 if (log_scale is None) or (not log_scale): 

428 l_min = 0 

429 elif name == "ecdf": 

430 if (log_scale is None) or (not log_scale): 

431 l_min = 0 

432 l_max = 1 

433 else: 

434 raise ValueError(f"Axis type {name!r} is unknown.") 

435 

436 if chosen_min is not None: 

437 l_min = chosen_min 

438 

439 if log_scale is not None: 

440 l_log = log_scale 

441 if use_data_max is not None: 

442 l_data_max = use_data_max 

443 if use_data_min is not None: 

444 l_data_min = use_data_min 

445 

446 return AxisRanger(l_min, chosen_max, l_data_min, l_data_max, 

447 l_log, log_base if l_log else None) 

448 

449 @staticmethod 

450 def for_axis_func(chosen_min: float | None = None, 

451 chosen_max: float | None = None, 

452 use_data_min: bool | None = None, 

453 use_data_max: bool | None = None, 

454 log_scale: bool | None = None, 

455 log_base: float | None = None) -> Callable: 

456 """ 

457 Generate a function that provides the default per-axis ranger. 

458 

459 :param chosen_min: the chosen minimum 

460 :param chosen_max: the chosen maximum 

461 :param use_data_min: should the data minimum be used 

462 :param use_data_max: should the data maximum be used 

463 :param log_scale: the log scale indicator 

464 :param log_base: the log base 

465 :return: a function in the shape of :meth:`for_axis` with the 

466 provided defaults 

467 """ 

468 def __func(name: str, 

469 cmi=chosen_min, 

470 cma=chosen_max, 

471 udmi=use_data_min, 

472 udma=use_data_max, 

473 ls=log_scale, 

474 lb=log_base) -> AxisRanger: 

475 return AxisRanger.for_axis(name, cmi, cma, udmi, udma, ls, lb) 

476 

477 return __func