Skip to content

API documentation

Map machinery to represent game board.

Map

Representation of a game board.

Source code in ticket_to_ride/map.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
class Map:
    """Representation of a game board."""

    def __init__(
        self: tp.Self,
        routes: tp.Iterable[Route],
        name: str = "Anonymous",
    ) -> None:
        """Constructor.

        Args:
            name: The name of the map, e.g. "North America".
            routes: Routes to form a map.
        """
        self.name = name
        self.graph: nx.Graph = nx.MultiGraph()
        for route in routes:
            route.add_as_edge(self.graph)
        self._check_is_suitable()

    def __str__(self: tp.Self) -> str:
        """Return readable string representation of self."""
        number_of_cities = self.graph.number_of_nodes()
        number_of_routes = self.graph.number_of_edges()
        return f"{self.name} map with {number_of_cities} cities and {number_of_routes} routes."

    def visualize(self: tp.Self, node_size: int = 1400) -> None:
        """Visualize self as a graph figure similar to actual game board.

        Args:
            node_size: Size of nodes to display.

        Returns:
            None, just plt.show()s the map.
        """
        plt.figure(figsize=(12, 12))

        # Resolve positions.
        pos = nx.spring_layout(self.graph, seed=42)

        # Draw nodes.
        centralities = self.calculate_centrality()
        node_colors = tuple(
            centralities.get(city, 0)
            for city in self.graph
        )
        nx.draw_networkx_nodes(
            self.graph,
            pos,
            node_size=node_size,
            node_color=node_colors,  # type: ignore[arg-type]
            cmap="Reds",
            edgecolors="black",
        )
        nx.draw_networkx_labels(
            self.graph,
            pos,
            labels={n: n.name for n in self.graph},
            font_size=6,
            font_family="sans-serif",
            font_weight="bold",
        )

        # Draw edges.
        for edges_between_pair in self._get_edges_by_neighbor_pair().values():
            nx.draw_networkx_edges(
                self.graph,
                pos,
                edgelist=edges_between_pair,
                width=4,
                alpha=0.7,
                # edge_color, if a sequence, should be a list rather than a tuple due to a bug in nx or plt package
                edge_color=[edge[2]["color"].value for edge in edges_between_pair],  # type: ignore[arg-type]
                # For the style parameter below, note that the sequence of strings should be specifically a list
                # because matplotlib style resolvers treat tuples as numeric parameters for styles.
                style=[edge[2]["transportation_type"].value for edge in edges_between_pair],  # type: ignore[arg-type]
                connectionstyle=generate_connectionstyle_iterable(len(edges_between_pair)),  # type: ignore[arg-type]
                node_size=node_size,
            )

        edge_labels = nx.get_edge_attributes(self.graph, name="length")
        nx.draw_networkx_edge_labels(self.graph, pos, edge_labels)

        plt.axis("off")
        plt.tight_layout()
        plt.show()

    def calculate_centrality(self: tp.Self) -> tp.Dict[City, float]:
        """Calculate centrality measure of all involved cities.

        Returns:
            A mapping from cities to their centrality measure, sorted from high to low centrality.
        """
        centrality = nx.betweenness_centrality(self.graph, weight="length")
        return dict(Counter(centrality).most_common())

    def calculate_routes_by_color(self: tp.Self) -> tp.Dict[Color, int]:
        """Calculate the number of routes by color and return as a mapping from color to number of routes."""
        routes_by_color = {color: len(edges) for color, edges in self._get_edges_by_color().items()}
        return dict(Counter(routes_by_color).most_common())

    def calculate_contrast_ratio(self: tp.Self) -> float:
        """Calculate the contrast ratio of a map which is a fraction of neutral-colored routes."""
        neutral_routes = self.calculate_routes_by_color()[Color.NEUTRAL]
        total_routes = self.graph.number_of_edges()
        return 1 - neutral_routes / total_routes

    def _get_edges_by_neighbor_pair(self: tp.Self) -> tp.Dict[tp.FrozenSet[City], tp.List[_TEdgeTupleWithData]]:
        """Get a mapping from neighbor pair to a collection of edges connecting it."""
        edges_by_pair = defaultdict(list)
        for (u, v, ddict) in self.graph.edges(data=True):
            pair = ddict["route_object"].involved_cities
            edges_by_pair[pair].append((u, v, ddict))
        return edges_by_pair

    def _get_routes_count_by_neighbor_pair(self: tp.Self) -> tp.Dict[tp.FrozenSet[City], int]:
        """Get a mapping from neighbor pair to the number of routes connecting them."""
        return {
            pair: len(edges)
            for pair, edges in self._get_edges_by_neighbor_pair().items()
        }

    def _get_edges_by_color(self: tp.Self) -> tp.Dict[Color, tp.List[_TEdgeTupleWithData]]:
        edges_by_color: tp.Dict[Color, tp.List[tp.Any]] = defaultdict(list)
        for (u, v, ddict) in self.graph.edges(data=True):
            color: Color = ddict["color"]
            edges_by_color[color].append((u, v, ddict))
        return edges_by_color  # TODO: make values tuples for safety?

    def _check_is_suitable(self: tp.Self) -> None:
        """Check that self is suitable for the Ticket to Ride game."""
        self._check_has_no_bridges()
        self._check_is_planar()

    def _check_has_no_bridges(self: tp.Self) -> None:
        """Check that the underlying graph has no bridges."""
        bridges = frozenset(nx.bridges(self.graph))
        if bridges:
            raise ValueError(f"Cannot initialize a {self.__class__.__name__} with bridges: {bridges}.")

    def _check_is_planar(self: tp.Self) -> None:
        """Check that the underlying graph is planar."""
        is_planar = nx.is_planar(self.graph)
        if not is_planar:
            raise ValueError(f"Cannot initialize a {self.__class__.__name__} with non-planar graph.")

