// // 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 { 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() 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 } } }