Coverage for moptipy / examples / jssp / gantt_space.py: 79%

137 statements  

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

1"""Here we provide a :class:`~moptipy.api.space.Space` of `Gantt` charts.""" 

2from math import factorial 

3from typing import Final 

4 

5import numpy as np 

6from pycommons.types import check_int_range, type_error 

7 

8from moptipy.api.space import Space 

9from moptipy.examples.jssp.gantt import Gantt 

10from moptipy.examples.jssp.instance import SCOPE_INSTANCE, Instance 

11from moptipy.utils.logger import CSV_SEPARATOR, KeyValueLogSection 

12from moptipy.utils.nputils import ( 

13 KEY_NUMPY_TYPE, 

14 int_range_to_dtype, 

15 numpy_type_to_str, 

16) 

17 

18#: the array shape 

19KEY_SHAPE: Final[str] = "shape" 

20 

21 

22def gantt_space_size(jobs: int, machines: int) -> int: 

23 """ 

24 Compute the size of the Gantt space. 

25 

26 :param jobs: the number of jobs 

27 :param machines: the number of machines 

28 :return: the size of the search 

29 

30 >>> print(gantt_space_size(8, 5)) 

31 106562062388507443200000 

32 """ 

33 return factorial(check_int_range(jobs, "jobs", 1, 1_000_000)) **\ 

34 check_int_range(machines, "machines", 1, 1_000_000) 

35 

36 

37# start book 

38class GanttSpace(Space): 

39 """An implementation of the `Space` API of for `Gantt` charts.""" 

40 

41 def __init__(self, instance: Instance) -> None: 

42 # end book 

43 """ 

44 Create a Gantt chart space. 

45 

46 :param instance: the JSSP instance 

47 """ 

48 if not isinstance(instance, Instance): 

49 raise type_error(instance, "instance", Instance) 

50 #: The JSSP Instance to which the Gantt record apply. 

51 self.instance: Final[Instance] = instance # +book 

52 #: The shape for the Gantt chart arrays. 

53 self.shape: Final[tuple[int, int, int]] = ( # +book 

54 instance.machines, instance.jobs, 3) # +book 

55 #: the data to be used for Gantt charts 

56 self.dtype: Final[np.dtype] = int_range_to_dtype( 

57 min_value=0, max_value=instance.makespan_upper_bound) 

58 #: fast call function forward 

59 self.copy = np.copyto # type: ignore # +book 

60 

61 def create(self) -> Gantt: # +book 

62 """ 

63 Create a Gantt chart object without assigning jobs to machines. 

64 

65 :return: the Gantt chart 

66 """ 

67 return Gantt(self) # +book 

68 

69 def to_str(self, x: Gantt) -> str: # +book 

70 """ 

71 Convert a Gantt chart to a string. 

72 

73 :param x: the Gantt chart 

74 :return: a string corresponding to the flattened `Gantt` array 

75 """ 

76 return CSV_SEPARATOR.join(map(str, np.nditer(x))) # +book 

77 

78 def is_equal(self, x1: Gantt, x2: Gantt) -> bool: # +book 

79 """ 

80 Check if two Gantt charts have the same contents. 

81 

82 :param x1: the first chart 

83 :param x2: the second chart 

84 :return: `True` if both charts are for the same instance and have the 

85 same structure 

86 """ 

87 # start book 

88 return (x1.instance is x2.instance) and np.array_equal(x1, x2) 

89 # end book 

90 

91 def from_str(self, text: str) -> Gantt: # +book 

92 """ 

93 Convert a string to a Gantt chart. 

94 

95 :param text: the string 

96 :return: the Gantt chart 

97 """ 

98 if not isinstance(text, str): 

99 raise type_error(text, "Gannt text", str) 

100 # start book 

101 x: Final[Gantt] = self.create() 

102 np.copyto(x, np.fromstring( 

103 text, dtype=self.dtype, sep=CSV_SEPARATOR) 

104 .reshape(self.shape)) 

105 self.validate(x) # -book 

106 return x 

107 # end book 

108 

109 def validate(self, x: Gantt) -> None: # +book 

