Coverage for moptipy / examples / jssp / spaces_sizes.py: 13%

287 statements  

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

1""" 

2Computations regarding the size of the solution spaces in a JSSP. 

3 

4We represent solutions for a Job Shop Scheduling Problems (JSSPs) as Gantt 

5diagrams. 

6Assume that we look at JSSPs with n jobs and m machines. 

7The number of possible Gantt charts (without useless delays) is 

8(jobs!)**machines. 

9 

10However, not all of them are necessarily feasible. 

11If all jobs pass through all machines in the same order, then all of the 

12possible Gantt charts are also feasible. 

13However, if, say job 0 first goes to machine 0 and then to machine 1 and 

14job 1 first goes to machine 1 and then to machine 0, there are possible 

15Gantt charts with deadlocks: If we put the second operation of job 0 to 

16be the first operation to be done by machine 1 and put the second operation 

17of job 1 to be the first operation to be done by machine 0, we end up with 

18an infeasible Gantt chart, i.e., one that cannot be executed. 

19Thus, the question arises: "For a given number n of jobs and m of machines, 

20what is the instance with the fewest feasible Gantt charts?" 

21 

22Well, I am sure that there are clever algorithms to compute this. Maybe we 

23can even ave an elegant combinatorial formula. 

24But I do not want to spend much time on this subject (and maybe would not 

25be able to figure it out even if I wanted to...). 

26So we try to find this information using a somewhat brute force approach: 

27By enumerating instances and, for each instance, the Gantt charts, and 

28count how many of them are feasible. 

29""" 

30import sys 

31from math import factorial, log10 

32from typing import Iterable 

33 

34import numba # type: ignore 

35import numpy as np 

36from pycommons.io.path import Path, write_lines 

37from pycommons.types import check_int_range 

38 

39from moptipy.examples.jssp import experiment 

40from moptipy.examples.jssp.gantt_space import gantt_space_size 

41from moptipy.examples.jssp.instance import Instance 

42from moptipy.utils.lang import Lang 

43 

44 

45def permutations_with_repetitions_space_size(n: int, m: int) -> int: 

46 """ 

47 Compute the number of n-permutations with m repetitions. 

48 

49 :param n: the number of different values 

50 :param m: the number of repetitions 

51 :returns: the space size 

52 :rtype: int 

53 """ 

54 return factorial(n * m) // (factorial(m) ** n) 

55 

56 

57#: the pre-computed values 

58__PRE_COMPUTED: tuple[tuple[int, int, int, 

59 tuple[tuple[int, ...], ...]], ...] = ( 

60 (3, 2, 22, ((0, 1), (0, 1), (1, 0))), 

61 (3, 3, 63, ((0, 1, 2), (1, 0, 2), (2, 0, 1))), 

62 (3, 4, 147, ((0, 1, 2, 3), (1, 0, 3, 2), (2, 3, 0, 1))), 

63 (3, 5, 317, ((0, 1, 2, 3, 4), (2, 1, 0, 4, 3), (3, 4, 0, 2, 1))), 

64 (4, 2, 244, ((0, 1), (0, 1), (1, 0), (1, 0))), 

65 (4, 3, 1630, ((0, 1, 2), (1, 0, 2), (2, 0, 1), (2, 1, 0))), 

66 (4, 4, 7451, ((0, 1, 2, 3), (1, 0, 3, 2), (2, 3, 1, 0), (3, 2, 0, 1))), 

67 (5, 2, 4548, ((0, 1), (0, 1), (0, 1), (1, 0), (1, 0))), 

68 (5, 3, 91461, ((0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1))), 

69 (6, 2, 108828, ((0, 1), (0, 1), (0, 1), (1, 0), (1, 0), (1, 0))), 

70 (7, 2, 3771792, ((0, 1), (0, 1), (0, 1), (0, 1), (1, 0), (1, 0), (1, 0))), 

71 (8, 2, 156073536, 

72 ((0, 1), (0, 1), (0, 1), (0, 1), (1, 0), (1, 0), (1, 0), (1, 0))), 

73) 

74 

75 

