Arpit-Bansal commited on
Commit
aece385
·
1 Parent(s): d35d9d7

moved to more generalized station input

Browse files
api/greedyoptim_api.py CHANGED
@@ -482,6 +482,203 @@ async def list_methods():
482
  }
483
 
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  @app.post("/optimize", response_model=ScheduleOptimizationResponse)
486
  async def optimize_schedule(request: ScheduleOptimizationRequest):
487
  """
 
482
  }
483
 
484
 
485
+ # ============================================================================
486
+ # Station & Route Information Endpoints
487
+ # ============================================================================
488
+
489
+ @app.get("/stations")
490
+ async def get_stations():
491
+ """Get all metro stations with their details.
492
+
493
+ Returns the complete list of stations from the configured route,
494
+ including distance information, terminal status, and depot locations.
495
+ """
496
+ try:
497
+ from greedyOptim.station_loader import get_station_loader
498
+
499
+ loader = get_station_loader()
500
+ return {
501
+ "success": True,
502
+ "route": loader.to_dict(),
503
+ "summary": {
504
+ "total_stations": loader.station_count,
505
+ "total_distance_km": loader.total_distance_km,
506
+ "terminals": loader.terminals
507
+ }
508
+ }
509
+ except Exception as e:
510
+ logger.error(f"Failed to get station data: {e}")
511
+ raise HTTPException(status_code=500, detail=f"Failed to load station data: {str(e)}")
512
+
513
+
514
+ @app.get("/stations/{station_identifier}")
515
+ async def get_station_details(station_identifier: str):
516
+ """Get details for a specific station by name or code.
517
+
518
+ Args:
519
+ station_identifier: Station name (e.g., 'Aluva') or code (e.g., 'ALV')
520
+ """
521
+ try:
522
+ from greedyOptim.station_loader import get_station_loader
523
+
524
+ loader = get_station_loader()
525
+
526
+ # Try by name first, then by code
527
+ station = loader.get_station_by_name(station_identifier)
528
+ if not station:
529
+ station = loader.get_station_by_code(station_identifier)
530
+
531
+ if not station:
532
+ raise HTTPException(
533
+ status_code=404,
534
+ detail=f"Station not found: {station_identifier}"
535
+ )
536
+
537
+ return {
538
+ "success": True,
539
+ "station": {
540
+ "sr_no": station.sr_no,
541
+ "code": station.code,
542
+ "name": station.name,
543
+ "distance_from_prev_km": station.distance_from_prev_km,
544
+ "cumulative_distance_km": station.cumulative_distance_km,
545
+ "is_terminal": station.is_terminal,
546
+ "has_depot": station.has_depot,
547
+ "platform_count": station.platform_count,
548
+ "depot_name": station.depot_name
549
+ }
550
+ }
551
+ except HTTPException:
552
+ raise
553
+ except Exception as e:
554
+ logger.error(f"Failed to get station details: {e}")
555
+ raise HTTPException(status_code=500, detail=str(e))
556
+
557
+
558
+ @app.get("/route/journey")
559
+ async def calculate_journey(
560
+ origin: str,
561
+ destination: str,
562
+ departure_time: str = "07:00"
563
+ ):
564
+ """Calculate journey details between two stations.
565
+
566
+ Args:
567
+ origin: Origin station name or code
568
+ destination: Destination station name or code
569
+ departure_time: Departure time in HH:MM format (default: 07:00)
570
+
571
+ Returns:
572
+ Journey details including intermediate stations with arrival times
573
+ """
574
+ try:
575
+ from greedyOptim.station_loader import get_station_loader
576
+
577
+ loader = get_station_loader()
578
+
579
+ # Validate stations exist
580
+ origin_station = loader.get_station_by_name(origin) or loader.get_station_by_code(origin)
581
+ dest_station = loader.get_station_by_name(destination) or loader.get_station_by_code(destination)
582
+
583
+ if not origin_station:
584
+ raise HTTPException(status_code=404, detail=f"Origin station not found: {origin}")
585
+ if not dest_station:
586
+ raise HTTPException(status_code=404, detail=f"Destination station not found: {destination}")
587
+
588
+ # Get journey details
589
+ station_sequence = loader.get_station_sequence_for_trip(
590
+ origin_station.name,
591
+ dest_station.name,
592
+ include_times=True,
593
+ departure_time=departure_time
594
+ )
595
+
596
+ distance = loader.get_distance_between(origin_station.name, dest_station.name)
597
+ journey_time = loader.calculate_journey_time(origin_station.name, dest_station.name)
598
+
599
+ return {
600
+ "success": True,
601
+ "journey": {
602
+ "origin": origin_station.name,
603
+ "destination": dest_station.name,
604
+ "distance_km": round(distance, 3),
605
+ "journey_time_minutes": round(journey_time, 1),
606
+ "departure_time": departure_time,
607
+ "num_stops": len(station_sequence) - 1,
608
+ "stations": station_sequence
609
+ }
610
+ }
611
+ except HTTPException:
612
+ raise
613
+ except Exception as e:
614
+ logger.error(f"Failed to calculate journey: {e}")
615
+ raise HTTPException(status_code=500, detail=str(e))
616
+
617
+
618
+ @app.get("/route/round-trip")
619
+ async def get_round_trip_info():
620
+ """Get round trip information for the full route.
621
+
622
+ Returns round trip time and distance between terminals.
623
+ """
624
+ try:
625
+ from greedyOptim.station_loader import get_station_loader
626
+
627
+ loader = get_station_loader()
628
+
629
+ round_trip_time = loader.calculate_round_trip_time()
630
+ terminals = loader.terminals
631
+ one_way_distance = loader.total_distance_km
632
+
633
+ return {
634
+ "success": True,
635
+ "round_trip": {
636
+ "terminals": terminals,
637
+ "one_way_distance_km": round(one_way_distance, 3),
638
+ "round_trip_distance_km": round(one_way_distance * 2, 3),
639
+ "round_trip_time_minutes": round(round_trip_time, 1),
640
+ "round_trip_time_hours": round(round_trip_time / 60, 2)
641
+ },
642
+ "operational_params": loader.route_info.operational_params
643
+ }
644
+ except Exception as e:
645
+ logger.error(f"Failed to get round trip info: {e}")
646
+ raise HTTPException(status_code=500, detail=str(e))
647
+
648
+
649
+ @app.get("/service-blocks")
650
+ async def get_service_blocks():
651
+ """Get all available service blocks for the day.
652
+
653
+ Returns pre-defined service blocks that can be assigned to trainsets.
654
+ """
655
+ try:
656
+ from greedyOptim.service_blocks import ServiceBlockGenerator
657
+
658
+ generator = ServiceBlockGenerator()
659
+ blocks = generator.get_all_service_blocks()
660
+
661
+ # Group by period
662
+ by_period = {}
663
+ for block in blocks:
664
+ period = block['period']
665
+ if period not in by_period:
666
+ by_period[period] = []
667
+ by_period[period].append(block)
668
+
669
+ return {
670
+ "success": True,
671
+ "total_blocks": len(blocks),
672
+ "route_length_km": generator.route_length_km,
673
+ "terminals": generator.terminals,
674
+ "blocks_by_period": by_period,
675
+ "all_blocks": blocks
676
+ }
677
+ except Exception as e:
678
+ logger.error(f"Failed to get service blocks: {e}")
679
+ raise HTTPException(status_code=500, detail=str(e))
680
+
681
+
682
  @app.post("/optimize", response_model=ScheduleOptimizationResponse)
683
  async def optimize_schedule(request: ScheduleOptimizationRequest):
684
  """
