Files
Sportstime/SportsTime/Planning/Engine/TravelEstimator.swift
Trey T 6cbcef47ae Add implementation code for all 4 improvement plan phases
Production changes:
- TravelEstimator: remove 300mi fallback, return nil on missing coords
- TripPlanningEngine: add warnings array, empty sports warning, inverted
  date range rejection, must-stop filter, segment validation gate
- GameDAGRouter: add routePreference parameter with preference-aware
  bucket ordering and sorting in selectDiverseRoutes()
- ScenarioA-E: pass routePreference through to GameDAGRouter
- ScenarioA: track games with missing stadium data
- ScenarioE: add region filtering for home games
- TravelSegment: add requiresOvernightStop and travelDays() helpers

Test changes:
- GameDAGRouterTests: +252 lines for route preference verification
- TripPlanningEngineTests: +153 lines for segment validation, date range,
  empty sports
- ScenarioEPlannerTests: +119 lines for region filter tests
- TravelEstimatorTests: remove obsolete fallback distance tests
- ItineraryBuilderTests: update nil-coords test expectation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 09:40:32 -05:00

275 lines
9.8 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)
///
/// - 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..<daysOfDriving {
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
days.append(nextDay)
}
}
return days
}
}