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

287 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-18 11:24 +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 = int(__enumerate_feasible_for( # type: ignore 

107 jobs, machines, dest)) # type: ignore 

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

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

110 res = int(__find_min_feasible(np.int64(jobs), # type: ignore 

111 np.int64(machines), dest)) 

112 

113 # turn the result into a tuple 

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

115 for i in range(jobs))) 

116 return res, arr 

117 

118 

119@numba.njit 

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

121 """ 

122 Copy an array. 

123 

124 :param dest: the destination 

125 :param source: the source 

126 :param n: the number of elements to copy 

127 """ 

128 for a in range(n): 

129 dest[a] = source[a] 

130 

131 

132@numba.njit 

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

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

135 """ 

136 Copy an instance. 

137 

138 :param dest: the destination 

139 :param source: the source 

140 :param jobs: the number of jobs 

141 :param machines: the machines 

142 """ 

143 for a in range(jobs): 

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

145 

146 

147@numba.njit 

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

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

150 """ 

151 Find the minimum number of feasible gantt charts. 

152 

153 :param jobs: the number of jobs 

154 :param machines: the number of machines 

155 :param dest: the destination array 

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

157 of the given configuration 

158 """ 

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

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

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

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

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

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

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

166 

167 for i in range(jobs): 

168 __first_perm(instance[i], inst_index, i, machines) # type: ignore 

169 

170 while True: 

171 if __check_sorted(instance, jobs, machines): 

172 upper_bound = __enumerate_feasible( 

173 instance, gantt, job_state, gantt_state, jobs, machines, 

174 upper_bound, gantt_index, dest) 

175 

176 k = jobs - 1 

177 while True: 

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

179 break 

180 k -= 1 

181 if k < 0: 

182 return upper_bound 

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

184 __first_perm(instance[j], inst_index, j, machines) # type: ignore 

185 

186 

187@numba.njit(nogil=True) 

188def __check_sorted(instance: np.ndarray, 

189 jobs: np.int64, 

190 machines: np.int64) -> bool: 

191 """ 

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

193 

194 :param instance: the instance 

195 :param jobs: the number of jobs 

196 :param machines: the number of machines 

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

198 """ 

199 i: np.int64 = jobs - 1 

200 arr1: np.ndarray = instance[i] 

201 while i > 0: 

202 arr2 = arr1 

203 i -= 1 

204 arr1 = instance[i] 

205 for j in range(machines): 

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

207 return True 

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

209 return False 

210 return True 

211 

212 

213@numba.njit 

214def __is_feasible(instance: np.ndarray, 

215 gantt: np.ndarray, 

216 job_state: np.ndarray, 

217 gantt_state: np.ndarray, 

218 jobs: np.int64, 

219 machines: np.int64, 

220 row: np.int64) -> bool: 

221 """ 

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

223 

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

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

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

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

228 :param jobs: the number of jobs 

229 :param machines: the number of machines 

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

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

232 """ 

233 if row <= 1: 

234 return True 

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

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

237 found: bool = True 

238 needed_jobs = jobs # the number of required jobs 

239 

240 while found: 

241 found = False 

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

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

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

245 if js >= machines: 

246 break # the job is already finished 

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

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

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

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

251 found = True # we did something! 

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

253 needed_jobs -= 1 # one less jobs to do 

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

255 return True # the chart is feasible! 

256 break # quit handling this job, as its finished 

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

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

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

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

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

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

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

264 found = True # we did something! 

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

266 needed_jobs -= 1 # one less jobs to do 

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

268 return True # the chart is feasible! 

269 break # quit handling this job, as its finished 

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

271 break # no, we cannot handle this job now 

272 

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

274 

275 

276@numba.njit 

277def __first_perm(arr: np.ndarray, 

278 index: np.ndarray, 

279 pi: np.int64, 

280 n: np.int64) -> None: 

281 """ 

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

283 

284 :param arr: the array to permute over 

285 :param index: the array with the index 

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

287 :param n: the length of arr 

288 """ 

289 for i in range(n): 

290 arr[i] = i 

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

292 

293 

294@numba.njit 

295def __next_perm(arr: np.ndarray, 

296 index: np.ndarray, 

297 pi: np.int64, 

298 n: np.int64) -> bool: 

299 """ 

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

301 

302 :param arr: the array to permute over 

303 :param index: the array with the index 

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

305 :param n: the length of arr 

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

307 """ 

308 idx = index[pi] 

309 if idx >= n - 1: 

310 return False 

311 

312 nidx = idx + 1 

313 

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

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

316 idx = nidx 

317 while True: # update index to the next increase 

318 nidx = idx + 1 

319 if nidx >= n: 

320 break # reached end 

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

322 break # found increase 

323 idx = nidx 

324 else: 

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

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

327 arr[nidx], arr[0] = arr[0], arr[nidx] 

328 else: 

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

330 # than arr[idx + 1] 

331 start = np.int64(0) 

332 end = idx 

333 mid = (start + end) // 2 

334 t_value = arr[nidx] 

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

336 if arr[mid] < t_value: 

337 end = mid - 1 

338 else: 

339 start = mid + 1 

340 mid = (start + end) // 2 

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

342 

343 # invert 0 to increase 

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

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

346 idx = 0 # reset increase 

347 

348 index[pi] = idx 

349 return True 

350 

351 

352@numba.njit 

