Coverage for src/data_api/domain/buses/graph.py: 100%
56 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""
2Buses graph definition.
3Contains Route topology, Stop definitions, and Route selection logic.
4"""
6from typing import Literal, Optional
8from . import models
10# --- 1. Stop Definitions ---
11STOPS_DATA = {
12 "M1": models.Stop("M1", "北校門口", "North Main Gate", "24.79589", "120.99633"),
13 "M2": models.Stop("M2", "綜二館", "General Building II", "24.79417", "120.99376"),
14 "M3": models.Stop("M3", "楓林小徑", "Maple Path", "24.79138", "120.99138"),
15 "M4": models.Stop("M4", "人社院&生科館", "CHSS/CLS Building", "24.78973", "120.99001"),
16 "M5": models.Stop("M5", "台積館", "TSMC Building", "24.78695", "120.9884"),
17 "M6": models.Stop("M6", "奕園停車場", "Yi Pavilion Parking Lot", "24.78828", "120.99246"),
18 "M7": models.Stop(
19 "M7",
20 "教育學院大樓&南門停車場",
21 "COE Building/South Gate Parking Lot",
22 "24.78593",
23 "120.99013",
24 ),
25 "S1": models.Stop(
26 "S1",
27 "南大校區校門口右側(食品路校牆邊)",
28 "The right side of NandaCampus front gate",
29 "24.79438",
30 "120.96538",
31 ),
32}
34# Helper to access stops easily
35S = STOPS_DATA
37# --- 2. Route Factory Helpers ---
40def create_route(route_id: str, stop_ids: list[str], deltas: list[int]) -> models.Route:
41 """
42 Creates a Route object with pre-calculated time offsets.
43 stop_ids: ["M1", "M2", ...]
44 deltas: [1, 2, ...] -> Time to travel FROM previous stop TO current stop.
45 First element is usually 0 (time to reach first stop).
46 """
47 stops = [S[sid] for sid in stop_ids]
49 # Calculate cumulative time offsets
50 # If deltas is [0, 1, 1, 2], offsets are [0, 1, 2, 4]
51 offsets = [0] * len(stops)
52 current_time = 0
53 for i, time_needed in enumerate(deltas):
54 current_time += time_needed
55 if i < len(offsets):
56 offsets[i] = current_time
58 return models.Route(id=route_id, stops=stops, time_offsets=offsets)
61# --- 3. Route Definitions (The Graph) ---
63# Red Lines
64# M1 -> M2 (1m) -> M3 (1m) -> M4 (2m) -> M5 (2m)
65red_M1_M5 = create_route("red_M1_M5", ["M1", "M2", "M3", "M4", "M5"], [0, 1, 1, 2, 2])
66# M2 -> M3 (1m) -> M4 (2m) -> M5 (2m) (Start from Gen 2)
67red_M2_M5 = create_route("red_M2_M5", ["M2", "M3", "M4", "M5"], [0, 1, 2, 2])
68# M5 -> M7 (1m) -> M6 (2m) -> M2 (1m) -> M1 (1m)
69red_M5_M1 = create_route("red_M5_M1", ["M5", "M7", "M6", "M2", "M1"], [0, 1, 2, 1, 1])
70# M5 -> M7 (1m) -> M6 (2m) -> M2 (1m) (End at Gen 2)
71red_M5_M2 = create_route("red_M5_M2", ["M5", "M7", "M6", "M2"], [0, 1, 2, 1])
73# Green Lines
74# M1 -> M2 (1m) -> M3 (1m) -> M6 (1m) -> M7 (2m) -> M5 (1m)
75green_M1_M5 = create_route("green_M1_M5", ["M1", "M2", "M3", "M6", "M7", "M5"], [0, 1, 1, 1, 2, 1])
76green_M2_M5 = create_route("green_M2_M5", ["M2", "M3", "M6", "M7", "M5"], [0, 1, 1, 2, 1])
77# M5 -> M4 (2m) -> M2 (3m) -> M1 (1m)
78green_M5_M1 = create_route("green_M5_M1", ["M5", "M4", "M2", "M1"], [0, 2, 3, 1])
79green_M5_M2 = create_route("green_M5_M2", ["M5", "M4", "M2"], [0, 2, 3])
81# Nanda Lines
82# Route 1: M1 -> M2 (1m) -> M4 (2m) -> M5 (2m) -> S1 (15m) (Estimated)
83nanda_M1_S1_r1 = create_route("nanda_M1_S1_r1", ["M1", "M2", "M4", "M5", "S1"], [0, 1, 2, 2, 15])
84# Route 2: M1 -> M2 (1m) -> M6 (4m) -> M7 (2m) -> S1 (15m)
85nanda_M1_S1_r2 = create_route("nanda_M1_S1_r2", ["M1", "M2", "M6", "M7", "S1"], [0, 1, 4, 2, 15])
87# Return: S1 -> M5 (15m) -> M4 (2m) -> M2 (2m) -> M1 (1m)
88nanda_S1_M1_r1 = create_route("nanda_S1_M1_r1", ["S1", "M5", "M4", "M2", "M1"], [0, 15, 2, 2, 1])
89# Return Route 2: S1 -> M7 (15m) -> M6 (2m) -> M2 (4m) -> M1 (1m)
90nanda_S1_M1_r2 = create_route("nanda_S1_M1_r2", ["S1", "M7", "M6", "M2", "M1"], [0, 15, 2, 4, 1])
93# --- 4. Route Resolver Logic ---
96class RouteResolver:
97 """Encapsulates the logic to determine which Route object matches a bus schedule."""
99 @staticmethod
100 def resolve_main_campus_route(
101 line: str, dep_stop: str, is_from_gen2: bool = False
102 ) -> Optional[models.Route]:
103 """
104 Resolves main campus routes (Red/Green).
105 line: 'red' or 'green'
106 dep_stop: '台積館' or '校門' or '綜二'
107 is_from_gen2: Special flag if inferred from schedule gap.
108 """
109 routes = {
110 "red": (red_M5_M1, red_M5_M2, red_M1_M5, red_M2_M5),
111 "green": (green_M5_M1, green_M5_M2, green_M1_M5, green_M2_M5),
112 }.get(line.lower())
113 if routes is None:
114 return None
115 downhill, downhill_gen2, uphill, uphill_gen2 = routes
116 if "台積" in dep_stop:
117 return downhill_gen2 if is_from_gen2 else downhill
118 if "校門" in dep_stop or "北校" in dep_stop:
119 return uphill
120 if "綜二" in dep_stop:
121 return uphill_gen2
122 return None
124 @staticmethod
125 def resolve_nanda_route(
126 direction: Literal["up", "down"], description: str, line: str = ""
127 ) -> models.Route:
128 """
129 Resolves Nanda routes using the published line, with a legacy description fallback.
130 direction: 'up' (Main -> Nanda) or 'down' (Nanda -> Main)
131 """
132 is_route_2 = RouteResolver.get_nanda_line(description, line) == "route_2"
134 if direction == "down":
135 # Going back to Main Campus
136 return nanda_S1_M1_r2 if is_route_2 else nanda_S1_M1_r1
137 else:
138 # Going to Nanda
139 return nanda_M1_S1_r2 if is_route_2 else nanda_M1_S1_r1
141 @staticmethod
142 def get_nanda_line(description: str, line: str = "") -> str:
143 """
144 Determines the Nanda route line from publisher data.
146 Route detection logic:
147 - A supplied line takes precedence over description.
148 - Missing lines use "路線二" or "教育學院" to detect route 2.
149 - Other legacy descriptions default to route 1.
151 Args:
152 description: Bus schedule description text
153 line: Published route1/route2 or normalized route identifier
155 Returns:
156 'route_1' or 'route_2'
158 Examples:
159 >>> get_nanda_line("路線二經過教育學院")
160 'route_2'
161 >>> get_nanda_line("一般路線")
162 'route_1'
163 """
164 if line:
165 lines = {
166 "route1": "route_1",
167 "route2": "route_2",
168 "route_1": "route_1",
169 "route_2": "route_2",
170 "nanda_route_1": "route_1",
171 "nanda_route_2": "route_2",
172 }
173 if line not in lines:
174 raise ValueError("Invalid Nanda bus line")
175 return lines[line]
176 description = description or ""
177 is_route_2 = "路線二" in description or "教育學院" in description
178 return "route_2" if is_route_2 else "route_1"
181# Instantiate global resolver
182resolver = RouteResolver()