diff --git a/SportsTime/Planning/Engine/RouteCandidateBuilder.swift b/SportsTime/Planning/Engine/RouteCandidateBuilder.swift deleted file mode 100644 index 47a6b25..0000000 --- a/SportsTime/Planning/Engine/RouteCandidateBuilder.swift +++ /dev/null @@ -1,232 +0,0 @@ -// -// RouteCandidateBuilder.swift -// SportsTime -// - -import Foundation -import CoreLocation - -/// Builds route candidates for different planning scenarios -enum RouteCandidateBuilder { - - // MARK: - Scenario A: Linear Candidates (Date Range) - - /// Builds linear route candidates from games sorted chronologically - /// - Parameters: - /// - games: Available games sorted by start time - /// - mustStop: Optional must-stop location - /// - Returns: Array of route candidates - static func buildLinearCandidates( - games: [Game], - stadiums: [UUID: Stadium], - mustStop: LocationInput? - ) -> [RouteCandidate] { - guard !games.isEmpty else { - return [] - } - - // Group games by stadium - var stadiumGames: [UUID: [Game]] = [:] - for game in games { - stadiumGames[game.stadiumId, default: []].append(game) - } - - // Build stops from chronological game order - var stops: [ItineraryStop] = [] - var processedStadiums: Set = [] - - for game in games { - guard !processedStadiums.contains(game.stadiumId) else { continue } - processedStadiums.insert(game.stadiumId) - - let gamesAtStop = stadiumGames[game.stadiumId] ?? [game] - let sortedGames = gamesAtStop.sorted { $0.startTime < $1.startTime } - - // Look up stadium for coordinates and city info - let stadium = stadiums[game.stadiumId] - let city = stadium?.city ?? "Unknown" - let state = stadium?.state ?? "" - let coordinate = stadium?.coordinate - - let location = LocationInput( - name: city, - coordinate: coordinate, - address: stadium?.fullAddress - ) - - let stop = ItineraryStop( - city: city, - state: state, - coordinate: coordinate, - games: sortedGames.map { $0.id }, - arrivalDate: sortedGames.first?.gameDate ?? Date(), - departureDate: sortedGames.last?.gameDate ?? Date(), - location: location, - firstGameStart: sortedGames.first?.startTime - ) - stops.append(stop) - } - - guard !stops.isEmpty else { - return [] - } - - return [RouteCandidate( - stops: stops, - rationale: "Linear route through \(stops.count) cities" - )] - } - - // MARK: - Scenario B: Expand Around Anchors (Selected Games) - - /// Expands route around user-selected anchor games - /// - Parameters: - /// - anchors: User-selected games (must-see) - /// - allGames: All available games - /// - dateRange: Trip date range - /// - mustStop: Optional must-stop location - /// - Returns: Array of route candidates - static func expandAroundAnchors( - anchors: [Game], - allGames: [Game], - stadiums: [UUID: Stadium], - dateRange: DateInterval, - mustStop: LocationInput? - ) -> [RouteCandidate] { - guard !anchors.isEmpty else { - return [] - } - - // Start with anchor games as the core route - let sortedAnchors = anchors.sorted { $0.startTime < $1.startTime } - - // Build stops from anchor games - var stops: [ItineraryStop] = [] - - for game in sortedAnchors { - let stadium = stadiums[game.stadiumId] - let city = stadium?.city ?? "Unknown" - let state = stadium?.state ?? "" - let coordinate = stadium?.coordinate - - let location = LocationInput( - name: city, - coordinate: coordinate, - address: stadium?.fullAddress - ) - - let stop = ItineraryStop( - city: city, - state: state, - coordinate: coordinate, - games: [game.id], - arrivalDate: game.gameDate, - departureDate: game.gameDate, - location: location, - firstGameStart: game.startTime - ) - stops.append(stop) - } - - guard !stops.isEmpty else { - return [] - } - - return [RouteCandidate( - stops: stops, - rationale: "Route connecting \(anchors.count) selected games" - )] - } - - // MARK: - Scenario C: Directional Routes (Start + End) - - /// Builds directional routes from start to end location - /// - Parameters: - /// - start: Start location - /// - end: End location - /// - games: Available games - /// - dateRange: Optional trip date range - /// - Returns: Array of route candidates - static func buildDirectionalRoutes( - start: LocationInput, - end: LocationInput, - games: [Game], - stadiums: [UUID: Stadium], - dateRange: DateInterval? - ) -> [RouteCandidate] { - // Filter games by date range if provided - let filteredGames: [Game] - if let range = dateRange { - filteredGames = games.filter { range.contains($0.startTime) } - } else { - filteredGames = games - } - - guard !filteredGames.isEmpty else { - return [] - } - - // Sort games chronologically - let sortedGames = filteredGames.sorted { $0.startTime < $1.startTime } - - // Build stops: start -> games -> end - var stops: [ItineraryStop] = [] - - // Start stop (no games) - let startStop = ItineraryStop( - city: start.name, - state: "", - coordinate: start.coordinate, - games: [], - arrivalDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(), - departureDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(), - location: start, - firstGameStart: nil - ) - stops.append(startStop) - - // Game stops - for game in sortedGames { - let stadium = stadiums[game.stadiumId] - let city = stadium?.city ?? "Unknown" - let state = stadium?.state ?? "" - let coordinate = stadium?.coordinate - - let location = LocationInput( - name: city, - coordinate: coordinate, - address: stadium?.fullAddress - ) - - let stop = ItineraryStop( - city: city, - state: state, - coordinate: coordinate, - games: [game.id], - arrivalDate: game.gameDate, - departureDate: game.gameDate, - location: location, - firstGameStart: game.startTime - ) - stops.append(stop) - } - - // End stop (no games) - let endStop = ItineraryStop( - city: end.name, - state: "", - coordinate: end.coordinate, - games: [], - arrivalDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(), - departureDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(), - location: end, - firstGameStart: nil - ) - stops.append(endStop) - - return [RouteCandidate( - stops: stops, - rationale: "Directional route from \(start.name) to \(end.name)" - )] - } -} diff --git a/SportsTime/Planning/Engine/RouteOptimizer.swift b/SportsTime/Planning/Engine/RouteOptimizer.swift deleted file mode 100644 index 36716a7..0000000 --- a/SportsTime/Planning/Engine/RouteOptimizer.swift +++ /dev/null @@ -1,283 +0,0 @@ -// -// 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 - } - } -} diff --git a/SportsTime/Planning/Engine/ScheduleMatcher.swift b/SportsTime/Planning/Engine/ScheduleMatcher.swift deleted file mode 100644 index baf5bb5..0000000 --- a/SportsTime/Planning/Engine/ScheduleMatcher.swift +++ /dev/null @@ -1,396 +0,0 @@ -// -// ScheduleMatcher.swift -// SportsTime -// - -import Foundation -import CoreLocation - -/// Finds and scores candidate games for trip planning. -/// -/// Updated for the new scenario-based planning: -/// - Scenario A (Date Range): Find games in date range, cluster by region -/// - Scenario B (Selected Games): Validate must-see games, find optional additions -/// - Scenario C (Start+End): Find games along directional corridor with progress check -struct ScheduleMatcher { - - // MARK: - Find Candidate Games (Legacy + Scenario C Support) - - /// Finds candidate games along a corridor between start and end. - /// Supports directional filtering for Scenario C. - /// - /// - Parameters: - /// - request: Planning request with preferences and games - /// - startCoordinate: Starting location - /// - endCoordinate: Ending location - /// - enforceDirection: If true, only include games that make progress toward end - /// - Returns: Array of game candidates sorted by score - func findCandidateGames( - from request: PlanningRequest, - startCoordinate: CLLocationCoordinate2D, - endCoordinate: CLLocationCoordinate2D, - enforceDirection: Bool = false - ) -> [GameCandidate] { - var candidates: [GameCandidate] = [] - - // Calculate the corridor between start and end - let corridor = RouteCorridorCalculator( - start: startCoordinate, - end: endCoordinate, - maxDetourFactor: detourFactorFor(request.preferences.leisureLevel) - ) - - for game in request.availableGames { - guard let stadium = request.stadiums[game.stadiumId], - let homeTeam = request.teams[game.homeTeamId], - let awayTeam = request.teams[game.awayTeamId] else { - continue - } - - // Check if game is within date range - guard game.dateTime >= request.preferences.startDate, - game.dateTime <= request.preferences.endDate else { - continue - } - - // Check sport filter - guard request.preferences.sports.contains(game.sport) else { - continue - } - - // Calculate detour distance - let detourDistance = corridor.detourDistance(to: stadium.coordinate) - - // For directional routes, check if this stadium makes progress - if enforceDirection { - let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate) - let startDistanceToEnd = corridor.directDistance - - // Skip if stadium is behind the start (going backwards) - if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance - continue - } - } - - // Skip if too far from route (unless must-see) - let isMustSee = request.preferences.mustSeeGameIds.contains(game.id) - if !isMustSee && detourDistance > corridor.maxDetourDistance { - continue - } - - // Score the game - let score = scoreGame( - game: game, - homeTeam: homeTeam, - awayTeam: awayTeam, - detourDistance: detourDistance, - isMustSee: isMustSee, - request: request - ) - - let candidate = GameCandidate( - id: game.id, - game: game, - stadium: stadium, - homeTeam: homeTeam, - awayTeam: awayTeam, - detourDistance: detourDistance, - score: score - ) - - candidates.append(candidate) - } - - // Sort by score (highest first) - return candidates.sorted { $0.score > $1.score } - } - - // MARK: - Directional Game Filtering (Scenario C) - - /// Finds games along a directional route from start to end. - /// Ensures monotonic progress toward destination. - /// - /// - Parameters: - /// - request: Planning request - /// - startCoordinate: Starting location - /// - endCoordinate: Destination - /// - corridorWidthPercent: Width of corridor as percentage of direct distance - /// - Returns: Games sorted by their position along the route - func findDirectionalGames( - from request: PlanningRequest, - startCoordinate: CLLocationCoordinate2D, - endCoordinate: CLLocationCoordinate2D, - corridorWidthPercent: Double = 0.3 - ) -> [GameCandidate] { - let corridor = RouteCorridorCalculator( - start: startCoordinate, - end: endCoordinate, - maxDetourFactor: 1.0 + corridorWidthPercent - ) - - var candidates: [GameCandidate] = [] - - for game in request.availableGames { - guard let stadium = request.stadiums[game.stadiumId], - let homeTeam = request.teams[game.homeTeamId], - let awayTeam = request.teams[game.awayTeamId] else { - continue - } - - // Date and sport filter - guard game.dateTime >= request.preferences.startDate, - game.dateTime <= request.preferences.endDate, - request.preferences.sports.contains(game.sport) else { - continue - } - - // Calculate progress along route (0 = start, 1 = end) - let progress = corridor.progressAlongRoute(point: stadium.coordinate) - - // Only include games that are along the route (positive progress, not behind start) - guard progress >= -0.1 && progress <= 1.1 else { - continue - } - - // Check corridor width - let detourDistance = corridor.detourDistance(to: stadium.coordinate) - let isMustSee = request.preferences.mustSeeGameIds.contains(game.id) - - if !isMustSee && detourDistance > corridor.maxDetourDistance { - continue - } - - let score = scoreGame( - game: game, - homeTeam: homeTeam, - awayTeam: awayTeam, - detourDistance: detourDistance, - isMustSee: isMustSee, - request: request - ) - - let candidate = GameCandidate( - id: game.id, - game: game, - stadium: stadium, - homeTeam: homeTeam, - awayTeam: awayTeam, - detourDistance: detourDistance, - score: score - ) - - candidates.append(candidate) - } - - // Sort by date (chronological order is the primary constraint) - return candidates.sorted { $0.game.dateTime < $1.game.dateTime } - } - - // MARK: - Game Scoring - - private func scoreGame( - game: Game, - homeTeam: Team, - awayTeam: Team, - detourDistance: Double, - isMustSee: Bool, - request: PlanningRequest - ) -> Double { - var score: Double = 50.0 // Base score - - // Must-see bonus - if isMustSee { - score += 100.0 - } - - // Playoff bonus - if game.isPlayoff { - score += 30.0 - } - - // Weekend bonus (more convenient) - let weekday = Calendar.current.component(.weekday, from: game.dateTime) - if weekday == 1 || weekday == 7 { // Sunday or Saturday - score += 10.0 - } - - // Evening game bonus (day games harder to schedule around) - let hour = Calendar.current.component(.hour, from: game.dateTime) - if hour >= 17 { // 5 PM or later - score += 5.0 - } - - // Detour penalty - let detourMiles = detourDistance * 0.000621371 - score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour - - // Preferred city bonus - if request.preferences.preferredCities.contains(homeTeam.city) { - score += 15.0 - } - - // Must-stop location bonus - if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) { - score += 25.0 - } - - return max(0, score) - } - - private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double { - switch leisureLevel { - case .packed: return 1.3 // 30% detour allowed - case .moderate: return 1.5 // 50% detour allowed - case .relaxed: return 2.0 // 100% detour allowed - } - } - - // MARK: - Find Games at Location - - func findGames( - at stadium: Stadium, - within dateRange: ClosedRange, - from games: [Game] - ) -> [Game] { - games.filter { game in - game.stadiumId == stadium.id && - dateRange.contains(game.dateTime) - }.sorted { $0.dateTime < $1.dateTime } - } - - // MARK: - Find Other Sports - - func findOtherSportsGames( - along route: [CLLocationCoordinate2D], - excludingSports: Set, - within dateRange: ClosedRange, - games: [Game], - stadiums: [UUID: Stadium], - teams: [UUID: Team], - maxDetourMiles: Double = 50 - ) -> [GameCandidate] { - var candidates: [GameCandidate] = [] - - for game in games { - // Skip if sport is already selected - if excludingSports.contains(game.sport) { continue } - - // Skip if outside date range - if !dateRange.contains(game.dateTime) { continue } - - guard let stadium = stadiums[game.stadiumId], - let homeTeam = teams[game.homeTeamId], - let awayTeam = teams[game.awayTeamId] else { - continue - } - - // Check if stadium is near the route - let minDistance = route.map { coord in - CLLocation(latitude: coord.latitude, longitude: coord.longitude) - .distance(from: stadium.location) - }.min() ?? .greatestFiniteMagnitude - - let distanceMiles = minDistance * 0.000621371 - - if distanceMiles <= maxDetourMiles { - let candidate = GameCandidate( - id: game.id, - game: game, - stadium: stadium, - homeTeam: homeTeam, - awayTeam: awayTeam, - detourDistance: minDistance, - score: 50.0 - distanceMiles // Score inversely proportional to detour - ) - candidates.append(candidate) - } - } - - return candidates.sorted { $0.score > $1.score } - } - - // MARK: - Validate Games for Scenarios - - /// Validates that all must-see games are within the date range. - /// Used for Scenario B validation. - func validateMustSeeGamesInRange( - mustSeeGameIds: Set, - allGames: [Game], - dateRange: ClosedRange - ) -> (valid: Bool, outOfRange: [UUID]) { - var outOfRange: [UUID] = [] - - for gameId in mustSeeGameIds { - guard let game = allGames.first(where: { $0.id == gameId }) else { - continue - } - if !dateRange.contains(game.dateTime) { - outOfRange.append(gameId) - } - } - - return (outOfRange.isEmpty, outOfRange) - } -} - -// MARK: - Route Corridor Calculator - -struct RouteCorridorCalculator { - let start: CLLocationCoordinate2D - let end: CLLocationCoordinate2D - let maxDetourFactor: Double - - var directDistance: CLLocationDistance { - CLLocation(latitude: start.latitude, longitude: start.longitude) - .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) - } - - var maxDetourDistance: CLLocationDistance { - directDistance * (maxDetourFactor - 1.0) - } - - func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance { - let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude) - .distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude)) - - let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude) - .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) - - let totalViaPoint = startToPoint + pointToEnd - - return max(0, totalViaPoint - directDistance) - } - - func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool { - detourDistance(to: point) <= maxDetourDistance - } - - /// Returns the distance from a point to the end location. - func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance { - CLLocation(latitude: point.latitude, longitude: point.longitude) - .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) - } - - /// Calculates progress along the route (0 = at start, 1 = at end). - /// Can be negative (behind start) or > 1 (past end). - func progressAlongRoute(point: CLLocationCoordinate2D) -> Double { - guard directDistance > 0 else { return 0 } - - let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude) - .distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude)) - - let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude) - .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) - - // Use the law of cosines to project onto the line - // progress = (d_start² + d_total² - d_end²) / (2 * d_total²) - let dStart = distFromStart - let dEnd = distFromEnd - let dTotal = directDistance - - let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd) - let denominator = 2 * dTotal * dTotal - - return numerator / denominator - } -} diff --git a/SportsTime/Planning/Models/LegacyPlanningTypes.swift b/SportsTime/Planning/Models/LegacyPlanningTypes.swift deleted file mode 100644 index bc887b0..0000000 --- a/SportsTime/Planning/Models/LegacyPlanningTypes.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// LegacyPlanningTypes.swift -// SportsTime -// -// Supporting types for legacy planning components. -// These are used by ScheduleMatcher and RouteOptimizer. -// - -import Foundation -import CoreLocation - -// MARK: - Game Candidate - -/// A game candidate with scoring information for route planning. -struct GameCandidate: Identifiable { - let id: UUID - let game: Game - let stadium: Stadium - let homeTeam: Team - let awayTeam: Team - let detourDistance: Double - let score: Double -} - -// MARK: - Route Graph - -/// Graph representation of possible routes for optimization. -struct RouteGraph { - var nodes: [RouteNode] - var edgesByFromNode: [UUID: [RouteEdge]] - - init(nodes: [RouteNode] = [], edges: [RouteEdge] = []) { - self.nodes = nodes - self.edgesByFromNode = [:] - for edge in edges { - edgesByFromNode[edge.fromNodeId, default: []].append(edge) - } - } - - func edges(from nodeId: UUID) -> [RouteEdge] { - edgesByFromNode[nodeId] ?? [] - } -} - -// MARK: - Route Node - -struct RouteNode: Identifiable { - let id: UUID - let type: RouteNodeType - let coordinate: CLLocationCoordinate2D? - - init(id: UUID = UUID(), type: RouteNodeType, coordinate: CLLocationCoordinate2D? = nil) { - self.id = id - self.type = type - self.coordinate = coordinate - } -} - -enum RouteNodeType: Equatable { - case start - case end - case stadium(UUID) - case waypoint -} - -// MARK: - Route Edge - -struct RouteEdge: Identifiable { - let id: UUID - let fromNodeId: UUID - let toNodeId: UUID - let distanceMeters: Double - let durationSeconds: Double - - init( - id: UUID = UUID(), - fromNodeId: UUID, - toNodeId: UUID, - distanceMeters: Double, - durationSeconds: Double - ) { - self.id = id - self.fromNodeId = fromNodeId - self.toNodeId = toNodeId - self.distanceMeters = distanceMeters - self.durationSeconds = durationSeconds - } -} - -// MARK: - Candidate Route - -/// A candidate route for optimization. -struct CandidateRoute { - var nodeSequence: [UUID] = [] - var games: [UUID] = [] - var totalDistance: Double = 0 - var totalDuration: Double = 0 - var score: Double = 0 -} diff --git a/SportsTime/Planning/Scoring/TripScorer.swift b/SportsTime/Planning/Scoring/TripScorer.swift deleted file mode 100644 index d3c88cc..0000000 --- a/SportsTime/Planning/Scoring/TripScorer.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// TripScorer.swift -// SportsTime -// - -import Foundation - -struct TripScorer { - - // MARK: - Score Trip - - func score(trip: Trip, request: PlanningRequest) -> Trip { - let gameQuality = calculateGameQualityScore(trip: trip, request: request) - let routeEfficiency = calculateRouteEfficiencyScore(trip: trip, request: request) - let leisureBalance = calculateLeisureBalanceScore(trip: trip, request: request) - let preferenceAlignment = calculatePreferenceAlignmentScore(trip: trip, request: request) - - // Weighted average - let weights = (game: 0.35, route: 0.25, leisure: 0.20, preference: 0.20) - - let overall = ( - gameQuality * weights.game + - routeEfficiency * weights.route + - leisureBalance * weights.leisure + - preferenceAlignment * weights.preference - ) - - let score = TripScore( - overallScore: overall, - gameQualityScore: gameQuality, - routeEfficiencyScore: routeEfficiency, - leisureBalanceScore: leisureBalance, - preferenceAlignmentScore: preferenceAlignment - ) - - var scoredTrip = trip - scoredTrip.score = score - return scoredTrip - } - - // MARK: - Game Quality Score - - private func calculateGameQualityScore(trip: Trip, request: PlanningRequest) -> Double { - var score: Double = 0 - - let totalPossibleGames = Double(max(1, request.availableGames.count)) - let gamesAttended = Double(trip.totalGames) - - // Base score from number of games - let gameRatio = gamesAttended / min(totalPossibleGames, Double(trip.tripDuration)) - score += gameRatio * 50 - - // Bonus for including must-see games - let mustSeeIncluded = trip.stops.flatMap { $0.games } - .filter { request.preferences.mustSeeGameIds.contains($0) } - .count - let mustSeeRatio = Double(mustSeeIncluded) / Double(max(1, request.preferences.mustSeeGameIds.count)) - score += mustSeeRatio * 30 - - // Bonus for sport variety - let sportsAttended = Set(request.availableGames - .filter { trip.stops.flatMap { $0.games }.contains($0.id) } - .map { $0.sport } - ) - let varietyBonus = Double(sportsAttended.count) / Double(max(1, request.preferences.sports.count)) * 20 - score += varietyBonus - - return min(100, score) - } - - // MARK: - Route Efficiency Score - - private func calculateRouteEfficiencyScore(trip: Trip, request: PlanningRequest) -> Double { - guard let startLocation = request.preferences.startLocation, - let endLocation = request.preferences.endLocation, - let startCoord = startLocation.coordinate, - let endCoord = endLocation.coordinate else { - return 50.0 - } - - // Calculate direct distance - let directDistance = CLLocation(latitude: startCoord.latitude, longitude: startCoord.longitude) - .distance(from: CLLocation(latitude: endCoord.latitude, longitude: endCoord.longitude)) - - guard trip.totalDistanceMeters > 0, directDistance > 0 else { - return 50.0 - } - - // Efficiency ratio (direct / actual) - let efficiency = directDistance / trip.totalDistanceMeters - - // Score: 100 for perfect efficiency, lower for longer routes - // Allow up to 3x direct distance before score drops significantly - let normalizedEfficiency = min(1.0, efficiency * 2) - - return normalizedEfficiency * 100 - } - - // MARK: - Leisure Balance Score - - private func calculateLeisureBalanceScore(trip: Trip, request: PlanningRequest) -> Double { - let leisureLevel = request.preferences.leisureLevel - var score: Double = 100 - - // Check average driving hours - let avgDrivingHours = trip.averageDrivingHoursPerDay - let targetDrivingHours: Double = switch leisureLevel { - case .packed: 8.0 - case .moderate: 6.0 - case .relaxed: 4.0 - } - - if avgDrivingHours > targetDrivingHours { - let excess = avgDrivingHours - targetDrivingHours - score -= excess * 10 - } - - // Check rest day ratio - let restDays = trip.stops.filter { $0.isRestDay }.count - let targetRestRatio = leisureLevel.restDaysPerWeek / 7.0 - let actualRestRatio = Double(restDays) / Double(max(1, trip.tripDuration)) - - let restDifference = abs(actualRestRatio - targetRestRatio) - score -= restDifference * 50 - - // Check games per day vs target - let gamesPerDay = Double(trip.totalGames) / Double(max(1, trip.tripDuration)) - let targetGamesPerDay = Double(leisureLevel.maxGamesPerWeek) / 7.0 - - if gamesPerDay > targetGamesPerDay { - let excess = gamesPerDay - targetGamesPerDay - score -= excess * 20 - } - - return max(0, min(100, score)) - } - - // MARK: - Preference Alignment Score - - private func calculatePreferenceAlignmentScore(trip: Trip, request: PlanningRequest) -> Double { - var score: Double = 100 - - // Check if must-stop locations are visited - let visitedCities = Set(trip.stops.map { $0.city.lowercased() }) - for location in request.preferences.mustStopLocations { - if !visitedCities.contains(location.name.lowercased()) { - score -= 15 - } - } - - // Bonus for preferred cities - for city in request.preferences.preferredCities { - if visitedCities.contains(city.lowercased()) { - score += 5 - } - } - - // Check EV charging if needed - if request.preferences.needsEVCharging { - let hasEVStops = trip.travelSegments.contains { !$0.evChargingStops.isEmpty } - if !hasEVStops && trip.travelSegments.contains(where: { $0.distanceMiles > 200 }) { - score -= 20 // Long drive without EV stops - } - } - - // Check lodging type alignment - let lodgingMatches = trip.stops.filter { stop in - stop.lodging?.type == request.preferences.lodgingType - }.count - let lodgingRatio = Double(lodgingMatches) / Double(max(1, trip.stops.count)) - score = score * (0.5 + lodgingRatio * 0.5) - - // Check if within stop limit - if let maxStops = request.preferences.numberOfStops { - if trip.stops.count > maxStops { - score -= Double(trip.stops.count - maxStops) * 10 - } - } - - return max(0, min(100, score)) - } -} - -// MARK: - CoreML Integration (Placeholder) - -extension TripScorer { - - /// Score using CoreML model if available - func scoreWithML(trip: Trip, request: PlanningRequest) -> Trip { - // In production, this would use a CoreML model for personalized scoring - // For now, fall back to rule-based scoring - return score(trip: trip, request: request) - } -} - -import CoreLocation diff --git a/SportsTime/Planning/Validators/DateRangeValidator.swift b/SportsTime/Planning/Validators/DateRangeValidator.swift deleted file mode 100644 index fb11a3b..0000000 --- a/SportsTime/Planning/Validators/DateRangeValidator.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// DateRangeValidator.swift -// SportsTime -// - -import Foundation - -/// Validates that all games fall within the specified date range. -/// Priority 1 in the rule hierarchy - checked first before any other constraints. -struct DateRangeValidator { - - // MARK: - Validation Result - - struct ValidationResult { - let isValid: Bool - let violations: [ConstraintViolation] - let gamesOutsideRange: [UUID] - - static let valid = ValidationResult(isValid: true, violations: [], gamesOutsideRange: []) - - static func invalid(games: [UUID]) -> ValidationResult { - let violations = games.map { gameId in - ConstraintViolation( - type: .dateRange, - description: "Game \(gameId.uuidString.prefix(8)) falls outside the specified date range", - severity: .error - ) - } - return ValidationResult(isValid: false, violations: violations, gamesOutsideRange: games) - } - } - - // MARK: - Validation - - /// Validates that ALL selected games (must-see games) fall within the date range. - /// This is a HARD constraint - if any selected game is outside the range, planning fails. - /// - /// - Parameters: - /// - mustSeeGameIds: Set of game IDs that MUST be included in the trip - /// - allGames: All available games to check against - /// - startDate: Start of the valid date range (inclusive) - /// - endDate: End of the valid date range (inclusive) - /// - Returns: ValidationResult indicating success or failure with specific violations - func validate( - mustSeeGameIds: Set, - allGames: [Game], - startDate: Date, - endDate: Date - ) -> ValidationResult { - // If no must-see games, validation passes - guard !mustSeeGameIds.isEmpty else { - return .valid - } - - // Find all must-see games that fall outside the range - let gamesOutsideRange = allGames - .filter { mustSeeGameIds.contains($0.id) } - .filter { game in - game.dateTime < startDate || game.dateTime > endDate - } - .map { $0.id } - - if gamesOutsideRange.isEmpty { - return .valid - } else { - return .invalid(games: gamesOutsideRange) - } - } - - /// Validates games for Scenario B (Selected Games mode). - /// ALL selected games MUST be within the date range - no exceptions. - /// - /// - Parameters: - /// - request: The planning request containing preferences and games - /// - Returns: ValidationResult with explicit failure if any selected game is out of range - func validateForScenarioB(_ request: PlanningRequest) -> ValidationResult { - return validate( - mustSeeGameIds: request.preferences.mustSeeGameIds, - allGames: request.availableGames, - startDate: request.preferences.startDate, - endDate: request.preferences.endDate - ) - } - - /// Checks if there are any games available within the date range. - /// Used to determine if planning can proceed at all. - /// - /// - Parameters: - /// - games: All available games - /// - startDate: Start of the valid date range - /// - endDate: End of the valid date range - /// - sports: Sports to filter by - /// - Returns: True if at least one game exists in the range - func hasGamesInRange( - games: [Game], - startDate: Date, - endDate: Date, - sports: Set - ) -> Bool { - games.contains { game in - game.dateTime >= startDate && - game.dateTime <= endDate && - sports.contains(game.sport) - } - } - - /// Returns all games that fall within the specified date range. - /// - /// - Parameters: - /// - games: All available games - /// - startDate: Start of the valid date range - /// - endDate: End of the valid date range - /// - sports: Sports to filter by - /// - Returns: Array of games within the range - func gamesInRange( - games: [Game], - startDate: Date, - endDate: Date, - sports: Set - ) -> [Game] { - games.filter { game in - game.dateTime >= startDate && - game.dateTime <= endDate && - sports.contains(game.sport) - } - } -} diff --git a/SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift b/SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift deleted file mode 100644 index 5dea570..0000000 --- a/SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift +++ /dev/null @@ -1,200 +0,0 @@ -// -// DrivingFeasibilityValidator.swift -// SportsTime -// - -import Foundation - -/// Validates driving feasibility based on daily hour limits. -/// Priority 4 in the rule hierarchy. -/// -/// A route is valid ONLY IF: -/// - Daily driving time ≤ maxDailyDrivingHours for EVERY day -/// - Games are reachable between scheduled times -struct DrivingFeasibilityValidator { - - // MARK: - Validation Result - - struct ValidationResult { - let isValid: Bool - let violations: [ConstraintViolation] - let failedSegment: SegmentFailure? - - static let valid = ValidationResult(isValid: true, violations: [], failedSegment: nil) - - static func drivingExceeded( - segment: String, - requiredHours: Double, - limitHours: Double - ) -> ValidationResult { - let violation = ConstraintViolation( - type: .drivingTime, - description: "\(segment) requires \(String(format: "%.1f", requiredHours)) hours driving (limit: \(String(format: "%.1f", limitHours)) hours)", - severity: .error - ) - let failure = SegmentFailure( - segmentDescription: segment, - requiredHours: requiredHours, - limitHours: limitHours - ) - return ValidationResult(isValid: false, violations: [violation], failedSegment: failure) - } - - static func gameUnreachable( - gameId: UUID, - arrivalTime: Date, - gameTime: Date - ) -> ValidationResult { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - let violation = ConstraintViolation( - type: .gameReachability, - description: "Cannot arrive (\(formatter.string(from: arrivalTime))) before game starts (\(formatter.string(from: gameTime)))", - severity: .error - ) - return ValidationResult(isValid: false, violations: [violation], failedSegment: nil) - } - } - - struct SegmentFailure { - let segmentDescription: String - let requiredHours: Double - let limitHours: Double - } - - // MARK: - Properties - - let constraints: DrivingConstraints - - // MARK: - Initialization - - init(constraints: DrivingConstraints = .default) { - self.constraints = constraints - } - - init(from preferences: TripPreferences) { - self.constraints = DrivingConstraints(from: preferences) - } - - // MARK: - Validation - - /// Validates that a single travel segment is feasible within daily driving limits. - /// - /// - Parameters: - /// - drivingHours: Required driving hours for this segment - /// - origin: Description of the origin location - /// - destination: Description of the destination location - /// - Returns: ValidationResult indicating if the segment is feasible - func validateSegment( - drivingHours: Double, - origin: String, - destination: String - ) -> ValidationResult { - let maxDaily = constraints.maxDailyDrivingHours - - // A segment is valid if it can be completed in one day OR - // can be split across multiple days with overnight stops - if drivingHours <= maxDaily { - return .valid - } - - // Check if it can be reasonably split across multiple days - // We allow up to 2 driving days for a single segment - let maxTwoDayDriving = maxDaily * 2 - if drivingHours <= maxTwoDayDriving { - return .valid - } - - // Segment requires more than 2 days of driving - too long - return .drivingExceeded( - segment: "\(origin) → \(destination)", - requiredHours: drivingHours, - limitHours: maxDaily - ) - } - - /// Validates that a game can be reached in time given departure time and driving duration. - /// - /// - Parameters: - /// - gameId: ID of the game to reach - /// - gameTime: When the game starts - /// - departureTime: When we leave the previous stop - /// - drivingHours: Hours of driving required - /// - bufferHours: Buffer time needed before game (default 1 hour for parking, etc.) - /// - Returns: ValidationResult indicating if we can reach the game in time - func validateGameReachability( - gameId: UUID, - gameTime: Date, - departureTime: Date, - drivingHours: Double, - bufferHours: Double = 1.0 - ) -> ValidationResult { - // Calculate arrival time - let drivingSeconds = drivingHours * 3600 - let bufferSeconds = bufferHours * 3600 - let arrivalTime = departureTime.addingTimeInterval(drivingSeconds) - let requiredArrivalTime = gameTime.addingTimeInterval(-bufferSeconds) - - if arrivalTime <= requiredArrivalTime { - return .valid - } else { - return .gameUnreachable( - gameId: gameId, - arrivalTime: arrivalTime, - gameTime: gameTime - ) - } - } - - /// Validates an entire itinerary's travel segments for driving feasibility. - /// - /// - Parameter segments: Array of travel segments to validate - /// - Returns: ValidationResult with first failure found, or valid if all pass - func validateItinerary(segments: [TravelSegment]) -> ValidationResult { - for segment in segments { - let result = validateSegment( - drivingHours: segment.estimatedDrivingHours, - origin: segment.fromLocation.name, - destination: segment.toLocation.name - ) - if !result.isValid { - return result - } - } - return .valid - } - - /// Calculates how many travel days are needed for a given driving distance. - /// - /// - Parameter drivingHours: Total hours of driving required - /// - Returns: Number of calendar days the travel will span - func travelDaysRequired(for drivingHours: Double) -> Int { - let maxDaily = constraints.maxDailyDrivingHours - if drivingHours <= maxDaily { - return 1 - } - return Int(ceil(drivingHours / maxDaily)) - } - - /// Determines if an overnight stop is needed between two points. - /// - /// - Parameters: - /// - drivingHours: Hours of driving between points - /// - departureTime: When we plan to leave - /// - Returns: True if an overnight stop is recommended - func needsOvernightStop(drivingHours: Double, departureTime: Date) -> Bool { - // If driving exceeds daily limit, we need an overnight - if drivingHours > constraints.maxDailyDrivingHours { - return true - } - - // Also check if arrival would be unreasonably late - let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600) - let calendar = Calendar.current - let arrivalHour = calendar.component(.hour, from: arrivalTime) - - // Arriving after 11 PM suggests we should have stopped - return arrivalHour >= 23 - } -} diff --git a/SportsTime/Planning/Validators/GeographicSanityChecker.swift b/SportsTime/Planning/Validators/GeographicSanityChecker.swift deleted file mode 100644 index 14c309e..0000000 --- a/SportsTime/Planning/Validators/GeographicSanityChecker.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// GeographicSanityChecker.swift -// SportsTime -// - -import Foundation -import CoreLocation - -/// Validates geographic sanity of routes - no zig-zagging or excessive backtracking. -/// Priority 5 in the rule hierarchy. -/// -/// For Scenario C (directional routes with start+end): -/// - Route MUST make net progress toward the end location -/// - Temporary increases in distance are allowed only if minor and followed by progress -/// - Large backtracking or oscillation is prohibited -/// -/// For all scenarios: -/// - Detects obvious zig-zag patterns (e.g., Chicago → Dallas → San Diego → Minnesota → NY) -struct GeographicSanityChecker { - - // MARK: - Validation Result - - struct ValidationResult { - let isValid: Bool - let violations: [ConstraintViolation] - let backtrackingDetails: BacktrackingInfo? - - static let valid = ValidationResult(isValid: true, violations: [], backtrackingDetails: nil) - - static func backtracking(info: BacktrackingInfo) -> ValidationResult { - let violation = ConstraintViolation( - type: .geographicSanity, - description: info.description, - severity: .error - ) - return ValidationResult(isValid: false, violations: [violation], backtrackingDetails: info) - } - } - - struct BacktrackingInfo { - let fromCity: String - let toCity: String - let distanceIncreasePercent: Double - let description: String - - init(fromCity: String, toCity: String, distanceIncreasePercent: Double) { - self.fromCity = fromCity - self.toCity = toCity - self.distanceIncreasePercent = distanceIncreasePercent - self.description = "Route backtracks from \(fromCity) to \(toCity) (distance to destination increased by \(String(format: "%.0f", distanceIncreasePercent))%)" - } - } - - // MARK: - Configuration - - /// Maximum allowed distance increase before flagging as backtracking (percentage) - private let maxAllowedDistanceIncrease: Double = 0.15 // 15% - - /// Number of consecutive distance increases before flagging as zig-zag - private let maxConsecutiveIncreases: Int = 2 - - // MARK: - Scenario C: Directional Route Validation - - /// Validates that a route makes monotonic progress toward the end location. - /// This is the primary validation for Scenario C (start + end location). - /// - /// - Parameters: - /// - stops: Ordered array of stops in the route - /// - endCoordinate: The target destination coordinate - /// - Returns: ValidationResult indicating if route has valid directional progress - func validateDirectionalProgress( - stops: [ItineraryStop], - endCoordinate: CLLocationCoordinate2D - ) -> ValidationResult { - guard stops.count >= 2 else { - return .valid // Single stop or empty route is trivially valid - } - - var consecutiveIncreases = 0 - var previousDistance: CLLocationDistance? - var previousCity: String? - - for stop in stops { - guard let coordinate = stop.coordinate else { continue } - - let currentDistance = distance(from: coordinate, to: endCoordinate) - - if let prevDist = previousDistance, let prevCity = previousCity { - if currentDistance > prevDist { - // Distance to end increased - potential backtracking - let increasePercent = (currentDistance - prevDist) / prevDist - consecutiveIncreases += 1 - - // Check if this increase is too large - if increasePercent > maxAllowedDistanceIncrease { - return .backtracking(info: BacktrackingInfo( - fromCity: prevCity, - toCity: stop.city, - distanceIncreasePercent: increasePercent * 100 - )) - } - - // Check for oscillation (too many consecutive increases) - if consecutiveIncreases >= maxConsecutiveIncreases { - return .backtracking(info: BacktrackingInfo( - fromCity: prevCity, - toCity: stop.city, - distanceIncreasePercent: increasePercent * 100 - )) - } - } else { - // Making progress - reset counter - consecutiveIncreases = 0 - } - } - - previousDistance = currentDistance - previousCity = stop.city - } - - return .valid - } - - // MARK: - General Geographic Sanity - - /// Validates that a route doesn't have obvious zig-zag patterns. - /// Uses compass bearing analysis to detect direction reversals. - /// - /// - Parameter stops: Ordered array of stops in the route - /// - Returns: ValidationResult indicating if route is geographically sane - func validateNoZigZag(stops: [ItineraryStop]) -> ValidationResult { - guard stops.count >= 3 else { - return .valid // Need at least 3 stops to detect zig-zag - } - - var bearingReversals = 0 - var previousBearing: Double? - - for i in 0..<(stops.count - 1) { - guard let from = stops[i].coordinate, - let to = stops[i + 1].coordinate else { continue } - - let currentBearing = bearing(from: from, to: to) - - if let prevBearing = previousBearing { - // Check if we've reversed direction (>90 degree change) - let bearingChange = abs(normalizedBearingDifference(prevBearing, currentBearing)) - if bearingChange > 90 { - bearingReversals += 1 - } - } - - previousBearing = currentBearing - } - - // Allow at most one major direction change (e.g., going east then north is fine) - // But multiple reversals indicate zig-zagging - if bearingReversals > 1 { - return .backtracking(info: BacktrackingInfo( - fromCity: stops.first?.city ?? "Start", - toCity: stops.last?.city ?? "End", - distanceIncreasePercent: Double(bearingReversals) * 30 // Rough estimate - )) - } - - return .valid - } - - /// Validates a complete route for both directional progress (if end is specified) - /// and general geographic sanity. - /// - /// - Parameters: - /// - stops: Ordered array of stops - /// - endCoordinate: Optional end coordinate for directional validation - /// - Returns: Combined validation result - func validate( - stops: [ItineraryStop], - endCoordinate: CLLocationCoordinate2D? - ) -> ValidationResult { - // If we have an end coordinate, validate directional progress - if let end = endCoordinate { - let directionalResult = validateDirectionalProgress(stops: stops, endCoordinate: end) - if !directionalResult.isValid { - return directionalResult - } - } - - // Always check for zig-zag patterns - return validateNoZigZag(stops: stops) - } - - // MARK: - Helper Methods - - /// Calculates distance between two coordinates in meters. - private func distance( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D - ) -> CLLocationDistance { - let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude) - let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude) - return fromLocation.distance(from: toLocation) - } - - /// Calculates bearing (direction) from one coordinate to another in degrees. - private func bearing( - from: CLLocationCoordinate2D, - to: CLLocationCoordinate2D - ) -> Double { - let lat1 = from.latitude * .pi / 180 - let lat2 = to.latitude * .pi / 180 - let dLon = (to.longitude - from.longitude) * .pi / 180 - - let y = sin(dLon) * cos(lat2) - let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) - - var bearing = atan2(y, x) * 180 / .pi - bearing = (bearing + 360).truncatingRemainder(dividingBy: 360) - - return bearing - } - - /// Calculates the normalized difference between two bearings (-180 to 180). - private func normalizedBearingDifference(_ bearing1: Double, _ bearing2: Double) -> Double { - var diff = bearing2 - bearing1 - while diff > 180 { diff -= 360 } - while diff < -180 { diff += 360 } - return diff - } -} diff --git a/SportsTime/Planning/Validators/MustStopValidator.swift b/SportsTime/Planning/Validators/MustStopValidator.swift deleted file mode 100644 index ca4cde7..0000000 --- a/SportsTime/Planning/Validators/MustStopValidator.swift +++ /dev/null @@ -1,253 +0,0 @@ -// -// MustStopValidator.swift -// SportsTime -// - -import Foundation -import CoreLocation - -/// Validates that must-stop locations are reachable by the route. -/// Priority 6 in the rule hierarchy (lowest priority). -/// -/// A route "passes" a must-stop location if: -/// - Any travel segment comes within the proximity threshold (default 25 miles) -/// - The must-stop does NOT require a separate overnight stay -struct MustStopValidator { - - // MARK: - Validation Result - - struct ValidationResult { - let isValid: Bool - let violations: [ConstraintViolation] - let unreachableLocations: [String] - - static let valid = ValidationResult(isValid: true, violations: [], unreachableLocations: []) - - static func unreachable(locations: [String]) -> ValidationResult { - let violations = locations.map { location in - ConstraintViolation( - type: .mustStop, - description: "Required stop '\(location)' is not reachable within \(Int(MustStopConfig.defaultProximityMiles)) miles of any route segment", - severity: .error - ) - } - return ValidationResult(isValid: false, violations: violations, unreachableLocations: locations) - } - } - - // MARK: - Properties - - let config: MustStopConfig - - // MARK: - Initialization - - init(config: MustStopConfig = MustStopConfig()) { - self.config = config - } - - // MARK: - Validation - - /// Validates that all must-stop locations can be reached by the route. - /// - /// - Parameters: - /// - mustStopLocations: Array of locations that must be visited/passed - /// - stops: The planned stops in the itinerary - /// - segments: The travel segments between stops - /// - Returns: ValidationResult indicating if all must-stops are reachable - func validate( - mustStopLocations: [LocationInput], - stops: [ItineraryStop], - segments: [TravelSegment] - ) -> ValidationResult { - guard !mustStopLocations.isEmpty else { - return .valid - } - - var unreachable: [String] = [] - - for mustStop in mustStopLocations { - if !isReachable(mustStop: mustStop, stops: stops, segments: segments) { - unreachable.append(mustStop.name) - } - } - - if unreachable.isEmpty { - return .valid - } else { - return .unreachable(locations: unreachable) - } - } - - /// Validates must-stop locations from a planning request. - /// - /// - Parameters: - /// - request: The planning request with must-stop preferences - /// - stops: The planned stops - /// - segments: The travel segments - /// - Returns: ValidationResult - func validate( - request: PlanningRequest, - stops: [ItineraryStop], - segments: [TravelSegment] - ) -> ValidationResult { - return validate( - mustStopLocations: request.preferences.mustStopLocations, - stops: stops, - segments: segments - ) - } - - // MARK: - Reachability Check - - /// Checks if a must-stop location is reachable by the route. - /// A location is reachable if: - /// 1. It's within proximity of any stop, OR - /// 2. It's within proximity of any travel segment path - /// - /// - Parameters: - /// - mustStop: The location to check - /// - stops: Planned stops - /// - segments: Travel segments - /// - Returns: True if the location is reachable - private func isReachable( - mustStop: LocationInput, - stops: [ItineraryStop], - segments: [TravelSegment] - ) -> Bool { - guard let mustStopCoord = mustStop.coordinate else { - // If we don't have coordinates, we can't validate - assume reachable - return true - } - - // Check if any stop is within proximity - for stop in stops { - if let stopCoord = stop.coordinate { - let distance = distanceInMiles(from: mustStopCoord, to: stopCoord) - if distance <= config.proximityMiles { - return true - } - } - } - - // Check if any segment passes within proximity - for segment in segments { - if isNearSegment(point: mustStopCoord, segment: segment) { - return true - } - } - - return false - } - - /// Checks if a point is near a travel segment. - /// Uses perpendicular distance to the segment line. - /// - /// - Parameters: - /// - point: The point to check - /// - segment: The travel segment - /// - Returns: True if within proximity - private func isNearSegment( - point: CLLocationCoordinate2D, - segment: TravelSegment - ) -> Bool { - guard let originCoord = segment.fromLocation.coordinate, - let destCoord = segment.toLocation.coordinate else { - return false - } - - // Calculate perpendicular distance from point to segment - let distance = perpendicularDistance( - point: point, - lineStart: originCoord, - lineEnd: destCoord - ) - - return distance <= config.proximityMiles - } - - /// Calculates the minimum distance from a point to a line segment in miles. - /// Uses the perpendicular distance if the projection falls on the segment, - /// otherwise uses the distance to the nearest endpoint. - private func perpendicularDistance( - point: CLLocationCoordinate2D, - lineStart: CLLocationCoordinate2D, - lineEnd: CLLocationCoordinate2D - ) -> Double { - let pointLoc = CLLocation(latitude: point.latitude, longitude: point.longitude) - let startLoc = CLLocation(latitude: lineStart.latitude, longitude: lineStart.longitude) - let endLoc = CLLocation(latitude: lineEnd.latitude, longitude: lineEnd.longitude) - - let lineLength = startLoc.distance(from: endLoc) - - // Handle degenerate case where start == end - if lineLength < 1 { - return pointLoc.distance(from: startLoc) / 1609.34 // meters to miles - } - - // Calculate projection parameter t - // t = ((P - A) · (B - A)) / |B - A|² - let dx = endLoc.coordinate.longitude - startLoc.coordinate.longitude - let dy = endLoc.coordinate.latitude - startLoc.coordinate.latitude - let px = point.longitude - lineStart.longitude - let py = point.latitude - lineStart.latitude - - let t = max(0, min(1, (px * dx + py * dy) / (dx * dx + dy * dy))) - - // Calculate closest point on segment - let closestLat = lineStart.latitude + t * dy - let closestLon = lineStart.longitude + t * dx - let closestLoc = CLLocation(latitude: closestLat, longitude: closestLon) - - // Return distance in miles - return pointLoc.distance(from: closestLoc) / 1609.34 - } - - /// Calculates distance between two coordinates in miles. - private func distanceInMiles( - 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) / 1609.34 // meters to miles - } - - // MARK: - Route Modification - - /// Finds the best position to insert a must-stop location into an itinerary. - /// Used when we need to add an explicit stop for a must-stop location. - /// - /// - Parameters: - /// - mustStop: The location to insert - /// - stops: Current stops in order - /// - Returns: The index where the stop should be inserted (1-based, between existing stops) - func bestInsertionIndex( - for mustStop: LocationInput, - in stops: [ItineraryStop] - ) -> Int { - guard let mustStopCoord = mustStop.coordinate, stops.count >= 2 else { - return 1 // Insert after first stop - } - - var bestIndex = 1 - var minDetour = Double.greatestFiniteMagnitude - - for i in 0..<(stops.count - 1) { - guard let fromCoord = stops[i].coordinate, - let toCoord = stops[i + 1].coordinate else { continue } - - // Calculate detour: (from→mustStop + mustStop→to) - (from→to) - let direct = distanceInMiles(from: fromCoord, to: toCoord) - let via = distanceInMiles(from: fromCoord, to: mustStopCoord) + - distanceInMiles(from: mustStopCoord, to: toCoord) - let detour = via - direct - - if detour < minDetour { - minDetour = detour - bestIndex = i + 1 - } - } - - return bestIndex - } -}