110 """ 

111 Check if a Gantt chart is valid and feasible. 

112 

113 This means that the operations of the jobs must appear in the right 

114 sequences and must not intersect in any way. 

115 The only exception are operations that need 0 time units. They are 

116 permitted to appear wherever. 

117 

118 :param x: the Gantt chart 

119 :raises TypeError: if any component of the chart is of the wrong type 

120 :raises ValueError: if the Gantt chart is not feasible or the makespan 

121 is wrong 

122 """ 

123 # start book 

124 # Checks if a Gantt chart if valid and feasible. 

125 if not isinstance(x, Gantt): 

126 raise type_error(x, "x", Gantt) 

127 # the rest of the checks is not printed for brevity reasons... 

128 # end book 

129 inst: Final[Instance] = self.instance 

130 if inst is not x.instance: 

131 raise ValueError( 

132 f"x.instance must be {inst} but is {x.instance}.") 

133 jobs: Final[int] = inst.jobs 

134 machines: Final[int] = inst.machines 

135 if len(x.shape) != 3: 

136 raise ValueError("x must be three-dimensional, " 

137 f"but is {len(x.shape)}-dimensional.") 

138 if x.shape[0] != machines: 

139 raise ValueError( 

140 f"x must have {machines} rows for instance {inst.name}, " 

141 f"but has {x.shape[0]}.") 

142 

143 if x.shape[1] != jobs: 

144 raise ValueError( 

145 f"x must have {jobs} columns for instance {inst.name}, " 

146 f"but has {x.shape[1]}.") 

147 if x.shape[2] != 3: 

148 raise ValueError("x must have three values per cell, " 

149 f"but has {x.shape[2]}.") 

150 if x.dtype != self.dtype: 

151 raise ValueError( 

152 f"x.dtype should be {self.dtype} for instance " 

153 f"{inst.name} but is {x.dtype}.") 

154 

155 # now check the sequence on operations per machine 

156 # we check for overlaps and incorrect operation times 

157 jobs_done: Final[set[int]] = set() 

158 for machinei in range(machines): 

159 jobs_done.clear() 

160 last_end = 0 

161 last_name = "[start]" 

162 for jobi in range(jobs): 

163 job = int(x[machinei, jobi, 0]) 

164 start = int(x[machinei, jobi, 1]) 

165 end = int(x[machinei, jobi, 2]) 

166 if not (0 <= job < jobs): 

167 raise ValueError( 

168 f"job {job} invalid for machine {machinei} on " 

169 f"instance {inst.name} with {jobs} jobs: " 

170 f"{x[machinei]}") 

171 if job in jobs_done: 

172 raise ValueError( 

173 f"job {job} appears twice on machine {machinei} " 

174 f"for instance {inst.name}: {x[machinei]}") 

175 jobs_done.add(job) 

176 time = -1 

177 for z in range(machines): 

178 if inst[job, z, 0] == machinei: 

179 time = int(inst[job, z, 1]) 

180 break 

181 if time < 0: 

182 raise ValueError( 

183 f"Did not find machine {machinei} for job {job} in " 

184 f"instance {inst.name}: {x[machinei]}, " 

185 f"{inst[machinei]}.") 

186 if (end - start) != time: 

187 raise ValueError( 

188 f"job {job} should need {time} time units on machine " 

189 f"{machinei} for instance {inst.name}, but starts at " 

190 f"{start} and ends at {end}: {x[machinei]}, " 

191 f"{inst[job]}.") 

192 if time <= 0: 

193 continue # job requires zero time, can skip 

194 if start < last_end: 

195 raise ValueError( 

196 f"job {job} starts at {start} on machine {machinei}, " 

197 f"for instance {inst.name} but cannot before " 

198 f"{last_name}: {x[machinei]}.") 

199 last_end = end 

200 

201 # now check the single jobs 

202 # we again check for operation overlaps and incorrect timing 

203 for jobi in range(jobs): 

204 done = [(start, end, machine) 

205 for machine in range(machines) 

206 for (idx, start, end) in x[machine, :, :] if idx == jobi] 

207 done.sort() 

208 if len(done) != machines: 

209 raise ValueError( 

210 f"Job {jobi} appears only {len(done)} times instead of " 

211 f"{machines} times on instance {inst.name}.") 

212 

213 # we allow operations of length 0 to appear at any position 

214 last_end = 0 