greedyOptim/__init__.py CHANGED
@@ -38,6 +38,11 @@ from .error_handling import (
38
  DataValidationError, ConstraintViolationError, ConfigurationError
39
  )
40
  from .schedule_generator import ScheduleGenerator, generate_schedule_from_result
 
 
 
 
 
41
 
42
  # Optional OR-Tools integration
43
  try:
@@ -89,7 +94,14 @@ __all__ = [
89
  'ConstraintViolationError',
90
  'ConfigurationError',
91
  'ScheduleGenerator',
92
- 'generate_schedule_from_result'
 
 
 
 
 
 
 
93
  ]
94
 
95
  # Add OR-Tools to exports if available
 
38
  DataValidationError, ConstraintViolationError, ConfigurationError
39
  )
40
  from .schedule_generator import ScheduleGenerator, generate_schedule_from_result
41
+ from .station_loader import (
42
+ StationDataLoader, get_station_loader, get_route_distance, get_terminals,
43
+ Station, RouteInfo
44
+ )
45
+ from .service_blocks import ServiceBlockGenerator
46
 
47
  # Optional OR-Tools integration
48
  try:
 
94
  'ConstraintViolationError',
95
  'ConfigurationError',
96
  'ScheduleGenerator',
97
+ 'generate_schedule_from_result',
98
+ 'StationDataLoader',
99
+ 'get_station_loader',
100
+ 'get_route_distance',
101
+ 'get_terminals',
102
+ 'Station',
103
+ 'RouteInfo',
104
+ 'ServiceBlockGenerator'
105
  ]
106
 
107
  # Add OR-Tools to exports if available
greedyOptim/data/kochi_metro_stations.json ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "line_info": {
3
+ "name": "Kochi Metro Blue Line",
4
+ "operator": "Kochi Metro Rail Ltd (KMRL)",
5
+ "gauge": "Standard Gauge (1435 mm)",
6
+ "traction": "750 V DC Third Rail",
7
+ "max_speed_kmh": 80,
8
+ "average_speed_kmh": 35
9
+ },
10
+ "stations": [
11
+ {
12
+ "sr_no": 1,
13
+ "code": "ALV",
14
+ "name": "Aluva",
15
+ "distance_from_prev_km": 0.0,
16
+ "cumulative_distance_km": 0.0,
17
+ "is_terminal": true,
18
+ "has_depot": false,
19
+ "platform_count": 2,
20
+ "interchange": null
21
+ },
22
+ {
23
+ "sr_no": 2,
24
+ "code": "PUL",
25
+ "name": "Pulinchodu",
26
+ "distance_from_prev_km": 1.729,
27
+ "cumulative_distance_km": 1.729,
28
+ "is_terminal": false,
29
+ "has_depot": false,
30
+ "platform_count": 2,
31
+ "interchange": null
32
+ },
33
+ {
34
+ "sr_no": 3,
35
+ "code": "CMP",
36
+ "name": "Companypady",
37
+ "distance_from_prev_km": 0.969,
38
+ "cumulative_distance_km": 2.698,
39
+ "is_terminal": false,
40
+ "has_depot": false,
41
+ "platform_count": 2,
42
+ "interchange": null
43
+ },
44
+ {
45
+ "sr_no": 4,
46
+ "code": "AMB",
47
+ "name": "Ambattukavu",
48
+ "distance_from_prev_km": 0.984,
49
+ "cumulative_distance_km": 3.682,
50
+ "is_terminal": false,
51
+ "has_depot": false,
52
+ "platform_count": 2,
53
+ "interchange": null
54
+ },
55
+ {
56
+ "sr_no": 5,
57
+ "code": "MUT",
58
+ "name": "Muttom",
59
+ "distance_from_prev_km": 0.937,
60
+ "cumulative_distance_km": 4.619,
61
+ "is_terminal": false,
62
+ "has_depot": true,
63
+ "platform_count": 2,
64
+ "interchange": null,
65
+ "depot_name": "Muttom Depot"
66
+ },
67
+ {
68
+ "sr_no": 6,
69
+ "code": "KLM",
70
+ "name": "Kalamassery",
71
+ "distance_from_prev_km": 2.052,
72
+ "cumulative_distance_km": 6.671,
73
+ "is_terminal": false,
74
+ "has_depot": false,
75
+ "platform_count": 2,
76
+ "interchange": null
77
+ },
78
+ {
79
+ "sr_no": 7,
80
+ "code": "CUU",
81
+ "name": "Cochin University",
82
+ "distance_from_prev_km": 1.379,
83
+ "cumulative_distance_km": 8.050,
84
+ "is_terminal": false,
85
+ "has_depot": false,
86
+ "platform_count": 2,
87
+ "interchange": null
88
+ },
89
+ {
90
+ "sr_no": 8,
91
+ "code": "PAT",
92
+ "name": "Pathadipalam",
93
+ "distance_from_prev_km": 1.247,
94
+ "cumulative_distance_km": 9.297,
95
+ "is_terminal": false,
96
+ "has_depot": false,
97
+ "platform_count": 2,
98
+ "interchange": null
99
+ },
100
+ {
101
+ "sr_no": 9,
102
+ "code": "EDP",
103
+ "name": "Edapally",
104
+ "distance_from_prev_km": 1.393,
105
+ "cumulative_distance_km": 10.690,
106
+ "is_terminal": false,
107
+ "has_depot": false,
108
+ "platform_count": 2,
109
+ "interchange": null
110
+ },
111
+ {
112
+ "sr_no": 10,
113
+ "code": "CPK",
114
+ "name": "Changampuzha Park",
115
+ "distance_from_prev_km": 1.300,
116
+ "cumulative_distance_km": 11.990,
117
+ "is_terminal": false,
118
+ "has_depot": false,
119
+ "platform_count": 2,
120
+ "interchange": null
121
+ },
122
+ {
123
+ "sr_no": 11,
124
+ "code": "PLV",
125
+ "name": "Palarivattom",
126
+ "distance_from_prev_km": 1.008,
127
+ "cumulative_distance_km": 12.998,
128
+ "is_terminal": false,
129
+ "has_depot": false,
130
+ "platform_count": 2,
131
+ "interchange": null
132
+ },
133
+ {
134
+ "sr_no": 12,
135
+ "code": "JLN",
136
+ "name": "J.L.N. Stadium",
137
+ "distance_from_prev_km": 1.121,
138
+ "cumulative_distance_km": 14.119,
139
+ "is_terminal": false,
140
+ "has_depot": false,
141
+ "platform_count": 2,
142
+ "interchange": null
143
+ },
144
+ {
145
+ "sr_no": 13,
146
+ "code": "KLR",
147
+ "name": "Kaloor",
148
+ "distance_from_prev_km": 1.033,
149
+ "cumulative_distance_km": 15.152,
150
+ "is_terminal": false,
151
+ "has_depot": false,
152
+ "platform_count": 2,
153
+ "interchange": null
154
+ },
155
+ {
156
+ "sr_no": 14,
157
+ "code": "TWH",
158
+ "name": "Town Hall",
159
+ "distance_from_prev_km": 0.473,
160
+ "cumulative_distance_km": 15.625,
161
+ "is_terminal": false,
162
+ "has_depot": false,
163
+ "platform_count": 2,
164
+ "interchange": null
165
+ },
166
+ {
167
+ "sr_no": 15,
168
+ "code": "MGR",
169
+ "name": "M.G. Road",
170
+ "distance_from_prev_km": 1.203,
171
+ "cumulative_distance_km": 16.828,
172
+ "is_terminal": false,
173
+ "has_depot": false,
174
+ "platform_count": 2,
175
+ "interchange": null
176
+ },
177
+ {
178
+ "sr_no": 16,
179
+ "code": "MHC",
180
+ "name": "Maharaja's College",
181
+ "distance_from_prev_km": 1.173,
182
+ "cumulative_distance_km": 18.001,
183
+ "is_terminal": false,
184
+ "has_depot": false,
185
+ "platform_count": 2,
186
+ "interchange": null
187
+ },
188
+ {
189
+ "sr_no": 17,
190
+ "code": "EKS",
191
+ "name": "Ernakulam South",
192
+ "distance_from_prev_km": 0.856,
193
+ "cumulative_distance_km": 18.857,
194
+ "is_terminal": false,
195
+ "has_depot": false,
196
+ "platform_count": 2,
197
+ "interchange": null
198
+ },
199
+ {
200
+ "sr_no": 18,
201
+ "code": "KDV",
202
+ "name": "Kadavanthra",
203
+ "distance_from_prev_km": 1.185,
204
+ "cumulative_distance_km": 20.042,
205
+ "is_terminal": false,
206
+ "has_depot": false,
207
+ "platform_count": 2,
208
+ "interchange": null
209
+ },
210
+ {
211
+ "sr_no": 19,
212
+ "code": "ELM",
213
+ "name": "Elamkulam",
214
+ "distance_from_prev_km": 1.155,
215
+ "cumulative_distance_km": 21.197,
216
+ "is_terminal": false,
217
+ "has_depot": false,
218
+ "platform_count": 2,
219
+ "interchange": null
220
+ },
221
+ {
222
+ "sr_no": 20,
223
+ "code": "VYT",
224
+ "name": "Vyttila",
225
+ "distance_from_prev_km": 1.439,
226
+ "cumulative_distance_km": 22.636,
227
+ "is_terminal": false,
228
+ "has_depot": false,
229
+ "platform_count": 2,
230
+ "interchange": null
231
+ },
232
+ {
233
+ "sr_no": 21,
234
+ "code": "TKD",
235
+ "name": "Thaikoodam",
236
+ "distance_from_prev_km": 1.024,
237
+ "cumulative_distance_km": 23.660,
238
+ "is_terminal": false,
239
+ "has_depot": false,
240
+ "platform_count": 2,
241
+ "interchange": null
242
+ },
243
+ {
244
+ "sr_no": 22,
245
+ "code": "PTH",
246
+ "name": "Pettah",
247
+ "distance_from_prev_km": 1.183,
248
+ "cumulative_distance_km": 24.843,
249
+ "is_terminal": true,
250
+ "has_depot": false,
251
+ "platform_count": 2,
252
+ "interchange": null
253
+ }
254
+ ],
255
+ "operational_params": {
256
+ "dwell_time_seconds": 30,
257
+ "terminal_turnaround_seconds": 180,
258
+ "min_headway_peak_minutes": 5,
259
+ "min_headway_offpeak_minutes": 10,
260
+ "operational_start": "05:00",
261
+ "operational_end": "23:00",
262
+ "peak_hours": [
263
+ {"start": "07:00", "end": "10:00", "type": "morning"},
264
+ {"start": "17:00", "end": "21:00", "type": "evening"}
265
+ ]
266
+ }
267
+ }
greedyOptim/service_blocks.py CHANGED
@@ -1,39 +1,191 @@
1
  """
2
  Service Block Generator
3
  Generates realistic service blocks with departure times for train schedules.
 
4
  """
