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
« 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
6import numpy as np
7from matplotlib.axes import Axes # type: ignore
8from pycommons.types import type_error
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
26#: The internal minimum float value for log-scaled axes.
27_MIN_LOG_FLOAT: Final[float] = sys.float_info.min
30class AxisRanger:
31 """An object for simplifying axis range computations."""
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.
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
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}.")
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}.")
74 #: The pre-defined, chosen minimum axis value.
75 self.__chosen_min: Final[float | None] = chosen_min
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}.")
88 #: The pre-defined, chosen maximum axis value.
89 self.__chosen_max: Final[float | None] = chosen_max
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
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
101 #: The minimum detected from the data.
102 self.__detected_min: float = inf
104 #: The maximum detected from the data.
105 self.__detected_max: float = _MIN_LOG_FLOAT if self.log_scale \
106 else -inf
108 #: Did we detect a minimum?
109 self.__has_detected_min = False
110 #: Did we detect a maximum?
111 self.__has_detected_max = False
113 def register_array(self, data: np.ndarray) -> None:
114 """
115 Register a data array.
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()))
126 def register_value(self, value: float) -> None:
127 """
128 Register a single value.
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
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.
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.
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.
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`.
163 :param pad_min: should we pad the minimum?
164 :param pad_max: should we pad the maximum?
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
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
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
201 if min_value >= max_value:
202 raise ValueError(
203 f"minimum={min_value} while maximum={max_value}.")
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
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
231 if new_min > new_max:
232 raise ValueError(f"new_min={new_min}, new_max={new_max}??")
234 def apply(self, axes: Axes, which_axis: str) -> None:
235 """
236 Apply this axis ranger to the given axis.
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)
245 for is_x_axis in (True, False):
246 if ("x" if is_x_axis else "y") not in which_axis:
247 continue
249 use_min, use_max = \
250 axes.get_xlim() if is_x_axis else axes.get_ylim()
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}].")
259 replace_range = False
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
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
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)
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)
302 def get_pinf_replacement(self) -> float:
303 """
304 Get a reasonable finite value that can replace positive infinity.
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))
316 def get_0_replacement(self) -> float:
317 """
318 Get a reasonable positive finite value that can replace `0`.
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))
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.
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.
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)
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
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
382 l_min = (1 if l_log else 0) if chosen_min is None else chosen_min
384 if use_data_max is not None:
385 l_data_max = use_data_max
387 l_data_min = False if use_data_min is None else use_data_min
389 if chosen_max is not None:
390 l_max = chosen_max
392 return AxisRanger(l_min, l_max, l_data_min, l_data_max,
393 l_log, log_base if l_log else None)
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
406 if use_data_max is not None:
407 l_data_max = use_data_max
409 l_data_min = False if use_data_min is None else use_data_min
411 return AxisRanger(l_min, chosen_max, l_data_min, l_data_max,
412 l_log, log_base if l_log else None)
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)
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.")
436 if chosen_min is not None:
437 l_min = chosen_min
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
446 return AxisRanger(l_min, chosen_max, l_data_min, l_data_max,
447 l_log, log_base if l_log else None)
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.
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)
477 return __func