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

1""" 

2Buses graph definition. 

3Contains Route topology, Stop definitions, and Route selection logic. 

4""" 

5 

6from typing import Literal, Optional 

7 

8from . import models 

9 

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} 

33 

34# Helper to access stops easily 

35S = STOPS_DATA 

36 

37# --- 2. Route Factory Helpers --- 

38 

39 

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] 

48 

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 

57 

58 return models.Route(id=route_id, stops=stops, time_offsets=offsets) 

59 

60 

61# --- 3. Route Definitions (The Graph) --- 

62 

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]) 

72 

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]) 

80 

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]) 

86 

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]) 

91 

92 

93# --- 4. Route Resolver Logic --- 

94 

95 

96class RouteResolver: 

97 """Encapsulates the logic to determine which Route object matches a bus schedule.""" 

98 

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 

123 

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" 

133 

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 

140 

141 @staticmethod 

142 def get_nanda_line(description: str, line: str = "") -> str: 

143 """ 

144 Determines the Nanda route line from publisher data. 

145 

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. 

150 

151 Args: 

152 description: Bus schedule description text 

153 line: Published route1/route2 or normalized route identifier 

154 

155 Returns: 

156 'route_1' or 'route_2' 

157 

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" 

179 

180 

181# Instantiate global resolver 

182resolver = RouteResolver()