Coverage for moptipy / mock / objective.py: 83%

121 statements  

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

1"""A mock-up of an objective function.""" 

2 

3from math import inf, isfinite, nextafter 

4from typing import Any, Final, Iterable, cast 

5 

6import numpy as np 

7from numpy.random import Generator, default_rng 

8from pycommons.strings.string_conv import num_to_str 

9from pycommons.types import type_error 

10 

11from moptipy.api.objective import Objective 

12from moptipy.mock.utils import make_ordered_list, sample_from_attractors 

13from moptipy.utils.logger import CSV_SEPARATOR, KeyValueLogSection 

14from moptipy.utils.nputils import is_np_float, is_np_int 

15 

16 

17class MockObjective(Objective): 

18 """A mock-up of an objective function.""" 

19 

20 def __init__(self, 

21 is_int: bool = True, 

22 lb: int | float = -inf, 

23 ub: int | float = inf, 

24 fmin: int | float | None = None, 

25 fattractors: Iterable[int | float | None] | None = None, 

26 fmax: int | float | None = None, 

27 seed: int | None = None) -> None: 

28 """ 

29 Create a mock objective function. 

30 

31 :param is_int: is this objective function always integer? 

32 :param lb: the lower bound 

33 :param ub: the upper bound 

34 :param fmin: the minimum value this objective actually takes on 

35 :param fattractors: the attractor points 

36 :param fmax: the maximum value this objective actually takes on 

37 """ 

38 if not isinstance(is_int, bool): 

39 raise type_error(is_int, "is_int", bool) 

40 #: is this objective integer? 

41 self.is_int: Final[bool] = is_int 

42 

43 if seed is None: 

44 seed = int(default_rng().integers(0, 1 << 63)) 

45 elif not isinstance(seed, int): 

46 raise type_error(seed, "seed", int) 

47 #: the random seed 

48 self.seed: Final[int] = seed 

49 

50 #: the generator for setting up the mock objective 

51 random: Final[Generator] = default_rng(seed) 

52 #: the name of this objective function 

53 self.name: Final[str] = f"mock{random.integers(1, 100_000_000):x}" 

54 

55 if not isinstance(lb, int | float): 

56 raise type_error(lb, "lb", (int, float)) 

57 if isfinite(lb) and is_int and not isinstance(lb, int): 

58 raise type_error(lb, f"finite lb @ is_int={is_int}", int) 

59 if not isinstance(ub, int | float): 

60 raise type_error(ub, "ub", (int, float)) 

61 if isfinite(ub) and is_int and not isinstance(ub, int): 

62 raise type_error(ub, f"finite lb @ is_int={is_int}", int) 

63 if lb >= ub: 

64 raise ValueError(f"lb={lb} >= ub={ub} not permitted") 

65 #: the lower bound 

66 self.lb: Final[int | float] = lb 

67 #: the upper bound 

68 self.ub: Final[int | float] = ub 

69 

70 if fmin is not None: 

71 if not isinstance(fmin, int if is_int else (int, float)): 

72 raise type_error(fmin, f"fmin[is_int={is_int}", 

73 int if is_int else (int, float)) 

74 if fmin < lb: 

75 raise ValueError(f"fmin={fmin} < lb={lb}") 

76 if fmax is not None: 

77 if not isinstance(fmax, int if is_int else (int, float)): 

78 raise type_error(fmax, f"fmax[is_int={is_int}", 

79 int if is_int else (int, float)) 

80 if fmax > ub: 

81 raise ValueError(f"fmax={fmax} < ub={ub}") 

82 if (fmin is not None) and (fmax is not None) and (fmin >= fmax): 

83 raise ValueError(f"fmin={fmin} >= fmax={fmax}") 

84 

85 values: list[int | float | None] = [lb, fmin] 

86 if fattractors is None: 

87 while True: 

88 values.append(None) 

89 if random.integers(2) <= 0: 

90 break 

91 else: 

92 values.extend(fattractors) 

93 values.extend((fmax, ub)) 

94 

95 values = make_ordered_list(values, is_int, random) 

96 if values is None: 

97 raise ValueError( 

98 f"could not create mock objective with lb={lb}, fmin={fmin}, " 

99 f"fattractors={fattractors}, fmax={fmax}, ub={ub}, " 

100 f"is_int={is_int}, and seed={seed}") 

101 

102 #: the minimum value the function actually takes on 

103 self.fmin: Final[int | float] = values[1] 

