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>
This commit is contained in:
283
SportsTime/Planning/Engine/RouteOptimizer.swift
Normal file
283
SportsTime/Planning/Engine/RouteOptimizer.swift
Normal file
@@ -0,0 +1,283 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user