__init__(routes, name='Anonymous')

Constructor.

Parameters:

Name Type Description Default
name str

The name of the map, e.g. "North America".

'Anonymous'
routes Iterable[Route]

Routes to form a map.

required
Source code in ticket_to_ride/map.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def __init__(
    self: tp.Self,
    routes: tp.Iterable[Route],
    name: str = "Anonymous",
) -> None:
    """Constructor.

    Args:
        name: The name of the map, e.g. "North America".
        routes: Routes to form a map.
    """
    self.name = name
    self.graph: nx.Graph = nx.MultiGraph()
    for route in routes:
        route.add_as_edge(self.graph)
    self._check_is_suitable()

__str__()

Return readable string representation of self.

Source code in ticket_to_ride/map.py
43
44
45
46
47
def __str__(self: tp.Self) -> str:
    """Return readable string representation of self."""
    number_of_cities = self.graph.number_of_nodes()
    number_of_routes = self.graph.number_of_edges()
    return f"{self.name} map with {number_of_cities} cities and {number_of_routes} routes."

calculate_centrality()

Calculate centrality measure of all involved cities.

Returns:

Type Description
Dict[City, float]

A mapping from cities to their centrality measure, sorted from high to low centrality.

Source code in ticket_to_ride/map.py
110
111
112
113
114
115
116
117
def calculate_centrality(self: tp.Self) -> tp.Dict[City, float]:
    """Calculate centrality measure of all involved cities.

    Returns:
        A mapping from cities to their centrality measure, sorted from high to low centrality.
    """
    centrality = nx.betweenness_centrality(self.graph, weight="length")
    return dict(Counter(centrality).most_common())

calculate_contrast_ratio()

Calculate the contrast ratio of a map which is a fraction of neutral-colored routes.

Source code in ticket_to_ride/map.py
124
125
126
127
128
def calculate_contrast_ratio(self: tp.Self) -> float:
    """Calculate the contrast ratio of a map which is a fraction of neutral-colored routes."""
    neutral_routes = self.calculate_routes_by_color()[Color.NEUTRAL]
    total_routes = self.graph.number_of_edges()
    return 1 - neutral_routes / total_routes

calculate_routes_by_color()

Calculate the number of routes by color and return as a mapping from color to number of routes.

Source code in ticket_to_ride/map.py
119
120
121
122
def calculate_routes_by_color(self: tp.Self) -> tp.Dict[Color, int]:
    """Calculate the number of routes by color and return as a mapping from color to number of routes."""
    routes_by_color = {color: len(edges) for color, edges in self._get_edges_by_color().items()}
    return dict(Counter(routes_by_color).most_common())

visualize(node_size=1400)