5
- from typing import List, Dict, Tuple
6
  from datetime import time, datetime, timedelta
 
 
 
 
 
 
 
 
 
 
7
 
8
 
9
  class ServiceBlockGenerator:
10
- """Generates service blocks for trains based on operational requirements."""
11
 
12
- # Kochi Metro operational parameters
13
- OPERATIONAL_START = time(5, 0) # 5:00 AM
14
- OPERATIONAL_END = time(23, 0) # 11:00 PM
15
-
16
- # Service patterns
17
- PEAK_HOURS = [(7, 9), (18, 21)] # Morning and evening peaks (7-9 AM, 6-9 PM)
18
- PEAK_HEADWAY_MINUTES = 6.0 # 6 minutes between trains during peak (target 5-7)
19
- OFFPEAK_HEADWAY_MINUTES = 15.0 # 15 minutes during off-peak
20
 
21
- # Route parameters
22
- ROUTE_LENGTH_KM = 25.612
23
- AVG_SPEED_KMH = 35.0
24
- TERMINALS = ['Aluva', 'Pettah']
 
 
 
 
 
 
25
 
26
- def __init__(self):
27
- """Initialize service block generator."""
28
- self.round_trip_time_hours = (self.ROUTE_LENGTH_KM * 2) / self.AVG_SPEED_KMH
29
- self.round_trip_time_minutes = self.round_trip_time_hours * 60
 
 
 
 
30
  self._all_blocks_cache = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  def get_all_service_blocks(self) -> List[Dict]:
33
  """Get all available service blocks for the day.
34
 
35
  Pre-generates all possible service blocks that need to be assigned to trainsets.
36
  These represent the "slots" that the optimizer will fill.
 
37
 
38
  Returns:
39
  List of all service block dictionaries with block_id, departure_time, etc.
@@ -44,77 +196,64 @@ class ServiceBlockGenerator:
44
  all_blocks = []
45
  block_counter = 0
46
 
47
- # Morning peak blocks (7:00 - 10:00)
48
- # Need departures every 6 minutes = 10 per hour = 30 blocks
49
- for hour in [7, 8, 9]:
50
- for minute in range(0, 60, 6):
51
- block_counter += 1
52
- origin = self.TERMINALS[block_counter % 2]
53
- destination = self.TERMINALS[(block_counter + 1) % 2]
54
- all_blocks.append({
55
- 'block_id': f'BLK-{block_counter:03d}',
56
- 'departure_time': f'{hour:02d}:{minute:02d}',
57
- 'origin': origin,
58
- 'destination': destination,
59
- 'trip_count': 3, # ~3 hours of peak service
60
- 'estimated_km': int(3 * self.ROUTE_LENGTH_KM * 2),
61
- 'period': 'morning_peak',
62
- 'is_peak': True
63
- })
64
-
65
- # Midday off-peak blocks (10:00 - 17:00)
66
- # Need departures every 15 minutes = 4 per hour = 28 blocks
67
- for hour in range(10, 17):
68
- for minute in range(0, 60, 15):
69
- block_counter += 1
70
- origin = self.TERMINALS[block_counter % 2]
71
- destination = self.TERMINALS[(block_counter + 1) % 2]
72
- all_blocks.append({
73
- 'block_id': f'BLK-{block_counter:03d}',
74
- 'departure_time': f'{hour:02d}:{minute:02d}',
75
- 'origin': origin,
76
- 'destination': destination,
77
- 'trip_count': 2,
78
- 'estimated_km': int(2 * self.ROUTE_LENGTH_KM * 2),
79
- 'period': 'midday',
80
- 'is_peak': False
81
- })
82
-
83
- # Evening peak blocks (17:00 - 21:00)
84
- # Need departures every 6 minutes = 10 per hour = 40 blocks
85
- for hour in range(17, 21):
86
- for minute in range(0, 60, 6):
87
- block_counter += 1
88
- origin = self.TERMINALS[block_counter % 2]
89
- destination = self.TERMINALS[(block_counter + 1) % 2]
90
- all_blocks.append({
91
- 'block_id': f'BLK-{block_counter:03d}',
92
- 'departure_time': f'{hour:02d}:{minute:02d}',
93
- 'origin': origin,
94
- 'destination': destination,
95
- 'trip_count': 3,
96
- 'estimated_km': int(3 * self.ROUTE_LENGTH_KM * 2),
97
- 'period': 'evening_peak',
98
- 'is_peak': True
99
- })
100
 
101
- # Late evening blocks (21:00 - 23:00)
102
- # Need departures every 15 minutes = 4 per hour = 8 blocks
103
- for hour in range(21, 23):
104
- for minute in range(0, 60, 15):
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  block_counter += 1
106
- origin = self.TERMINALS[block_counter % 2]
107
- destination = self.TERMINALS[(block_counter + 1) % 2]
108
- all_blocks.append({
 
 
 
 
 
 
 
 
 
 
 
109
  'block_id': f'BLK-{block_counter:03d}',
110
- 'departure_time': f'{hour:02d}:{minute:02d}',
111
  'origin': origin,
112
  'destination': destination,
113
- 'trip_count': 1,
114
- 'estimated_km': int(1 * self.ROUTE_LENGTH_KM * 2),
115
- 'period': 'late_evening',
116
- 'is_peak': False
117
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  self._all_blocks_cache = all_blocks
120
  return all_blocks
@@ -142,13 +281,13 @@ class ServiceBlockGenerator:
142
  num_service_trains: Total number of trains in service
143
 
144
  Returns:
145
- List of service block dictionaries
146
  """