353def __enumerate_feasible(instance: np.ndarray, 

354 gantt: np.ndarray, 

355 job_state: np.ndarray, 

356 gantt_state: np.ndarray, 

357 jobs: np.int64, 

358 machines: np.int64, 

359 upper_bound: np.int64, 

360 index: np.ndarray, 

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

362 """ 

363 Enumerate the feasible gantt charts for an instance. 

364 

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

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

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

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

369 :param jobs: the number of jobs 

370 :param machines: the number of machines 

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

372 charts than this. 

373 :param index: the index array 

374 :param dest: the destination array 

375 :returns: the number of enumerated feasible gantt charts 

376 """ 

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

378 for z in range(machines): 

379 __first_perm(gantt[z], index, z, jobs) # type: ignore 

380 

381 while True: 

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

383 jobs, machines, machines): 

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

385 if counter >= upper_bound: 

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

387 

388 i = machines - 1 

389 while True: 

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

391 if i >= machines - 1: 

392 break 

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

394 jobs, machines, i + 1): 

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

396 __first_perm(gantt[j], index, j, jobs) # type: ignore 

397 break 

398 else: 

399 i -= 1 

400 if i < 0: 

401 if instance is not dest: 

402 __copy_instance(dest, instance, jobs, machines) 

403 return counter # we have enumerated all gantt charts 

404 

405 

406@numba.njit 

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

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

409 """ 

410 Find the minimum number of feasible gantt charts. 

411 

412 :param jobs: the number of jobs 

413 :param machines: the number of machines 

414 :param instance: the provided instance array 

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

416 of the given configuration 

417 """ 

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

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

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

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

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

423 return __enumerate_feasible(instance, gantt, job_state, 

424 gantt_state, jobs, machines, 

425 upper_bound, gantt_index, 

426 instance) 

427 

428 

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

430 """ 

431 Convert a value to a string. 

432 

433 :param value: the value 

434 :returns: the string representation 

435 """ 

436 if value < 0: 

437 return "" 

438 if value <= 1_000_000_000_000: 

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

440 logg = log10(value) 

441 exp = int(logg) 

442 base = value / (10 ** exp) 

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

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

445 

446 

447def make_gantt_space_size_table( 

448 dest: str = "solution_space_size.md", 

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

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

451 """ 

452 Print a table of solution space sizes. 

453 

454 :param dest: the destination file 

455 :param instances: the instances to add 

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

457 """ 

458 file = Path(dest) 

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

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

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

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

463 r"|:--|--:|--:|--:|--:|"] 

464 

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

466 

467 # enumerate the pre-defined instances 

468 for inst in set(instances): 

469 instance = Instance.from_resource(inst) 

470 min_size = -1 

471 for tup in __PRE_COMPUTED: 

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

473 min_size = tup[2] 

474 break 

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

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

477 min_size = gantt_min_feasible( 

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

479 inst_scales.append( 

480 (instance.jobs, instance.machines, 

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

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

483 del instance 

484 

485 # enumerate some default values 

486 for jobs in range(2, 6): 

487 for machines in range(2, 6): 

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

489 for tupp in inst_scales: 

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

491 found = True 

492 break 

493 if found: 

494 continue 

495 

496 min_size = -1 

497 for tup in __PRE_COMPUTED: 

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

499 min_size = tup[2] 

500 break 

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

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

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

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

505 inst_scales.append( 

506 (jobs, machines, gantt_space_size(jobs, machines), 

507 min_size, name)) 

508 

509 inst_scales.sort() 

510 for i, ua in enumerate(inst_scales): 

511 a = ua 

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

513 b = inst_scales[j] 

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

515 inst_scales[i] = b # noqa: B909 

516 inst_scales[j] = a # noqa: B909 

517 a = b 

518 

519 text.extend( 

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

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

522 

523 with file.open_for_write() as wd: 

524 write_lines(text, wd) 

525 file.enforce_file() 

526 return file 

527 

528 

529def make_search_space_size_table( 

530 dest: str = "solution_space_size.md", 

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

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

533 """ 

534 Print a table of search space sizes. 

535 

536 :param dest: the destination file 

537 :param instances: the instances to add 

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

539 """ 

540 file = Path(dest) 

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

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

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

544 r"|:--|--:|--:|--:|--:|"] 

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

546 

547 # enumerate the pre-defined instances 

548 for inst in set(instances): 

549 instance = Instance.from_resource(inst) 

550 inst_scales.append( 

551 (instance.jobs, instance.machines, 

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

553 permutations_with_repetitions_space_size( 

554 instance.jobs, instance.machines), 

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

556 del instance 

557 

558 # enumerate some default values 

559 for jobs in range(3, 6): 

560 for machines in range(2, 6): 

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

562 for tupp in inst_scales: 

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

564 found = True 

565 break 

566 if found: 

567 continue 

568 inst_scales.append( 

569 (jobs, machines, gantt_space_size(jobs, machines), 

570 permutations_with_repetitions_space_size( 

571 jobs, machines), "")) 

572 

573 inst_scales.sort() 

574 for i, ua in enumerate(inst_scales): 

575 a = ua 

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

577 b = inst_scales[j] 

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

579 inst_scales[i] = b # noqa: B909 

580 inst_scales[j] = a # noqa: B909 

581 a = b 

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

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

584 

585 with file.open_for_write() as wd: 

586 write_lines(text, wd) 

587 file.enforce_file() 

588 return file 

589 

590 

591# create the tables if this is the main script 

592if __name__ == "__main__": 

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

594 dest_dir.ensure_dir_exists() 

595 for lang in Lang.all_langs(): 

596 lang.set_current() 

597 make_gantt_space_size_table( 

598 dest_dir.resolve_inside( 

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

600 make_search_space_size_table( 

601 dest_dir.resolve_inside( 

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