Commit
·
aece385
1
Parent(s):
d35d9d7
moved to more generalized station input
Browse files- api/greedyoptim_api.py +197 -0
- greedyOptim/__init__.py +13 -1
- greedyOptim/data/kochi_metro_stations.json +267 -0
- greedyOptim/service_blocks.py +297 -108
- greedyOptim/station_loader.py +375 -0
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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 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 |
-
#
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
def __init__(self):
|
| 27 |
-
"""Initialize service block generator.
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 48 |
-
|
| 49 |
-
|
| 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 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
block_counter += 1
|
| 106 |
-
origin = self.
|
| 107 |
-
destination = self.
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
'block_id': f'BLK-{block_counter:03d}',
|
| 110 |
-
'departure_time':
|
| 111 |
'origin': origin,
|
| 112 |
'destination': destination,
|
| 113 |
-
'trip_count':
|
| 114 |
-
'estimated_km': int(
|
| 115 |
-
'period':
|
| 116 |
-
'is_peak':
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
'block_id': f'BLK-M-{train_index+1:03d}',
|
| 161 |
-
'departure_time':
|
| 162 |
-
'origin':
|
| 163 |
-
'destination':
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
'block_id': f'BLK-D-{train_index+1:03d}',
|
| 174 |
-
'departure_time':
|
| 175 |
-
'origin':
|
| 176 |
-
'destination':
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
'block_id': f'BLK-E-{train_index+1:03d}',
|
| 187 |
-
'departure_time':
|
| 188 |
-
'origin':
|
| 189 |
-
'destination':
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
'block_id': f'BLK-L-{train_index+1:03d}',
|
| 198 |
-
'departure_time':
|
| 199 |
-
'origin':
|
| 200 |
-
'destination':
|
| 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 |
-
|
|
|
|
| 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.
|
| 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
|