147
  blocks = []
148
 
149
  # Calculate departure interval based on number of trains
150
  # Distribute trains evenly throughout peak hours
151
- peak_interval = max(5, int(self.PEAK_HEADWAY_MINUTES))
152
 
153
  # Stagger departures so trains are evenly spaced
154
  offset_minutes = (train_index * peak_interval) % 60
@@ -156,57 +295,107 @@ class ServiceBlockGenerator:
156
  # Morning peak block (7-10 AM, 3 hours)
157
  morning_start_hour = 7 + (train_index * peak_interval) // 60
158
  if morning_start_hour < 10: # Only if within morning peak
159
- blocks.append({
 
 
 
 
160
  'block_id': f'BLK-M-{train_index+1:03d}',
161
- 'departure_time': f'{morning_start_hour:02d}:{offset_minutes:02d}',
162
- 'origin': self.TERMINALS[0] if train_index % 2 == 0 else self.TERMINALS[1],
163
- 'destination': self.TERMINALS[1] if train_index % 2 == 0 else self.TERMINALS[0],
164
  'trip_count': self._calculate_trips(3.0), # 3 hours
165
  'estimated_km': self._calculate_km(3.0)
166
- })
 
 
 
 
 
 
 
 
 
167
 
168
  # Midday block (11-16, 5 hours)
169
  midday_start_hour = 11 + (train_index * 15) // 60 # 15 min intervals
170
  midday_minute = (train_index * 15) % 60
171
  if midday_start_hour < 16:
172
- blocks.append({
 
 
 
 
173
  'block_id': f'BLK-D-{train_index+1:03d}',
174
- 'departure_time': f'{midday_start_hour:02d}:{midday_minute:02d}',
175
- 'origin': self.TERMINALS[1] if train_index % 2 == 0 else self.TERMINALS[0],
176
- 'destination': self.TERMINALS[0] if train_index % 2 == 0 else self.TERMINALS[1],
177
  'trip_count': self._calculate_trips(5.0, peak=False),
178
  'estimated_km': self._calculate_km(5.0, peak=False)
179
- })
 
 
 
 
 
 
 
 
180
 
181
  # Evening peak block (17-20, 3 hours)
182
  evening_start_hour = 17 + (train_index * peak_interval) // 60
183
  evening_minute = (train_index * peak_interval) % 60
184
  if evening_start_hour < 20:
185
- blocks.append({
 
 
 
 
186
  'block_id': f'BLK-E-{train_index+1:03d}',
187
- 'departure_time': f'{evening_start_hour:02d}:{evening_minute:02d}',
188
- 'origin': self.TERMINALS[0] if train_index % 2 == 0 else self.TERMINALS[1],
189
- 'destination': self.TERMINALS[1] if train_index % 2 == 0 else self.TERMINALS[0],
190
  'trip_count': self._calculate_trips(3.0),
191
  'estimated_km': self._calculate_km(3.0)
192
- })
 
 
 
 
 
 
 
 
193
 
194
  # Late evening block (20-22, 2 hours) - lower frequency
195
  if train_index % 2 == 0: # Only half the fleet for late evening
196
- blocks.append({
 
 
 
 
197
  'block_id': f'BLK-L-{train_index+1:03d}',
198
- 'departure_time': f'20:{(train_index * 20) % 60:02d}',
199
- 'origin': self.TERMINALS[1],
200
- 'destination': self.TERMINALS[0],
201
  'trip_count': self._calculate_trips(2.0, peak=False),
202
  'estimated_km': self._calculate_km(2.0, peak=False)
203
- })
 
 
 
 
 
 
 
 
204
 
205
  return blocks
206
 
207
  def _calculate_trips(self, duration_hours: float, peak: bool = True) -> int:
208
  """Calculate number of round trips in a time block."""
209
- trips_per_hour = 60 / (self.PEAK_HEADWAY_MINUTES if peak else self.OFFPEAK_HEADWAY_MINUTES)
 
210
  trips_per_hour = trips_per_hour / 2 # One-way trips, so divide by 2 for round trips
211
  total_trips = int(duration_hours * trips_per_hour)
212
  return max(1, total_trips)
@@ -214,7 +403,7 @@ class ServiceBlockGenerator:
214
  def _calculate_km(self, duration_hours: float, peak: bool = True) -> int:
215
  """Calculate estimated kilometers for a time block."""
216
  trips = self._calculate_trips(duration_hours, peak)
217
- km = trips * self.ROUTE_LENGTH_KM * 2 # Round trips
218
  return int(km)
219
 
220
  def generate_all_service_blocks(self, num_service_trains: int) -> Dict[int, List[Dict]]:
 
1
  """
2
  Service Block Generator
3
  Generates realistic service blocks with departure times for train schedules.
4
+ Uses station data from JSON configuration for flexible route support.
5
  """
6
+ from typing import List, Dict, Tuple, Optional
7
  from datetime import time, datetime, timedelta
8
+ import logging
9
+
10
+ # Import station loader for route configuration
11
+ try:
12
+ from .station_loader import get_station_loader, StationDataLoader
13
+ STATION_LOADER_AVAILABLE = True
14
+ except ImportError:
15
+ STATION_LOADER_AVAILABLE = False
16
+
17
+ logger = logging.getLogger(__name__)
18
 
19
 
20
  class ServiceBlockGenerator:
21
+ """Generates service blocks for trains based on operational requirements.
22
 
23
+ Loads route parameters from station configuration JSON, allowing
24
+ easy customization for different metro lines.
25
+ """
 
 
 
 
 
26
 
