Coverage for moptipyapps / ttp / instance.py: 81%
202 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-28 08:47 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-28 08:47 +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, # noqa: PYI034
336 teams: Iterable[str], rounds: int, home_streak_min: int,
337 home_streak_max: int, away_streak_min: int,
338 away_streak_max: int, separation_min: int,
339 separation_max: int,
340 tour_length_lower_bound: int = 0) -> "Instance":
341 """
342 Create an instance of the Traveling Salesperson Problem.
344 :param cls: the class
345 :param name: the name of the instance
346 :param matrix: the matrix with the data (will be copied)
347 :param teams: the iterable with the team names
348 :param rounds: the number of rounds
349 :param tour_length_lower_bound: the lower bound of the tour length
350 :param home_streak_min: the minimum number of games that can be played
351 at home in a row
352 :param home_streak_max: the maximum number of games that can be played
353 at home in a row
354 :param away_streak_min: the minimum number of games that can be played
355 away in a row
356 :param away_streak_max: the maximum number of games that can be played
357 away in a row
358 :param separation_min: the minimum number of games between a repetition
359 of a game setup
360 :param separation_max: the maximum number of games between a repetition
361 of a game setup
362 """
363 names: Final[tuple[str, ...]] = tuple(map(str.strip, teams))
364 n: Final[int] = len(names)
365 if (n % 2) != 0:
366 raise ValueError(f"the number of teams must be even, but is {n}.")
367 if n != len(set(names)):
368 raise ValueError(f"some team name appears twice in {teams!r} "
369 f"after fixing it to {names!r}.")
370 for nn in names:
371 for char in nn:
372 if char.isspace():
373 raise ValueError(
374 f"team name must not contain space, but {nn} does.")
376 obj: Final[Instance] = cast("Instance", super().__new__(
377 cls, name, tour_length_lower_bound, matrix, rounds * n))
379 if (obj.shape[0] != n) or (obj.shape[1] != n) or (obj.n_cities != n):
380 raise ValueError(f"inconsistent n_teams={n}, n_cities="
381 f"{obj.n_cities} and shape={obj.shape}")
382 #: the names of the teams that compete
383 obj.teams = names
384 #: the number of rounds
385 obj.rounds = check_int_range(rounds, "rounds", 1, 100)
386 ll: Final[int] = rounds * n - 1 # an upper bound for streaks
387 #: the minimum number of games that can be played at home in a row
388 obj.home_streak_min = check_int_range(
389 home_streak_min, "home_streak_min", 1, ll)
390 #: the maximum number of games that can be played at home in a row
391 obj.home_streak_max = check_int_range(
392 home_streak_max, "home_streak_max", home_streak_min, ll)
393 #: the minimum number of games that can be played away in a row
394 obj.away_streak_min = check_int_range(
395 away_streak_min, "away_streak_min", 1, ll)
396 #: the maximum number of games that can be played away in a row
397 obj.away_streak_max = check_int_range(
398 away_streak_max, "away_streak_max", away_streak_min, ll)
399 #: the minimum number of games between a repetition of a game setup
400 obj.separation_min = check_int_range(
401 separation_min, "separation_min", 0, ll)
402 #: the maximum number of games between a repetition of a game setup
403 obj.separation_max = check_int_range(
404 separation_max, "separation_max", separation_min, ll)
405 #: the data type to be used for the game plans
406 obj.game_plan_dtype = int_range_to_dtype(-n, n)
407 return obj
409 def log_parameters_to(self, logger: KeyValueLogSection) -> None:
410 """
411 Log the parameters of the instance to the given logger.
413 :param logger: the logger for the parameters
415 >>> from moptipy.utils.logger import InMemoryLogger
416 >>> with InMemoryLogger() as l:
417 ... with l.key_values("I") as kv:
418 ... Instance.from_resource("gal4").log_parameters_to(kv)
419 ... print(repr('@'.join(l.get_log())))
420 'BEGIN_I@name: gal4@class: moptipyapps.ttp.instance.Instance@\
421nCities: 4@tourLengthLowerBound: 67@tourLengthUpperBound: 160@symmetric: T@\
422dtype: h@rounds: 2@homeStreakMin: 1@homeStreakMax: 3@\
423awayStreakMin: 1@awayStreakMax: 3@separationMin: 1@separationMax: 6@\
424gamePlanDtype: b@END_I'
425 """
426 super().log_parameters_to(logger)
427 logger.key_value("rounds", self.rounds)
428 logger.key_value("homeStreakMin", self.home_streak_min)
429 logger.key_value("homeStreakMax", self.home_streak_max)
430 logger.key_value("awayStreakMin", self.away_streak_min)
431 logger.key_value("awayStreakMax", self.away_streak_max)
432 logger.key_value("separationMin", self.separation_min)
433 logger.key_value("separationMax", self.separation_max)
434 logger.key_value("gamePlanDtype", self.game_plan_dtype.char)
436 def get_optimal_plan_length_bounds(self) -> tuple[int, int]:
437 """
438 Get lower and upper bounds in which the *optimal* plan length resides.
440 These are the bounds for the optimal tour length of *feasible*
441 solutions. If we know the feasible solution with the smallest possible
442 tour length, then the :class:`~moptipyapps.ttp.game_plan` objective
443 function would return a value within these limits for this solution.
444 The limits for the RobinX instance have been taken from
445 https://robinxval.ugent.be/RobinX/travelRepo.php on 2024-05-10.
447 :return: a tuple of the lower and upper limit for the optimal
448 plan length
449 """
450 if self.name in _OPT_DISTANCE_BOUNDS:
451 return _OPT_DISTANCE_BOUNDS[self.name]
452 # unknown instance, compute bounds including penalty
453 n: Final[int] = self.n_cities
454 rounds: Final[int] = self.rounds
455 days: Final[int] = (n - 1) * rounds
456 return 0, ((2 * int(self.max())) + 1) * n * days
458 @staticmethod
459 def from_file(path: str, lower_bound_getter: Callable[[ # noqa: ARG004
460 str], int] | None = None) -> "Instance":
461 """
462 Read a TTP instance from a `robinX` formatted XML file.
464 :param path: the path to the file
465 :param lower_bound_getter: ignored
466 :return: the instance
468 >>> from os.path import dirname
469 >>> inst = Instance.from_file(dirname(__file__) + "/robinx/con20.xml")
470 >>> inst.name
471 'con20'
472 """
473 file: Final[Path] = file_path(path)
474 with file.open_for_read() as stream:
475 try:
476 return _from_stream(cast("TextIO", stream))
477 except (TypeError, ValueError) as err:
478 raise ValueError(f"error when parsing file {file!r}") from err
480 @staticmethod
481 def list_resources(symmetric: bool = True,
482 asymmetric: bool = True) -> tuple[str, ...]: # noqa
483 """
484 Get a tuple of all the TTP instances available as resource.
486 All instances of the `robinX` set provided here are symmetric.
488 :param symmetric: include the instances with symmetric distance
489 matrices
490 :param asymmetric: include the asymmetric instances with asymmetric
491 distance matrices
492 :return: the tuple with the instance names
494 >>> len(Instance.list_resources())
495 118
496 >>> len(Instance.list_resources(False, True))
497 0
498 >>> len(Instance.list_resources(True, False))
499 118
500 """
501 return _INSTANCES if symmetric else ()
503 @staticmethod
504 def from_resource(name: str) -> "Instance":
505 """
506 Load a TTP instance from a resource.
508 :param name: the name string
509 :return: the instance
511 >>> insta = Instance.from_resource("bra24")
512 >>> insta.n_cities
513 24
514 >>> insta.name
515 'bra24'
516 >>> insta.teams[0]
517 'Atl.Mineiro'
518 >>> insta.teams[1]
519 'Atl.Paranaense'
520 >>> insta.rounds
521 2
522 >>> insta.home_streak_min
523 1
524 >>> insta.home_streak_max
525 3
526 >>> insta.away_streak_min
527 1
528 >>> insta.away_streak_max
529 3
530 >>> insta.separation_min
531 1
532 >>> insta.separation_max
533 46
534 """
535 if not isinstance(name, str):
536 raise type_error(name, "name", str)
537 container: Final = Instance.from_resource
538 inst_attr: Final[str] = f"__inst_{name}"
539 if hasattr(container, inst_attr): # instance loaded?
540 return cast("Instance", getattr(container, inst_attr))
542 with open_resource_stream(f"{name}.xml") as stream:
543 inst: Final[Instance] = _from_stream(stream)
545 if inst.name != name:
546 raise ValueError(f"got {inst.name!r} for instance {name!r}?")
547 if inst.n_cities <= 1000:
548 setattr(container, inst_attr, inst)
549 return inst