76def gantt_min_feasible(jobs: int, machines: int) \ 

77 -> tuple[int, tuple[tuple[int, ...], ...]]: 

78 """ 

79 Find the minimum number of feasible gantt charts. 

80 

81 :param jobs: the number of jobs 

82 :param machines: the number of machines 

83 :return: the minimum number of feasible solutions for any instance 

84 of the given configuration and one example of such an instance 

85 """ 

86 check_int_range(jobs, "jobs", 1, 127) 

87 check_int_range(machines, "machines", 1, 127) 

88 

89 if machines <= 1: 

90 return factorial(jobs), (tuple([0] * jobs), ) 

91 if jobs <= 1: 

92 return 1, (tuple(range(machines)), ) 

93 if jobs <= 2: 

94 return machines + 1, ( 

95 (*list(range(machines - 2, -1, -1)), machines - 1), 

96 (machines - 1, *list(range(machines - 1)))) 

97 

98 for tup in __PRE_COMPUTED: 

99 if (tup[0] == jobs) and (tup[1] == machines): 

100 return tup[2], tup[3] 

101 

102 if machines <= 2: # if there are two machines, we know the shape 

103 lst = [[0, 1]] * (jobs - (jobs // 2)) 

104 lst.extend([[1, 0]] * (jobs // 2)) 

105 dest = np.array(lst, dtype=np.uint8) 

106 res = __enumerate_feasible_for(jobs, machines, dest) 

107 else: # more than two machines: need to enumerate 

108 dest = np.ndarray(shape=(jobs, machines), dtype=np.uint8) 

109 res = int(__find_min_feasible(np.int64(jobs), 

110 np.int64(machines), dest)) 

111 

112 # turn the result into a tuple 

113 arr = tuple(sorted(tuple(int(dest[i, j]) for j in range(machines)) 

114 for i in range(jobs))) 

115 return res, arr 

116 

117 

118@numba.njit 

119def __copy(dest: np.ndarray, source: np.ndarray, n: np.int64) -> None: 

120 """ 

121 Copy an array. 

122 

123 :param dest: the destination 

124 :param source: the source 

125 :param n: the number of elements to copy 

126 """ 

127 for a in range(n): 

128 dest[a] = source[a] 

129 

130 

131@numba.njit 

132def __copy_instance(dest: np.ndarray, source: np.ndarray, 

133 jobs: np.int64, machines: np.int64) -> None: 

134 """ 

135 Copy an instance. 

136 

137 :param dest: the destination 

138 :param source: the source 

139 :param jobs: the number of jobs 

140 :param machines: the machines 

141 """ 

142 for a in range(jobs): 

143 __copy(dest[a], source[a], machines) 

144 

145 

146@numba.njit 

147def __find_min_feasible(jobs: np.int64, machines: np.int64, 

148 dest: np.ndarray) -> np.int64: 

149 """ 

150 Find the minimum number of feasible gantt charts. 

151 

152 :param jobs: the number of jobs 

153 :param machines: the number of machines 

154 :param dest: the destination array 

155 :return: the minimum number of feasible solutions for any instance 

156 of the given configuration 

157 """ 

158 instance = np.empty(shape=(jobs, machines), dtype=np.uint8) 

159 gantt = np.empty(shape=(machines, jobs), dtype=np.uint8) 

160 gantt_index = np.zeros(machines, dtype=np.int64) 

161 inst_index = np.zeros(jobs, dtype=np.int64) 

162 job_state = np.zeros(jobs, dtype=np.int64) 

163 gantt_state = np.zeros(machines, dtype=np.int64) 

164 upper_bound: np.int64 = np.int64(9223372036854775807) 

165 

166 for i in range(jobs): 

167 __first_perm(instance[i], inst_index, i, machines) 

168 

169 while True: 

170 if __check_sorted(instance, jobs, machines): 

171 upper_bound = __enumerate_feasible( 

172 instance, gantt, job_state, gantt_state, jobs, machines, 

173 upper_bound, gantt_index, dest) 

174 

175 k = jobs - 1 

176 while True: 

177 if __next_perm(instance[k], inst_index, k, machines): 

178 break 

179 k -= 1 

180 if k < 0: 

181 return upper_bound 

182 for j in range(k + 1, jobs): 

183 __first_perm(instance[j], inst_index, j, machines) 

184 

185 

186@numba.njit(nogil=True) 

187def __check_sorted(instance: np.ndarray, 

188 jobs: np.int64, 

189 machines: np.int64) -> bool: 

190 """ 

191 Check if the instance is such that all jobs are sorted. 

192 

193 :param instance: the instance 

194 :param jobs: the number of jobs 

195 :param machines: the number of machines 

196 :return: `True` if the instance is sorted, `False` otherwise 

197 """ 

198 i: np.int64 = jobs - 1 

199 arr1: np.ndarray = instance[i] 

200 while i > 0: 

201 arr2 = arr1 

202 i -= 1 

203 arr1 = instance[i] 

204 for j in range(machines): 

205 if arr1[j] > arr2[j]: 

206 return True 

207 if arr1[j] < arr2[j]: 

208 return False 

209 return True 

210 

211 

212@numba.njit 

213def __is_feasible(instance: np.ndarray, 

214 gantt: np.ndarray, 

215 job_state: np.ndarray, 

216 gantt_state: np.ndarray, 

217 jobs: np.int64, 

218 machines: np.int64, 

219 row: np.int64) -> bool: 

220 """ 

221 Check if a Gantt diagram populated until a given row is feasible. 

222 

223 :param instance: the JSSP instance, size jobs*machines 

224 :param gantt: the gantt chart, size machines*jobs 

225 :param job_state: the job state, of length jobs 

226 :param gantt_state: the machine state, of length machines 

227 :param jobs: the number of jobs 

228 :param machines: the number of machines 

229 :param row: the number of valid rows of the Gantt chart 

230 :return: `True` if the chart is feasible so far, `False` otherwise 

231 """ 

232 if row <= 1: 

233 return True 

234 job_state.fill(0) # all jobs start at the 0'th operations 

235 gantt_state.fill(0) # all machines start at op 0 

236 found: bool = True 

237 needed_jobs = jobs # the number of required jobs 

238 

239 while found: 

240 found = False 

241 for job in range(jobs): # check all jobs 

242 while True: # we process each job as long as we can 

243 js = job_state[job] # which operation is required? 

244 if js >= machines: 

245 break # the job is already finished 

246 nm = instance[job, js] # the machine that the operation needs 

247 if nm >= row: # ok, this machine is outside of the chart 

248 js += 1 # so we assume it's ok 

249 job_state[job] = js # and step forward the state 

250 found = True # we did something! 

251 if js >= machines: # oh, we finished the job? 

252 needed_jobs -= 1 # one less jobs to do 

253 if needed_jobs <= 0: # no more jobs to do? 

254 return True # the chart is feasible! 

255 break # quit handling this job, as its finished 

256 continue # move to next operation, as job is not finished 

257 ms = gantt_state[nm] # get next item in gantt chart 

258 mj = gantt[nm, ms] # next job on this machine? 

259 if mj == job: # great, this job! 

260 gantt_state[nm] = ms + 1 # advance gantt state 

261 js += 1 # so we can perform the operation 

262 job_state[job] = js # and step forward the state 

263 found = True # we did something! 

264 if js >= machines: # oh, we finished the job? 

265 needed_jobs -= 1 # one less jobs to do 

266 if needed_jobs <= 0: # no more jobs to do? 

267 return True # the chart is feasible! 

268 break # quit handling this job, as its finished 

269 continue # move to next operation, as job is not finished 

270 break # no, we cannot handle this job now 

271 

272 return False # we ended one round without being able to proceed 

273 

274 

275@numba.njit 

276def __first_perm(arr: np.ndarray, 

277 index: np.ndarray, 

278 pi: np.int64, 

279 n: np.int64) -> None: 

280 """ 

281 Create the first permutation for a given array of values. 

282 

283 :param arr: the array to permute over 

284 :param index: the array with the index 

285 :param pi: the index of the index to use in pi 

286 :param n: the length of arr 

287 """ 

288 for i in range(n): 

289 arr[i] = i 

290 index[pi] = np.int64(0) 

291 

292 

293@numba.njit 

294def __next_perm(arr: np.ndarray, 

295 index: np.ndarray, 

296 pi: np.int64, 

297 n: np.int64) -> bool: 

298 """ 

299 Get the next permutation for a given array of values. 

300 

301 :param arr: the array to permute over 

302 :param index: the array with the index 

303 :param pi: the index of the index to use in pi 

304 :param n: the length of arr 

305 :returns: `True` if there is a next permutation, `False` if not 

306 """ 

307 idx = index[pi] 

308 if idx >= n - 1: 

309 return False 

310 

311 nidx = idx + 1 

312 

313 if idx == 0: # increase is at the very beginning 

314 arr[0], arr[1] = arr[1], arr[0] # swap 

315 idx = nidx 

316 while True: # update index to the next increase 

317 nidx = idx + 1 

318 if nidx >= n: 

319 break # reached end 

320 if arr[idx] <= arr[nidx]: 

321 break # found increase 

322 idx = nidx 

323 else: 

324 if arr[nidx] > arr[0]: # value at arr[idx + 1] is greater than arr[0] 

325 # no need for binary search, just swap arr[idx + 1] and arr[0] 

326 arr[nidx], arr[0] = arr[0], arr[nidx] 

327 else: 

328 # binary search to find the greatest value which is less 

329 # than arr[idx + 1] 

330 start = np.int64(0) 

331 end = idx 

332 mid = (start + end) // 2 

333 t_value = arr[nidx] 

334 while not (arr[mid] < t_value < arr[mid - 1]): 

335 if arr[mid] < t_value: 

336 end = mid - 1 

337 else: 

338 start = mid + 1 

339 mid = (start + end) // 2 

340 arr[nidx], arr[mid] = arr[mid], arr[nidx] # swap 

341 

342 # invert 0 to increase 

343 for i in range((idx // 2) + 1): 

344 arr[i], arr[idx - i] = arr[idx - i], arr[i] 

345 idx = 0 # reset increase 

346 

347 index[pi] = idx 

348 return True 

349 

350 

351@numba.njit 

352def __enumerate_feasible(instance: np.ndarray, 

353 gantt: np.ndarray, 

354 job_state: np.ndarray, 

355 gantt_state: np.ndarray, 

356 jobs: np.int64, 

357 machines: np.int64, 

358 upper_bound: np.int64, 

359 index: np.ndarray, 

360 dest: np.ndarray) -> np.int64: 

361 """ 

362 Enumerate the feasible gantt charts for an instance. 

363 

364 :param instance: the JSSP instance, size jobs*machines 

365 :param gantt: the gantt chart array, size machines*jobs 

366 :param job_state: the job state, of length jobs 

367 :param gantt_state: the machine state, of length machines 

368 :param jobs: the number of jobs 

369 :param machines: the number of machines 

370 :param upper_bound: the upper bound - we won't enumerate more 

371 charts than this. 

372 :param index: the index array 

373 :param dest: the destination array 

374 :returns: the number of enumerated feasible gantt charts 

375 """ 

376 counter: np.int64 = np.int64(0) 

377 for z in range(machines): 

378 __first_perm(gantt[z], index, z, jobs) 

379 

380 while True: 

381 if __is_feasible(instance, gantt, job_state, gantt_state, 

382 jobs, machines, machines): 

383 counter += 1 # found another feasible gantt chart for instance 

384 if counter >= upper_bound: 

385 return counter # if we have reached the minimum, we can stop 

386 

387 i = machines - 1 

388 while True: 

389 if __next_perm(gantt[i], index, i, jobs): 

390 if i >= machines - 1: 

391 break 

392 if __is_feasible(instance, gantt, job_state, gantt_state, 

393 jobs, machines, i + 1): 

394 for j in range(i + 1, machines): 

395 __first_perm(gantt[j], index, j, jobs) 

396 break 

397 else: 

398 i -= 1 

399 if i < 0: 

400 if instance is not dest: 

401 __copy_instance(dest, instance, jobs, machines) 

402 return counter # we have enumerated all gantt charts 

403 

404 

405@numba.njit 

406def __enumerate_feasible_for(jobs: np.int64, machines: np.int64, 

407 instance: np.ndarray) -> np.int64: 

408 """ 

409 Find the minimum number of feasible gantt charts. 

410 

411 :param jobs: the number of jobs 

412 :param machines: the number of machines 

413 :param instance: the provided instance array 

414 :return: the minimum number of feasible solutions for any instance 

415 of the given configuration 

416 """ 

417 gantt = np.empty(shape=(machines, jobs), dtype=np.uint8) 

418 gantt_index = np.zeros(machines, dtype=np.int64) 

419 job_state = np.zeros(jobs, dtype=np.int64) 

420 gantt_state = np.zeros(machines, dtype=np.int64) 

421 upper_bound: np.int64 = np.int64(9223372036854775807) 

422 return __enumerate_feasible(instance, gantt, job_state, 

423 gantt_state, jobs, machines, 

424 upper_bound, gantt_index, 

425 instance) 

426 

427 

428def __long_str(value: int) -> str: 

429 """ 

430 Convert a value to a string. 

431 

432 :param value: the value 

433 :returns: the string representation 

434 """ 

435 if value < 0: 

436 return "" 

437 if value <= 1_000_000_000_000: 

438 return Lang.current().format_int(value) 

439 logg = log10(value) 

440 exp = int(logg) 

441 base = value / (10 ** exp) 

442 expf = Lang.current().format_int(exp) 

443 return f"$\\approx$&nbsp;{base:.3f}*10^{expf}^" 

444 

445 

446def make_gantt_space_size_table( 

447 dest: str = "solution_space_size.md", 

448 instances: Iterable[str] = tuple(list( # noqa 

449 experiment.INSTANCES) + ["demo"])) -> Path: # noqa 

450 """ 

451 Print a table of solution space sizes. 

452 

453 :param dest: the destination file 

454 :param instances: the instances to add 

455 :returns: the fully-qualified path to the generated file 

456 """ 

457 file = Path(dest) 

458 text = [(f'|{Lang.current()["name"]}|' 

459 r"$\jsspJobs$|$\jsspMachines$|$\min(\#\text{" 

460 f'{Lang.current()["feasible"]}' 

461 r"})$|$\left|\solutionSpace\right|$|"), 

462 r"|:--|--:|--:|--:|--:|"] 

463 

464 inst_scales: list[tuple[int, int, int, int, str]] = [] 

465 

466 # enumerate the pre-defined instances 

467 for inst in set(instances): 

468 instance = Instance.from_resource(inst) 

469 min_size = -1 

470 for tup in __PRE_COMPUTED: 

471 if (tup[0] == instance.jobs) and (tup[1] == instance.machines): 

472 min_size = tup[2] 

473 break 

474 if (min_size < 0) and ((instance.jobs <= 2) 

475 or (instance.machines <= 2)): 

476 min_size = gantt_min_feasible( 

477 instance.jobs, instance.machines)[0] 

478 inst_scales.append( 

479 (instance.jobs, instance.machines, 

480 gantt_space_size(instance.jobs, instance.machines), 

481 min_size, f"`{instance.name}`")) 

482 del instance 

483 

484 # enumerate some default values 

485 for jobs in range(2, 6): 

486 for machines in range(2, 6): 

487 found: bool = False # skip over already added scales 

488 for tupp in inst_scales: 

489 if (tupp[0] == jobs) and (tupp[1] == machines): 

490 found = True 

491 break 

492 if found: 

493 continue 

494 

495 min_size = -1 

496 for tup in __PRE_COMPUTED: 

497 if (tup[0] == jobs) and (tup[1] == machines): 

498 min_size = tup[2] 

499 break 

500 if (min_size < 0) and ((jobs <= 2) or (machines <= 2)): 

501 min_size = gantt_min_feasible(jobs, machines)[0] 

502 name = "[@fig:jssp_feasible_gantt]" \ 

503 if (jobs == 2) and (machines == 2) else "" 

504 inst_scales.append( 

505 (jobs, machines, gantt_space_size(jobs, machines), 

506 min_size, name)) 

507 

508 inst_scales.sort() 

509 for i, ua in enumerate(inst_scales): 

510 a = ua 

511 for j in range(i, len(inst_scales)): 

512 b = inst_scales[j] 

513 if (a[-1] and b[-1]) and (a[-3] > b[-3]): 

514 inst_scales[i] = b # noqa: B909 

515 inst_scales[j] = a # noqa: B909 

516 a = b 

517 

518 text.extend( 

519 f"|{scale[4]}|{scale[0]}|{scale[1]}|{__long_str(scale[3])}|" 

520 f"{__long_str(scale[2])}|" for scale in inst_scales) 

521 

522 with file.open_for_write() as wd: 

523 write_lines(text, wd) 

524 file.enforce_file() 

525 return file 

526 

527 

528def make_search_space_size_table( 

529 dest: str = "solution_space_size.md", 

530 instances: Iterable[str] = tuple(list( # noqa 

531 experiment.INSTANCES) + ["demo"])) -> Path: # noqa 

532 """ 

533 Print a table of search space sizes. 

534 

535 :param dest: the destination file 

536 :param instances: the instances to add 

537 :returns: the fully-qualified path to the generated file 

538 """ 

539 file = Path(dest) 

540 text = [(f'|{Lang.current()["name"]}|' 

541 r"$\jsspJobs$|$\jsspMachines$|$\left|\solutionSpace\right|$|" 

542 r"$\left|\searchSpace\right|$|"), 

543 r"|:--|--:|--:|--:|--:|"] 

544 inst_scales: list[tuple[int, int, int, int, str]] = [] 

545 

546 # enumerate the pre-defined instances 

547 for inst in set(instances): 

548 instance = Instance.from_resource(inst) 

549 inst_scales.append( 

550 (instance.jobs, instance.machines, 

551 gantt_space_size(instance.jobs, instance.machines), 

552 permutations_with_repetitions_space_size( 

553 instance.jobs, instance.machines), 

554 f"`{instance.name}`")) 

555 del instance 

556 

557 # enumerate some default values 

558 for jobs in range(3, 6): 

559 for machines in range(2, 6): 

560 found: bool = False # skip over already added scales 

561 for tupp in inst_scales: 

562 if (tupp[0] == jobs) and (tupp[1] == machines): 

563 found = True 

564 break 

565 if found: 

566 continue 

567 inst_scales.append( 

568 (jobs, machines, gantt_space_size(jobs, machines), 

569 permutations_with_repetitions_space_size( 

570 jobs, machines), "")) 

571 

572 inst_scales.sort() 

573 for i, ua in enumerate(inst_scales): 

574 a = ua 

575 for j in range(i, len(inst_scales)): 

576 b = inst_scales[j] 

577 if (a[-1] and b[-1]) and (a[-2] > b[-2]): 

578 inst_scales[i] = b # noqa: B909 

579 inst_scales[j] = a # noqa: B909 

580 a = b 

581 text.extend(f"|{scale[4]}|{scale[0]}|{scale[1]}|{__long_str(scale[2])}|" 

582 f"{__long_str(scale[3])}|" for scale in inst_scales) 

583 

584 with file.open_for_write() as wd: 

585 write_lines(text, wd) 

586 file.enforce_file() 

587 return file 

588 

589 

590# create the tables if this is the main script 

591if __name__ == "__main__": 

592 dest_dir = Path(sys.argv[1]) 

593 dest_dir.ensure_dir_exists() 

594 for lang in Lang.all_langs(): 

595 lang.set_current() 

596 make_gantt_space_size_table( 

597 dest_dir.resolve_inside( 

598 lang.filename("solution_space_size") + ".md")) 

599 make_search_space_size_table( 

600 dest_dir.resolve_inside( 

601 lang.filename("search_space_size") + ".md"))