27
+ # Fallback defaults (used if station config not available)
28
+ DEFAULT_ROUTE_LENGTH_KM = 24.843 # Aluva to Pettah
29
+ DEFAULT_AVG_SPEED_KMH = 35.0
30
+ DEFAULT_TERMINALS = ['Aluva', 'Pettah']
31
+ DEFAULT_OPERATIONAL_START = time(5, 0)
32
+ DEFAULT_OPERATIONAL_END = time(23, 0)
33
+ DEFAULT_PEAK_HEADWAY_MINUTES = 6.0
34
+ DEFAULT_OFFPEAK_HEADWAY_MINUTES = 15.0
35
+ DEFAULT_DWELL_TIME_SECONDS = 30
36
+ DEFAULT_TURNAROUND_SECONDS = 180
37
 
38
+ def __init__(self, station_config_path: Optional[str] = None):
39
+ """Initialize service block generator.
40
+
41
+ Args:
42
+ station_config_path: Optional path to station JSON config.
43
+ If None, uses default configuration.
44
+ """
45
+ self._station_loader: Optional[StationDataLoader] = None
46
  self._all_blocks_cache = None
47
+ self._stations_cache = None
48
+
49
+ # Load station configuration
50
+ self._load_station_config(station_config_path)
51
+
52
+ # Calculate derived values
53
+ self.round_trip_time_hours = (self.route_length_km * 2) / self.avg_speed_kmh
54
+ self.round_trip_time_minutes = self.round_trip_time_hours * 60
55
+
56
+ def _load_station_config(self, config_path: Optional[str] = None):
57
+ """Load station configuration from JSON."""
58
+ if STATION_LOADER_AVAILABLE:
59
+ try:
60
+ self._station_loader = get_station_loader(config_path)
61
+ route_info = self._station_loader.route_info
62
+ op_params = route_info.operational_params
63
+
64
+ # Route parameters from config
65
+ self.route_length_km = self._station_loader.total_distance_km
66
+ self.terminals = self._station_loader.terminals
67
+ self.avg_speed_kmh = self._station_loader.load()['line_info'].get(
68
+ 'average_speed_kmh', self.DEFAULT_AVG_SPEED_KMH
69
+ )
70
+
71
+ # Operational parameters
72
+ self.dwell_time_seconds = op_params.get(
73
+ 'dwell_time_seconds', self.DEFAULT_DWELL_TIME_SECONDS
74
+ )
75
+ self.turnaround_seconds = op_params.get(
76
+ 'terminal_turnaround_seconds', self.DEFAULT_TURNAROUND_SECONDS
77
+ )
78
+ self.peak_headway_minutes = op_params.get(
79
+ 'min_headway_peak_minutes', self.DEFAULT_PEAK_HEADWAY_MINUTES
80
+ )
81
+ self.offpeak_headway_minutes = op_params.get(
82
+ 'min_headway_offpeak_minutes', self.DEFAULT_OFFPEAK_HEADWAY_MINUTES
83
+ )
84
+
85
+ # Parse operational hours
86
+ op_start = op_params.get('operational_start', '05:00')
87
+ op_end = op_params.get('operational_end', '23:00')
88
+ self.operational_start = datetime.strptime(op_start, '%H:%M').time()
89
+ self.operational_end = datetime.strptime(op_end, '%H:%M').time()
90
+
91
+ # Peak hours configuration
92
+ self.peak_hours = []
93
+ for peak in op_params.get('peak_hours', []):
94
+ start = int(peak['start'].split(':')[0])
95
+ end = int(peak['end'].split(':')[0])
96
+ self.peak_hours.append((start, end))
97
+
98
+ if not self.peak_hours:
99
+ self.peak_hours = [(7, 10), (17, 21)] # Default peaks
100
+
101
+ logger.info(f"Loaded station config: {len(self._station_loader.stations)} stations, "
102
+ f"{self.route_length_km:.3f} km route")
103
+ return
104
+
105
+ except Exception as e:
106
+ logger.warning(f"Failed to load station config: {e}. Using defaults.")
107
+
108
+ # Use defaults if loading failed
109
+ self._use_defaults()
110
+
111
+ def _use_defaults(self):
112
+ """Set default values when config loading fails."""
113
+ self.route_length_km = self.DEFAULT_ROUTE_LENGTH_KM
114
+ self.terminals = self.DEFAULT_TERMINALS
115
+ self.avg_speed_kmh = self.DEFAULT_AVG_SPEED_KMH
116
+ self.dwell_time_seconds = self.DEFAULT_DWELL_TIME_SECONDS
117
+ self.turnaround_seconds = self.DEFAULT_TURNAROUND_SECONDS
118
+ self.peak_headway_minutes = self.DEFAULT_PEAK_HEADWAY_MINUTES
119
+ self.offpeak_headway_minutes = self.DEFAULT_OFFPEAK_HEADWAY_MINUTES
120
+ self.operational_start = self.DEFAULT_OPERATIONAL_START
121
+ self.operational_end = self.DEFAULT_OPERATIONAL_END
122
+ self.peak_hours = [(7, 10), (17, 21)]
123
+
124
+ @property
125
+ def stations(self) -> List[Dict]:
126
+ """Get list of all stations with their details."""
127
+ if self._stations_cache is not None:
128
+ return self._stations_cache
129
+
130
+ if self._station_loader:
131
+ self._stations_cache = [
132
+ {
133
+ 'sr_no': s.sr_no,
134
+ 'code': s.code,
135
+ 'name': s.name,
136
+ 'distance_from_prev_km': s.distance_from_prev_km,
137
+ 'cumulative_distance_km': s.cumulative_distance_km,
138
+ 'is_terminal': s.is_terminal,
139
+ 'has_depot': s.has_depot
140
+ }
141
+ for s in self._station_loader.stations
142
+ ]
143
+ else:
144
+ # Minimal fallback
145
+ self._stations_cache = [
146
+ {'sr_no': 1, 'code': 'ALV', 'name': 'Aluva', 'cumulative_distance_km': 0, 'is_terminal': True},
147
+ {'sr_no': 22, 'code': 'PTH', 'name': 'Pettah', 'cumulative_distance_km': self.route_length_km, 'is_terminal': True}
148
+ ]
149
+
150
+ return self._stations_cache
151
+
152
+ def get_station_sequence(self, origin: str, destination: str, departure_time: str = "07:00") -> List[Dict]:
153
+ """Get detailed station sequence for a trip with arrival times.
154
+
155
+ Args:
156
+ origin: Origin station name
157
+ destination: Destination station name
158
+ departure_time: Departure time (HH:MM format)
159
+
160
+ Returns:
161
+ List of dicts with station info and calculated times
162
+ """
163
+ if self._station_loader:
164
+ return self._station_loader.get_station_sequence_for_trip(
165
+ origin, destination,
166
+ include_times=True,
167
+ departure_time=departure_time
168
+ )
169
+
170
+ # Fallback for no station loader
171
+ return [
172
+ {'name': origin, 'departure_time': departure_time, 'arrival_time': None},
173
+ {'name': destination, 'arrival_time': self._estimate_arrival(departure_time), 'departure_time': None}
174
+ ]
175
+
176
+ def _estimate_arrival(self, departure_time: str) -> str:
177
+ """Estimate arrival time at destination (fallback)."""
178
+ dep = datetime.strptime(departure_time, '%H:%M')
179
+ travel_minutes = (self.route_length_km / self.avg_speed_kmh) * 60
180
+ arr = dep + timedelta(minutes=travel_minutes)
181
+ return arr.strftime('%H:%M')
182
 