215 last_machine = "[start]" 

216 done_i = 0 

217 machine_i = 0 

218 while True: 

219 if machine_i < machines: 

220 machine, time = inst[jobi, machine_i] 

221 else: 

222 machine = time = -1 

223 if done_i < machines: 

224 start = int(done[machine_i][0]) 

225 end = int(done[machine_i][1]) 

226 used_machine = int(done[machine_i][2]) 

227 else: 

228 start = end = used_machine = -1 

229 

230 needed = end - start 

231 if needed < 0: 

232 raise ValueError( 

233 f"Operation {machine_i} of job {jobi} scheduled " 

234 f"from {start} to {end}?") 

235 if machine != used_machine: 

236 # This is only possible for operations that require zero 

237 # time units. We will skip such operations in the checks. 

238 if (time == 0) and (machine != -1): 

239 machine_i += 1 

240 continue 

241 if (needed == 0) and (machine != -1): 

242 done_i += 1 

243 continue 

244 raise ValueError( 

245 f"Machine at index {done_i} of job {jobi} " 

246 f"must be {machine} for instance {inst.name}, " 

247 f"for {time} time units, but is {used_machine}" 

248 f"from {start} to {end}.") 

249 if machine == -1: 

250 if (machine_i < machines) or (done_i < machines): 

251 raise ValueError( # this should never be possible 

252 f"Done {machine_i + 1} machines for job {jobi}, " 

253 f"which has {done_i + 1} operations done??") 

254 break # we can stop 

255 

256 # ok, we are at a regular operation 

257 if needed != time: 

258 raise ValueError( 

259 f"Job {jobi} must be processed on {machine} for " 

260 f"{time} time units on instance {inst.name}, but " 

261 f"only {needed} are used from {start} to {end}.") 

262 if needed < 0: 

263 raise ValueError( 

264 f"Processing time of job {jobi} on machine {machine} " 

265 f"cannot be < 0 for instance {inst.name}, but " 

266 f"is {needed}.") 

267 

268 if start < last_end: 

269 raise ValueError( 

270 f"Processing time window [{start},{end}] on " 

271 f"machine {machine} for job {jobi} on instance " 

272 f"{inst.name} intersects with last operation end" 

273 f"{last_end} on machine {last_machine}.") 

274 

275 last_end = end 

276 last_machine = machine 

277 machine_i += 1 

278 done_i += 1 

279 

280 maxtime: Final[int] = int(x[:, -1, 2].max()) 

281 if maxtime < inst.makespan_lower_bound: 

282 raise ValueError( 

283 f"Makespan {maxtime} computed, which is smaller than the " 

284 f"lower bound {inst.makespan_lower_bound} of the JSSP " 

285 f"instance {str(inst)!r}'.") 

286 if maxtime > inst.makespan_upper_bound: 

287 raise ValueError( 

288 f"Makespan {maxtime} computed, which is larger than the " 

289 f"upper bound {inst.makespan_upper_bound} of the JSSP " 

290 f"instance {str(inst)!r}.") 

291 

292 def n_points(self) -> int: 

293 """ 

294 Get the number of possible Gantt charts without useless delays. 

295 

296 :return: `factorial(jobs) ** machines` 

297 

298 >>> print(GanttSpace(Instance.from_resource("demo")).n_points()) 

299 7962624 

300 """ 

301 return gantt_space_size(self.instance.jobs, self.instance.machines) 

302 

303 def __str__(self) -> str: 

304 """ 

305 Get the name of the Gantt space. 

306 

307 :return: the name 

308 

309 >>> space = GanttSpace(Instance.from_resource("abz7")) 

310 >>> print(space) 

311 gantt_abz7 

312 """ 

313 return f"gantt_{self.instance}" 

314 

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

316 """ 

317 Log the parameters of the Gantt space to the given logger. 

318 

319 :param logger: the logger for the parameters 

320 """ 

321 super().log_parameters_to(logger) 

322 logger.key_value(KEY_SHAPE, repr(self.shape)) 

323 logger.key_value(KEY_NUMPY_TYPE, numpy_type_to_str(self.dtype)) 

324 with logger.scope(SCOPE_INSTANCE) as kv: 

325 self.instance.log_parameters_to(kv)