Visualize self as a graph figure similar to actual game board.

Parameters:

Name Type Description Default
node_size int

Size of nodes to display.

1400

Returns:

Type Description
None

None, just plt.show()s the map.

Source code in ticket_to_ride/map.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def visualize(self: tp.Self, node_size: int = 1400) -> None:
    """Visualize self as a graph figure similar to actual game board.

    Args:
        node_size: Size of nodes to display.

    Returns:
        None, just plt.show()s the map.
    """
    plt.figure(figsize=(12, 12))

    # Resolve positions.
    pos = nx.spring_layout(self.graph, seed=42)

    # Draw nodes.
    centralities = self.calculate_centrality()
    node_colors = tuple(
        centralities.get(city, 0)
        for city in self.graph
    )
    nx.draw_networkx_nodes(
        self.graph,
        pos,
        node_size=node_size,
        node_color=node_colors,  # type: ignore[arg-type]
        cmap="Reds",
        edgecolors="black",
    )
    nx.draw_networkx_labels(
        self.graph,
        pos,
        labels={n: n.name for n in self.graph},
        font_size=6,
        font_family="sans-serif",
        font_weight="bold",
    )

    # Draw edges.
    for edges_between_pair in self._get_edges_by_neighbor_pair().values():
        nx.draw_networkx_edges(
            self.graph,
            pos,
            edgelist=edges_between_pair,
            width=4,
            alpha=0.7,
            # edge_color, if a sequence, should be a list rather than a tuple due to a bug in nx or plt package
            edge_color=[edge[2]["color"].value for edge in edges_between_pair],  # type: ignore[arg-type]
            # For the style parameter below, note that the sequence of strings should be specifically a list
            # because matplotlib style resolvers treat tuples as numeric parameters for styles.
            style=[edge[2]["transportation_type"].value for edge in edges_between_pair],  # type: ignore[arg-type]
            connectionstyle=generate_connectionstyle_iterable(len(edges_between_pair)),  # type: ignore[arg-type]
            node_size=node_size,
        )

    edge_labels = nx.get_edge_attributes(self.graph, name="length")
    nx.draw_networkx_edge_labels(self.graph, pos, edge_labels)

    plt.axis("off")
    plt.tight_layout()
    plt.show()

Route machinery to represent routes between cities.

Route

Bases: BaseModel

Representation of a route between cities.

Source code in ticket_to_ride/route.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class Route(BaseModel):
    """Representation of a route between cities."""
    model_config = ConfigDict(validate_default=True)

    cities: tp.Tuple[City, City]
    length: int = Field(ge=1, default=1)
    color: Color = Field(default=Color.NEUTRAL)
    transportation_type: TransportationType = Field(default=TransportationType.TRAIN)

    def __eq__(self: tp.Self, other: tp.Any) -> bool:  # noqa: ANN401
        """Check if two routes are equivalent."""
        if isinstance(other, Route):
            conditions = (
                self.involved_cities == other.involved_cities,
                self.length == other.length,
                self.color == other.color,
                self.transportation_type == other.transportation_type,
            )
            return all(conditions)
        return False

    @property
    def involved_cities(self: tp.Self) -> tp.FrozenSet[City]:
        """Get cities involved in a route.

        TODO: Move to __post_init__ since value in not changed along lifecycle?
        """
        return frozenset(self.cities)

    @property
    def points_value(self: tp.Self) -> float:
        """Get points value of a route."""
        return _ROUTE_POINTS_BY_LENGTH[self.length]

    def add_as_edge(self: tp.Self, graph: nx.Graph) -> None:
        """Add self as an edge on a given graph.

        Args:
            graph: A graph to add self as an edge on.

        Returns:
            None: Modifies the graph passed as an argument in place.
        """
        graph.add_edge(
            *self.cities,
            length=self.length,
            color=self.color,
            transportation_type=self.transportation_type,
            route_object=self,
        )

involved_cities: tp.FrozenSet[City] property

Get cities involved in a route.

TODO: Move to post_init since value in not changed along lifecycle?

points_value: float property

Get points value of a route.

__eq__(other)

Check if two routes are equivalent.

