Files
Sportstime/SportsTime/Planning/Engine/TravelEstimator.swift
Trey t ab89c25f2f Refactor trip planning: DAG router + trip options UI + simplified itinerary
- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search
- Add geographic diversity to route selection (returns routes from distinct regions)
- Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes
- Simplify itinerary display: separate games and travel segments by date
- Remove complex ItineraryDay bundling, query games/travel directly per day
- Update ScenarioA/B/C planners to use GameDAGRouter
- Add new test suites for planners and travel estimator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:26:17 -06:00

181 lines
5.7 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
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 stops.
/// Returns nil only if the segment exceeds maximum driving time.
static func estimate(
from: ItineraryStop,
to: ItineraryStop,
constraints: DrivingConstraints
) -> TravelSegment? {
let distanceMiles = calculateDistanceMiles(from: from, to: to)
let drivingHours = distanceMiles / averageSpeedMph
// Reject if segment requires more than 2 days of driving
let maxDailyHours = constraints.maxDailyDrivingHours
if drivingHours > maxDailyHours * 2 {
return nil
}
// Calculate times (assume 8 AM departure)
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from.location,
toLocation: to.location,
travelMode: .drive,
distanceMeters: distanceMiles * 1609.34,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
)
}
/// Estimates a travel segment between two LocationInputs.
/// Returns nil if coordinates are missing or segment exceeds max driving time.
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
// Reject if > 2 days of driving
if drivingHours > constraints.maxDailyDrivingHours * 2 {
return nil
}
let departureTime = Date()
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from,
toLocation: to,
travelMode: .drive,
distanceMeters: distanceMeters * roadRoutingFactor,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
)
}
// MARK: - Distance Calculations
/// Calculates distance in miles between two stops.
/// Uses Haversine formula if coordinates available, fallback otherwise.
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 distance in miles between two coordinates using Haversine.
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 distance in meters between two coordinates using Haversine.
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.
static func estimateFallbackDistance(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if from.city == to.city {
return 0
}
return fallbackDistanceMiles
}
// MARK: - Travel Days
/// Calculates which calendar days travel spans.
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
}
}