- 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>
284 lines
10 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|