// // TravelEstimator.swift // SportsTime // // Shared travel estimation logic used by all scenario planners. // Estimating travel from A to B is the same regardless of planning scenario. // import Foundation import CoreLocation /// Travel estimation utilities for calculating distances and driving times. /// /// Uses Haversine formula for coordinate-based distance with a road routing factor, /// or fallback distances when coordinates are unavailable. /// /// - Constants: /// - averageSpeedMph: 60 mph /// - roadRoutingFactor: 1.3 (accounts for roads vs straight-line distance) /// /// - Invariants: /// - All distance calculations are symmetric: distance(A,B) == distance(B,A) /// - Road distance >= straight-line distance (roadRoutingFactor >= 1.0) /// - Travel duration is always distance / averageSpeedMph /// - Segments exceeding 5x maxDailyDrivingHours return nil (unreachable) /// - Missing coordinates → returns nil (no guessing with fallback distances) enum TravelEstimator { // MARK: - Constants private static let averageSpeedMph: Double = 60.0 private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance // MARK: - Travel Estimation /// Estimates a travel segment between two ItineraryStops. /// /// - Parameters: /// - from: Origin stop /// - to: Destination stop /// - constraints: Driving constraints (drivers, hours per day) /// - Returns: TravelSegment or nil if unreachable /// /// - Expected Behavior: /// - With valid coordinates → calculates distance using Haversine * roadRoutingFactor /// - Missing coordinates → returns nil (no fallback guessing) /// - Same city (no coords) → 0 distance, 0 duration /// - Driving hours > 5x maxDailyDrivingHours → returns nil /// - Duration = distance / 60 mph /// - Result distance in meters, duration in seconds static func estimate( from: ItineraryStop, to: ItineraryStop, constraints: DrivingConstraints ) -> TravelSegment? { // If either stop is missing coordinates, the segment is infeasible // (unless same city, which returns 0 distance) guard let distanceMiles = calculateDistanceMiles(from: from, to: to) else { // Same city with no coords: zero-distance segment if from.city == to.city { return TravelSegment( fromLocation: from.location, toLocation: to.location, travelMode: .drive, distanceMeters: 0, durationSeconds: 0 ) } return nil } let drivingHours = distanceMiles / averageSpeedMph // Maximum allowed: 5 days of driving as a conservative hard cap. // This allows multi-day cross-country segments like Chicago → Anaheim. let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil } return TravelSegment( fromLocation: from.location, toLocation: to.location, travelMode: .drive, distanceMeters: distanceMiles * 1609.34, durationSeconds: drivingHours * 3600 ) } /// Estimates a travel segment between two LocationInputs. /// /// - Parameters: /// - from: Origin location /// - to: Destination location /// - constraints: Driving constraints /// - Returns: TravelSegment or nil if unreachable/invalid /// /// - Expected Behavior: /// - Missing from.coordinate → returns nil /// - Missing to.coordinate → returns nil /// - Valid coordinates → calculates distance using Haversine * roadRoutingFactor /// - Driving hours > 5x maxDailyDrivingHours → returns nil /// - Duration = distance / 60 mph static func estimate( from: LocationInput, to: LocationInput, constraints: DrivingConstraints ) -> TravelSegment? { guard let fromCoord = from.coordinate, let toCoord = to.coordinate else { return nil } let distanceMeters = haversineDistanceMeters(from: fromCoord, to: toCoord) let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor let drivingHours = distanceMiles / averageSpeedMph // Maximum allowed: 5 days of driving as a conservative hard cap. // This allows multi-day cross-country segments like Chicago → Anaheim. let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil } return TravelSegment( fromLocation: from, toLocation: to, travelMode: .drive, distanceMeters: distanceMeters * roadRoutingFactor, durationSeconds: drivingHours * 3600 ) } // MARK: - Distance Calculations /// Calculates road distance in miles between two ItineraryStops. /// /// - Parameters: /// - from: Origin stop /// - to: Destination stop /// - Returns: Distance in miles, or nil if coordinates are missing /// /// - Expected Behavior: /// - Both have coordinates → Haversine distance * 1.3 /// - Either missing coordinates → nil (no fallback guessing) static func calculateDistanceMiles( from: ItineraryStop, to: ItineraryStop ) -> Double? { guard let fromCoord = from.coordinate, let toCoord = to.coordinate else { return nil } return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor } /// Calculates straight-line distance in miles using Haversine formula. /// /// - Parameters: /// - from: Origin coordinate /// - to: Destination coordinate /// - Returns: Straight-line distance in miles /// /// - Expected Behavior: /// - Same point → 0 miles /// - NYC to Boston → ~190 miles (validates formula accuracy) /// - Symmetric: distance(A,B) == distance(B,A) /// - Uses Earth radius of 3958.8 miles static func haversineDistanceMiles( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D ) -> Double { let earthRadiusMiles = 3958.8 let lat1 = from.latitude * .pi / 180 let lat2 = to.latitude * .pi / 180 let deltaLat = (to.latitude - from.latitude) * .pi / 180 let deltaLon = (to.longitude - from.longitude) * .pi / 180 let a = sin(deltaLat / 2) * sin(deltaLat / 2) + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return earthRadiusMiles * c } /// Calculates straight-line distance in meters using Haversine formula. /// /// - Parameters: /// - from: Origin coordinate /// - to: Destination coordinate /// - Returns: Straight-line distance in meters /// /// - Expected Behavior: /// - Same point → 0 meters /// - Symmetric: distance(A,B) == distance(B,A) /// - Uses Earth radius of 6,371,000 meters /// - haversineDistanceMeters / 1609.34 ≈ haversineDistanceMiles static func haversineDistanceMeters( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D ) -> Double { let earthRadiusMeters = 6371000.0 let lat1 = from.latitude * .pi / 180 let lat2 = to.latitude * .pi / 180 let deltaLat = (to.latitude - from.latitude) * .pi / 180 let deltaLon = (to.longitude - from.longitude) * .pi / 180 let a = sin(deltaLat / 2) * sin(deltaLat / 2) + cos(lat1) * cos(lat2) * sin(deltaLon / 2) * sin(deltaLon / 2) let c = 2 * atan2(sqrt(a), sqrt(1 - a)) return earthRadiusMeters * c } // MARK: - Overnight Stop Detection /// Determines if a travel segment requires an overnight stop. /// /// - Parameters: /// - segment: The travel segment to evaluate /// - constraints: Driving constraints (max daily hours) /// - Returns: true if driving hours exceed the daily limit static func requiresOvernightStop( segment: TravelSegment, constraints: DrivingConstraints ) -> Bool { segment.estimatedDrivingHours > constraints.maxDailyDrivingHours } // MARK: - Travel Days /// Calculates which calendar days a driving segment spans. /// /// - Parameters: /// - departure: Departure date/time /// - drivingHours: Total driving hours /// - drivingConstraints: Optional driving constraints to determine max daily hours (defaults to 8.0) /// - Returns: Array of calendar days (start of day) that travel spans /// /// - Expected Behavior: /// - 0 hours → [departure day] /// - 1-maxDaily hours → [departure day] (1 day) /// - maxDaily+0.01 to 2*maxDaily hours → [departure day, next day] (2 days) /// - All dates are normalized to start of day (midnight) /// - Uses maxDailyDrivingHours from constraints when provided static func calculateTravelDays( departure: Date, drivingHours: Double, drivingConstraints: DrivingConstraints? = nil ) -> [Date] { var days: [Date] = [] let calendar = Calendar.current let startDay = calendar.startOfDay(for: departure) days.append(startDay) // Use max daily hours from constraints, defaulting to 8.0 let maxDailyHours = drivingConstraints?.maxDailyDrivingHours ?? 8.0 let daysOfDriving = max(1, Int(ceil(drivingHours / maxDailyHours))) for dayOffset in 1..