Coverage for moptipyapps / ttp / plan_length.py: 52%

52 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 04:40 +0000

1""" 

2An objective computing the total travel length of all teams in a game plan. 

3 

4This objective function takes a game plan as argument and computes the total 

5travel length for all teams. 

6A :mod:`~moptipyapps.ttp.game_plan` is basically a matrix that, for each day 

7(first dimension) stores against which each team (second dimension) plays. 

8If a team plays at home (has a home game), its opponent is stored as a 

9positive number. 

10If the team has an away game, i.e., needs to visit the opponent, then this 

11opponent is stored as a negative number. 

12Team IDs go from `1` to `n`, i.e., a value in `1..n` indicates a home game 

13and a value in `-n..-1` indicates an away game. 

14The value `0` denotes a `bye`, i.e., that no game is scheduled for a team 

15at the given day. 

16 

17The total game plan length is computed as follows: 

18 

191. the total length = 0 

202. for each team, 

21 1. start at the current location = home 

22 2. for each day, 

23 1. if the opponent number is negative, the next location is the 

24 opponent's hometown; 

25 2. else if the opponent number is positive, the next location is the 

26 own hometown; 

27 3. else (if the opponent number is 0): add the `bye penalty` to the 

28 total length and jump to the next iteration 

29 4. add the distance from the current to the next location to the total 

30 length 

31 5. set the current location = the next location 

32 3. if the current location != own hometown, add the travel distance back 

33 from the current location to the hometown to the total travel length. 

34 

35As penalty for the `bye` situation where no game is scheduled, we use twice 

36the maximum distance between any two teams plus 1. 

37The logic is that if a `bye` (i.e., a `0`) inserted into a game plan, it 

38replaces one game. Since it replaces one game, it affects up to two travels, 

39namely from the previous location to the game location and from the game 

40location to the next location. 

41So the optimization process could sneakily try to cut longer legs of the 

42tournament by inserting a `bye`. 

43The longest imaginable travel would be between the two cities that are 

44farthest away from each other and back. 

45By making the penalty for a `bye` exactly one distance unit longer than 

46this longest imaginable distance, we ensure that the travel length can 

47never be reduced by inserting a `bye`. 

48Thus, having a `bye` is always more costly than any other travel it could 

49replace. 

50""" 

51 

52 

53from typing import Final 

54 

55import numba # type: ignore 

56import numpy as np 

57from moptipy.api.objective import Objective 

58from moptipy.utils.logger import KeyValueLogSection 

59from pycommons.types import type_error 

60 

61from moptipyapps.ttp.game_plan import GamePlan 

62from moptipyapps.ttp.instance import Instance 

63 

64 

65@numba.njit(cache=True, inline="always", fastmath=True, boundscheck=False) 

66def game_plan_length( 

67 y: np.ndarray, distances: np.ndarray, bye_penalty: int) -> int: 

68 """ 

69 Compute the total travel length of a game plan. 

70 

71 :param y: the game plan 

72 :param distances: the distance matrix 

73 :param bye_penalty: the penalty for `bye = 0` entries, i.e., days where 

74 no game is scheduled 

75 :returns: the total plan length 

76 

77 >>> yy = np.array([[ 2, -1, 4, -3], 

78 ... [-2, 1, -4, 3], 

79 ... [ 3, 4, -1, -2], 

80 ... [-3, -4, 1, 2], 

81 ... [ 4, 3, -2, -1], 

82 ... [-4, -3, 2, 1]], int) 

83 >>> dd = np.array([[ 0, 1, 2, 3], 

84 ... [ 7, 0, 4, 5], 

85 ... [ 8, 10, 0, 6], 

86 ... [ 9, 11, 12, 0]], int) 

87 >>> 0 + 1 + 7 + 2 + 8 + 3 + 9 # team 1 

88 30 

89 >>> 7 + 1 + 0 + 5 + 11 + 4 + 10 # team 2 

90 38 

91 >>> 0 + 6 + 9 + 2 + 10 + 4 # team 3 

92 31 

93 >>> 12 + 6 + 11 + 5 + 9 + 3 # team 4 

94 46 

95 >>> 30 + 38 + 31 + 46 # total sum 

96 145 

97 >>> game_plan_length(yy, dd, 0) 

98 145 

99 

100 >>> yy[1, 0] = 0 # add a bye 

101 >>> 0 + 25 + 0 + 2 + 8 + 3 + 9 # team 1 

102 47 

103 >>> game_plan_length(yy, dd, 2 * 12 + 1) 

104 162 

105 """ 

106 days, teams = y.shape # get the number of days and teams 

107 length: int = 0 

108 

109 for team in range(teams): # for each team 

110 current_location: int = team # start at home 

111 for day in range(days): # for each day 

112 next_location: int = y[day, team] 

113 if next_location < 0: # away game at other team 

114 next_location = (-next_location) - 1 

115 elif next_location > 0: # home game at home 

116 next_location = team 

117 else: # bye = no game = penalty 

118 length += bye_penalty 

119 continue # team stays in current location 

120 if current_location == next_location: 

121 continue # no move 

122 length += distances[current_location, next_location] 

123 current_location = next_location 

124 

125 if current_location != team: # go back home 

126 length += distances[current_location, team] 

127 

128 return int(length) 

129 

130 

131class GamePlanLength(Objective): 

132 """ 

133 Compute the total travel length of a game plan. 

134 

135 This objective function sums up all the travel lengths over all teams. 

136 Days without game (`bye`) are penalized. 

137 """ 

138 

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

140 """ 

141 Initialize the game plan length objective function. 

142 

143 :param instance: the TTP instance 

144 """ 

145 if not isinstance(instance, Instance): 

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

147 super().__init__() 

148 

149 #: the TTP instance 

150 self.instance: Final[Instance] = instance 

151 #: the bye penalty 

152 self.bye_penalty: Final[int] = (2 * int(instance.max())) + 1 

153 

154 def evaluate(self, x: GamePlan) -> int: 

155 """ 

156 Count the errors in a game plan as objective value. 

157 

158 :param x: the game plan 

159 :return: the number of errors in the plan 

160 """ 

161 return game_plan_length(x, x.instance, self.bye_penalty) 

162 

163 def lower_bound(self) -> int: 

164 """ 

165 Obtain the lower bound for the travel length. 

166 

167 :return: `0` 

168 """ 

169 return 0 

170 

171 def upper_bound(self) -> int: 

172 """ 

173 Compute upper bound for the travel length: All `n*days*bye_penalty`. 

174 

175 :returns: `n * days * self.bye_penalty` 

176 """ 

177 n: Final[int] = self.instance.n_cities 

178 rounds: Final[int] = self.instance.rounds 

179 days: Final[int] = (n - 1) * rounds 

180 return n * days * self.bye_penalty 

181 

182 def is_always_integer(self) -> bool: 

183 """ 

184 State that this objective function is always integer-valued. 

185 

186 :return: `True` 

187 """ 

188 return True 

189 

190 def __str__(self) -> str: 

191 """Get the name of this objective function.""" 

192 return "planLength" 

193 

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

195 """ 

196 Log the parameters of the instance to the given logger. 

197 

198 :param logger: the logger for the parameters 

199 """ 

200 super().log_parameters_to(logger) 

201 logger.key_value("byePenalty", self.bye_penalty)