104 #: the maximum value the function actually takes on 

105 self.fmax: Final[int | float] = values[-2] 

106 #: the mean value the function actually takes on 

107 self.fattractors: Final[tuple[int | float, ...]] =\ 

108 cast("tuple[int | float, ...]", tuple(values[2:-2])) 

109 #: the internal random number generator 

110 self.__random: Final[Generator] = random 

111 

112 def sample(self) -> int | float: 

113 """ 

114 Sample the mock objective function. 

115 

116 :returns: the value of the mock objective function 

117 """ 

118 return sample_from_attractors(self.__random, self.fattractors, 

119 self.is_int, self.lb, self.ub) 

120 

121 def evaluate(self, x) -> float | int: 

122 """ 

123 Return a mock objective value. 

124 

125 :param x: the candidate solution 

126 :return: the objective value 

127 """ 

128 seed: int | None = None 

129 if hasattr(x, "__hash__") and (x.__hash__ is not None): 

130 seed = hash(x) 

131 elif isinstance(x, np.ndarray): 

132 seed = hash(x.tobytes()) 

133 elif isinstance(x, list): 

134 seed = hash(str(x)) 

135 random = self.__random if seed is None else default_rng(abs(seed)) 

136 

137 return sample_from_attractors(random, self.fattractors, 

138 self.is_int, self.lb, self.ub) 

139 

140 def lower_bound(self) -> float | int: 

141 """ 

142 Get the lower bound of the objective value. 

143 

144 :return: the lower bound of the objective value 

145 """ 

146 return self.lb 

147 

148 def upper_bound(self) -> float | int: 

149 """ 

150 Get the upper bound of the objective value. 

151 

152 :return: the upper bound of the objective value 

153 """ 

154 return self.ub 

155 

156 def is_always_integer(self) -> bool: 

157 """ 

158 Return `True` if :meth:`~evaluate` will always return an `int` value. 

159 

160 :returns: `True` if :meth:`~evaluate` will always return an `int` 

161 or `False` if also a `float` may be returned. 

162 """ 

163 return self.is_int 

164 

165 def __str__(self): 

166 """Get the name of this mock objective function.""" 

167 return self.name 

168 

169 def log_parameters_to(self, logger: KeyValueLogSection) -> None: 

170 """Log the special parameters of tis mock objective function.""" 

171 super().log_parameters_to(logger) 

172 logger.key_value("min", self.fmin) 

173 logger.key_value("attractors", CSV_SEPARATOR.join([ 

174 num_to_str(n) for n in self.fattractors])) 

175 logger.key_value("max", self.fmax) 

176 logger.key_value("seed", self.seed) 

177 logger.key_value("is_int", self.is_int) 

178 

179 @staticmethod 

180 def for_type(dtype: np.dtype) -> "MockObjective": 

181 """ 

182 Create a mock objective function with values bound by a given `dtype`. 

183 

184 :param dtype: the numpy data type 

185 :returns: the mock objective function 

186 """ 

187 if not isinstance(dtype, np.dtype): 

188 raise type_error(dtype, "dtype", np.dtype) 

189 

190 random = default_rng() 

191 params: dict[str, Any] = {} 

192 use_min = bool(random.integers(2) <= 0) 

193 use_max = bool(random.integers(2) <= 0) 

194 if not (use_min or use_max): 

195 if random.integers(5) <= 0: 

196 use_min = True 

197 else: 

198 use_max = True 

199 if is_np_int(dtype): 

200 params["is_int"] = True 

201 iix = np.iinfo(cast("Any", dtype)) 

202 params["lb"] = lbi = max(int(iix.min), -(1 << 58)) 

203 params["ub"] = ubi = min(int(iix.max), (1 << 58)) 

204 if use_min: 

205 params["fmin"] = lbi + 1 

206 if use_max: 

207 params["fmax"] = ubi - 1 

208 

209 if is_np_float(dtype): 

210 params["is_int"] = False 

211 fix = np.finfo(dtype) 

212 params["lb"] = lbf = max(float(fix.min), -1e300) 

213 params["ub"] = ubf = min(float(fix.max), 1e300) 

214 if use_min: 

215 params["fmin"] = nextafter(float(lbf + float(fix.eps)), inf) 

216 if use_max: 

217 params["fmax"] = nextafter(float(ubf - float(fix.eps)), -inf) 

218 

219 if len(params) > 0: 

220 return MockObjective(**params) 

221 raise ValueError(f"unsupported dtype: {dtype}")