183
  def get_all_service_blocks(self) -> List[Dict]:
184
  """Get all available service blocks for the day.
185
 
186
  Pre-generates all possible service blocks that need to be assigned to trainsets.
187
  These represent the "slots" that the optimizer will fill.
188
+ Includes intermediate station information for each block.
189
 
190
  Returns:
191
  List of all service block dictionaries with block_id, departure_time, etc.
 
196
  all_blocks = []
197
  block_counter = 0
198
 
199
+ # Generate blocks based on peak hours configuration
200
+ current_hour = self.operational_start.hour
201
+ end_hour = self.operational_end.hour
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
+ while current_hour < end_hour:
204
+ # Check if current hour is in peak
205
+ is_peak = any(start <= current_hour < end for start, end in self.peak_hours)
206
+ headway = self.peak_headway_minutes if is_peak else self.offpeak_headway_minutes
207
+
208
+ # Determine period name
209
+ if current_hour < 10:
210
+ period = 'morning_peak' if is_peak else 'early_morning'
211
+ elif current_hour < 17:
212
+ period = 'midday'
213
+ elif current_hour < 21:
214
+ period = 'evening_peak' if is_peak else 'evening'
215
+ else:
216
+ period = 'late_evening'
217
+
218
+ # Generate blocks for this hour
219
+ for minute in range(0, 60, int(headway)):
220
  block_counter += 1
221
+ origin = self.terminals[block_counter % 2]
222
+ destination = self.terminals[(block_counter + 1) % 2]
223
+
224
+ # Calculate trips based on period
225
+ if is_peak:
226
+ trip_count = 3 # More trips during peak
227
+ elif current_hour >= 21:
228
+ trip_count = 1 # Fewer trips late evening
229
+ else:
230
+ trip_count = 2 # Normal off-peak
231
+
232
+ departure_time = f'{current_hour:02d}:{minute:02d}'
233
+
234
+ block = {
235
  'block_id': f'BLK-{block_counter:03d}',
236
+ 'departure_time': departure_time,
237
  'origin': origin,
238
  'destination': destination,
239
+ 'trip_count': trip_count,
240
+ 'estimated_km': int(trip_count * self.route_length_km * 2),
241
+ 'period': period,
242
+ 'is_peak': is_peak
243
+ }
244
+
245
+ # Add station sequence if loader available
246
+ if self._station_loader:
247
+ block['station_count'] = len(self._station_loader.stations)
248
+ block['intermediate_stops'] = len(self._station_loader.stations) - 2
249
+ # Add journey details for first trip
250
+ block['journey_time_minutes'] = round(
251
+ self._station_loader.calculate_journey_time(origin, destination), 1
252
+ )
253
+
254
+ all_blocks.append(block)
255
+
256
+ current_hour += 1
257
 
258
  self._all_blocks_cache = all_blocks
259
  return all_blocks
 
281
  num_service_trains: Total number of trains in service
282
 
283
  Returns:
284
+ List of service block dictionaries with station details
285
  """
286
  blocks = []
287
 
288
  # Calculate departure interval based on number of trains
289
  # Distribute trains evenly throughout peak hours
290
+ peak_interval = max(5, int(self.peak_headway_minutes))
291
 
292
  # Stagger departures so trains are evenly spaced
293
  offset_minutes = (train_index * peak_interval) % 60
 
295
  # Morning peak block (7-10 AM, 3 hours)
296
  morning_start_hour = 7 + (train_index * peak_interval) // 60
297
  if morning_start_hour < 10: # Only if within morning peak
298
+ origin = self.terminals[0] if train_index % 2 == 0 else self.terminals[1]
299
+ destination = self.terminals[1] if train_index % 2 == 0 else self.terminals[0]
300
+ departure_time = f'{morning_start_hour:02d}:{offset_minutes:02d}'
301
+
302
+ block = {
303
  'block_id': f'BLK-M-{train_index+1:03d}',
304
+ 'departure_time': departure_time,
305
+ 'origin': origin,
306
+ 'destination': destination,
307
  'trip_count': self._calculate_trips(3.0), # 3 hours
308
  'estimated_km': self._calculate_km(3.0)
309
+ }
310
+
311
+ # Add station sequence
312
+ if self._station_loader:
313
+ block['stations'] = self.get_station_sequence(origin, destination, departure_time)
314
+ block['journey_time_minutes'] = round(
315
+ self._station_loader.calculate_journey_time(origin, destination), 1
316
+ )
317
+
318
+ blocks.append(block)
319
 
320
  # Midday block (11-16, 5 hours)
321
  midday_start_hour = 11 + (train_index * 15) // 60 # 15 min intervals
322
  midday_minute = (train_index * 15) % 60
323
  if midday_start_hour < 16:
324
+ origin = self.terminals[1] if train_index % 2 == 0 else self.terminals[0]
325
+ destination = self.terminals[0] if train_index % 2 == 0 else self.terminals[1]
326
+ departure_time = f'{midday_start_hour:02d}:{midday_minute:02d}'
327
+
328
+ block = {
329
  'block_id': f'BLK-D-{train_index+1:03d}',
330
+ 'departure_time': departure_time,
331
+ 'origin': origin,
332
+ 'destination': destination,
333
  'trip_count': self._calculate_trips(5.0, peak=False),
334
  'estimated_km': self._calculate_km(5.0, peak=False)
335
+ }
336
+
337
+ if self._station_loader:
338
+ block['stations'] = self.get_station_sequence(origin, destination, departure_time)
339
+ block['journey_time_minutes'] = round(
340
+ self._station_loader.calculate_journey_time(origin, destination), 1
341
+ )
342
+
343
+ blocks.append(block)
344
 
345
  # Evening peak block (17-20, 3 hours)
346
  evening_start_hour = 17 + (train_index * peak_interval) // 60
347
  evening_minute = (train_index * peak_interval) % 60
348
  if evening_start_hour < 20:
349
+ origin = self.terminals[0] if train_index % 2 == 0 else self.terminals[1]
350
+ destination = self.terminals[1] if train_index % 2 == 0 else self.terminals[0]
351
+ departure_time = f'{evening_start_hour:02d}:{evening_minute:02d}'
352
+
353
+ block = {
354
  'block_id': f'BLK-E-{train_index+1:03d}',
355
+ 'departure_time': departure_time,
356
+ 'origin': origin,
357
+ 'destination': destination,
358
  'trip_count': self._calculate_trips(3.0),
359
  'estimated_km': self._calculate_km(3.0)
360
+ }
361
+
362
+ if self._station_loader:
363
+ block['stations'] = self.get_station_sequence(origin, destination, departure_time)
364
+ block['journey_time_minutes'] = round(
365
+ self._station_loader.calculate_journey_time(origin, destination), 1
366
+ )
367
+
368
+ blocks.append(block)
369
 
370
  # Late evening block (20-22, 2 hours) - lower frequency
371
  if train_index % 2 == 0: # Only half the fleet for late evening