Source code in ticket_to_ride/route.py
39
40
41
42
43
44
45
46
47
48
49
def __eq__(self: tp.Self, other: tp.Any) -> bool:  # noqa: ANN401
    """Check if two routes are equivalent."""
    if isinstance(other, Route):
        conditions = (
            self.involved_cities == other.involved_cities,
            self.length == other.length,
            self.color == other.color,
            self.transportation_type == other.transportation_type,
        )
        return all(conditions)
    return False

add_as_edge(graph)

Add self as an edge on a given graph.

Parameters:

Name Type Description Default
graph Graph

A graph to add self as an edge on.

required

Returns:

Name Type Description
None None

Modifies the graph passed as an argument in place.

Source code in ticket_to_ride/route.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def add_as_edge(self: tp.Self, graph: nx.Graph) -> None:
    """Add self as an edge on a given graph.

    Args:
        graph: A graph to add self as an edge on.

    Returns:
        None: Modifies the graph passed as an argument in place.
    """
    graph.add_edge(
        *self.cities,
        length=self.length,
        color=self.color,
        transportation_type=self.transportation_type,
        route_object=self,
    )

Ticket machinery to represent destination tickets.

Ticket

Ticket machinery to represent destination tickets.

Parameters:

Name Type Description Default
origin

Origin city as defined in the ticket.

required
destination

Destination city as defined in the ticket.

required
face_value

Points value specified on the ticket.

required
Source code in ticket_to_ride/ticket.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@frozen
class Ticket:
    """Ticket machinery to represent destination tickets.

    Args:
        origin: Origin city as defined in the ticket.
        destination: Destination city as defined in the ticket.
        face_value: Points value specified on the ticket.
    """
    origin: City
    destination: City
    face_value: int = field(validator=validators.ge(1))

    @property
    def objective_cities(self: tp.Self) -> tp.FrozenSet[City]:
        """Get an unordered pair of cities which a ticket prescribes to connect."""
        return frozenset((self.origin, self.destination))

objective_cities: tp.FrozenSet[City] property

Get an unordered pair of cities which a ticket prescribes to connect.

City machinery to represent nodes of the board.

City

Bases: BaseModel

City machinery to represent nodes of the board.

Source code in ticket_to_ride/city.py
12
13
14
15
16
class City(BaseModel):
    """City machinery to represent nodes of the board."""
    model_config = ConfigDict(frozen=True)

    name: str = Field(min_length=1)

Functional APIs to work with various machinery across the project.

evaluate_tickets(tickets, board_map)

Evaluate tickets against a board map and get a summary table.

Parameters:

Name Type Description Default
tickets Iterable[Ticket]

Tickets to evaluate.

required
board_map Map

A board map to evaluate tickets against.

required

Returns:

Type Description
DataFrame

A pandas DataFrame with one row representing one ticket and columns storing various statistics.

Notes

Ticket profitability concept is inspired by the article from Rakesh Chintha: https://genielab.github.io/data-stories/ttr_analysis/#

Source code in ticket_to_ride/functional.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def evaluate_tickets(tickets: tp.Iterable[Ticket], board_map: Map) -> pd.DataFrame:
    """Evaluate tickets against a board map and get a summary table.

    Args:
        tickets: Tickets to evaluate.
        board_map: A board map to evaluate tickets against.

    Returns:
        A pandas DataFrame with one row representing one ticket and columns storing various statistics.

    Notes:
        Ticket profitability concept is inspired by the article from Rakesh Chintha:
            https://genielab.github.io/data-stories/ttr_analysis/#
    """
    tickets = tuple(tickets)
    origins = tuple(ticket.origin.name for ticket in tickets)
    destinations = tuple(ticket.destination.name for ticket in tickets)
    face_value = tuple(ticket.face_value for ticket in tickets)
    shortest_path_length = tuple(
        nx.shortest_path_length(
            G=board_map.graph,
            source=ticket.origin,
            target=ticket.destination,
            weight="length",
        )
        for ticket in tickets
    )
    shortest_path_route_points = tuple(
        _evaluate_shortest_path_route_points(ticket, board_map)
        for ticket in tickets
    )
    output = pd.DataFrame({
        "origin": origins,
        "destination": destinations,
        "face_value": face_value,
        "shortest_path_length": shortest_path_length,
        "shortest_path_route_points": shortest_path_route_points,
    })
    output["profitability"] = output.eval("(shortest_path_route_points + face_value) / shortest_path_length")
    return output