Files
Sportstime/SportsTime/Planning/Engine/TravelEstimator.swift
Trey t 9736773475 feat: improve planning engine travel handling, itinerary reordering, and scenario planners
Add TravelInfo initializers and city normalization helpers to fix repeat
city-pair disambiguation. Improve drag-and-drop reordering with segment
index tracking and source-row-aware zone calculation. Enhance all five
scenario planners with better next-day departure handling and travel
segment placement. Add comprehensive tests across all planners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:55:23 -06:00

267 lines
9.2 KiB
Swift

//
// 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)
/// - fallbackDistanceMiles: 300 miles (when coordinates unavailable)
///
/// - 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)
enum TravelEstimator {
// MARK: - Constants
private static let averageSpeedMph: Double = 60.0
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
private static let fallbackDistanceMiles: Double = 300.0
// 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 uses fallback distance (300 miles)
/// - 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? {
let distanceMiles = calculateDistanceMiles(from: from, to: to)
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
///
/// - Expected Behavior:
/// - Both have coordinates Haversine distance * 1.3
/// - Either missing coordinates fallback distance
/// - Same city (no coords) 0 miles
/// - Different cities (no coords) 300 miles
static func calculateDistanceMiles(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if let fromCoord = from.coordinate,
let toCoord = to.coordinate {
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
}
return estimateFallbackDistance(from: from, to: to)
}
/// 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
}
/// Fallback distance when coordinates aren't available.
///
/// - Parameters:
/// - from: Origin stop
/// - to: Destination stop
/// - Returns: Estimated distance in miles
///
/// - Expected Behavior:
/// - Same city 0 miles
/// - Different cities 300 miles (fallback constant)
static func estimateFallbackDistance(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if from.city == to.city {
return 0
}
return fallbackDistanceMiles
}
// MARK: - Travel Days
/// Calculates which calendar days a driving segment spans.
///
/// - Parameters:
/// - departure: Departure date/time
/// - drivingHours: Total driving hours
/// - Returns: Array of calendar days (start of day) that travel spans
///
/// - Expected Behavior:
/// - 0 hours [departure day]
/// - 1-8 hours [departure day] (1 day)
/// - 8.01-16 hours [departure day, next day] (2 days)
/// - 16.01-24 hours [departure day, +1, +2] (3 days)
/// - All dates are normalized to start of day (midnight)
/// - Assumes 8 driving hours per day max
static func calculateTravelDays(
departure: Date,
drivingHours: Double
) -> [Date] {
var days: [Date] = []
let calendar = Calendar.current
let startDay = calendar.startOfDay(for: departure)
days.append(startDay)
// Add days if driving takes multiple days (8 hrs/day max)
let daysOfDriving = max(1, Int(ceil(drivingHours / 8.0)))
for dayOffset in 1..<daysOfDriving {
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
days.append(nextDay)
}
}
return days
}
}