Coverage for moptipyapps / ttp / instance.py: 81%
202 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 04:40 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 04:40 +0000
1"""
2An instance of the Traveling Tournament Problem (TTP).
4The Traveling Tournament Problem (TTP) describes the logistics of a sports
5league. In this league, `n` teams compete. In each time slot, each team plays
6against one other team. In each game, one team plays at home and one team
7plays away with the other team. In each round, every team plays once against
8every other team. The league may have multiple :attr:`~moptipyapps.ttp.\
9instance.Instance.rounds`. If there are two rounds, then each team plays
10against each other team once at home and once abroad. If a team plays at home
11(or abroad) several times in a row, this is called a "streak". There are
12minimum and maximum streak length constraints defined, for both at home and
13abroad. Additionally, if team A plays a team B in one time slot, then the
14exact inverse game cannot take place in the next time slot. A minimum
15number of games must take place in between for separation. There can also be
16a maximum separation length.
18David Van Bulck of the Sports Scheduling Research group, part of the
19Faculty of Economics and Business Administration at Ghent University, Belgium,
20maintains "RobinX: An XML-driven Classification for Round-Robin Sports
21Timetabling", a set of benchmark data instances and results of the TTP.
22We provide some of these instances as resources here. You can also download
23them directly at <https://robinxval.ugent.be/RobinX/travelRepo.php>. Also,
24see <https://robinxval.ugent.be/> for more information.
261. David Van Bulck. Minimum Travel Objective Repository. *RobinX: An
27 XML-driven Classification for Round-Robin Sports Timetabling.* Faculty of
28 Economics and Business Administration at Ghent University, Belgium.
29 https://robinxval.ugent.be/
302. Kelly Easton, George L. Nemhauser, and Michael K. Trick. The Traveling
31 Tournament Problem Description and Benchmarks. In *Principles and Practice
32 of Constraint Programming (CP'01),* November 26 - December 1, 2001, Paphos,
33 Cyprus, pages 580-584, Berlin/Heidelberg, Germany: Springer.
34 ISBN: 978-3-540-42863-3. https://doi.org/10.1007/3-540-45578-7_43
35 https://www.researchgate.net/publication/220270875
36"""
38from typing import Callable, Final, Iterable, TextIO, cast
40import numpy as np
41from defusedxml import ElementTree # type: ignore
42from moptipy.utils.logger import KeyValueLogSection
43from moptipy.utils.nputils import DEFAULT_INT, int_range_to_dtype
44from moptipy.utils.strings import sanitize_name
45from pycommons.io.path import Path, file_path
46from pycommons.types import check_int_range, check_to_int_range, type_error
48from moptipyapps.tsp.instance import Instance as TSPInstance
49from moptipyapps.ttp.robinx import open_resource_stream
52def _from_stream(stream: TextIO) -> "Instance":
53 """
54 Read a TTP instance from an `robinxval.ugent.be`-formatted XML file.
56 This procedure ignores most of the data in the file and only focuses on
57 the instance name, the team names, and the distance matrix as well as the
58 constraints for home streak length, away streak length, and same-game
59 separations. Everything else is ignored.
61 :param stream: the text stream
62 :return: the instance
63 """
64 used_names: set[str] = set()
65 team_names: dict[int, str] = {}
66 distances: dict[tuple[int, int], int] = {}
67 name: str | None = None
68 rounds: int | None = None
69 home_streak_min: int | None = None
70 home_streak_max: int | None = None
71 away_streak_min: int | None = None
72 away_streak_max: int | None = None
73 separation_min: int | None = None
74 separation_max: int | None = None
76 for _event, element in ElementTree.iterparse(stream, forbid_dtd=True,
77 forbid_entities=True,
78 forbid_external=True):
79 if element.tag is None:
80 continue
81 tag: str = element.tag.strip().lower()
82 if tag == "instancename":
83 if name is None:
84 name = sanitize_name(element.text).lower()
85 if name in used_names:
86 raise ValueError(
87 f"name {element.text!r} invalid, as it "
88 f"maps to {name!r}, which is already used.")
89 used_names.add(name)
90 else:
91 raise ValueError(f"already got name={name!r}, but tag "
92 f"'InstanceName' appears again?")
93 elif tag == "distance":
94 t1: int = check_to_int_range(element.attrib["team1"],
95 "team1", 0, 1_000_000)
96 t2: int = check_to_int_range(element.attrib["team2"],
97 "team2", 0, 1_000_000)
98 dst: int = check_to_int_range(
99 element.attrib["dist"], "dist", 0, 1_000_000_000_000)
100 if t1 == t2:
101 if dst == 0:
102 continue
103 raise ValueError(f"distance for team1={t1}, team2={t2} is"
104 f" {dst} but must be 0.")
105 tpl: tuple[int, int] = (t1, t2)
106 if tpl in distances:
107 raise ValueError(
108 f"got distance={dst} for {tpl!r}, but "
109 f"{distances[tpl]} was already specified before")
110 distances[tpl] = dst
111 elif element.tag == "team":
112 team: int = check_to_int_range(element.attrib["id"], "id",
113 0, 1_000_000)
114 nn: str = element.attrib["name"]
115 tname: str = nn.strip()
116 if tname in used_names:
117 raise ValueError(f"name {nn!r} is invalid, as it maps to "
118 f"{tname!r}, which is already used.")
119 used_names.add(tname)
120 team_names[team] = tname
121 elif element.tag == "numberroundrobin":
122 if rounds is not None:
123 raise ValueError(f"rounds already set to {rounds}")
124 rounds = check_to_int_range(element.text, "rounds", 1, 1000)
125 elif tag == "ca3":
126 if "mode1" not in element.attrib:
127 continue
128 if "mode2" not in element.attrib:
129 continue
130 if element.attrib["mode2"].lower() != "games":
131 continue
132 mode = element.attrib["mode1"].lower()
133 mi = check_to_int_range(
134 element.attrib["min"], "min", 0, 1_000_000) \
135 if "min" in element.attrib else None
136 ma = check_to_int_range(
137 element.attrib["max"], "max", 1, 1_000_000) \
138 if "max" in element.attrib else None
139 if mode == "h":
140 if mi is not None:
141 if home_streak_min is not None:
142 raise ValueError("minimum home streak already defined")
143 home_streak_min = max(mi, 1)
144 if ma is not None:
145 if home_streak_max is not None:
146 raise ValueError("maximum home streak already defined")
147 home_streak_max = ma
148 elif mode == "a":
149 if mi is not None:
150 if away_streak_min is not None:
151 raise ValueError("minimum away streak already defined")
152 away_streak_min = max(mi, 1)
153 if ma is not None:
154 if away_streak_max is not None:
155 raise ValueError("maximum away streak already defined")
156 away_streak_max = ma
157 elif tag == "se1":
158 mi = check_to_int_range(
159 element.attrib["min"], "min", 0, 1_000_000) \
160 if "min" in element.attrib else None
161 ma = check_to_int_range(
162 element.attrib["max"], "max", 0, 1_000_000) \
163 if "max" in element.attrib else None
164 if mi is not None:
165 if separation_min is not None:
166 raise ValueError("minimum separation already defined")
167 separation_min = mi
168 if ma is not None:
169 if separation_max is not None:
170 raise ValueError("maximum separation already defined")
171 separation_max = ma
173 if name is None:
174 raise ValueError("did not find instance name")
175 n_teams: Final[int] = len(team_names)
176 if n_teams <= 0:
177 raise ValueError("did not find any team name")
178 if len(used_names) != (n_teams + 1):
179 raise ValueError(f"set of used names {used_names!r} has wrong "
180 f"length, should be {n_teams + 1}.")
181 dm: np.ndarray = np.zeros((n_teams, n_teams), DEFAULT_INT)
182 for tup, dst in distances.items():
183 dm[tup[0], tup[1]] = dst
185 if rounds is None:
186 rounds = 2
187 ll: Final[int] = rounds * n_teams - 1
188 if home_streak_min is None:
189 home_streak_min = 1
190 if home_streak_max is None:
191 home_streak_max = min(max(home_streak_min, 3), ll)
192 if away_streak_min is None:
193 away_streak_min = 1
194 if away_streak_max is None:
195 away_streak_max = min(max(away_streak_min, 3), ll)
196 if separation_min is None:
197 separation_min = 1
198 if separation_max is None:
199 separation_max = min(max(separation_min, 1), ll)
201 return Instance(name, dm, [team_names[i] for i in range(n_teams)],
202 rounds, home_streak_min, home_streak_max, away_streak_min,
203 away_streak_max, separation_min, separation_max)
206#: The instances made available within this package are taken from
207#: <https://robinxval.ugent.be/RobinX/travelRepo.php>, where the following
208#: descriptions are given:
209#: - *Constant Distance (`con*`):* The constant distance instances are the
210#: most simple instances in which the distance between the home venues of
211#: any two teams is one. In this case, Urrutia and Ribeiro showed that
212#: distance minimization is equivalent with break maximization.
213#: - *Circular Distance (`circ*`):* Somewhat similar are the circular
214#: distance instances in which the teams' venues are placed on a
215#: circle. Any two consecutive teams are connected by an edge and the
216#: distance between two teams is equal to the minimal number of edges that
217#: must be traversed to get to the other team. Although traveling
218#: salesperson problems with a circular distance matrix have a trivial
219#: solution, it remains challenging to solve circular traveling tournament
220#: instances.
221#: - *Galaxy (`gal*`):* This artificial instance class consists of the Galaxy
222#: instances that use a 3D-space that embeds the Earth and 39 other
223#: exoplanets.
224#: - *National league (`nl*`):* The `nl`-instances are based on air distance
225#: between the city centers from teams in the National League of the Major
226#: League Baseball.
227#: - *National football league (`nfl*`):* The NFL-instances are based on air
228#: distance between the city centers from teams in the National Football
229#: League.
230#: - *Super 14 (`sup*`):* The super 14 instances are based on air distance
231#: between the city centers from teams in the Super 14 rugby cup.
232#: - *Brazilian (`bra24`)):* The Brazilian instance is based on the air
233#: distance between the home cities of 24 teams in the main division of the
234#: 2003 edition of the Brazilian soccer championship.
235#: - *Linear (`line*`):* In the linear instances, `n` teams are located on a
236#: straight line with a distance of one unit separating each pair of
237#: adjacent teams.
238#: - *Increasing distance (`incr*`):* In the increasing distance instances,
239#: `n` teams are located on a straight line with an increasing distance
240#: separating each pair of adjacent teams such that the distance between
241#: team `k` and `k+1` equals `k`.
242_INSTANCES: Final[tuple[str, ...]] = (
243 "bra24", "circ4", "circ6", "circ8", "circ10", "circ12", "circ14",
244 "circ16", "circ18", "circ20", "circ22", "circ24", "circ26", "circ28",
245 "circ30", "circ32", "circ34", "circ36", "circ38", "circ40", "con4",
246 "con6", "con8", "con10", "con12", "con14", "con16", "con18", "con20",
247 "con22", "con24", "con26", "con28", "con30", "con32", "con34", "con36",
248 "con38", "con40", "gal4", "gal6", "gal8", "gal10", "gal12", "gal14",
249 "gal16", "gal18", "gal20", "gal22", "gal24", "gal26", "gal28", "gal30",
250 "gal32", "gal34", "gal36", "gal38", "gal40", "incr4", "incr6", "incr8",
251 "incr10", "incr12", "incr14", "incr16", "incr18", "incr20", "incr22",
252 "incr24", "incr26", "incr28", "incr30", "incr32", "incr34", "incr36",
253 "incr38", "incr40", "line4", "line6", "line8", "line10", "line12",
254 "line14", "line16", "line18", "line20", "line22", "line24", "line26",
255 "line28", "line30", "line32", "line34", "line36", "line38", "line40",
256 "nfl16", "nfl18", "nfl20", "nfl22", "nfl24", "nfl26", "nfl28", "nfl30",
257 "nfl32", "nl4", "nl6", "nl8", "nl10", "nl12", "nl14", "nl16", "sup4",
258 "sup6", "sup8", "sup10", "sup12", "sup14")
261#: The lower and upper bound for the *optimal* total tournament length, taken
262#: from https://robinxval.ugent.be/RobinX/travelRepo.php on 2024-05-10.
263_OPT_DISTANCE_BOUNDS: Final[dict[str, tuple[int, int]]] = {
264 "bra24": (451406, 538866), "circ4": (20, 20), "circ6": (64, 64),
265 "circ8": (132, 132), "circ10": (242, 242), "circ12": (388, 400),
266 "circ14": (588, 616), "circ16": (846, 898), "circ18": (1188, 1268),
267 "circ20": (1600, 1724), "circ22": (2068, 2366), "circ24": (2688, 3146),
268 "circ26": (3380, 3992), "circ28": (4144, 4642), "circ30": (5100, 5842),
269 "circ32": (6144, 7074), "circ34": (7276, 8042), "circ36": (8640, 9726),
270 "circ38": (10108, 11424), "circ40": (11680, 12752), "con4": (17, 17),
271 "con6": (43, 43), "con8": (80, 80), "con10": (124, 124),
272 "con12": (181, 181), "con14": (252, 252), "con16": (327, 327),
273 "con18": (414, 416), "con20": (520, 520), "con22": (626, 626),
274 "con24": (744, 747), "con26": (884, 884), "con28": (1021, 1021),
275 "con30": (1170, 1177), "con32": (1344, 1359), "con34": (1512, 1512),
276 "con36": (1692, 1703), "con38": (1900, 1918), "con40": (2099, 2099),
277 "gal4": (416, 416), "gal6": (1365, 1365), "gal8": (2373, 2373),
278 "gal10": (4535, 4535), "gal12": (7034, 7135), "gal14": (10255, 10840),
279 "gal16": (13619, 14583), "gal18": (19050, 20205), "gal20": (23738, 25401),
280 "gal22": (31461, 33901), "gal24": (41287, 44260), "gal26": (53802, 58968),
281 "gal28": (69992, 75276), "gal30": (88831, 95158),
282 "gal32": (108374, 119665), "gal34": (133976, 143298),
283 "gal36": (158549, 169387), "gal38": (189126, 204980),
284 "gal40": (226820, 241908), "incr4": (48, 48), "incr6": (228, 228),
285 "incr8": (624, 638), "incr10": (1440, 1612), "incr12": (2880, 3398),
286 "incr14": (5180, 6488), "incr16": (8640, 10332), "incr18": (13548, 17278),
287 "incr20": (20368, 25672), "incr22": (29484, 40944),
288 "incr24": (41360, 56602), "incr26": (56500, 81866),
289 "incr28": (75456, 106870), "incr30": (98820, 136810),
290 "incr32": (127224, 177990), "incr34": (161348, 222082),
291 "incr36": (201912, 278060), "incr38": (249686, 336008),
292 "incr40": (305470, 406960), "line4": (24, 24), "line6": (76, 76),
293 "line8": (156, 162), "line10": (288, 370), "line12": (480, 584),
294 "line14": (740, 918), "line16": (1080, 1320), "line18": (1512, 1926),
295 "line20": (2044, 2548), "line22": (2688, 3684), "line24": (3456, 4732),
296 "line26": (4356, 6382), "line28": (5400, 7778), "line30": (6600, 9312),
297 "line32": (7964, 11234), "line34": (9504, 13190),
298 "line36": (11232, 15536), "line38": (13156, 17862),
299 "line40": (15294, 20546), "nfl16": (223800, 231483),
300 "nfl18": (272834, 282258), "nfl20": (316721, 332041),
301 "nfl22": (378813, 400636), "nfl24": (431226, 463657),
302 "nfl26": (495982, 536792), "nfl28": (560697, 598123),
303 "nfl30": (688875, 739697), "nfl32": (836031, 914620),
304 "nl4": (8276, 8276), "nl6": (23916, 23916), "nl8": (39721, 39721),
305 "nl10": (59436, 59436), "nl12": (108629, 110729),
306 "nl14": (183354, 188728), "nl16": (249477, 261687),
307 "sup4": (63405, 63405), "sup6": (130365, 130365),
308 "sup8": (182409, 182409), "sup10": (316329, 316329),
309 "sup12": (453860, 458810), "sup14": (557354, 567891),
310}
313class Instance(TSPInstance):
314 """An instance of Traveling Tournament Problem (TTP)."""
316 #: the names of the teams
317 teams: tuple[str, ...]
318 #: the number of rounds
319 rounds: int
320 #: the minimum number of games that can be played at home in a row
321 home_streak_min: int
322 #: the maximum number of games that can be played at home in a row
323 home_streak_max: int
324 #: the minimum number of games that can be played away in a row
325 away_streak_min: int
326 #: the maximum number of games that can be played away in a row
327 away_streak_max: int
328 #: the minimum number of games between a repetition of a game setup
329 separation_min: int
330 #: the maximum number of games between a repetition of a game setup
331 separation_max: int
332 #: the data type to be used for plans
333 game_plan_dtype: np.dtype
335 def __new__(cls, name: str, matrix: np.ndarray, teams: Iterable[str],
336 rounds: int, home_streak_min: int, home_streak_max: int,
337 away_streak_min: int, away_streak_max: int,
338 separation_min: int, separation_max: int,
339 tour_length_lower_bound: int = 0) -> "Instance":
340 """
341 Create an instance of the Traveling Salesperson Problem.
343 :param cls: the class
344 :param name: the name of the instance
345 :param matrix: the matrix with the data (will be copied)
346 :param teams: the iterable with the team names
347 :param rounds: the number of rounds
348 :param tour_length_lower_bound: the lower bound of the tour length
349 :param home_streak_min: the minimum number of games that can be played
350 at home in a row
351 :param home_streak_max: the maximum number of games that can be played
352 at home in a row
353 :param away_streak_min: the minimum number of games that can be played
354 away in a row
355 :param away_streak_max: the maximum number of games that can be played
356 away in a row
357 :param separation_min: the minimum number of games between a repetition
358 of a game setup
359 :param separation_max: the maximum number of games between a repetition
360 of a game setup
361 """
362 names: Final[tuple[str, ...]] = tuple(map(str.strip, teams))
363 n: Final[int] = len(names)
364 if (n % 2) != 0:
365 raise ValueError(f"the number of teams must be even, but is {n}.")
366 if n != len(set(names)):
367 raise ValueError(f"some team name appears twice in {teams!r} "
368 f"after fixing it to {names!r}.")
369 for nn in names:
370 for char in nn:
371 if char.isspace():
372 raise ValueError(
373 f"team name must not contain space, but {nn} does.")
375 obj: Final[Instance] = cast("Instance", super().__new__(
376 cls, name, tour_length_lower_bound, matrix, rounds * n))
378 if (obj.shape[0] != n) or (obj.shape[1] != n) or (obj.n_cities != n):
379 raise ValueError(f"inconsistent n_teams={n}, n_cities="
380 f"{obj.n_cities} and shape={obj.shape}")
381 #: the names of the teams that compete
382 obj.teams = names
383 #: the number of rounds
384 obj.rounds = check_int_range(rounds, "rounds", 1, 100)
385 ll: Final[int] = rounds * n - 1 # an upper bound for streaks
386 #: the minimum number of games that can be played at home in a row
387 obj.home_streak_min = check_int_range(
388 home_streak_min, "home_streak_min", 1, ll)
389 #: the maximum number of games that can be played at home in a row
390 obj.home_streak_max = check_int_range(
391 home_streak_max, "home_streak_max", home_streak_min, ll)
392 #: the minimum number of games that can be played away in a row
393 obj.away_streak_min = check_int_range(
394 away_streak_min, "away_streak_min", 1, ll)
395 #: the maximum number of games that can be played away in a row
396 obj.away_streak_max = check_int_range(
397 away_streak_max, "away_streak_max", away_streak_min, ll)
398 #: the minimum number of games between a repetition of a game setup
399 obj.separation_min = check_int_range(
400 separation_min, "separation_min", 0, ll)
401 #: the maximum number of games between a repetition of a game setup
402 obj.separation_max = check_int_range(
403 separation_max, "separation_max", separation_min, ll)
404 #: the data type to be used for the game plans
405 obj.game_plan_dtype = int_range_to_dtype(-n, n)
406 return obj
408 def log_parameters_to(self, logger: KeyValueLogSection) -> None:
409 """
410 Log the parameters of the instance to the given logger.
412 :param logger: the logger for the parameters
414 >>> from moptipy.utils.logger import InMemoryLogger
415 >>> with InMemoryLogger() as l:
416 ... with l.key_values("I") as kv:
417 ... Instance.from_resource("gal4").log_parameters_to(kv)
418 ... print(repr('@'.join(l.get_log())))
419 'BEGIN_I@name: gal4@class: moptipyapps.ttp.instance.Instance@\
420nCities: 4@tourLengthLowerBound: 67@tourLengthUpperBound: 160@symmetric: T@\
421dtype: h@rounds: 2@homeStreakMin: 1@homeStreakMax: 3@\
422awayStreakMin: 1@awayStreakMax: 3@separationMin: 1@separationMax: 6@\
423gamePlanDtype: b@END_I'
424 """
425 super().log_parameters_to(logger)
426 logger.key_value("rounds", self.rounds)
427 logger.key_value("homeStreakMin", self.home_streak_min)
428 logger.key_value("homeStreakMax", self.home_streak_max)
429 logger.key_value("awayStreakMin", self.away_streak_min)
430 logger.key_value("awayStreakMax", self.away_streak_max)
431 logger.key_value("separationMin", self.separation_min)
432 logger.key_value("separationMax", self.separation_max)
433 logger.key_value("gamePlanDtype", self.game_plan_dtype.char)
435 def get_optimal_plan_length_bounds(self) -> tuple[int, int]:
436 """
437 Get lower and upper bounds in which the *optimal* plan length resides.
439 These are the bounds for the optimal tour length of *feasible*
440 solutions. If we know the feasible solution with the smallest possible
441 tour length, then the :class:`~moptipyapps.ttp.game_plan` objective
442 function would return a value within these limits for this solution.
443 The limits for the RobinX instance have been taken from
444 https://robinxval.ugent.be/RobinX/travelRepo.php on 2024-05-10.
446 :return: a tuple of the lower and upper limit for the optimal
447 plan length
448 """
449 if self.name in _OPT_DISTANCE_BOUNDS:
450 return _OPT_DISTANCE_BOUNDS[self.name]
451 # unknown instance, compute bounds including penalty
452 n: Final[int] = self.n_cities
453 rounds: Final[int] = self.rounds
454 days: Final[int] = (n - 1) * rounds
455 return 0, ((2 * int(self.max())) + 1) * n * days
457 @staticmethod
458 def from_file(path: str, lower_bound_getter: Callable[[ # noqa: ARG004
459 str], int] | None = None) -> "Instance":
460 """
461 Read a TTP instance from a `robinX` formatted XML file.
463 :param path: the path to the file
464 :param lower_bound_getter: ignored
465 :return: the instance
467 >>> from os.path import dirname
468 >>> inst = Instance.from_file(dirname(__file__) + "/robinx/con20.xml")
469 >>> inst.name
470 'con20'
471 """
472 file: Final[Path] = file_path(path)
473 with file.open_for_read() as stream:
474 try:
475 return _from_stream(cast("TextIO", stream))
476 except (TypeError, ValueError) as err:
477 raise ValueError(f"error when parsing file {file!r}") from err
479 @staticmethod
480 def list_resources(symmetric: bool = True,
481 asymmetric: bool = True) -> tuple[str, ...]: # noqa
482 """
483 Get a tuple of all the TTP instances available as resource.
485 All instances of the `robinX` set provided here are symmetric.
487 :param symmetric: include the instances with symmetric distance
488 matrices
489 :param asymmetric: include the asymmetric instances with asymmetric
490 distance matrices
491 :return: the tuple with the instance names
493 >>> len(Instance.list_resources())
494 118
495 >>> len(Instance.list_resources(False, True))
496 0
497 >>> len(Instance.list_resources(True, False))
498 118
499 """
500 return _INSTANCES if symmetric else ()
502 @staticmethod
503 def from_resource(name: str) -> "Instance":
504 """
505 Load a TTP instance from a resource.
507 :param name: the name string
508 :return: the instance
510 >>> insta = Instance.from_resource("bra24")
511 >>> insta.n_cities
512 24
513 >>> insta.name
514 'bra24'
515 >>> insta.teams[0]
516 'Atl.Mineiro'
517 >>> insta.teams[1]
518 'Atl.Paranaense'
519 >>> insta.rounds
520 2
521 >>> insta.home_streak_min
522 1
523 >>> insta.home_streak_max
524 3
525 >>> insta.away_streak_min
526 1
527 >>> insta.away_streak_max
528 3
529 >>> insta.separation_min
530 1
531 >>> insta.separation_max
532 46
533 """
534 if not isinstance(name, str):
535 raise type_error(name, "name", str)
536 container: Final = Instance.from_resource
537 inst_attr: Final[str] = f"__inst_{name}"
538 if hasattr(container, inst_attr): # instance loaded?
539 return cast("Instance", getattr(container, inst_attr))
541 with open_resource_stream(f"{name}.xml") as stream:
542 inst: Final[Instance] = _from_stream(stream)
544 if inst.name != name:
545 raise ValueError(f"got {inst.name!r} for instance {name!r}?")
546 if inst.n_cities <= 1000:
547 setattr(container, inst_attr, inst)
548 return inst