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>
267 lines
9.2 KiB
Swift
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
|
|
}
|
|
|
|
}
|