Files
Sportstime/SportsTime/Planning/Engine/RouteOptimizer.swift
Trey t 9088b46563 Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes)
- GeographicRouteExplorer with anchor game support for route exploration
- Shared ItineraryBuilder for travel segment calculation
- TravelEstimator for driving time/distance estimation
- SwiftUI views for trip creation and detail display
- CloudKit integration for schedule data
- Python scraping scripts for sports schedules

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:46:40 -06:00

284 lines
10 KiB
Swift

//
// RouteOptimizer.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Optimization strategy for ranking itinerary options.
enum OptimizationStrategy {
case balanced // Balance games vs driving
case maximizeGames // Prioritize seeing more games
case minimizeDriving // Prioritize shorter routes
case scenic // Prioritize scenic routes
}
/// Route optimizer for ranking and scoring itinerary options.
///
/// The TSP-solving logic has been moved to scenario-specific candidate
/// generation in TripPlanningEngine. This optimizer now focuses on:
/// - Ranking multiple route options
/// - Scoring routes based on optimization strategy
/// - Reordering games within constraints
struct RouteOptimizer {
// MARK: - Route Ranking
/// Ranks a list of itinerary options based on the optimization strategy.
///
/// - Parameters:
/// - options: Unranked itinerary options
/// - strategy: Optimization strategy for scoring
/// - request: Planning request for context
/// - Returns: Options sorted by score (best first) with rank assigned
func rankOptions(
_ options: [ItineraryOption],
strategy: OptimizationStrategy = .balanced,
request: PlanningRequest
) -> [ItineraryOption] {
// Score each option
let scoredOptions = options.map { option -> (ItineraryOption, Double) in
let score = scoreOption(option, strategy: strategy, request: request)
return (option, score)
}
// Sort by score (lower is better for our scoring system)
let sorted = scoredOptions.sorted { $0.1 < $1.1 }
// Assign ranks
return sorted.enumerated().map { index, scored in
ItineraryOption(
rank: index + 1,
stops: scored.0.stops,
travelSegments: scored.0.travelSegments,
totalDrivingHours: scored.0.totalDrivingHours,
totalDistanceMiles: scored.0.totalDistanceMiles,
geographicRationale: scored.0.geographicRationale
)
}
}
// MARK: - Scoring
/// Scores an itinerary option based on the optimization strategy.
/// Lower scores are better.
///
/// - Parameters:
/// - option: Itinerary option to score
/// - strategy: Optimization strategy
/// - request: Planning request for context
/// - Returns: Score value (lower is better)
func scoreOption(
_ option: ItineraryOption,
strategy: OptimizationStrategy,
request: PlanningRequest
) -> Double {
switch strategy {
case .balanced:
return scoreBalanced(option, request: request)
case .maximizeGames:
return scoreMaximizeGames(option, request: request)
case .minimizeDriving:
return scoreMinimizeDriving(option, request: request)
case .scenic:
return scoreScenic(option, request: request)
}
}
/// Balanced scoring: trade-off between games and driving.
private func scoreBalanced(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Each game "saves" 2 hours of driving in value
let gameValue = Double(option.totalGames) * 2.0
let drivingPenalty = option.totalDrivingHours
// Also factor in must-see games coverage
let mustSeeCoverage = calculateMustSeeCoverage(option, request: request)
let mustSeeBonus = mustSeeCoverage * 10.0 // Strong bonus for must-see coverage
return drivingPenalty - gameValue - mustSeeBonus
}
/// Maximize games scoring: prioritize number of games.
private func scoreMaximizeGames(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Heavily weight game count
let gameScore = -Double(option.totalGames) * 100.0
let drivingPenalty = option.totalDrivingHours * 0.1 // Minimal driving penalty
return gameScore + drivingPenalty
}
/// Minimize driving scoring: prioritize shorter routes.
private func scoreMinimizeDriving(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Primarily driving time
let drivingScore = option.totalDrivingHours
let gameBonus = Double(option.totalGames) * 0.5 // Small bonus for games
return drivingScore - gameBonus
}
/// Scenic scoring: balance games with route pleasantness.
private func scoreScenic(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// More relaxed pacing is better
let gamesPerDay = Double(option.totalGames) / Double(max(1, option.stops.count))
let pacingScore = abs(gamesPerDay - 1.5) * 5.0 // Ideal is ~1.5 games per day
let drivingScore = option.totalDrivingHours * 0.3
let gameBonus = Double(option.totalGames) * 2.0
return pacingScore + drivingScore - gameBonus
}
/// Calculates what percentage of must-see games are covered.
private func calculateMustSeeCoverage(
_ option: ItineraryOption,
request: PlanningRequest
) -> Double {
let mustSeeIds = request.preferences.mustSeeGameIds
if mustSeeIds.isEmpty { return 1.0 }
let coveredGames = option.stops.flatMap { $0.games }
let coveredMustSee = Set(coveredGames).intersection(mustSeeIds)
return Double(coveredMustSee.count) / Double(mustSeeIds.count)
}
// MARK: - Route Improvement
/// Attempts to improve a route by swapping non-essential stops.
/// Only applies to stops without must-see games.
///
/// - Parameters:
/// - option: Itinerary option to improve
/// - request: Planning request for context
/// - Returns: Improved itinerary option, or original if no improvement found
func improveRoute(
_ option: ItineraryOption,
request: PlanningRequest
) -> ItineraryOption {
// For now, return as-is since games must remain in chronological order
// Future: implement swap logic for same-day games in different cities
return option
}
// MARK: - Validation Helpers
/// Checks if all must-see games are included in the option.
func includesAllMustSeeGames(
_ option: ItineraryOption,
request: PlanningRequest
) -> Bool {
let includedGames = Set(option.stops.flatMap { $0.games })
return request.preferences.mustSeeGameIds.isSubset(of: includedGames)
}
/// Returns must-see games that are missing from the option.
func missingMustSeeGames(
_ option: ItineraryOption,
request: PlanningRequest
) -> Set<UUID> {
let includedGames = Set(option.stops.flatMap { $0.games })
return request.preferences.mustSeeGameIds.subtracting(includedGames)
}
// MARK: - Distance Calculations
/// Calculates total route distance for a sequence of coordinates.
func totalDistance(for coordinates: [CLLocationCoordinate2D]) -> Double {
guard coordinates.count >= 2 else { return 0 }
var total: Double = 0
for i in 0..<(coordinates.count - 1) {
total += distance(from: coordinates[i], to: coordinates[i + 1])
}
return total
}
/// Calculates distance between two coordinates in meters.
private func distance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
return fromLoc.distance(from: toLoc)
}
// MARK: - Legacy Support
/// Legacy optimization method for backward compatibility.
/// Delegates to the new TripPlanningEngine for actual routing.
func optimize(
graph: RouteGraph,
request: PlanningRequest,
candidates: [GameCandidate],
strategy: OptimizationStrategy = .balanced
) -> CandidateRoute {
// Build a simple chronological route from candidates
let sortedCandidates = candidates.sorted { $0.game.dateTime < $1.game.dateTime }
var route = CandidateRoute()
// Add start node
if let startNode = graph.nodes.first(where: { $0.type == .start }) {
route.nodeSequence.append(startNode.id)
}
// Add stadium nodes in chronological order
var visitedStadiums = Set<UUID>()
for candidate in sortedCandidates {
// Find the node for this stadium
for node in graph.nodes {
if case .stadium(let stadiumId) = node.type,
stadiumId == candidate.stadium.id,
!visitedStadiums.contains(stadiumId) {
route.nodeSequence.append(node.id)
route.games.append(candidate.game.id)
visitedStadiums.insert(stadiumId)
break
}
}
}
// Add end node
if let endNode = graph.nodes.first(where: { $0.type == .end }) {
route.nodeSequence.append(endNode.id)
}
// Calculate totals
for i in 0..<(route.nodeSequence.count - 1) {
if let edge = graph.edges(from: route.nodeSequence[i])
.first(where: { $0.toNodeId == route.nodeSequence[i + 1] }) {
route.totalDistance += edge.distanceMeters
route.totalDuration += edge.durationSeconds
}
}
// Score the route
route.score = scoreRoute(route, strategy: strategy, graph: graph)
return route
}
/// Legacy route scoring for CandidateRoute.
private func scoreRoute(
_ route: CandidateRoute,
strategy: OptimizationStrategy,
graph: RouteGraph
) -> Double {
switch strategy {
case .balanced:
return route.totalDuration - Double(route.games.count) * 3600 * 2
case .maximizeGames:
return -Double(route.games.count) * 10000 + route.totalDuration
case .minimizeDriving:
return route.totalDuration
case .scenic:
return route.totalDuration * 0.5 - Double(route.games.count) * 3600
}
}
}