372
+ origin = self.terminals[1]
373
+ destination = self.terminals[0]
374
+ departure_time = f'20:{(train_index * 20) % 60:02d}'
375
+
376
+ block = {
377
  'block_id': f'BLK-L-{train_index+1:03d}',
378
+ 'departure_time': departure_time,
379
+ 'origin': origin,
380
+ 'destination': destination,
381
  'trip_count': self._calculate_trips(2.0, peak=False),
382
  'estimated_km': self._calculate_km(2.0, peak=False)
383
+ }
384
+
385
+ if self._station_loader:
386
+ block['stations'] = self.get_station_sequence(origin, destination, departure_time)
387
+ block['journey_time_minutes'] = round(
388
+ self._station_loader.calculate_journey_time(origin, destination), 1
389
+ )
390
+
391
+ blocks.append(block)
392
 
393
  return blocks
394
 
395
  def _calculate_trips(self, duration_hours: float, peak: bool = True) -> int:
396
  """Calculate number of round trips in a time block."""
397
+ headway = self.peak_headway_minutes if peak else self.offpeak_headway_minutes
398
+ trips_per_hour = 60 / headway
399
  trips_per_hour = trips_per_hour / 2 # One-way trips, so divide by 2 for round trips
400
  total_trips = int(duration_hours * trips_per_hour)
401
  return max(1, total_trips)
 
403
  def _calculate_km(self, duration_hours: float, peak: bool = True) -> int:
404
  """Calculate estimated kilometers for a time block."""
405
  trips = self._calculate_trips(duration_hours, peak)
406
+ km = trips * self.route_length_km * 2 # Round trips
407
  return int(km)
408
 
409
  def generate_all_service_blocks(self, num_service_trains: int) -> Dict[int, List[Dict]]:
greedyOptim/station_loader.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Station Data Loader
3
+ Loads and manages station/route information from JSON configuration.
4
+ Enables flexible route configuration for different metro lines.
5
+ """
6
+ import json
7
+ import os
8
+ from typing import Dict, List, Optional, Tuple
9
+ from dataclasses import dataclass
10
+ from functools import lru_cache
11
+
12
+
13
+ @dataclass
14
+ class Station:
15
+ """Represents a metro station."""
16
+ sr_no: int
17
+ code: str
18
+ name: str
19
+ distance_from_prev_km: float
20
+ cumulative_distance_km: float
21
+ is_terminal: bool
22
+ has_depot: bool
23
+ platform_count: int
24
+ interchange: Optional[str] = None
25
+ depot_name: Optional[str] = None
26
+
27
+
28
+ @dataclass
29
+ class RouteInfo:
30
+ """Complete route information."""
31
+ name: str
32
+ operator: str
33
+ stations: List[Station]
34
+ total_distance_km: float
35
+ terminal_stations: List[str]
36
+ depot_stations: List[str]
37
+ operational_params: Dict
38
+
39
+
40
+ class StationDataLoader:
41
+ """Loads and manages station data from JSON configuration."""
42
+
43
+ # Default path to station data
44
+ DEFAULT_DATA_PATH = os.path.join(
45
+ os.path.dirname(__file__),
46
+ 'data',
47
+ 'kochi_metro_stations.json'
48
+ )
49
+
50
+ def __init__(self, config_path: Optional[str] = None):
51
+ """Initialize station data loader.
52
+
53
+ Args:
54
+ config_path: Path to station JSON file. If None, uses default.
55
+ """
56
+ self.config_path = config_path or self.DEFAULT_DATA_PATH
57
+ self._data: Optional[Dict] = None
58
+ self._stations: Optional[List[Station]] = None
59
+ self._route_info: Optional[RouteInfo] = None
60
+
61
+ def load(self) -> Dict:
62
+ """Load station data from JSON file."""
63
+ if self._data is not None:
64
+ return self._data
65
+
66
+ if not os.path.exists(self.config_path):
67
+ raise FileNotFoundError(
68
+ f"Station data file not found: {self.config_path}\n"
69
+ f"Please ensure the station configuration exists."
70
+ )
71
+
72
+ with open(self.config_path, 'r') as f:
73
+ self._data = json.load(f)
74
+
75
+ return self._data
76
+
77
+ @property
78
+ def stations(self) -> List[Station]:
79
+ """Get list of Station objects."""
80
+ if self._stations is not None:
81
+ return self._stations
82
+
83
+ data = self.load()
84
+ self._stations = []
85
+
86
+ for s in data['stations']:
87
+ station = Station(
88
+ sr_no=s['sr_no'],
89
+ code=s['code'],
90
+ name=s['name'],
91
+ distance_from_prev_km=s['distance_from_prev_km'],
92
+ cumulative_distance_km=s['cumulative_distance_km'],
93
+ is_terminal=s['is_terminal'],
94
+ has_depot=s['has_depot'],
95
+ platform_count=s['platform_count'],
96
+ interchange=s.get('interchange'),
97
+ depot_name=s.get('depot_name')
98
+ )
99
+ self._stations.append(station)
100
+
101
+ return self._stations
102
+
103
+ @property
104
+ def route_info(self) -> RouteInfo:
105
+ """Get complete route information."""
106
+ if self._route_info is not None:
107
+ return self._route_info
108
+
109
+ data = self.load()
110
+ stations = self.stations
111
+
112
+ self._route_info = RouteInfo(
113
+ name=data['line_info']['name'],
114
+ operator=data['line_info']['operator'],
115
+ stations=stations,
116
+ total_distance_km=stations[-1].cumulative_distance_km if stations else 0,
117
+ terminal_stations=[s.name for s in stations if s.is_terminal],
118
+ depot_stations=[s.name for s in stations if s.has_depot],
119
+ operational_params=data.get('operational_params', {})
120
+ )
121
+
122
+ return self._route_info
123
+
124
+ @property
125
+ def total_distance_km(self) -> float:
126
+ """Get total route distance in km."""
127
+ return self.route_info.total_distance_km
128
+
129
+ @property
130
+ def terminals(self) -> List[str]:
131
+ """Get terminal station names."""
132
+ return self.route_info.terminal_stations
133
+
134
+ @property
135
+ def station_count(self) -> int:
136
+ """Get number of stations."""
137
+ return len(self.stations)
138
+
139
+ def get_station_by_name(self, name: str) -> Optional[Station]:
140
+ """Get station by name (case-insensitive)."""
141
+ name_lower = name.lower()
142
+ for station in self.stations:
143
+ if station.name.lower() == name_lower:
144
+ return station
145
+ return None
146
+
147
+ def get_station_by_code(self, code: str) -> Optional[Station]:
148
+ """Get station by code."""
149
+ code_upper = code.upper()
150
+ for station in self.stations:
151
+ if station.code.upper() == code_upper:
152
+ return station
153
+ return None
154
+
155
+ def get_distance_between(self, station1: str, station2: str) -> float:
156
+ """Get distance between two stations (by name or code).
157
+
158
+ Args:
159
+ station1: Name or code of first station
160
+ station2: Name or code of second station
161
+
162
+ Returns:
163
+ Distance in km (absolute value)
164
+ """
165
+ s1 = self.get_station_by_name(station1) or self.get_station_by_code(station1)
166
+ s2 = self.get_station_by_name(station2) or self.get_station_by_code(station2)
167
+
168
+ if not s1 or not s2:
169
+ raise ValueError(f"Station not found: {station1 if not s1 else station2}")
170
+
171
+ return abs(s2.cumulative_distance_km - s1.cumulative_distance_km)
172
+
173
+ def get_intermediate_stations(self, origin: str, destination: str) -> List[Station]:
174
+ """Get all intermediate stations between origin and destination.
175
+
176
+ Args:
177
+ origin: Origin station name or code
178
+ destination: Destination station name or code
179
+
180
+ Returns:
181
+ List of stations between origin and destination (inclusive)
182
+ """
183
+ s1 = self.get_station_by_name(origin) or self.get_station_by_code(origin)
184
+ s2 = self.get_station_by_name(destination) or self.get_station_by_code(destination)
185
+
186
+ if not s1 or not s2:
187
+ raise ValueError(f"Station not found: {origin if not s1 else destination}")
188
+
189
+ # Get indices
190
+ idx1 = s1.sr_no - 1 # sr_no is 1-based
191
+ idx2 = s2.sr_no - 1
192
+
193
+ # Ensure correct order
194
+ start_idx, end_idx = min(idx1, idx2), max(idx1, idx2)
195
+
196
+ return self.stations[start_idx:end_idx + 1]
197
+
198
+ def calculate_journey_time(
199
+ self,
200
+ origin: str,
201
+ destination: str,
202
+ avg_speed_kmh: Optional[float] = None
203
+ ) -> float:
204
+ """Calculate journey time between two stations.
205
+
206
+ Args:
207
+ origin: Origin station name or code
208
+ destination: Destination station name or code
209
+ avg_speed_kmh: Average speed (uses config default if None)
210
+
211
+ Returns:
212
+ Journey time in minutes (including dwell times)
213
+ """
214
+ data = self.load()
215
+
216
+ if avg_speed_kmh is None:
217
+ avg_speed_kmh = data['line_info'].get('average_speed_kmh', 35)
218
+
219
+ dwell_time_sec = data['operational_params'].get('dwell_time_seconds', 30)
220
+
221
+ distance = self.get_distance_between(origin, destination)
222
+ intermediate = self.get_intermediate_stations(origin, destination)
223
+
224
+ # Travel time
225
+ travel_time_hours = distance / avg_speed_kmh
226
+ travel_time_minutes = travel_time_hours * 60
227
+
228
+ # Add dwell time at each intermediate station (except destination)
229
+ num_stops = len(intermediate) - 1 # Exclude destination
230
+ total_dwell_minutes = (num_stops * dwell_time_sec) / 60
231
+
232
+ return travel_time_minutes + total_dwell_minutes
233
+
234
+ def calculate_round_trip_time(self, avg_speed_kmh: Optional[float] = None) -> float:
235
+ """Calculate round trip time between terminals.
236
+
237
+ Returns:
238
+ Round trip time in minutes (including turnaround)
239
+ """
240
+ data = self.load()
241
+ terminals = self.terminals
242
+
243
+ if len(terminals) < 2:
244
+ raise ValueError("Need at least 2 terminal stations for round trip")
245
+
246
+ turnaround_sec = data['operational_params'].get('terminal_turnaround_seconds', 180)
247
+
248
+ # One-way journey time
249
+ one_way_time = self.calculate_journey_time(terminals[0], terminals[-1], avg_speed_kmh)
250
+
251
+ # Round trip = 2 * one_way + 2 * turnaround
252
+ return (2 * one_way_time) + (2 * turnaround_sec / 60)
253
+
254
+ def get_station_sequence_for_trip(
255
+ self,
256
+ origin: str,
257
+ destination: str,
258
+ include_times: bool = True,
259
+ departure_time: str = "07:00"
260
+ ) -> List[Dict]:
261
+ """Get detailed station sequence for a trip.
262
+
263
+ Args:
264
+ origin: Origin station name
265
+ destination: Destination station name
266
+ include_times: Whether to calculate arrival times
267
+ departure_time: Departure time from origin (HH:MM format)
268
+
269
+ Returns:
270
+ List of dicts with station info and arrival times
271
+ """
272
+ data = self.load()
273
+ avg_speed = data['line_info'].get('average_speed_kmh', 35)
274
+ dwell_time_sec = data['operational_params'].get('dwell_time_seconds', 30)
275
+
276
+ stations = self.get_intermediate_stations(origin, destination)
277
+
278
+ # Parse departure time
279
+ from datetime import datetime, timedelta
280
+ current_time = datetime.strptime(departure_time, "%H:%M")
281
+
282
+ sequence = []
283
+ prev_cumulative = stations[0].cumulative_distance_km
284
+
285
+ for i, station in enumerate(stations):
286
+ entry = {
287
+ 'sr_no': station.sr_no,
288
+ 'code': station.code,
289
+ 'name': station.name,
290
+ 'distance_from_origin_km': round(station.cumulative_distance_km - stations[0].cumulative_distance_km, 3),
291
+ 'is_terminal': station.is_terminal
292
+ }
293
+
294
+ if include_times:
295
+ if i == 0:
296
+ # Origin station - departure time
297
+ entry['arrival_time'] = None
298
+ entry['departure_time'] = current_time.strftime("%H:%M")
299
+ else:
300
+ # Calculate travel time from previous station
301
+ segment_distance = station.cumulative_distance_km - prev_cumulative
302
+ travel_time_min = (segment_distance / avg_speed) * 60
303
+ current_time += timedelta(minutes=travel_time_min)
304
+
305
+ entry['arrival_time'] = current_time.strftime("%H:%M")
306
+
307
+ if i < len(stations) - 1:
308
+ # Not destination - add dwell time
309
+ current_time += timedelta(seconds=dwell_time_sec)
310
+ entry['departure_time'] = current_time.strftime("%H:%M")
311
+ else:
312
+ # Destination - no departure
313
+ entry['departure_time'] = None
314
+
315
+ prev_cumulative = station.cumulative_distance_km
316
+
317
+ sequence.append(entry)
318
+
319
+ return sequence
320
+
321
+ def to_dict(self) -> Dict:
322
+ """Export route info as dictionary."""
323
+ return {
324
+ 'line_name': self.route_info.name,
325
+ 'operator': self.route_info.operator,
326
+ 'total_distance_km': self.total_distance_km,
327
+ 'station_count': self.station_count,
328
+ 'terminals': self.terminals,
329
+ 'stations': [
330
+ {
331
+ 'sr_no': s.sr_no,
332
+ 'code': s.code,
333
+ 'name': s.name,
334
+ 'distance_from_prev_km': s.distance_from_prev_km,
335
+ 'cumulative_distance_km': s.cumulative_distance_km,
336
+ 'is_terminal': s.is_terminal,
337
+ 'has_depot': s.has_depot
338
+ }
339
+ for s in self.stations
340
+ ],
341
+ 'operational_params': self.route_info.operational_params
342
+ }
343
+
344
+
345
+ # Global cached instance for performance
346
+ _default_loader: Optional[StationDataLoader] = None
347
+
348
+
349
+ def get_station_loader(config_path: Optional[str] = None) -> StationDataLoader:
350
+ """Get station data loader (cached for default path).
351
+
352
+ Args:
353
+ config_path: Custom config path, or None for default
354
+
355
+ Returns:
356
+ StationDataLoader instance
357
+ """
358
+ global _default_loader
359
+
360
+ if config_path is None:
361
+ if _default_loader is None:
362
+ _default_loader = StationDataLoader()
363
+ return _default_loader
364
+
365
+ return StationDataLoader(config_path)
366
+
367
+
368
+ def get_route_distance() -> float:
369
+ """Get total route distance (convenience function)."""
370
+ return get_station_loader().total_distance_km
371
+
372
+
373
+ def get_terminals() -> List[str]:
374
+ """Get terminal station names (convenience function)."""
375
+ return get_station_loader().terminals