Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
278
SportsTime/Planning/Engine/GeographicRouteExplorer.swift
Normal file
278
SportsTime/Planning/Engine/GeographicRouteExplorer.swift
Normal file
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// GeographicRouteExplorer.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Shared logic for finding geographically sensible route variations.
|
||||
// Used by all scenario planners to explore and prune route combinations.
|
||||
//
|
||||
// Key Features:
|
||||
// - Tree exploration with pruning for route combinations
|
||||
// - Geographic sanity check using bounding box diagonal vs actual travel ratio
|
||||
// - Support for "anchor" games that cannot be removed from routes (Scenario B)
|
||||
//
|
||||
// Algorithm Overview:
|
||||
// Given games [A, B, C, D, E] in chronological order, we build a decision tree
|
||||
// where at each node we can either include or skip a game. Routes that would
|
||||
// create excessive zig-zagging are pruned. When anchors are specified, any
|
||||
// route that doesn't include ALL anchors is automatically discarded.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
enum GeographicRouteExplorer {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Maximum ratio of actual travel to bounding box diagonal.
|
||||
/// Routes exceeding this are considered zig-zags.
|
||||
/// - 1.0x = perfectly linear route
|
||||
/// - 1.5x = some detours, normal
|
||||
/// - 2.0x = significant detours, borderline
|
||||
/// - 2.5x+ = excessive zig-zag, reject
|
||||
private static let maxZigZagRatio = 2.5
|
||||
|
||||
/// Minimum bounding box diagonal (miles) to apply zig-zag check.
|
||||
/// Routes within a small area are always considered sane.
|
||||
private static let minDiagonalForCheck = 100.0
|
||||
|
||||
/// Maximum number of route options to return.
|
||||
private static let maxOptions = 10
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Finds ALL geographically sensible subsets of games.
|
||||
///
|
||||
/// The problem: Games in a date range might be scattered across the country.
|
||||
/// Visiting all of them in chronological order could mean crazy zig-zags.
|
||||
///
|
||||
/// The solution: Explore all possible subsets, keeping those that pass
|
||||
/// geographic sanity. Return multiple options for the user to choose from.
|
||||
///
|
||||
/// Algorithm (tree exploration with pruning):
|
||||
///
|
||||
/// Input: [NY, TX, SC, DEN, NM, CA] (chronological order)
|
||||
///
|
||||
/// Build a decision tree:
|
||||
/// [NY]
|
||||
/// / \
|
||||
/// +TX / \ skip TX
|
||||
/// / \
|
||||
/// [NY,TX] [NY]
|
||||
/// / \ / \
|
||||
/// +SC / \ +SC / \
|
||||
/// ✗ | | |
|
||||
/// (prune) +DEN [NY,SC] ...
|
||||
///
|
||||
/// Each path that reaches the end = one valid option
|
||||
/// Pruning: If adding a game breaks sanity, don't explore that branch
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - games: All games to consider, should be in chronological order
|
||||
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
|
||||
/// - anchorGameIds: Game IDs that MUST be included in every route (for Scenario B)
|
||||
/// - stopBuilder: Closure that converts games to ItineraryStops
|
||||
///
|
||||
/// - Returns: Array of valid game combinations, sorted by number of games (most first)
|
||||
///
|
||||
static func findAllSensibleRoutes(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
anchorGameIds: Set<UUID> = [],
|
||||
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
|
||||
) -> [[Game]] {
|
||||
|
||||
// 0-2 games = always sensible, only one option
|
||||
// But still verify anchors are present
|
||||
guard games.count > 2 else {
|
||||
// Verify all anchors are in the game list
|
||||
let gameIds = Set(games.map { $0.id })
|
||||
if anchorGameIds.isSubset(of: gameIds) {
|
||||
return games.isEmpty ? [] : [games]
|
||||
} else {
|
||||
// Missing anchors - no valid routes
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// First, check if all games already form a sensible route
|
||||
let allStops = stopBuilder(games, stadiums)
|
||||
if isGeographicallySane(stops: allStops) {
|
||||
print("[GeographicExplorer] All \(games.count) games form a sensible route")
|
||||
return [games]
|
||||
}
|
||||
|
||||
print("[GeographicExplorer] Exploring route variations (anchors: \(anchorGameIds.count))...")
|
||||
|
||||
// Explore all valid subsets using recursive tree traversal
|
||||
var validRoutes: [[Game]] = []
|
||||
exploreRoutes(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: stopBuilder,
|
||||
currentRoute: [],
|
||||
index: 0,
|
||||
validRoutes: &validRoutes
|
||||
)
|
||||
|
||||
// Filter routes that don't contain all anchors
|
||||
let routesWithAnchors = validRoutes.filter { route in
|
||||
let routeGameIds = Set(route.map { $0.id })
|
||||
return anchorGameIds.isSubset(of: routeGameIds)
|
||||
}
|
||||
|
||||
// Sort by number of games (most games first = best options)
|
||||
let sorted = routesWithAnchors.sorted { $0.count > $1.count }
|
||||
|
||||
// Limit to top options to avoid overwhelming the user
|
||||
let topRoutes = Array(sorted.prefix(maxOptions))
|
||||
|
||||
print("[GeographicExplorer] Found \(routesWithAnchors.count) valid routes with anchors, returning top \(topRoutes.count)")
|
||||
return topRoutes
|
||||
}
|
||||
|
||||
// MARK: - Geographic Sanity Check
|
||||
|
||||
/// Determines if a route is geographically sensible or zig-zags excessively.
|
||||
///
|
||||
/// The goal: Reject routes that oscillate back and forth across large distances.
|
||||
/// We want routes that make generally linear progress, not cross-country ping-pong.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Calculate the "bounding box" of all stops (geographic spread)
|
||||
/// 2. Calculate total travel distance if we visit stops in order
|
||||
/// 3. Compare actual travel to the bounding box diagonal
|
||||
/// 4. If actual travel is WAY more than the diagonal, it's zig-zagging
|
||||
///
|
||||
/// Example VALID:
|
||||
/// Stops: LA, SF, Portland, Seattle
|
||||
/// Bounding box diagonal: ~1,100 miles
|
||||
/// Actual travel: ~1,200 miles (reasonable, mostly linear)
|
||||
/// Ratio: 1.1x → PASS
|
||||
///
|
||||
/// Example INVALID:
|
||||
/// Stops: NY, TX, SC, CA (zig-zag)
|
||||
/// Bounding box diagonal: ~2,500 miles
|
||||
/// Actual travel: ~6,000 miles (back and forth)
|
||||
/// Ratio: 2.4x → FAIL
|
||||
///
|
||||
static func isGeographicallySane(stops: [ItineraryStop]) -> Bool {
|
||||
|
||||
// Single stop or two stops = always valid (no zig-zag possible)
|
||||
guard stops.count > 2 else { return true }
|
||||
|
||||
// Collect all coordinates
|
||||
let coordinates = stops.compactMap { $0.coordinate }
|
||||
guard coordinates.count == stops.count else {
|
||||
// Missing coordinates - can't validate, assume valid
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate bounding box
|
||||
let lats = coordinates.map { $0.latitude }
|
||||
let lons = coordinates.map { $0.longitude }
|
||||
|
||||
guard let minLat = lats.min(), let maxLat = lats.max(),
|
||||
let minLon = lons.min(), let maxLon = lons.max() else {
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate bounding box diagonal distance
|
||||
let corner1 = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
|
||||
let corner2 = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
|
||||
let diagonalMiles = TravelEstimator.haversineDistanceMiles(from: corner1, to: corner2)
|
||||
|
||||
// Tiny bounding box = all games are close together = always valid
|
||||
if diagonalMiles < minDiagonalForCheck {
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate actual travel distance through all stops in order
|
||||
var actualTravelMiles: Double = 0
|
||||
for i in 0..<(stops.count - 1) {
|
||||
let from = stops[i]
|
||||
let to = stops[i + 1]
|
||||
actualTravelMiles += TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
||||
}
|
||||
|
||||
// Compare: is actual travel reasonable compared to the geographic spread?
|
||||
let ratio = actualTravelMiles / diagonalMiles
|
||||
|
||||
if ratio > maxZigZagRatio {
|
||||
print("[GeographicExplorer] Sanity FAILED: travel=\(Int(actualTravelMiles))mi, diagonal=\(Int(diagonalMiles))mi, ratio=\(String(format: "%.1f", ratio))x")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Recursive helper to explore all valid route combinations.
|
||||
///
|
||||
/// At each game, we have two choices:
|
||||
/// 1. Include the game (if it doesn't break sanity)
|
||||
/// 2. Skip the game (only if it's not an anchor)
|
||||
///
|
||||
/// We explore BOTH branches when possible, building up all valid combinations.
|
||||
/// Anchor games MUST be included - we cannot skip them.
|
||||
///
|
||||
private static func exploreRoutes(
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
anchorGameIds: Set<UUID>,
|
||||
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop],
|
||||
currentRoute: [Game],
|
||||
index: Int,
|
||||
validRoutes: inout [[Game]]
|
||||
) {
|
||||
// Base case: we've processed all games
|
||||
if index >= games.count {
|
||||
// Only save routes with at least 1 game
|
||||
if !currentRoute.isEmpty {
|
||||
validRoutes.append(currentRoute)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let game = games[index]
|
||||
let isAnchor = anchorGameIds.contains(game.id)
|
||||
|
||||
// Option 1: Try INCLUDING this game
|
||||
var routeWithGame = currentRoute
|
||||
routeWithGame.append(game)
|
||||
let stopsWithGame = stopBuilder(routeWithGame, stadiums)
|
||||
|
||||
if isGeographicallySane(stops: stopsWithGame) {
|
||||
// This branch is valid, continue exploring
|
||||
exploreRoutes(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: stopBuilder,
|
||||
currentRoute: routeWithGame,
|
||||
index: index + 1,
|
||||
validRoutes: &validRoutes
|
||||
)
|
||||
} else if isAnchor {
|
||||
// Anchor game breaks sanity - this entire branch is invalid
|
||||
// Don't explore further, don't add to valid routes
|
||||
// (We can't skip an anchor, and including it breaks sanity)
|
||||
return
|
||||
}
|
||||
|
||||
// Option 2: Try SKIPPING this game (only if it's not an anchor)
|
||||
if !isAnchor {
|
||||
exploreRoutes(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: stopBuilder,
|
||||
currentRoute: currentRoute,
|
||||
index: index + 1,
|
||||
validRoutes: &validRoutes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
SportsTime/Planning/Engine/ItineraryBuilder.swift
Normal file
134
SportsTime/Planning/Engine/ItineraryBuilder.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// ItineraryBuilder.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Shared utility for building itineraries with travel segments.
|
||||
// Used by all scenario planners to convert stops into complete itineraries.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Result of building an itinerary from stops.
|
||||
struct BuiltItinerary {
|
||||
let stops: [ItineraryStop]
|
||||
let travelSegments: [TravelSegment]
|
||||
let totalDrivingHours: Double
|
||||
let totalDistanceMiles: Double
|
||||
}
|
||||
|
||||
/// Shared logic for building itineraries across all scenario planners.
|
||||
enum ItineraryBuilder {
|
||||
|
||||
/// Validation that can be performed on each travel segment.
|
||||
/// Return `true` if the segment is valid, `false` to reject the itinerary.
|
||||
typealias SegmentValidator = (TravelSegment, _ fromStop: ItineraryStop, _ toStop: ItineraryStop) -> Bool
|
||||
|
||||
/// Builds a complete itinerary with travel segments between consecutive stops.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Handle edge case: single stop = no travel needed
|
||||
/// 2. For each consecutive pair of stops, estimate travel
|
||||
/// 3. Optionally validate each segment with custom validator
|
||||
/// 4. Accumulate driving hours and distance
|
||||
/// 5. Verify invariant: travelSegments.count == stops.count - 1
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stops: The stops to connect with travel segments
|
||||
/// - constraints: Driving constraints (drivers, max hours per day)
|
||||
/// - logPrefix: Prefix for log messages (e.g., "[ScenarioA]")
|
||||
/// - segmentValidator: Optional validation for each segment
|
||||
///
|
||||
/// - Returns: Built itinerary if successful, nil if any segment fails
|
||||
///
|
||||
static func build(
|
||||
stops: [ItineraryStop],
|
||||
constraints: DrivingConstraints,
|
||||
logPrefix: String = "[ItineraryBuilder]",
|
||||
segmentValidator: SegmentValidator? = nil
|
||||
) -> BuiltItinerary? {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Edge case: Single stop or empty = no travel needed
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if stops.count <= 1 {
|
||||
return BuiltItinerary(
|
||||
stops: stops,
|
||||
travelSegments: [],
|
||||
totalDrivingHours: 0,
|
||||
totalDistanceMiles: 0
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Build travel segments between consecutive stops
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
var travelSegments: [TravelSegment] = []
|
||||
var totalDrivingHours: Double = 0
|
||||
var totalDistance: Double = 0
|
||||
|
||||
for index in 0..<(stops.count - 1) {
|
||||
let fromStop = stops[index]
|
||||
let toStop = stops[index + 1]
|
||||
|
||||
// Estimate travel for this segment
|
||||
guard let segment = TravelEstimator.estimate(
|
||||
from: fromStop,
|
||||
to: toStop,
|
||||
constraints: constraints
|
||||
) else {
|
||||
print("\(logPrefix) Failed to estimate travel: \(fromStop.city) -> \(toStop.city)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run optional validator (e.g., arrival time check for Scenario B)
|
||||
if let validator = segmentValidator {
|
||||
if !validator(segment, fromStop, toStop) {
|
||||
print("\(logPrefix) Segment validation failed: \(fromStop.city) -> \(toStop.city)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
travelSegments.append(segment)
|
||||
totalDrivingHours += segment.estimatedDrivingHours
|
||||
totalDistance += segment.estimatedDistanceMiles
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Verify invariant: segments = stops - 1
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
guard travelSegments.count == stops.count - 1 else {
|
||||
print("\(logPrefix) Invariant violated: \(travelSegments.count) segments for \(stops.count) stops")
|
||||
return nil
|
||||
}
|
||||
|
||||
return BuiltItinerary(
|
||||
stops: stops,
|
||||
travelSegments: travelSegments,
|
||||
totalDrivingHours: totalDrivingHours,
|
||||
totalDistanceMiles: totalDistance
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Common Validators
|
||||
|
||||
/// Validator that ensures arrival time is before game start (with buffer).
|
||||
/// Used by Scenario B where selected games have fixed start times.
|
||||
///
|
||||
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
|
||||
/// - Returns: Validator closure
|
||||
///
|
||||
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
|
||||
return { segment, _, toStop in
|
||||
guard let gameStart = toStop.firstGameStart else {
|
||||
return true // No game = no constraint
|
||||
}
|
||||
|
||||
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
|
||||
if segment.arrivalTime > deadline {
|
||||
print("[ItineraryBuilder] Cannot arrive in time: arrival \(segment.arrivalTime) > deadline \(deadline)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
232
SportsTime/Planning/Engine/RouteCandidateBuilder.swift
Normal file
232
SportsTime/Planning/Engine/RouteCandidateBuilder.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
//
|
||||
// RouteCandidateBuilder.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Builds route candidates for different planning scenarios
|
||||
enum RouteCandidateBuilder {
|
||||
|
||||
// MARK: - Scenario A: Linear Candidates (Date Range)
|
||||
|
||||
/// Builds linear route candidates from games sorted chronologically
|
||||
/// - Parameters:
|
||||
/// - games: Available games sorted by start time
|
||||
/// - mustStop: Optional must-stop location
|
||||
/// - Returns: Array of route candidates
|
||||
static func buildLinearCandidates(
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
mustStop: LocationInput?
|
||||
) -> [RouteCandidate] {
|
||||
guard !games.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Group games by stadium
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
}
|
||||
|
||||
// Build stops from chronological game order
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = []
|
||||
|
||||
for game in games {
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
let gamesAtStop = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStop.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Look up stadium for coordinates and city info
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
guard !stops.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return [RouteCandidate(
|
||||
stops: stops,
|
||||
rationale: "Linear route through \(stops.count) cities"
|
||||
)]
|
||||
}
|
||||
|
||||
// MARK: - Scenario B: Expand Around Anchors (Selected Games)
|
||||
|
||||
/// Expands route around user-selected anchor games
|
||||
/// - Parameters:
|
||||
/// - anchors: User-selected games (must-see)
|
||||
/// - allGames: All available games
|
||||
/// - dateRange: Trip date range
|
||||
/// - mustStop: Optional must-stop location
|
||||
/// - Returns: Array of route candidates
|
||||
static func expandAroundAnchors(
|
||||
anchors: [Game],
|
||||
allGames: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
dateRange: DateInterval,
|
||||
mustStop: LocationInput?
|
||||
) -> [RouteCandidate] {
|
||||
guard !anchors.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Start with anchor games as the core route
|
||||
let sortedAnchors = anchors.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Build stops from anchor games
|
||||
var stops: [ItineraryStop] = []
|
||||
|
||||
for game in sortedAnchors {
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: [game.id],
|
||||
arrivalDate: game.gameDate,
|
||||
departureDate: game.gameDate,
|
||||
location: location,
|
||||
firstGameStart: game.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
guard !stops.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return [RouteCandidate(
|
||||
stops: stops,
|
||||
rationale: "Route connecting \(anchors.count) selected games"
|
||||
)]
|
||||
}
|
||||
|
||||
// MARK: - Scenario C: Directional Routes (Start + End)
|
||||
|
||||
/// Builds directional routes from start to end location
|
||||
/// - Parameters:
|
||||
/// - start: Start location
|
||||
/// - end: End location
|
||||
/// - games: Available games
|
||||
/// - dateRange: Optional trip date range
|
||||
/// - Returns: Array of route candidates
|
||||
static func buildDirectionalRoutes(
|
||||
start: LocationInput,
|
||||
end: LocationInput,
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
dateRange: DateInterval?
|
||||
) -> [RouteCandidate] {
|
||||
// Filter games by date range if provided
|
||||
let filteredGames: [Game]
|
||||
if let range = dateRange {
|
||||
filteredGames = games.filter { range.contains($0.startTime) }
|
||||
} else {
|
||||
filteredGames = games
|
||||
}
|
||||
|
||||
guard !filteredGames.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Sort games chronologically
|
||||
let sortedGames = filteredGames.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Build stops: start -> games -> end
|
||||
var stops: [ItineraryStop] = []
|
||||
|
||||
// Start stop (no games)
|
||||
let startStop = ItineraryStop(
|
||||
city: start.name,
|
||||
state: "",
|
||||
coordinate: start.coordinate,
|
||||
games: [],
|
||||
arrivalDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
|
||||
departureDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
|
||||
location: start,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(startStop)
|
||||
|
||||
// Game stops
|
||||
for game in sortedGames {
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: [game.id],
|
||||
arrivalDate: game.gameDate,
|
||||
departureDate: game.gameDate,
|
||||
location: location,
|
||||
firstGameStart: game.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
// End stop (no games)
|
||||
let endStop = ItineraryStop(
|
||||
city: end.name,
|
||||
state: "",
|
||||
coordinate: end.coordinate,
|
||||
games: [],
|
||||
arrivalDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
|
||||
location: end,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(endStop)
|
||||
|
||||
return [RouteCandidate(
|
||||
stops: stops,
|
||||
rationale: "Directional route from \(start.name) to \(end.name)"
|
||||
)]
|
||||
}
|
||||
}
|
||||
283
SportsTime/Planning/Engine/RouteOptimizer.swift
Normal file
283
SportsTime/Planning/Engine/RouteOptimizer.swift
Normal file
@@ -0,0 +1,283 @@
|
||||
//
|
||||
// RouteOptimizer.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Optimization strategy for ranking itinerary options.
|
||||
enum OptimizationStrategy {
|
||||
case balanced // Balance games vs driving
|
||||
case maximizeGames // Prioritize seeing more games
|
||||
case minimizeDriving // Prioritize shorter routes
|
||||
case scenic // Prioritize scenic routes
|
||||
}
|
||||
|
||||
/// Route optimizer for ranking and scoring itinerary options.
|
||||
///
|
||||
/// The TSP-solving logic has been moved to scenario-specific candidate
|
||||
/// generation in TripPlanningEngine. This optimizer now focuses on:
|
||||
/// - Ranking multiple route options
|
||||
/// - Scoring routes based on optimization strategy
|
||||
/// - Reordering games within constraints
|
||||
struct RouteOptimizer {
|
||||
|
||||
// MARK: - Route Ranking
|
||||
|
||||
/// Ranks a list of itinerary options based on the optimization strategy.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - options: Unranked itinerary options
|
||||
/// - strategy: Optimization strategy for scoring
|
||||
/// - request: Planning request for context
|
||||
/// - Returns: Options sorted by score (best first) with rank assigned
|
||||
func rankOptions(
|
||||
_ options: [ItineraryOption],
|
||||
strategy: OptimizationStrategy = .balanced,
|
||||
request: PlanningRequest
|
||||
) -> [ItineraryOption] {
|
||||
// Score each option
|
||||
let scoredOptions = options.map { option -> (ItineraryOption, Double) in
|
||||
let score = scoreOption(option, strategy: strategy, request: request)
|
||||
return (option, score)
|
||||
}
|
||||
|
||||
// Sort by score (lower is better for our scoring system)
|
||||
let sorted = scoredOptions.sorted { $0.1 < $1.1 }
|
||||
|
||||
// Assign ranks
|
||||
return sorted.enumerated().map { index, scored in
|
||||
ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: scored.0.stops,
|
||||
travelSegments: scored.0.travelSegments,
|
||||
totalDrivingHours: scored.0.totalDrivingHours,
|
||||
totalDistanceMiles: scored.0.totalDistanceMiles,
|
||||
geographicRationale: scored.0.geographicRationale
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scoring
|
||||
|
||||
/// Scores an itinerary option based on the optimization strategy.
|
||||
/// Lower scores are better.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - option: Itinerary option to score
|
||||
/// - strategy: Optimization strategy
|
||||
/// - request: Planning request for context
|
||||
/// - Returns: Score value (lower is better)
|
||||
func scoreOption(
|
||||
_ option: ItineraryOption,
|
||||
strategy: OptimizationStrategy,
|
||||
request: PlanningRequest
|
||||
) -> Double {
|
||||
switch strategy {
|
||||
case .balanced:
|
||||
return scoreBalanced(option, request: request)
|
||||
case .maximizeGames:
|
||||
return scoreMaximizeGames(option, request: request)
|
||||
case .minimizeDriving:
|
||||
return scoreMinimizeDriving(option, request: request)
|
||||
case .scenic:
|
||||
return scoreScenic(option, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Balanced scoring: trade-off between games and driving.
|
||||
private func scoreBalanced(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// Each game "saves" 2 hours of driving in value
|
||||
let gameValue = Double(option.totalGames) * 2.0
|
||||
let drivingPenalty = option.totalDrivingHours
|
||||
|
||||
// Also factor in must-see games coverage
|
||||
let mustSeeCoverage = calculateMustSeeCoverage(option, request: request)
|
||||
let mustSeeBonus = mustSeeCoverage * 10.0 // Strong bonus for must-see coverage
|
||||
|
||||
return drivingPenalty - gameValue - mustSeeBonus
|
||||
}
|
||||
|
||||
/// Maximize games scoring: prioritize number of games.
|
||||
private func scoreMaximizeGames(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// Heavily weight game count
|
||||
let gameScore = -Double(option.totalGames) * 100.0
|
||||
let drivingPenalty = option.totalDrivingHours * 0.1 // Minimal driving penalty
|
||||
|
||||
return gameScore + drivingPenalty
|
||||
}
|
||||
|
||||
/// Minimize driving scoring: prioritize shorter routes.
|
||||
private func scoreMinimizeDriving(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// Primarily driving time
|
||||
let drivingScore = option.totalDrivingHours
|
||||
let gameBonus = Double(option.totalGames) * 0.5 // Small bonus for games
|
||||
|
||||
return drivingScore - gameBonus
|
||||
}
|
||||
|
||||
/// Scenic scoring: balance games with route pleasantness.
|
||||
private func scoreScenic(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||
// More relaxed pacing is better
|
||||
let gamesPerDay = Double(option.totalGames) / Double(max(1, option.stops.count))
|
||||
let pacingScore = abs(gamesPerDay - 1.5) * 5.0 // Ideal is ~1.5 games per day
|
||||
|
||||
let drivingScore = option.totalDrivingHours * 0.3
|
||||
let gameBonus = Double(option.totalGames) * 2.0
|
||||
|
||||
return pacingScore + drivingScore - gameBonus
|
||||
}
|
||||
|
||||
/// Calculates what percentage of must-see games are covered.
|
||||
private func calculateMustSeeCoverage(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> Double {
|
||||
let mustSeeIds = request.preferences.mustSeeGameIds
|
||||
if mustSeeIds.isEmpty { return 1.0 }
|
||||
|
||||
let coveredGames = option.stops.flatMap { $0.games }
|
||||
let coveredMustSee = Set(coveredGames).intersection(mustSeeIds)
|
||||
|
||||
return Double(coveredMustSee.count) / Double(mustSeeIds.count)
|
||||
}
|
||||
|
||||
// MARK: - Route Improvement
|
||||
|
||||
/// Attempts to improve a route by swapping non-essential stops.
|
||||
/// Only applies to stops without must-see games.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - option: Itinerary option to improve
|
||||
/// - request: Planning request for context
|
||||
/// - Returns: Improved itinerary option, or original if no improvement found
|
||||
func improveRoute(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> ItineraryOption {
|
||||
// For now, return as-is since games must remain in chronological order
|
||||
// Future: implement swap logic for same-day games in different cities
|
||||
return option
|
||||
}
|
||||
|
||||
// MARK: - Validation Helpers
|
||||
|
||||
/// Checks if all must-see games are included in the option.
|
||||
func includesAllMustSeeGames(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> Bool {
|
||||
let includedGames = Set(option.stops.flatMap { $0.games })
|
||||
return request.preferences.mustSeeGameIds.isSubset(of: includedGames)
|
||||
}
|
||||
|
||||
/// Returns must-see games that are missing from the option.
|
||||
func missingMustSeeGames(
|
||||
_ option: ItineraryOption,
|
||||
request: PlanningRequest
|
||||
) -> Set<UUID> {
|
||||
let includedGames = Set(option.stops.flatMap { $0.games })
|
||||
return request.preferences.mustSeeGameIds.subtracting(includedGames)
|
||||
}
|
||||
|
||||
// MARK: - Distance Calculations
|
||||
|
||||
/// Calculates total route distance for a sequence of coordinates.
|
||||
func totalDistance(for coordinates: [CLLocationCoordinate2D]) -> Double {
|
||||
guard coordinates.count >= 2 else { return 0 }
|
||||
|
||||
var total: Double = 0
|
||||
for i in 0..<(coordinates.count - 1) {
|
||||
total += distance(from: coordinates[i], to: coordinates[i + 1])
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Calculates distance between two coordinates in meters.
|
||||
private func distance(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||
return fromLoc.distance(from: toLoc)
|
||||
}
|
||||
|
||||
// MARK: - Legacy Support
|
||||
|
||||
/// Legacy optimization method for backward compatibility.
|
||||
/// Delegates to the new TripPlanningEngine for actual routing.
|
||||
func optimize(
|
||||
graph: RouteGraph,
|
||||
request: PlanningRequest,
|
||||
candidates: [GameCandidate],
|
||||
strategy: OptimizationStrategy = .balanced
|
||||
) -> CandidateRoute {
|
||||
// Build a simple chronological route from candidates
|
||||
let sortedCandidates = candidates.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
|
||||
var route = CandidateRoute()
|
||||
|
||||
// Add start node
|
||||
if let startNode = graph.nodes.first(where: { $0.type == .start }) {
|
||||
route.nodeSequence.append(startNode.id)
|
||||
}
|
||||
|
||||
// Add stadium nodes in chronological order
|
||||
var visitedStadiums = Set<UUID>()
|
||||
for candidate in sortedCandidates {
|
||||
// Find the node for this stadium
|
||||
for node in graph.nodes {
|
||||
if case .stadium(let stadiumId) = node.type,
|
||||
stadiumId == candidate.stadium.id,
|
||||
!visitedStadiums.contains(stadiumId) {
|
||||
route.nodeSequence.append(node.id)
|
||||
route.games.append(candidate.game.id)
|
||||
visitedStadiums.insert(stadiumId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add end node
|
||||
if let endNode = graph.nodes.first(where: { $0.type == .end }) {
|
||||
route.nodeSequence.append(endNode.id)
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
for i in 0..<(route.nodeSequence.count - 1) {
|
||||
if let edge = graph.edges(from: route.nodeSequence[i])
|
||||
.first(where: { $0.toNodeId == route.nodeSequence[i + 1] }) {
|
||||
route.totalDistance += edge.distanceMeters
|
||||
route.totalDuration += edge.durationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// Score the route
|
||||
route.score = scoreRoute(route, strategy: strategy, graph: graph)
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
/// Legacy route scoring for CandidateRoute.
|
||||
private func scoreRoute(
|
||||
_ route: CandidateRoute,
|
||||
strategy: OptimizationStrategy,
|
||||
graph: RouteGraph
|
||||
) -> Double {
|
||||
switch strategy {
|
||||
case .balanced:
|
||||
return route.totalDuration - Double(route.games.count) * 3600 * 2
|
||||
|
||||
case .maximizeGames:
|
||||
return -Double(route.games.count) * 10000 + route.totalDuration
|
||||
|
||||
case .minimizeDriving:
|
||||
return route.totalDuration
|
||||
|
||||
case .scenic:
|
||||
return route.totalDuration * 0.5 - Double(route.games.count) * 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
265
SportsTime/Planning/Engine/ScenarioAPlanner.swift
Normal file
265
SportsTime/Planning/Engine/ScenarioAPlanner.swift
Normal file
@@ -0,0 +1,265 @@
|
||||
//
|
||||
// ScenarioAPlanner.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Scenario A: Date range only planning.
|
||||
// User provides a date range, we find all games and build chronological routes.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Scenario A: Date range planning
|
||||
///
|
||||
/// This is the simplest scenario - user just picks a date range and we find games.
|
||||
///
|
||||
/// Input:
|
||||
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
|
||||
/// - must_stop: Optional. A location they must visit (not yet implemented)
|
||||
///
|
||||
/// Output:
|
||||
/// - Success: Ranked list of itinerary options
|
||||
/// - Failure: Explicit error with reason (no games, dates invalid, etc.)
|
||||
///
|
||||
/// Example:
|
||||
/// User selects Jan 5-10, 2026
|
||||
/// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9)
|
||||
/// Output: Single itinerary visiting LA → SF → Sacramento in order
|
||||
///
|
||||
final class ScenarioAPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
/// Main entry point for Scenario A planning.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Validate inputs (date range must exist)
|
||||
/// 2. Find all games within the date range
|
||||
/// 3. Convert games to stops (grouping by stadium)
|
||||
/// 4. Calculate travel between stops
|
||||
/// 5. Return the complete itinerary
|
||||
///
|
||||
/// Failure cases:
|
||||
/// - No date range provided → .missingDateRange
|
||||
/// - No games in date range → .noGamesInRange
|
||||
/// - Can't build valid route → .constraintsUnsatisfiable
|
||||
///
|
||||
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 1: Validate date range exists
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Scenario A requires a date range. Without it, we can't filter games.
|
||||
guard let dateRange = request.dateRange else {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingDateRange,
|
||||
violations: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Filter games within date range
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Get all games that fall within the user's travel dates.
|
||||
// Sort by start time so we visit them in chronological order.
|
||||
let gamesInRange = request.allGames
|
||||
.filter { dateRange.contains($0.startTime) }
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// No games? Nothing to plan.
|
||||
if gamesInRange.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: Find ALL geographically sensible route variations
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Not all games in the date range may form a sensible route.
|
||||
// Example: NY (Jan 5), TX (Jan 6), SC (Jan 7), CA (Jan 8)
|
||||
// - Including all = zig-zag nightmare
|
||||
// - Option 1: NY, TX, DEN, NM, CA (skip SC)
|
||||
// - Option 2: NY, SC, DEN, NM, CA (skip TX)
|
||||
// - etc.
|
||||
//
|
||||
// We explore ALL valid combinations and return multiple options.
|
||||
// Uses shared GeographicRouteExplorer for tree exploration.
|
||||
//
|
||||
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
if validRoutes.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: "No geographically sensible route found for games in this date range",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 4: Build itineraries for each valid route
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// For each valid game combination, build stops and calculate travel.
|
||||
// Some routes may fail driving constraints - filter those out.
|
||||
//
|
||||
var itineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for (index, routeGames) in validRoutes.enumerated() {
|
||||
// Build stops for this route
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
// Calculate travel segments using shared ItineraryBuilder
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
constraints: request.drivingConstraints,
|
||||
logPrefix: "[ScenarioA]"
|
||||
) else {
|
||||
// This route fails driving constraints, skip it
|
||||
print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Create the option
|
||||
let cities = stops.map { $0.city }.joined(separator: " → ")
|
||||
let option = ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: itinerary.stops,
|
||||
travelSegments: itinerary.travelSegments,
|
||||
totalDrivingHours: itinerary.totalDrivingHours,
|
||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||
geographicRationale: "\(stops.count) games: \(cities)"
|
||||
)
|
||||
itineraryOptions.append(option)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 5: Return ranked results
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// If no routes passed all constraints, fail.
|
||||
// Otherwise, return all valid options for the user to choose from.
|
||||
//
|
||||
if itineraryOptions.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .constraintsUnsatisfiable,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .drivingTime,
|
||||
description: "No routes satisfy driving constraints",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Re-rank by number of games (already sorted, but update rank numbers)
|
||||
let rankedOptions = itineraryOptions.enumerated().map { index, option in
|
||||
ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: option.stops,
|
||||
travelSegments: option.travelSegments,
|
||||
totalDrivingHours: option.totalDrivingHours,
|
||||
totalDistanceMiles: option.totalDistanceMiles,
|
||||
geographicRationale: option.geographicRationale
|
||||
)
|
||||
}
|
||||
|
||||
print("[ScenarioA] Returning \(rankedOptions.count) itinerary options")
|
||||
return .success(rankedOptions)
|
||||
}
|
||||
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts a list of games into itinerary stops.
|
||||
///
|
||||
/// The goal: Create one stop per stadium, in the order we first encounter each stadium
|
||||
/// when walking through games chronologically.
|
||||
///
|
||||
/// Example:
|
||||
/// Input games (already sorted by date):
|
||||
/// 1. Jan 5 - Lakers @ Staples Center (LA)
|
||||
/// 2. Jan 6 - Clippers @ Staples Center (LA) <- same stadium as #1
|
||||
/// 3. Jan 8 - Warriors @ Chase Center (SF)
|
||||
///
|
||||
/// Output stops:
|
||||
/// Stop 1: Los Angeles (contains game 1 and 2)
|
||||
/// Stop 2: San Francisco (contains game 3)
|
||||
///
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
|
||||
// Step 1: Group all games by their stadium
|
||||
// This lets us find ALL games at a stadium when we create that stop
|
||||
// Result: { stadiumId: [game1, game2, ...], ... }
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
}
|
||||
|
||||
// Step 2: Walk through games in chronological order
|
||||
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
|
||||
|
||||
for game in games {
|
||||
// Skip if we already created a stop for this stadium
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
// Get ALL games at this stadium (not just this one)
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Look up stadium info for location data
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// Create the stop
|
||||
// - arrivalDate: when we need to arrive (first game at this stop)
|
||||
// - departureDate: when we can leave (after last game at this stop)
|
||||
// - games: IDs of all games we'll attend at this stop
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
}
|
||||
348
SportsTime/Planning/Engine/ScenarioBPlanner.swift
Normal file
348
SportsTime/Planning/Engine/ScenarioBPlanner.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
//
|
||||
// ScenarioBPlanner.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Scenario B: Selected games planning.
|
||||
// User selects specific games they MUST see. Those are fixed anchors that cannot be removed.
|
||||
//
|
||||
// Key Features:
|
||||
// - Selected games are "anchors" - they MUST appear in every valid route
|
||||
// - Sliding window logic when only trip duration (no specific dates) is provided
|
||||
// - Additional games from date range can be added if they fit geographically
|
||||
//
|
||||
// Sliding Window Algorithm:
|
||||
// When user provides selected games + day span (e.g., 10 days) without specific dates:
|
||||
// 1. Find first and last selected game dates
|
||||
// 2. Generate all possible windows of the given duration that contain ALL selected games
|
||||
// 3. Window 1: Last selected game is on last day
|
||||
// 4. Window N: First selected game is on first day
|
||||
// 5. Explore routes for each window, return best options
|
||||
//
|
||||
// Example:
|
||||
// Selected games on Jan 5, Jan 8, Jan 12. Day span = 10 days.
|
||||
// - Window 1: Jan 3-12 (Jan 12 is last day)
|
||||
// - Window 2: Jan 4-13
|
||||
// - Window 3: Jan 5-14 (Jan 5 is first day)
|
||||
// For each window, find all games and explore routes with selected as anchors.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Scenario B: Selected games planning
|
||||
/// Input: selected_games, date_range (or trip_duration), optional must_stop
|
||||
/// Output: Itinerary options connecting all selected games with possible bonus games
|
||||
final class ScenarioBPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
let selectedGames = request.selectedGames
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 1: Validate selected games exist
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if selectedGames.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .selectedGames,
|
||||
description: "No games selected",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Generate date ranges (sliding window or single range)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let dateRanges = generateDateRanges(
|
||||
selectedGames: selectedGames,
|
||||
request: request
|
||||
)
|
||||
|
||||
if dateRanges.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingDateRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "Cannot determine valid date range for selected games",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: For each date range, find routes with anchors
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let anchorGameIds = Set(selectedGames.map { $0.id })
|
||||
var allItineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for dateRange in dateRanges {
|
||||
// Find all games in this date range
|
||||
let gamesInRange = request.allGames
|
||||
.filter { dateRange.contains($0.startTime) }
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Skip if no games (shouldn't happen if date range is valid)
|
||||
guard !gamesInRange.isEmpty else { continue }
|
||||
|
||||
// Verify all selected games are in range
|
||||
let selectedInRange = selectedGames.allSatisfy { game in
|
||||
dateRange.contains(game.startTime)
|
||||
}
|
||||
guard selectedInRange else { continue }
|
||||
|
||||
// Find all sensible routes that include the anchor games
|
||||
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
// Build itineraries for each valid route
|
||||
for routeGames in validRoutes {
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
// Use shared ItineraryBuilder with arrival time validator
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
constraints: request.drivingConstraints,
|
||||
logPrefix: "[ScenarioB]",
|
||||
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let selectedCount = routeGames.filter { anchorGameIds.contains($0.id) }.count
|
||||
let bonusCount = routeGames.count - selectedCount
|
||||
let cities = stops.map { $0.city }.joined(separator: " → ")
|
||||
|
||||
let option = ItineraryOption(
|
||||
rank: 0, // Will re-rank later
|
||||
stops: itinerary.stops,
|
||||
travelSegments: itinerary.travelSegments,
|
||||
totalDrivingHours: itinerary.totalDrivingHours,
|
||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||
geographicRationale: "\(selectedCount) selected + \(bonusCount) bonus games: \(cities)"
|
||||
)
|
||||
allItineraryOptions.append(option)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 4: Return ranked results
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if allItineraryOptions.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .constraintsUnsatisfiable,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: "Cannot create a geographically sensible route connecting selected games",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by total games (most first), then by driving hours (less first)
|
||||
let sorted = allItineraryOptions.sorted { a, b in
|
||||
if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count {
|
||||
return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count
|
||||
}
|
||||
return a.totalDrivingHours < b.totalDrivingHours
|
||||
}
|
||||
|
||||
// Re-rank and limit
|
||||
let rankedOptions = sorted.prefix(10).enumerated().map { index, option in
|
||||
ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: option.stops,
|
||||
travelSegments: option.travelSegments,
|
||||
totalDrivingHours: option.totalDrivingHours,
|
||||
totalDistanceMiles: option.totalDistanceMiles,
|
||||
geographicRationale: option.geographicRationale
|
||||
)
|
||||
}
|
||||
|
||||
print("[ScenarioB] Returning \(rankedOptions.count) itinerary options")
|
||||
return .success(Array(rankedOptions))
|
||||
}
|
||||
|
||||
// MARK: - Date Range Generation (Sliding Window)
|
||||
|
||||
/// Generates all valid date ranges for the selected games.
|
||||
///
|
||||
/// Two modes:
|
||||
/// 1. If explicit date range provided: Use it directly (validate selected games fit)
|
||||
/// 2. If only trip duration provided: Generate sliding windows
|
||||
///
|
||||
/// Sliding Window Logic:
|
||||
/// Selected games: Jan 5, Jan 8, Jan 12. Duration: 10 days.
|
||||
/// - Window must contain all selected games
|
||||
/// - First window: ends on last selected game date (Jan 3-12)
|
||||
/// - Slide forward one day at a time
|
||||
/// - Last window: starts on first selected game date (Jan 5-14)
|
||||
///
|
||||
private func generateDateRanges(
|
||||
selectedGames: [Game],
|
||||
request: PlanningRequest
|
||||
) -> [DateInterval] {
|
||||
|
||||
// If explicit date range exists, use it
|
||||
if let dateRange = request.dateRange {
|
||||
return [dateRange]
|
||||
}
|
||||
|
||||
// Otherwise, use trip duration to create sliding windows
|
||||
let duration = request.preferences.effectiveTripDuration
|
||||
guard duration > 0 else { return [] }
|
||||
|
||||
// Find the span of selected games
|
||||
let sortedGames = selectedGames.sorted { $0.startTime < $1.startTime }
|
||||
guard let firstGame = sortedGames.first,
|
||||
let lastGame = sortedGames.last else {
|
||||
return []
|
||||
}
|
||||
|
||||
let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime)
|
||||
let lastGameDate = Calendar.current.startOfDay(for: lastGame.startTime)
|
||||
|
||||
// Calculate how many days the selected games span
|
||||
let gameSpanDays = Calendar.current.dateComponents(
|
||||
[.day],
|
||||
from: firstGameDate,
|
||||
to: lastGameDate
|
||||
).day ?? 0
|
||||
|
||||
// If selected games span more days than trip duration, can't fit
|
||||
if gameSpanDays >= duration {
|
||||
// Just return one window that exactly covers the games
|
||||
let start = firstGameDate
|
||||
let end = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: gameSpanDays + 1,
|
||||
to: start
|
||||
) ?? lastGameDate
|
||||
return [DateInterval(start: start, end: end)]
|
||||
}
|
||||
|
||||
// Generate sliding windows
|
||||
var dateRanges: [DateInterval] = []
|
||||
|
||||
// First window: last selected game is on last day of window
|
||||
// Window end = lastGameDate + 1 day (to include the game)
|
||||
// Window start = end - duration days
|
||||
let firstWindowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: 1,
|
||||
to: lastGameDate
|
||||
)!
|
||||
let firstWindowStart = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: -duration,
|
||||
to: firstWindowEnd
|
||||
)!
|
||||
|
||||
// Last window: first selected game is on first day of window
|
||||
// Window start = firstGameDate
|
||||
// Window end = start + duration days
|
||||
let lastWindowStart = firstGameDate
|
||||
let lastWindowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: duration,
|
||||
to: lastWindowStart
|
||||
)!
|
||||
|
||||
// Slide from first window to last window
|
||||
var currentStart = firstWindowStart
|
||||
while currentStart <= lastWindowStart {
|
||||
let windowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: duration,
|
||||
to: currentStart
|
||||
)!
|
||||
|
||||
let window = DateInterval(start: currentStart, end: windowEnd)
|
||||
dateRanges.append(window)
|
||||
|
||||
// Slide forward one day
|
||||
currentStart = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: 1,
|
||||
to: currentStart
|
||||
)!
|
||||
}
|
||||
|
||||
print("[ScenarioB] Generated \(dateRanges.count) sliding windows for \(duration)-day trip")
|
||||
return dateRanges
|
||||
}
|
||||
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts a list of games into itinerary stops.
|
||||
/// Groups games by stadium, creates one stop per unique stadium.
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
|
||||
// Group games by stadium
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
}
|
||||
|
||||
// Create stops in chronological order (first game at each stadium)
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = []
|
||||
|
||||
for game in games {
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
}
|
||||
582
SportsTime/Planning/Engine/ScenarioCPlanner.swift
Normal file
582
SportsTime/Planning/Engine/ScenarioCPlanner.swift
Normal file
@@ -0,0 +1,582 @@
|
||||
//
|
||||
// ScenarioCPlanner.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Scenario C: Start + End location planning.
|
||||
// User specifies starting city and ending city. We find games along the route.
|
||||
//
|
||||
// Key Features:
|
||||
// - Start/End are cities with stadiums from user's selected sports
|
||||
// - Directional filtering: stadiums that "generally move toward" the end
|
||||
// - When only day span provided (no dates): generate date ranges from games at start/end
|
||||
// - Uses GeographicRouteExplorer for sensible route exploration
|
||||
// - Returns top 5 options with most games
|
||||
//
|
||||
// Date Range Generation (when only day span provided):
|
||||
// 1. Find all games at start city's stadiums
|
||||
// 2. Find all games at end city's stadiums
|
||||
// 3. For each start game + end game combo within day span: create a date range
|
||||
// 4. Explore routes for each date range
|
||||
//
|
||||
// Directional Filtering:
|
||||
// - Find stadiums that make forward progress from start to end
|
||||
// - "Forward progress" = distance to end decreases (with tolerance)
|
||||
// - Filter games to only those at directional stadiums
|
||||
//
|
||||
// Example:
|
||||
// Start: Chicago, End: New York, Day span: 7 days
|
||||
// Start game: Jan 5 at Chicago
|
||||
// End game: Jan 10 at New York
|
||||
// Date range: Jan 5-10
|
||||
// Directional stadiums: Detroit, Cleveland, Pittsburgh (moving east)
|
||||
// NOT directional: Minneapolis, St. Louis (moving away from NY)
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Scenario C: Directional route planning from start city to end city
|
||||
/// Input: start_location, end_location, day_span (or date_range)
|
||||
/// Output: Top 5 itinerary options with games along the directional route
|
||||
final class ScenarioCPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Maximum number of itinerary options to return
|
||||
private let maxOptions = 5
|
||||
|
||||
/// Tolerance for "forward progress" - allow small increases in distance to end
|
||||
/// A stadium is "directional" if it doesn't increase distance to end by more than this ratio
|
||||
private let forwardProgressTolerance = 0.15 // 15% tolerance
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 1: Validate start and end locations
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
guard let startLocation = request.startLocation else {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingLocations,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .general,
|
||||
description: "Start location is required for Scenario C",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard let endLocation = request.endLocation else {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingLocations,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .general,
|
||||
description: "End location is required for Scenario C",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard let startCoord = startLocation.coordinate,
|
||||
let endCoord = endLocation.coordinate else {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingLocations,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .general,
|
||||
description: "Start and end locations must have coordinates",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Find stadiums at start and end cities
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let startStadiums = findStadiumsInCity(
|
||||
cityName: startLocation.name,
|
||||
stadiums: request.stadiums
|
||||
)
|
||||
let endStadiums = findStadiumsInCity(
|
||||
cityName: endLocation.name,
|
||||
stadiums: request.stadiums
|
||||
)
|
||||
|
||||
if startStadiums.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .general,
|
||||
description: "No stadiums found in start city: \(startLocation.name)",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if endStadiums.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .general,
|
||||
description: "No stadiums found in end city: \(endLocation.name)",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: Generate date ranges
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let dateRanges = generateDateRanges(
|
||||
startStadiumIds: Set(startStadiums.map { $0.id }),
|
||||
endStadiumIds: Set(endStadiums.map { $0.id }),
|
||||
allGames: request.allGames,
|
||||
request: request
|
||||
)
|
||||
|
||||
if dateRanges.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingDateRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "No valid date ranges found with games at both start and end cities",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 4: Find directional stadiums (moving from start toward end)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let directionalStadiums = findDirectionalStadiums(
|
||||
from: startCoord,
|
||||
to: endCoord,
|
||||
stadiums: request.stadiums
|
||||
)
|
||||
|
||||
print("[ScenarioC] Found \(directionalStadiums.count) directional stadiums from \(startLocation.name) to \(endLocation.name)")
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 5: For each date range, explore routes
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
var allItineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for dateRange in dateRanges {
|
||||
// Find games at directional stadiums within date range
|
||||
let gamesInRange = request.allGames
|
||||
.filter { dateRange.contains($0.startTime) }
|
||||
.filter { directionalStadiums.contains($0.stadiumId) }
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
guard !gamesInRange.isEmpty else { continue }
|
||||
|
||||
// Use GeographicRouteExplorer to find sensible routes
|
||||
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: [], // No anchors in Scenario C
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
// Build itineraries for each valid route
|
||||
for routeGames in validRoutes {
|
||||
let stops = buildStopsWithEndpoints(
|
||||
start: startLocation,
|
||||
end: endLocation,
|
||||
games: routeGames,
|
||||
stadiums: request.stadiums
|
||||
)
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
// Validate monotonic progress toward end
|
||||
guard validateMonotonicProgress(
|
||||
stops: stops,
|
||||
toward: endCoord
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use shared ItineraryBuilder
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
constraints: request.drivingConstraints,
|
||||
logPrefix: "[ScenarioC]"
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let gameCount = routeGames.count
|
||||
let cities = stops.compactMap { $0.games.isEmpty ? nil : $0.city }.joined(separator: " → ")
|
||||
|
||||
let option = ItineraryOption(
|
||||
rank: 0, // Will re-rank later
|
||||
stops: itinerary.stops,
|
||||
travelSegments: itinerary.travelSegments,
|
||||
totalDrivingHours: itinerary.totalDrivingHours,
|
||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||
geographicRationale: "\(startLocation.name) → \(gameCount) games → \(endLocation.name): \(cities)"
|
||||
)
|
||||
allItineraryOptions.append(option)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 6: Return top 5 ranked results
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if allItineraryOptions.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: "No valid directional routes found from \(startLocation.name) to \(endLocation.name)",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by game count (most first), then by driving hours (less first)
|
||||
let sorted = allItineraryOptions.sorted { a, b in
|
||||
let aGames = a.stops.flatMap { $0.games }.count
|
||||
let bGames = b.stops.flatMap { $0.games }.count
|
||||
if aGames != bGames {
|
||||
return aGames > bGames
|
||||
}
|
||||
return a.totalDrivingHours < b.totalDrivingHours
|
||||
}
|
||||
|
||||
// Take top N and re-rank
|
||||
let rankedOptions = sorted.prefix(maxOptions).enumerated().map { index, option in
|
||||
ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: option.stops,
|
||||
travelSegments: option.travelSegments,
|
||||
totalDrivingHours: option.totalDrivingHours,
|
||||
totalDistanceMiles: option.totalDistanceMiles,
|
||||
geographicRationale: option.geographicRationale
|
||||
)
|
||||
}
|
||||
|
||||
print("[ScenarioC] Returning \(rankedOptions.count) itinerary options")
|
||||
return .success(Array(rankedOptions))
|
||||
}
|
||||
|
||||
// MARK: - Stadium Finding
|
||||
|
||||
/// Finds all stadiums in a given city (case-insensitive match).
|
||||
private func findStadiumsInCity(
|
||||
cityName: String,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [Stadium] {
|
||||
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
return stadiums.values.filter { stadium in
|
||||
stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds stadiums that make forward progress from start to end.
|
||||
///
|
||||
/// A stadium is "directional" if visiting it doesn't significantly increase
|
||||
/// the distance to the end point. This filters out stadiums that would
|
||||
/// require backtracking.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Calculate distance from start to end
|
||||
/// 2. For each stadium, calculate: distance(start, stadium) + distance(stadium, end)
|
||||
/// 3. If this "detour distance" is reasonable (within tolerance), include it
|
||||
///
|
||||
/// The tolerance allows for stadiums slightly off the direct path.
|
||||
///
|
||||
private func findDirectionalStadiums(
|
||||
from start: CLLocationCoordinate2D,
|
||||
to end: CLLocationCoordinate2D,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> Set<UUID> {
|
||||
let directDistance = distanceBetween(start, end)
|
||||
|
||||
// Allow detours up to 50% longer than direct distance
|
||||
let maxDetourDistance = directDistance * 1.5
|
||||
|
||||
var directionalIds: Set<UUID> = []
|
||||
|
||||
for (id, stadium) in stadiums {
|
||||
let stadiumCoord = stadium.coordinate
|
||||
|
||||
// Calculate the detour: start → stadium → end
|
||||
let toStadium = distanceBetween(start, stadiumCoord)
|
||||
let fromStadium = distanceBetween(stadiumCoord, end)
|
||||
let detourDistance = toStadium + fromStadium
|
||||
|
||||
// Also check that stadium is making progress (closer to end than start is)
|
||||
let distanceFromStart = distanceBetween(start, stadiumCoord)
|
||||
let distanceToEnd = distanceBetween(stadiumCoord, end)
|
||||
|
||||
// Stadium should be within the "cone" from start to end
|
||||
// Either closer to end than start, or the detour is acceptable
|
||||
if detourDistance <= maxDetourDistance {
|
||||
// Additional check: don't include if it's behind the start point
|
||||
// (i.e., distance to end is greater than original distance)
|
||||
if distanceToEnd <= directDistance * (1 + forwardProgressTolerance) {
|
||||
directionalIds.insert(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return directionalIds
|
||||
}
|
||||
|
||||
// MARK: - Date Range Generation
|
||||
|
||||
/// Generates date ranges for Scenario C.
|
||||
///
|
||||
/// Two modes:
|
||||
/// 1. Explicit date range provided: Use it directly
|
||||
/// 2. Only day span provided: Find game combinations at start/end cities
|
||||
///
|
||||
/// For mode 2:
|
||||
/// - Find all games at start city stadiums
|
||||
/// - Find all games at end city stadiums
|
||||
/// - For each (start_game, end_game) pair where end_game - start_game <= day_span:
|
||||
/// Create a date range from start_game.date to end_game.date
|
||||
///
|
||||
private func generateDateRanges(
|
||||
startStadiumIds: Set<UUID>,
|
||||
endStadiumIds: Set<UUID>,
|
||||
allGames: [Game],
|
||||
request: PlanningRequest
|
||||
) -> [DateInterval] {
|
||||
|
||||
// If explicit date range exists, use it
|
||||
if let dateRange = request.dateRange {
|
||||
return [dateRange]
|
||||
}
|
||||
|
||||
// Otherwise, use day span to find valid combinations
|
||||
let daySpan = request.preferences.effectiveTripDuration
|
||||
guard daySpan > 0 else { return [] }
|
||||
|
||||
// Find games at start and end cities
|
||||
let startGames = allGames
|
||||
.filter { startStadiumIds.contains($0.stadiumId) }
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
let endGames = allGames
|
||||
.filter { endStadiumIds.contains($0.stadiumId) }
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
if startGames.isEmpty || endGames.isEmpty {
|
||||
return []
|
||||
}
|
||||
|
||||
// Generate all valid (start_game, end_game) combinations
|
||||
var dateRanges: [DateInterval] = []
|
||||
let calendar = Calendar.current
|
||||
|
||||
for startGame in startGames {
|
||||
let startDate = calendar.startOfDay(for: startGame.startTime)
|
||||
|
||||
for endGame in endGames {
|
||||
let endDate = calendar.startOfDay(for: endGame.startTime)
|
||||
|
||||
// End must be after start
|
||||
guard endDate >= startDate else { continue }
|
||||
|
||||
// Calculate days between
|
||||
let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
||||
|
||||
// Must be within day span
|
||||
guard daysBetween < daySpan else { continue }
|
||||
|
||||
// Create date range (end date + 1 day to include the end game)
|
||||
let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate
|
||||
let range = DateInterval(start: startDate, end: rangeEnd)
|
||||
|
||||
// Avoid duplicate ranges
|
||||
if !dateRanges.contains(where: { $0.start == range.start && $0.end == range.end }) {
|
||||
dateRanges.append(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("[ScenarioC] Generated \(dateRanges.count) date ranges for \(daySpan)-day trip")
|
||||
return dateRanges
|
||||
}
|
||||
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts games to stops (used by GeographicRouteExplorer callback).
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
}
|
||||
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = []
|
||||
|
||||
for game in games {
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
/// Builds stops with start and end location endpoints.
|
||||
private func buildStopsWithEndpoints(
|
||||
start: LocationInput,
|
||||
end: LocationInput,
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
|
||||
var stops: [ItineraryStop] = []
|
||||
|
||||
// Start stop (no games)
|
||||
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
|
||||
let startStop = ItineraryStop(
|
||||
city: start.name,
|
||||
state: "",
|
||||
coordinate: start.coordinate,
|
||||
games: [],
|
||||
arrivalDate: startArrival,
|
||||
departureDate: startArrival,
|
||||
location: start,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(startStop)
|
||||
|
||||
// Game stops
|
||||
let gameStops = buildStops(from: games, stadiums: stadiums)
|
||||
stops.append(contentsOf: gameStops)
|
||||
|
||||
// End stop (no games)
|
||||
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
|
||||
let endStop = ItineraryStop(
|
||||
city: end.name,
|
||||
state: "",
|
||||
coordinate: end.coordinate,
|
||||
games: [],
|
||||
arrivalDate: endArrival,
|
||||
departureDate: endArrival,
|
||||
location: end,
|
||||
firstGameStart: nil
|
||||
)
|
||||
stops.append(endStop)
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
// MARK: - Monotonic Progress Validation
|
||||
|
||||
/// Validates that the route makes generally forward progress toward the end.
|
||||
///
|
||||
/// Each stop should be closer to (or not significantly farther from) the end
|
||||
/// than the previous stop. Small detours are allowed within tolerance.
|
||||
///
|
||||
private func validateMonotonicProgress(
|
||||
stops: [ItineraryStop],
|
||||
toward end: CLLocationCoordinate2D
|
||||
) -> Bool {
|
||||
|
||||
var previousDistance: Double?
|
||||
|
||||
for stop in stops {
|
||||
guard let stopCoord = stop.coordinate else { continue }
|
||||
|
||||
let currentDistance = distanceBetween(stopCoord, end)
|
||||
|
||||
if let prev = previousDistance {
|
||||
// Allow increases up to tolerance percentage
|
||||
let allowedIncrease = prev * forwardProgressTolerance
|
||||
if currentDistance > prev + allowedIncrease {
|
||||
print("[ScenarioC] Backtracking: \(stop.city) increases distance to end (\(Int(currentDistance))mi vs \(Int(prev))mi)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
previousDistance = currentDistance
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Geometry Helpers
|
||||
|
||||
/// Distance between two coordinates in miles using Haversine formula.
|
||||
private func distanceBetween(
|
||||
_ coord1: CLLocationCoordinate2D,
|
||||
_ coord2: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let earthRadiusMiles = 3958.8
|
||||
|
||||
let lat1 = coord1.latitude * .pi / 180
|
||||
let lat2 = coord2.latitude * .pi / 180
|
||||
let deltaLat = (coord2.latitude - coord1.latitude) * .pi / 180
|
||||
let deltaLon = (coord2.longitude - coord1.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
|
||||
}
|
||||
}
|
||||
49
SportsTime/Planning/Engine/ScenarioPlanner.swift
Normal file
49
SportsTime/Planning/Engine/ScenarioPlanner.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// ScenarioPlanner.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Protocol for scenario-based trip planning.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol that all scenario planners must implement.
|
||||
/// Each scenario (A, B, C) has its own isolated implementation.
|
||||
protocol ScenarioPlanner {
|
||||
|
||||
/// Plan itineraries for this scenario.
|
||||
/// - Parameter request: The planning request with all inputs
|
||||
/// - Returns: Success with ranked itineraries, or explicit failure
|
||||
func plan(request: PlanningRequest) -> ItineraryResult
|
||||
}
|
||||
|
||||
/// Factory for creating the appropriate scenario planner
|
||||
enum ScenarioPlannerFactory {
|
||||
|
||||
/// Creates the appropriate planner based on the request inputs
|
||||
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
|
||||
// Scenario B: User selected specific games
|
||||
if !request.selectedGames.isEmpty {
|
||||
return ScenarioBPlanner()
|
||||
}
|
||||
|
||||
// Scenario C: User specified start and end locations
|
||||
if request.startLocation != nil && request.endLocation != nil {
|
||||
return ScenarioCPlanner()
|
||||
}
|
||||
|
||||
// Scenario A: Date range only (default)
|
||||
return ScenarioAPlanner()
|
||||
}
|
||||
|
||||
/// Classifies which scenario applies to this request
|
||||
static func classify(_ request: PlanningRequest) -> PlanningScenario {
|
||||
if !request.selectedGames.isEmpty {
|
||||
return .scenarioB
|
||||
}
|
||||
if request.startLocation != nil && request.endLocation != nil {
|
||||
return .scenarioC
|
||||
}
|
||||
return .scenarioA
|
||||
}
|
||||
}
|
||||
396
SportsTime/Planning/Engine/ScheduleMatcher.swift
Normal file
396
SportsTime/Planning/Engine/ScheduleMatcher.swift
Normal file
@@ -0,0 +1,396 @@
|
||||
//
|
||||
// ScheduleMatcher.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Finds and scores candidate games for trip planning.
|
||||
///
|
||||
/// Updated for the new scenario-based planning:
|
||||
/// - Scenario A (Date Range): Find games in date range, cluster by region
|
||||
/// - Scenario B (Selected Games): Validate must-see games, find optional additions
|
||||
/// - Scenario C (Start+End): Find games along directional corridor with progress check
|
||||
struct ScheduleMatcher {
|
||||
|
||||
// MARK: - Find Candidate Games (Legacy + Scenario C Support)
|
||||
|
||||
/// Finds candidate games along a corridor between start and end.
|
||||
/// Supports directional filtering for Scenario C.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: Planning request with preferences and games
|
||||
/// - startCoordinate: Starting location
|
||||
/// - endCoordinate: Ending location
|
||||
/// - enforceDirection: If true, only include games that make progress toward end
|
||||
/// - Returns: Array of game candidates sorted by score
|
||||
func findCandidateGames(
|
||||
from request: PlanningRequest,
|
||||
startCoordinate: CLLocationCoordinate2D,
|
||||
endCoordinate: CLLocationCoordinate2D,
|
||||
enforceDirection: Bool = false
|
||||
) -> [GameCandidate] {
|
||||
var candidates: [GameCandidate] = []
|
||||
|
||||
// Calculate the corridor between start and end
|
||||
let corridor = RouteCorridorCalculator(
|
||||
start: startCoordinate,
|
||||
end: endCoordinate,
|
||||
maxDetourFactor: detourFactorFor(request.preferences.leisureLevel)
|
||||
)
|
||||
|
||||
for game in request.availableGames {
|
||||
guard let stadium = request.stadiums[game.stadiumId],
|
||||
let homeTeam = request.teams[game.homeTeamId],
|
||||
let awayTeam = request.teams[game.awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if game is within date range
|
||||
guard game.dateTime >= request.preferences.startDate,
|
||||
game.dateTime <= request.preferences.endDate else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check sport filter
|
||||
guard request.preferences.sports.contains(game.sport) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate detour distance
|
||||
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
||||
|
||||
// For directional routes, check if this stadium makes progress
|
||||
if enforceDirection {
|
||||
let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate)
|
||||
let startDistanceToEnd = corridor.directDistance
|
||||
|
||||
// Skip if stadium is behind the start (going backwards)
|
||||
if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if too far from route (unless must-see)
|
||||
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
||||
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
||||
continue
|
||||
}
|
||||
|
||||
// Score the game
|
||||
let score = scoreGame(
|
||||
game: game,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
isMustSee: isMustSee,
|
||||
request: request
|
||||
)
|
||||
|
||||
let candidate = GameCandidate(
|
||||
id: game.id,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
score: score
|
||||
)
|
||||
|
||||
candidates.append(candidate)
|
||||
}
|
||||
|
||||
// Sort by score (highest first)
|
||||
return candidates.sorted { $0.score > $1.score }
|
||||
}
|
||||
|
||||
// MARK: - Directional Game Filtering (Scenario C)
|
||||
|
||||
/// Finds games along a directional route from start to end.
|
||||
/// Ensures monotonic progress toward destination.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: Planning request
|
||||
/// - startCoordinate: Starting location
|
||||
/// - endCoordinate: Destination
|
||||
/// - corridorWidthPercent: Width of corridor as percentage of direct distance
|
||||
/// - Returns: Games sorted by their position along the route
|
||||
func findDirectionalGames(
|
||||
from request: PlanningRequest,
|
||||
startCoordinate: CLLocationCoordinate2D,
|
||||
endCoordinate: CLLocationCoordinate2D,
|
||||
corridorWidthPercent: Double = 0.3
|
||||
) -> [GameCandidate] {
|
||||
let corridor = RouteCorridorCalculator(
|
||||
start: startCoordinate,
|
||||
end: endCoordinate,
|
||||
maxDetourFactor: 1.0 + corridorWidthPercent
|
||||
)
|
||||
|
||||
var candidates: [GameCandidate] = []
|
||||
|
||||
for game in request.availableGames {
|
||||
guard let stadium = request.stadiums[game.stadiumId],
|
||||
let homeTeam = request.teams[game.homeTeamId],
|
||||
let awayTeam = request.teams[game.awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Date and sport filter
|
||||
guard game.dateTime >= request.preferences.startDate,
|
||||
game.dateTime <= request.preferences.endDate,
|
||||
request.preferences.sports.contains(game.sport) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate progress along route (0 = start, 1 = end)
|
||||
let progress = corridor.progressAlongRoute(point: stadium.coordinate)
|
||||
|
||||
// Only include games that are along the route (positive progress, not behind start)
|
||||
guard progress >= -0.1 && progress <= 1.1 else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check corridor width
|
||||
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
||||
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
||||
|
||||
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
||||
continue
|
||||
}
|
||||
|
||||
let score = scoreGame(
|
||||
game: game,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
isMustSee: isMustSee,
|
||||
request: request
|
||||
)
|
||||
|
||||
let candidate = GameCandidate(
|
||||
id: game.id,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: detourDistance,
|
||||
score: score
|
||||
)
|
||||
|
||||
candidates.append(candidate)
|
||||
}
|
||||
|
||||
// Sort by date (chronological order is the primary constraint)
|
||||
return candidates.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
|
||||
// MARK: - Game Scoring
|
||||
|
||||
private func scoreGame(
|
||||
game: Game,
|
||||
homeTeam: Team,
|
||||
awayTeam: Team,
|
||||
detourDistance: Double,
|
||||
isMustSee: Bool,
|
||||
request: PlanningRequest
|
||||
) -> Double {
|
||||
var score: Double = 50.0 // Base score
|
||||
|
||||
// Must-see bonus
|
||||
if isMustSee {
|
||||
score += 100.0
|
||||
}
|
||||
|
||||
// Playoff bonus
|
||||
if game.isPlayoff {
|
||||
score += 30.0
|
||||
}
|
||||
|
||||
// Weekend bonus (more convenient)
|
||||
let weekday = Calendar.current.component(.weekday, from: game.dateTime)
|
||||
if weekday == 1 || weekday == 7 { // Sunday or Saturday
|
||||
score += 10.0
|
||||
}
|
||||
|
||||
// Evening game bonus (day games harder to schedule around)
|
||||
let hour = Calendar.current.component(.hour, from: game.dateTime)
|
||||
if hour >= 17 { // 5 PM or later
|
||||
score += 5.0
|
||||
}
|
||||
|
||||
// Detour penalty
|
||||
let detourMiles = detourDistance * 0.000621371
|
||||
score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour
|
||||
|
||||
// Preferred city bonus
|
||||
if request.preferences.preferredCities.contains(homeTeam.city) {
|
||||
score += 15.0
|
||||
}
|
||||
|
||||
// Must-stop location bonus
|
||||
if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) {
|
||||
score += 25.0
|
||||
}
|
||||
|
||||
return max(0, score)
|
||||
}
|
||||
|
||||
private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double {
|
||||
switch leisureLevel {
|
||||
case .packed: return 1.3 // 30% detour allowed
|
||||
case .moderate: return 1.5 // 50% detour allowed
|
||||
case .relaxed: return 2.0 // 100% detour allowed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Find Games at Location
|
||||
|
||||
func findGames(
|
||||
at stadium: Stadium,
|
||||
within dateRange: ClosedRange<Date>,
|
||||
from games: [Game]
|
||||
) -> [Game] {
|
||||
games.filter { game in
|
||||
game.stadiumId == stadium.id &&
|
||||
dateRange.contains(game.dateTime)
|
||||
}.sorted { $0.dateTime < $1.dateTime }
|
||||
}
|
||||
|
||||
// MARK: - Find Other Sports
|
||||
|
||||
func findOtherSportsGames(
|
||||
along route: [CLLocationCoordinate2D],
|
||||
excludingSports: Set<Sport>,
|
||||
within dateRange: ClosedRange<Date>,
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
teams: [UUID: Team],
|
||||
maxDetourMiles: Double = 50
|
||||
) -> [GameCandidate] {
|
||||
var candidates: [GameCandidate] = []
|
||||
|
||||
for game in games {
|
||||
// Skip if sport is already selected
|
||||
if excludingSports.contains(game.sport) { continue }
|
||||
|
||||
// Skip if outside date range
|
||||
if !dateRange.contains(game.dateTime) { continue }
|
||||
|
||||
guard let stadium = stadiums[game.stadiumId],
|
||||
let homeTeam = teams[game.homeTeamId],
|
||||
let awayTeam = teams[game.awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if stadium is near the route
|
||||
let minDistance = route.map { coord in
|
||||
CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
||||
.distance(from: stadium.location)
|
||||
}.min() ?? .greatestFiniteMagnitude
|
||||
|
||||
let distanceMiles = minDistance * 0.000621371
|
||||
|
||||
if distanceMiles <= maxDetourMiles {
|
||||
let candidate = GameCandidate(
|
||||
id: game.id,
|
||||
game: game,
|
||||
stadium: stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
detourDistance: minDistance,
|
||||
score: 50.0 - distanceMiles // Score inversely proportional to detour
|
||||
)
|
||||
candidates.append(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.sorted { $0.score > $1.score }
|
||||
}
|
||||
|
||||
// MARK: - Validate Games for Scenarios
|
||||
|
||||
/// Validates that all must-see games are within the date range.
|
||||
/// Used for Scenario B validation.
|
||||
func validateMustSeeGamesInRange(
|
||||
mustSeeGameIds: Set<UUID>,
|
||||
allGames: [Game],
|
||||
dateRange: ClosedRange<Date>
|
||||
) -> (valid: Bool, outOfRange: [UUID]) {
|
||||
var outOfRange: [UUID] = []
|
||||
|
||||
for gameId in mustSeeGameIds {
|
||||
guard let game = allGames.first(where: { $0.id == gameId }) else {
|
||||
continue
|
||||
}
|
||||
if !dateRange.contains(game.dateTime) {
|
||||
outOfRange.append(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
return (outOfRange.isEmpty, outOfRange)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Corridor Calculator
|
||||
|
||||
struct RouteCorridorCalculator {
|
||||
let start: CLLocationCoordinate2D
|
||||
let end: CLLocationCoordinate2D
|
||||
let maxDetourFactor: Double
|
||||
|
||||
var directDistance: CLLocationDistance {
|
||||
CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
}
|
||||
|
||||
var maxDetourDistance: CLLocationDistance {
|
||||
directDistance * (maxDetourFactor - 1.0)
|
||||
}
|
||||
|
||||
func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||
let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
||||
|
||||
let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
|
||||
let totalViaPoint = startToPoint + pointToEnd
|
||||
|
||||
return max(0, totalViaPoint - directDistance)
|
||||
}
|
||||
|
||||
func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool {
|
||||
detourDistance(to: point) <= maxDetourDistance
|
||||
}
|
||||
|
||||
/// Returns the distance from a point to the end location.
|
||||
func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||
CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
}
|
||||
|
||||
/// Calculates progress along the route (0 = at start, 1 = at end).
|
||||
/// Can be negative (behind start) or > 1 (past end).
|
||||
func progressAlongRoute(point: CLLocationCoordinate2D) -> Double {
|
||||
guard directDistance > 0 else { return 0 }
|
||||
|
||||
let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
||||
|
||||
let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||
|
||||
// Use the law of cosines to project onto the line
|
||||
// progress = (d_start² + d_total² - d_end²) / (2 * d_total²)
|
||||
let dStart = distFromStart
|
||||
let dEnd = distFromEnd
|
||||
let dTotal = directDistance
|
||||
|
||||
let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd)
|
||||
let denominator = 2 * dTotal * dTotal
|
||||
|
||||
return numerator / denominator
|
||||
}
|
||||
}
|
||||
180
SportsTime/Planning/Engine/TravelEstimator.swift
Normal file
180
SportsTime/Planning/Engine/TravelEstimator.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// 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 = 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
|
||||
}
|
||||
|
||||
}
|
||||
41
SportsTime/Planning/Engine/TripPlanningEngine.swift
Normal file
41
SportsTime/Planning/Engine/TripPlanningEngine.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// TripPlanningEngine.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Thin orchestrator that delegates to scenario-specific planners.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Main entry point for trip planning.
|
||||
/// Delegates to scenario-specific planners via the ScenarioPlanner protocol.
|
||||
final class TripPlanningEngine {
|
||||
|
||||
/// Plans itineraries based on the request inputs.
|
||||
/// Automatically detects which scenario applies and delegates to the appropriate planner.
|
||||
///
|
||||
/// - Parameter request: The planning request containing all inputs
|
||||
/// - Returns: Ranked itineraries on success, or explicit failure with reason
|
||||
func planItineraries(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
// Detect scenario and get the appropriate planner
|
||||
let scenario = ScenarioPlannerFactory.classify(request)
|
||||
let planner = ScenarioPlannerFactory.planner(for: request)
|
||||
|
||||
print("[TripPlanningEngine] Detected scenario: \(scenario)")
|
||||
print("[TripPlanningEngine] Using planner: \(type(of: planner))")
|
||||
|
||||
// Delegate to the scenario planner
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Log result
|
||||
switch result {
|
||||
case .success(let options):
|
||||
print("[TripPlanningEngine] Success: \(options.count) itinerary options")
|
||||
case .failure(let failure):
|
||||
print("[TripPlanningEngine] Failure: \(failure.reason)")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
99
SportsTime/Planning/Models/LegacyPlanningTypes.swift
Normal file
99
SportsTime/Planning/Models/LegacyPlanningTypes.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// LegacyPlanningTypes.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Supporting types for legacy planning components.
|
||||
// These are used by ScheduleMatcher and RouteOptimizer.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - Game Candidate
|
||||
|
||||
/// A game candidate with scoring information for route planning.
|
||||
struct GameCandidate: Identifiable {
|
||||
let id: UUID
|
||||
let game: Game
|
||||
let stadium: Stadium
|
||||
let homeTeam: Team
|
||||
let awayTeam: Team
|
||||
let detourDistance: Double
|
||||
let score: Double
|
||||
}
|
||||
|
||||
// MARK: - Route Graph
|
||||
|
||||
/// Graph representation of possible routes for optimization.
|
||||
struct RouteGraph {
|
||||
var nodes: [RouteNode]
|
||||
var edgesByFromNode: [UUID: [RouteEdge]]
|
||||
|
||||
init(nodes: [RouteNode] = [], edges: [RouteEdge] = []) {
|
||||
self.nodes = nodes
|
||||
self.edgesByFromNode = [:]
|
||||
for edge in edges {
|
||||
edgesByFromNode[edge.fromNodeId, default: []].append(edge)
|
||||
}
|
||||
}
|
||||
|
||||
func edges(from nodeId: UUID) -> [RouteEdge] {
|
||||
edgesByFromNode[nodeId] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Node
|
||||
|
||||
struct RouteNode: Identifiable {
|
||||
let id: UUID
|
||||
let type: RouteNodeType
|
||||
let coordinate: CLLocationCoordinate2D?
|
||||
|
||||
init(id: UUID = UUID(), type: RouteNodeType, coordinate: CLLocationCoordinate2D? = nil) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.coordinate = coordinate
|
||||
}
|
||||
}
|
||||
|
||||
enum RouteNodeType: Equatable {
|
||||
case start
|
||||
case end
|
||||
case stadium(UUID)
|
||||
case waypoint
|
||||
}
|
||||
|
||||
// MARK: - Route Edge
|
||||
|
||||
struct RouteEdge: Identifiable {
|
||||
let id: UUID
|
||||
let fromNodeId: UUID
|
||||
let toNodeId: UUID
|
||||
let distanceMeters: Double
|
||||
let durationSeconds: Double
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
fromNodeId: UUID,
|
||||
toNodeId: UUID,
|
||||
distanceMeters: Double,
|
||||
durationSeconds: Double
|
||||
) {
|
||||
self.id = id
|
||||
self.fromNodeId = fromNodeId
|
||||
self.toNodeId = toNodeId
|
||||
self.distanceMeters = distanceMeters
|
||||
self.durationSeconds = durationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Candidate Route
|
||||
|
||||
/// A candidate route for optimization.
|
||||
struct CandidateRoute {
|
||||
var nodeSequence: [UUID] = []
|
||||
var games: [UUID] = []
|
||||
var totalDistance: Double = 0
|
||||
var totalDuration: Double = 0
|
||||
var score: Double = 0
|
||||
}
|
||||
281
SportsTime/Planning/Models/PlanningModels.swift
Normal file
281
SportsTime/Planning/Models/PlanningModels.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
//
|
||||
// PlanningModels.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Clean model types for trip planning.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - Planning Scenario
|
||||
|
||||
/// Exactly one scenario per request. No blending.
|
||||
enum PlanningScenario: Equatable {
|
||||
case scenarioA // Date range only
|
||||
case scenarioB // Selected games + date range
|
||||
case scenarioC // Start + end locations
|
||||
}
|
||||
|
||||
// MARK: - Planning Failure
|
||||
|
||||
/// Explicit failure with reason. No silent failures.
|
||||
struct PlanningFailure: Error {
|
||||
let reason: FailureReason
|
||||
let violations: [ConstraintViolation]
|
||||
|
||||
enum FailureReason: Equatable {
|
||||
case noGamesInRange
|
||||
case noValidRoutes
|
||||
case missingDateRange
|
||||
case missingLocations
|
||||
case dateRangeViolation(games: [Game])
|
||||
case drivingExceedsLimit
|
||||
case cannotArriveInTime
|
||||
case travelSegmentMissing
|
||||
case constraintsUnsatisfiable
|
||||
case geographicBacktracking
|
||||
|
||||
static func == (lhs: FailureReason, rhs: FailureReason) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.noGamesInRange, .noGamesInRange),
|
||||
(.noValidRoutes, .noValidRoutes),
|
||||
(.missingDateRange, .missingDateRange),
|
||||
(.missingLocations, .missingLocations),
|
||||
(.drivingExceedsLimit, .drivingExceedsLimit),
|
||||
(.cannotArriveInTime, .cannotArriveInTime),
|
||||
(.travelSegmentMissing, .travelSegmentMissing),
|
||||
(.constraintsUnsatisfiable, .constraintsUnsatisfiable),
|
||||
(.geographicBacktracking, .geographicBacktracking):
|
||||
return true
|
||||
case (.dateRangeViolation(let g1), .dateRangeViolation(let g2)):
|
||||
return g1.map { $0.id } == g2.map { $0.id }
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(reason: FailureReason, violations: [ConstraintViolation] = []) {
|
||||
self.reason = reason
|
||||
self.violations = violations
|
||||
}
|
||||
|
||||
var message: String {
|
||||
switch reason {
|
||||
case .noGamesInRange: return "No games found within the date range"
|
||||
case .noValidRoutes: return "No valid routes could be constructed"
|
||||
case .missingDateRange: return "Date range is required"
|
||||
case .missingLocations: return "Start and end locations are required"
|
||||
case .dateRangeViolation(let games):
|
||||
return "\(games.count) selected game(s) fall outside the date range"
|
||||
case .drivingExceedsLimit: return "Driving time exceeds daily limit"
|
||||
case .cannotArriveInTime: return "Cannot arrive before game starts"
|
||||
case .travelSegmentMissing: return "Travel segment could not be created"
|
||||
case .constraintsUnsatisfiable: return "Cannot satisfy all trip constraints"
|
||||
case .geographicBacktracking: return "Route requires excessive backtracking"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Constraint Violation
|
||||
|
||||
struct ConstraintViolation: Equatable {
|
||||
let type: ConstraintType
|
||||
let description: String
|
||||
let severity: ViolationSeverity
|
||||
|
||||
init(constraint: String, detail: String) {
|
||||
self.type = .general
|
||||
self.description = "\(constraint): \(detail)"
|
||||
self.severity = .error
|
||||
}
|
||||
|
||||
init(type: ConstraintType, description: String, severity: ViolationSeverity) {
|
||||
self.type = type
|
||||
self.description = description
|
||||
self.severity = severity
|
||||
}
|
||||
}
|
||||
|
||||
enum ConstraintType: String, Equatable {
|
||||
case dateRange
|
||||
case drivingTime
|
||||
case geographicSanity
|
||||
case mustStop
|
||||
case selectedGames
|
||||
case gameReachability
|
||||
case general
|
||||
}
|
||||
|
||||
enum ViolationSeverity: Equatable {
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
// MARK: - Must Stop Config
|
||||
|
||||
struct MustStopConfig {
|
||||
static let defaultProximityMiles: Double = 25
|
||||
let proximityMiles: Double
|
||||
|
||||
init(proximityMiles: Double = MustStopConfig.defaultProximityMiles) {
|
||||
self.proximityMiles = proximityMiles
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Itinerary Result
|
||||
|
||||
/// Either success with ranked options, or explicit failure.
|
||||
enum ItineraryResult {
|
||||
case success([ItineraryOption])
|
||||
case failure(PlanningFailure)
|
||||
|
||||
var isSuccess: Bool {
|
||||
if case .success = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var options: [ItineraryOption] {
|
||||
if case .success(let opts) = self { return opts }
|
||||
return []
|
||||
}
|
||||
|
||||
var failure: PlanningFailure? {
|
||||
if case .failure(let f) = self { return f }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Candidate
|
||||
|
||||
/// Intermediate structure during planning.
|
||||
struct RouteCandidate {
|
||||
let stops: [ItineraryStop]
|
||||
let rationale: String
|
||||
}
|
||||
|
||||
// MARK: - Itinerary Option
|
||||
|
||||
/// A valid, ranked itinerary option.
|
||||
struct ItineraryOption: Identifiable {
|
||||
let id = UUID()
|
||||
let rank: Int
|
||||
let stops: [ItineraryStop]
|
||||
let travelSegments: [TravelSegment]
|
||||
let totalDrivingHours: Double
|
||||
let totalDistanceMiles: Double
|
||||
let geographicRationale: String
|
||||
|
||||
/// INVARIANT: travelSegments.count == stops.count - 1 (or 0 if single stop)
|
||||
var isValid: Bool {
|
||||
if stops.count <= 1 { return travelSegments.isEmpty }
|
||||
return travelSegments.count == stops.count - 1
|
||||
}
|
||||
|
||||
var totalGames: Int {
|
||||
stops.reduce(0) { $0 + $1.games.count }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Itinerary Stop
|
||||
|
||||
/// A stop in the itinerary.
|
||||
struct ItineraryStop: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let city: String
|
||||
let state: String
|
||||
let coordinate: CLLocationCoordinate2D?
|
||||
let games: [UUID]
|
||||
let arrivalDate: Date
|
||||
let departureDate: Date
|
||||
let location: LocationInput
|
||||
let firstGameStart: Date?
|
||||
|
||||
var hasGames: Bool { !games.isEmpty }
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: ItineraryStop, rhs: ItineraryStop) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Driving Constraints
|
||||
|
||||
/// Driving feasibility constraints.
|
||||
struct DrivingConstraints {
|
||||
let numberOfDrivers: Int
|
||||
let maxHoursPerDriverPerDay: Double
|
||||
|
||||
static let `default` = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||||
|
||||
var maxDailyDrivingHours: Double {
|
||||
Double(numberOfDrivers) * maxHoursPerDriverPerDay
|
||||
}
|
||||
|
||||
init(numberOfDrivers: Int = 1, maxHoursPerDriverPerDay: Double = 8.0) {
|
||||
self.numberOfDrivers = max(1, numberOfDrivers)
|
||||
self.maxHoursPerDriverPerDay = max(1.0, maxHoursPerDriverPerDay)
|
||||
}
|
||||
|
||||
init(from preferences: TripPreferences) {
|
||||
self.numberOfDrivers = max(1, preferences.numberOfDrivers)
|
||||
self.maxHoursPerDriverPerDay = preferences.maxDrivingHoursPerDriver ?? 8.0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Planning Request
|
||||
|
||||
/// Input to the planning engine.
|
||||
struct PlanningRequest {
|
||||
let preferences: TripPreferences
|
||||
let availableGames: [Game]
|
||||
let teams: [UUID: Team]
|
||||
let stadiums: [UUID: Stadium]
|
||||
|
||||
// MARK: - Computed Properties for Engine
|
||||
|
||||
/// Games the user explicitly selected (anchors for Scenario B)
|
||||
var selectedGames: [Game] {
|
||||
availableGames.filter { preferences.mustSeeGameIds.contains($0.id) }
|
||||
}
|
||||
|
||||
/// All available games
|
||||
var allGames: [Game] {
|
||||
availableGames
|
||||
}
|
||||
|
||||
/// Start location (for Scenario C)
|
||||
var startLocation: LocationInput? {
|
||||
preferences.startLocation
|
||||
}
|
||||
|
||||
/// End location (for Scenario C)
|
||||
var endLocation: LocationInput? {
|
||||
preferences.endLocation
|
||||
}
|
||||
|
||||
/// Date range as DateInterval
|
||||
var dateRange: DateInterval? {
|
||||
guard preferences.endDate > preferences.startDate else { return nil }
|
||||
return DateInterval(start: preferences.startDate, end: preferences.endDate)
|
||||
}
|
||||
|
||||
/// First must-stop location (if any)
|
||||
var mustStopLocation: LocationInput? {
|
||||
preferences.mustStopLocations.first
|
||||
}
|
||||
|
||||
/// Driving constraints
|
||||
var drivingConstraints: DrivingConstraints {
|
||||
DrivingConstraints(from: preferences)
|
||||
}
|
||||
|
||||
/// Get stadium for a game
|
||||
func stadium(for game: Game) -> Stadium? {
|
||||
stadiums[game.stadiumId]
|
||||
}
|
||||
}
|
||||
196
SportsTime/Planning/Scoring/TripScorer.swift
Normal file
196
SportsTime/Planning/Scoring/TripScorer.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// TripScorer.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TripScorer {
|
||||
|
||||
// MARK: - Score Trip
|
||||
|
||||
func score(trip: Trip, request: PlanningRequest) -> Trip {
|
||||
let gameQuality = calculateGameQualityScore(trip: trip, request: request)
|
||||
let routeEfficiency = calculateRouteEfficiencyScore(trip: trip, request: request)
|
||||
let leisureBalance = calculateLeisureBalanceScore(trip: trip, request: request)
|
||||
let preferenceAlignment = calculatePreferenceAlignmentScore(trip: trip, request: request)
|
||||
|
||||
// Weighted average
|
||||
let weights = (game: 0.35, route: 0.25, leisure: 0.20, preference: 0.20)
|
||||
|
||||
let overall = (
|
||||
gameQuality * weights.game +
|
||||
routeEfficiency * weights.route +
|
||||
leisureBalance * weights.leisure +
|
||||
preferenceAlignment * weights.preference
|
||||
)
|
||||
|
||||
let score = TripScore(
|
||||
overallScore: overall,
|
||||
gameQualityScore: gameQuality,
|
||||
routeEfficiencyScore: routeEfficiency,
|
||||
leisureBalanceScore: leisureBalance,
|
||||
preferenceAlignmentScore: preferenceAlignment
|
||||
)
|
||||
|
||||
var scoredTrip = trip
|
||||
scoredTrip.score = score
|
||||
return scoredTrip
|
||||
}
|
||||
|
||||
// MARK: - Game Quality Score
|
||||
|
||||
private func calculateGameQualityScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||
var score: Double = 0
|
||||
|
||||
let totalPossibleGames = Double(max(1, request.availableGames.count))
|
||||
let gamesAttended = Double(trip.totalGames)
|
||||
|
||||
// Base score from number of games
|
||||
let gameRatio = gamesAttended / min(totalPossibleGames, Double(trip.tripDuration))
|
||||
score += gameRatio * 50
|
||||
|
||||
// Bonus for including must-see games
|
||||
let mustSeeIncluded = trip.stops.flatMap { $0.games }
|
||||
.filter { request.preferences.mustSeeGameIds.contains($0) }
|
||||
.count
|
||||
let mustSeeRatio = Double(mustSeeIncluded) / Double(max(1, request.preferences.mustSeeGameIds.count))
|
||||
score += mustSeeRatio * 30
|
||||
|
||||
// Bonus for sport variety
|
||||
let sportsAttended = Set(request.availableGames
|
||||
.filter { trip.stops.flatMap { $0.games }.contains($0.id) }
|
||||
.map { $0.sport }
|
||||
)
|
||||
let varietyBonus = Double(sportsAttended.count) / Double(max(1, request.preferences.sports.count)) * 20
|
||||
score += varietyBonus
|
||||
|
||||
return min(100, score)
|
||||
}
|
||||
|
||||
// MARK: - Route Efficiency Score
|
||||
|
||||
private func calculateRouteEfficiencyScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||
guard let startLocation = request.preferences.startLocation,
|
||||
let endLocation = request.preferences.endLocation,
|
||||
let startCoord = startLocation.coordinate,
|
||||
let endCoord = endLocation.coordinate else {
|
||||
return 50.0
|
||||
}
|
||||
|
||||
// Calculate direct distance
|
||||
let directDistance = CLLocation(latitude: startCoord.latitude, longitude: startCoord.longitude)
|
||||
.distance(from: CLLocation(latitude: endCoord.latitude, longitude: endCoord.longitude))
|
||||
|
||||
guard trip.totalDistanceMeters > 0, directDistance > 0 else {
|
||||
return 50.0
|
||||
}
|
||||
|
||||
// Efficiency ratio (direct / actual)
|
||||
let efficiency = directDistance / trip.totalDistanceMeters
|
||||
|
||||
// Score: 100 for perfect efficiency, lower for longer routes
|
||||
// Allow up to 3x direct distance before score drops significantly
|
||||
let normalizedEfficiency = min(1.0, efficiency * 2)
|
||||
|
||||
return normalizedEfficiency * 100
|
||||
}
|
||||
|
||||
// MARK: - Leisure Balance Score
|
||||
|
||||
private func calculateLeisureBalanceScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||
let leisureLevel = request.preferences.leisureLevel
|
||||
var score: Double = 100
|
||||
|
||||
// Check average driving hours
|
||||
let avgDrivingHours = trip.averageDrivingHoursPerDay
|
||||
let targetDrivingHours: Double = switch leisureLevel {
|
||||
case .packed: 8.0
|
||||
case .moderate: 6.0
|
||||
case .relaxed: 4.0
|
||||
}
|
||||
|
||||
if avgDrivingHours > targetDrivingHours {
|
||||
let excess = avgDrivingHours - targetDrivingHours
|
||||
score -= excess * 10
|
||||
}
|
||||
|
||||
// Check rest day ratio
|
||||
let restDays = trip.stops.filter { $0.isRestDay }.count
|
||||
let targetRestRatio = leisureLevel.restDaysPerWeek / 7.0
|
||||
let actualRestRatio = Double(restDays) / Double(max(1, trip.tripDuration))
|
||||
|
||||
let restDifference = abs(actualRestRatio - targetRestRatio)
|
||||
score -= restDifference * 50
|
||||
|
||||
// Check games per day vs target
|
||||
let gamesPerDay = Double(trip.totalGames) / Double(max(1, trip.tripDuration))
|
||||
let targetGamesPerDay = Double(leisureLevel.maxGamesPerWeek) / 7.0
|
||||
|
||||
if gamesPerDay > targetGamesPerDay {
|
||||
let excess = gamesPerDay - targetGamesPerDay
|
||||
score -= excess * 20
|
||||
}
|
||||
|
||||
return max(0, min(100, score))
|
||||
}
|
||||
|
||||
// MARK: - Preference Alignment Score
|
||||
|
||||
private func calculatePreferenceAlignmentScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||
var score: Double = 100
|
||||
|
||||
// Check if must-stop locations are visited
|
||||
let visitedCities = Set(trip.stops.map { $0.city.lowercased() })
|
||||
for location in request.preferences.mustStopLocations {
|
||||
if !visitedCities.contains(location.name.lowercased()) {
|
||||
score -= 15
|
||||
}
|
||||
}
|
||||
|
||||
// Bonus for preferred cities
|
||||
for city in request.preferences.preferredCities {
|
||||
if visitedCities.contains(city.lowercased()) {
|
||||
score += 5
|
||||
}
|
||||
}
|
||||
|
||||
// Check EV charging if needed
|
||||
if request.preferences.needsEVCharging {
|
||||
let hasEVStops = trip.travelSegments.contains { !$0.evChargingStops.isEmpty }
|
||||
if !hasEVStops && trip.travelSegments.contains(where: { $0.distanceMiles > 200 }) {
|
||||
score -= 20 // Long drive without EV stops
|
||||
}
|
||||
}
|
||||
|
||||
// Check lodging type alignment
|
||||
let lodgingMatches = trip.stops.filter { stop in
|
||||
stop.lodging?.type == request.preferences.lodgingType
|
||||
}.count
|
||||
let lodgingRatio = Double(lodgingMatches) / Double(max(1, trip.stops.count))
|
||||
score = score * (0.5 + lodgingRatio * 0.5)
|
||||
|
||||
// Check if within stop limit
|
||||
if let maxStops = request.preferences.numberOfStops {
|
||||
if trip.stops.count > maxStops {
|
||||
score -= Double(trip.stops.count - maxStops) * 10
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, min(100, score))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreML Integration (Placeholder)
|
||||
|
||||
extension TripScorer {
|
||||
|
||||
/// Score using CoreML model if available
|
||||
func scoreWithML(trip: Trip, request: PlanningRequest) -> Trip {
|
||||
// In production, this would use a CoreML model for personalized scoring
|
||||
// For now, fall back to rule-based scoring
|
||||
return score(trip: trip, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
import CoreLocation
|
||||
127
SportsTime/Planning/Validators/DateRangeValidator.swift
Normal file
127
SportsTime/Planning/Validators/DateRangeValidator.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// DateRangeValidator.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Validates that all games fall within the specified date range.
|
||||
/// Priority 1 in the rule hierarchy - checked first before any other constraints.
|
||||
struct DateRangeValidator {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let gamesOutsideRange: [UUID]
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], gamesOutsideRange: [])
|
||||
|
||||
static func invalid(games: [UUID]) -> ValidationResult {
|
||||
let violations = games.map { gameId in
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "Game \(gameId.uuidString.prefix(8)) falls outside the specified date range",
|
||||
severity: .error
|
||||
)
|
||||
}
|
||||
return ValidationResult(isValid: false, violations: violations, gamesOutsideRange: games)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Validates that ALL selected games (must-see games) fall within the date range.
|
||||
/// This is a HARD constraint - if any selected game is outside the range, planning fails.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustSeeGameIds: Set of game IDs that MUST be included in the trip
|
||||
/// - allGames: All available games to check against
|
||||
/// - startDate: Start of the valid date range (inclusive)
|
||||
/// - endDate: End of the valid date range (inclusive)
|
||||
/// - Returns: ValidationResult indicating success or failure with specific violations
|
||||
func validate(
|
||||
mustSeeGameIds: Set<UUID>,
|
||||
allGames: [Game],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) -> ValidationResult {
|
||||
// If no must-see games, validation passes
|
||||
guard !mustSeeGameIds.isEmpty else {
|
||||
return .valid
|
||||
}
|
||||
|
||||
// Find all must-see games that fall outside the range
|
||||
let gamesOutsideRange = allGames
|
||||
.filter { mustSeeGameIds.contains($0.id) }
|
||||
.filter { game in
|
||||
game.dateTime < startDate || game.dateTime > endDate
|
||||
}
|
||||
.map { $0.id }
|
||||
|
||||
if gamesOutsideRange.isEmpty {
|
||||
return .valid
|
||||
} else {
|
||||
return .invalid(games: gamesOutsideRange)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates games for Scenario B (Selected Games mode).
|
||||
/// ALL selected games MUST be within the date range - no exceptions.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: The planning request containing preferences and games
|
||||
/// - Returns: ValidationResult with explicit failure if any selected game is out of range
|
||||
func validateForScenarioB(_ request: PlanningRequest) -> ValidationResult {
|
||||
return validate(
|
||||
mustSeeGameIds: request.preferences.mustSeeGameIds,
|
||||
allGames: request.availableGames,
|
||||
startDate: request.preferences.startDate,
|
||||
endDate: request.preferences.endDate
|
||||
)
|
||||
}
|
||||
|
||||
/// Checks if there are any games available within the date range.
|
||||
/// Used to determine if planning can proceed at all.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - games: All available games
|
||||
/// - startDate: Start of the valid date range
|
||||
/// - endDate: End of the valid date range
|
||||
/// - sports: Sports to filter by
|
||||
/// - Returns: True if at least one game exists in the range
|
||||
func hasGamesInRange(
|
||||
games: [Game],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
sports: Set<Sport>
|
||||
) -> Bool {
|
||||
games.contains { game in
|
||||
game.dateTime >= startDate &&
|
||||
game.dateTime <= endDate &&
|
||||
sports.contains(game.sport)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all games that fall within the specified date range.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - games: All available games
|
||||
/// - startDate: Start of the valid date range
|
||||
/// - endDate: End of the valid date range
|
||||
/// - sports: Sports to filter by
|
||||
/// - Returns: Array of games within the range
|
||||
func gamesInRange(
|
||||
games: [Game],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
sports: Set<Sport>
|
||||
) -> [Game] {
|
||||
games.filter { game in
|
||||
game.dateTime >= startDate &&
|
||||
game.dateTime <= endDate &&
|
||||
sports.contains(game.sport)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift
Normal file
200
SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// DrivingFeasibilityValidator.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Validates driving feasibility based on daily hour limits.
|
||||
/// Priority 4 in the rule hierarchy.
|
||||
///
|
||||
/// A route is valid ONLY IF:
|
||||
/// - Daily driving time ≤ maxDailyDrivingHours for EVERY day
|
||||
/// - Games are reachable between scheduled times
|
||||
struct DrivingFeasibilityValidator {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let failedSegment: SegmentFailure?
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], failedSegment: nil)
|
||||
|
||||
static func drivingExceeded(
|
||||
segment: String,
|
||||
requiredHours: Double,
|
||||
limitHours: Double
|
||||
) -> ValidationResult {
|
||||
let violation = ConstraintViolation(
|
||||
type: .drivingTime,
|
||||
description: "\(segment) requires \(String(format: "%.1f", requiredHours)) hours driving (limit: \(String(format: "%.1f", limitHours)) hours)",
|
||||
severity: .error
|
||||
)
|
||||
let failure = SegmentFailure(
|
||||
segmentDescription: segment,
|
||||
requiredHours: requiredHours,
|
||||
limitHours: limitHours
|
||||
)
|
||||
return ValidationResult(isValid: false, violations: [violation], failedSegment: failure)
|
||||
}
|
||||
|
||||
static func gameUnreachable(
|
||||
gameId: UUID,
|
||||
arrivalTime: Date,
|
||||
gameTime: Date
|
||||
) -> ValidationResult {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
let violation = ConstraintViolation(
|
||||
type: .gameReachability,
|
||||
description: "Cannot arrive (\(formatter.string(from: arrivalTime))) before game starts (\(formatter.string(from: gameTime)))",
|
||||
severity: .error
|
||||
)
|
||||
return ValidationResult(isValid: false, violations: [violation], failedSegment: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct SegmentFailure {
|
||||
let segmentDescription: String
|
||||
let requiredHours: Double
|
||||
let limitHours: Double
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let constraints: DrivingConstraints
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(constraints: DrivingConstraints = .default) {
|
||||
self.constraints = constraints
|
||||
}
|
||||
|
||||
init(from preferences: TripPreferences) {
|
||||
self.constraints = DrivingConstraints(from: preferences)
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Validates that a single travel segment is feasible within daily driving limits.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - drivingHours: Required driving hours for this segment
|
||||
/// - origin: Description of the origin location
|
||||
/// - destination: Description of the destination location
|
||||
/// - Returns: ValidationResult indicating if the segment is feasible
|
||||
func validateSegment(
|
||||
drivingHours: Double,
|
||||
origin: String,
|
||||
destination: String
|
||||
) -> ValidationResult {
|
||||
let maxDaily = constraints.maxDailyDrivingHours
|
||||
|
||||
// A segment is valid if it can be completed in one day OR
|
||||
// can be split across multiple days with overnight stops
|
||||
if drivingHours <= maxDaily {
|
||||
return .valid
|
||||
}
|
||||
|
||||
// Check if it can be reasonably split across multiple days
|
||||
// We allow up to 2 driving days for a single segment
|
||||
let maxTwoDayDriving = maxDaily * 2
|
||||
if drivingHours <= maxTwoDayDriving {
|
||||
return .valid
|
||||
}
|
||||
|
||||
// Segment requires more than 2 days of driving - too long
|
||||
return .drivingExceeded(
|
||||
segment: "\(origin) → \(destination)",
|
||||
requiredHours: drivingHours,
|
||||
limitHours: maxDaily
|
||||
)
|
||||
}
|
||||
|
||||
/// Validates that a game can be reached in time given departure time and driving duration.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - gameId: ID of the game to reach
|
||||
/// - gameTime: When the game starts
|
||||
/// - departureTime: When we leave the previous stop
|
||||
/// - drivingHours: Hours of driving required
|
||||
/// - bufferHours: Buffer time needed before game (default 1 hour for parking, etc.)
|
||||
/// - Returns: ValidationResult indicating if we can reach the game in time
|
||||
func validateGameReachability(
|
||||
gameId: UUID,
|
||||
gameTime: Date,
|
||||
departureTime: Date,
|
||||
drivingHours: Double,
|
||||
bufferHours: Double = 1.0
|
||||
) -> ValidationResult {
|
||||
// Calculate arrival time
|
||||
let drivingSeconds = drivingHours * 3600
|
||||
let bufferSeconds = bufferHours * 3600
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingSeconds)
|
||||
let requiredArrivalTime = gameTime.addingTimeInterval(-bufferSeconds)
|
||||
|
||||
if arrivalTime <= requiredArrivalTime {
|
||||
return .valid
|
||||
} else {
|
||||
return .gameUnreachable(
|
||||
gameId: gameId,
|
||||
arrivalTime: arrivalTime,
|
||||
gameTime: gameTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates an entire itinerary's travel segments for driving feasibility.
|
||||
///
|
||||
/// - Parameter segments: Array of travel segments to validate
|
||||
/// - Returns: ValidationResult with first failure found, or valid if all pass
|
||||
func validateItinerary(segments: [TravelSegment]) -> ValidationResult {
|
||||
for segment in segments {
|
||||
let result = validateSegment(
|
||||
drivingHours: segment.estimatedDrivingHours,
|
||||
origin: segment.fromLocation.name,
|
||||
destination: segment.toLocation.name
|
||||
)
|
||||
if !result.isValid {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return .valid
|
||||
}
|
||||
|
||||
/// Calculates how many travel days are needed for a given driving distance.
|
||||
///
|
||||
/// - Parameter drivingHours: Total hours of driving required
|
||||
/// - Returns: Number of calendar days the travel will span
|
||||
func travelDaysRequired(for drivingHours: Double) -> Int {
|
||||
let maxDaily = constraints.maxDailyDrivingHours
|
||||
if drivingHours <= maxDaily {
|
||||
return 1
|
||||
}
|
||||
return Int(ceil(drivingHours / maxDaily))
|
||||
}
|
||||
|
||||
/// Determines if an overnight stop is needed between two points.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - drivingHours: Hours of driving between points
|
||||
/// - departureTime: When we plan to leave
|
||||
/// - Returns: True if an overnight stop is recommended
|
||||
func needsOvernightStop(drivingHours: Double, departureTime: Date) -> Bool {
|
||||
// If driving exceeds daily limit, we need an overnight
|
||||
if drivingHours > constraints.maxDailyDrivingHours {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check if arrival would be unreasonably late
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||
let calendar = Calendar.current
|
||||
let arrivalHour = calendar.component(.hour, from: arrivalTime)
|
||||
|
||||
// Arriving after 11 PM suggests we should have stopped
|
||||
return arrivalHour >= 23
|
||||
}
|
||||
}
|
||||
229
SportsTime/Planning/Validators/GeographicSanityChecker.swift
Normal file
229
SportsTime/Planning/Validators/GeographicSanityChecker.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// GeographicSanityChecker.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Validates geographic sanity of routes - no zig-zagging or excessive backtracking.
|
||||
/// Priority 5 in the rule hierarchy.
|
||||
///
|
||||
/// For Scenario C (directional routes with start+end):
|
||||
/// - Route MUST make net progress toward the end location
|
||||
/// - Temporary increases in distance are allowed only if minor and followed by progress
|
||||
/// - Large backtracking or oscillation is prohibited
|
||||
///
|
||||
/// For all scenarios:
|
||||
/// - Detects obvious zig-zag patterns (e.g., Chicago → Dallas → San Diego → Minnesota → NY)
|
||||
struct GeographicSanityChecker {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let backtrackingDetails: BacktrackingInfo?
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], backtrackingDetails: nil)
|
||||
|
||||
static func backtracking(info: BacktrackingInfo) -> ValidationResult {
|
||||
let violation = ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: info.description,
|
||||
severity: .error
|
||||
)
|
||||
return ValidationResult(isValid: false, violations: [violation], backtrackingDetails: info)
|
||||
}
|
||||
}
|
||||
|
||||
struct BacktrackingInfo {
|
||||
let fromCity: String
|
||||
let toCity: String
|
||||
let distanceIncreasePercent: Double
|
||||
let description: String
|
||||
|
||||
init(fromCity: String, toCity: String, distanceIncreasePercent: Double) {
|
||||
self.fromCity = fromCity
|
||||
self.toCity = toCity
|
||||
self.distanceIncreasePercent = distanceIncreasePercent
|
||||
self.description = "Route backtracks from \(fromCity) to \(toCity) (distance to destination increased by \(String(format: "%.0f", distanceIncreasePercent))%)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Maximum allowed distance increase before flagging as backtracking (percentage)
|
||||
private let maxAllowedDistanceIncrease: Double = 0.15 // 15%
|
||||
|
||||
/// Number of consecutive distance increases before flagging as zig-zag
|
||||
private let maxConsecutiveIncreases: Int = 2
|
||||
|
||||
// MARK: - Scenario C: Directional Route Validation
|
||||
|
||||
/// Validates that a route makes monotonic progress toward the end location.
|
||||
/// This is the primary validation for Scenario C (start + end location).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stops: Ordered array of stops in the route
|
||||
/// - endCoordinate: The target destination coordinate
|
||||
/// - Returns: ValidationResult indicating if route has valid directional progress
|
||||
func validateDirectionalProgress(
|
||||
stops: [ItineraryStop],
|
||||
endCoordinate: CLLocationCoordinate2D
|
||||
) -> ValidationResult {
|
||||
guard stops.count >= 2 else {
|
||||
return .valid // Single stop or empty route is trivially valid
|
||||
}
|
||||
|
||||
var consecutiveIncreases = 0
|
||||
var previousDistance: CLLocationDistance?
|
||||
var previousCity: String?
|
||||
|
||||
for stop in stops {
|
||||
guard let coordinate = stop.coordinate else { continue }
|
||||
|
||||
let currentDistance = distance(from: coordinate, to: endCoordinate)
|
||||
|
||||
if let prevDist = previousDistance, let prevCity = previousCity {
|
||||
if currentDistance > prevDist {
|
||||
// Distance to end increased - potential backtracking
|
||||
let increasePercent = (currentDistance - prevDist) / prevDist
|
||||
consecutiveIncreases += 1
|
||||
|
||||
// Check if this increase is too large
|
||||
if increasePercent > maxAllowedDistanceIncrease {
|
||||
return .backtracking(info: BacktrackingInfo(
|
||||
fromCity: prevCity,
|
||||
toCity: stop.city,
|
||||
distanceIncreasePercent: increasePercent * 100
|
||||
))
|
||||
}
|
||||
|
||||
// Check for oscillation (too many consecutive increases)
|
||||
if consecutiveIncreases >= maxConsecutiveIncreases {
|
||||
return .backtracking(info: BacktrackingInfo(
|
||||
fromCity: prevCity,
|
||||
toCity: stop.city,
|
||||
distanceIncreasePercent: increasePercent * 100
|
||||
))
|
||||
}
|
||||
} else {
|
||||
// Making progress - reset counter
|
||||
consecutiveIncreases = 0
|
||||
}
|
||||
}
|
||||
|
||||
previousDistance = currentDistance
|
||||
previousCity = stop.city
|
||||
}
|
||||
|
||||
return .valid
|
||||
}
|
||||
|
||||
// MARK: - General Geographic Sanity
|
||||
|
||||
/// Validates that a route doesn't have obvious zig-zag patterns.
|
||||
/// Uses compass bearing analysis to detect direction reversals.
|
||||
///
|
||||
/// - Parameter stops: Ordered array of stops in the route
|
||||
/// - Returns: ValidationResult indicating if route is geographically sane
|
||||
func validateNoZigZag(stops: [ItineraryStop]) -> ValidationResult {
|
||||
guard stops.count >= 3 else {
|
||||
return .valid // Need at least 3 stops to detect zig-zag
|
||||
}
|
||||
|
||||
var bearingReversals = 0
|
||||
var previousBearing: Double?
|
||||
|
||||
for i in 0..<(stops.count - 1) {
|
||||
guard let from = stops[i].coordinate,
|
||||
let to = stops[i + 1].coordinate else { continue }
|
||||
|
||||
let currentBearing = bearing(from: from, to: to)
|
||||
|
||||
if let prevBearing = previousBearing {
|
||||
// Check if we've reversed direction (>90 degree change)
|
||||
let bearingChange = abs(normalizedBearingDifference(prevBearing, currentBearing))
|
||||
if bearingChange > 90 {
|
||||
bearingReversals += 1
|
||||
}
|
||||
}
|
||||
|
||||
previousBearing = currentBearing
|
||||
}
|
||||
|
||||
// Allow at most one major direction change (e.g., going east then north is fine)
|
||||
// But multiple reversals indicate zig-zagging
|
||||
if bearingReversals > 1 {
|
||||
return .backtracking(info: BacktrackingInfo(
|
||||
fromCity: stops.first?.city ?? "Start",
|
||||
toCity: stops.last?.city ?? "End",
|
||||
distanceIncreasePercent: Double(bearingReversals) * 30 // Rough estimate
|
||||
))
|
||||
}
|
||||
|
||||
return .valid
|
||||
}
|
||||
|
||||
/// Validates a complete route for both directional progress (if end is specified)
|
||||
/// and general geographic sanity.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stops: Ordered array of stops
|
||||
/// - endCoordinate: Optional end coordinate for directional validation
|
||||
/// - Returns: Combined validation result
|
||||
func validate(
|
||||
stops: [ItineraryStop],
|
||||
endCoordinate: CLLocationCoordinate2D?
|
||||
) -> ValidationResult {
|
||||
// If we have an end coordinate, validate directional progress
|
||||
if let end = endCoordinate {
|
||||
let directionalResult = validateDirectionalProgress(stops: stops, endCoordinate: end)
|
||||
if !directionalResult.isValid {
|
||||
return directionalResult
|
||||
}
|
||||
}
|
||||
|
||||
// Always check for zig-zag patterns
|
||||
return validateNoZigZag(stops: stops)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Calculates distance between two coordinates in meters.
|
||||
private func distance(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> CLLocationDistance {
|
||||
let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||
let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||
return fromLocation.distance(from: toLocation)
|
||||
}
|
||||
|
||||
/// Calculates bearing (direction) from one coordinate to another in degrees.
|
||||
private func bearing(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let lat1 = from.latitude * .pi / 180
|
||||
let lat2 = to.latitude * .pi / 180
|
||||
let dLon = (to.longitude - from.longitude) * .pi / 180
|
||||
|
||||
let y = sin(dLon) * cos(lat2)
|
||||
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
|
||||
|
||||
var bearing = atan2(y, x) * 180 / .pi
|
||||
bearing = (bearing + 360).truncatingRemainder(dividingBy: 360)
|
||||
|
||||
return bearing
|
||||
}
|
||||
|
||||
/// Calculates the normalized difference between two bearings (-180 to 180).
|
||||
private func normalizedBearingDifference(_ bearing1: Double, _ bearing2: Double) -> Double {
|
||||
var diff = bearing2 - bearing1
|
||||
while diff > 180 { diff -= 360 }
|
||||
while diff < -180 { diff += 360 }
|
||||
return diff
|
||||
}
|
||||
}
|
||||
253
SportsTime/Planning/Validators/MustStopValidator.swift
Normal file
253
SportsTime/Planning/Validators/MustStopValidator.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
//
|
||||
// MustStopValidator.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Validates that must-stop locations are reachable by the route.
|
||||
/// Priority 6 in the rule hierarchy (lowest priority).
|
||||
///
|
||||
/// A route "passes" a must-stop location if:
|
||||
/// - Any travel segment comes within the proximity threshold (default 25 miles)
|
||||
/// - The must-stop does NOT require a separate overnight stay
|
||||
struct MustStopValidator {
|
||||
|
||||
// MARK: - Validation Result
|
||||
|
||||
struct ValidationResult {
|
||||
let isValid: Bool
|
||||
let violations: [ConstraintViolation]
|
||||
let unreachableLocations: [String]
|
||||
|
||||
static let valid = ValidationResult(isValid: true, violations: [], unreachableLocations: [])
|
||||
|
||||
static func unreachable(locations: [String]) -> ValidationResult {
|
||||
let violations = locations.map { location in
|
||||
ConstraintViolation(
|
||||
type: .mustStop,
|
||||
description: "Required stop '\(location)' is not reachable within \(Int(MustStopConfig.defaultProximityMiles)) miles of any route segment",
|
||||
severity: .error
|
||||
)
|
||||
}
|
||||
return ValidationResult(isValid: false, violations: violations, unreachableLocations: locations)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let config: MustStopConfig
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(config: MustStopConfig = MustStopConfig()) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Validates that all must-stop locations can be reached by the route.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustStopLocations: Array of locations that must be visited/passed
|
||||
/// - stops: The planned stops in the itinerary
|
||||
/// - segments: The travel segments between stops
|
||||
/// - Returns: ValidationResult indicating if all must-stops are reachable
|
||||
func validate(
|
||||
mustStopLocations: [LocationInput],
|
||||
stops: [ItineraryStop],
|
||||
segments: [TravelSegment]
|
||||
) -> ValidationResult {
|
||||
guard !mustStopLocations.isEmpty else {
|
||||
return .valid
|
||||
}
|
||||
|
||||
var unreachable: [String] = []
|
||||
|
||||
for mustStop in mustStopLocations {
|
||||
if !isReachable(mustStop: mustStop, stops: stops, segments: segments) {
|
||||
unreachable.append(mustStop.name)
|
||||
}
|
||||
}
|
||||
|
||||
if unreachable.isEmpty {
|
||||
return .valid
|
||||
} else {
|
||||
return .unreachable(locations: unreachable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates must-stop locations from a planning request.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: The planning request with must-stop preferences
|
||||
/// - stops: The planned stops
|
||||
/// - segments: The travel segments
|
||||
/// - Returns: ValidationResult
|
||||
func validate(
|
||||
request: PlanningRequest,
|
||||
stops: [ItineraryStop],
|
||||
segments: [TravelSegment]
|
||||
) -> ValidationResult {
|
||||
return validate(
|
||||
mustStopLocations: request.preferences.mustStopLocations,
|
||||
stops: stops,
|
||||
segments: segments
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Reachability Check
|
||||
|
||||
/// Checks if a must-stop location is reachable by the route.
|
||||
/// A location is reachable if:
|
||||
/// 1. It's within proximity of any stop, OR
|
||||
/// 2. It's within proximity of any travel segment path
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustStop: The location to check
|
||||
/// - stops: Planned stops
|
||||
/// - segments: Travel segments
|
||||
/// - Returns: True if the location is reachable
|
||||
private func isReachable(
|
||||
mustStop: LocationInput,
|
||||
stops: [ItineraryStop],
|
||||
segments: [TravelSegment]
|
||||
) -> Bool {
|
||||
guard let mustStopCoord = mustStop.coordinate else {
|
||||
// If we don't have coordinates, we can't validate - assume reachable
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if any stop is within proximity
|
||||
for stop in stops {
|
||||
if let stopCoord = stop.coordinate {
|
||||
let distance = distanceInMiles(from: mustStopCoord, to: stopCoord)
|
||||
if distance <= config.proximityMiles {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any segment passes within proximity
|
||||
for segment in segments {
|
||||
if isNearSegment(point: mustStopCoord, segment: segment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Checks if a point is near a travel segment.
|
||||
/// Uses perpendicular distance to the segment line.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - point: The point to check
|
||||
/// - segment: The travel segment
|
||||
/// - Returns: True if within proximity
|
||||
private func isNearSegment(
|
||||
point: CLLocationCoordinate2D,
|
||||
segment: TravelSegment
|
||||
) -> Bool {
|
||||
guard let originCoord = segment.fromLocation.coordinate,
|
||||
let destCoord = segment.toLocation.coordinate else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate perpendicular distance from point to segment
|
||||
let distance = perpendicularDistance(
|
||||
point: point,
|
||||
lineStart: originCoord,
|
||||
lineEnd: destCoord
|
||||
)
|
||||
|
||||
return distance <= config.proximityMiles
|
||||
}
|
||||
|
||||
/// Calculates the minimum distance from a point to a line segment in miles.
|
||||
/// Uses the perpendicular distance if the projection falls on the segment,
|
||||
/// otherwise uses the distance to the nearest endpoint.
|
||||
private func perpendicularDistance(
|
||||
point: CLLocationCoordinate2D,
|
||||
lineStart: CLLocationCoordinate2D,
|
||||
lineEnd: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let pointLoc = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||
let startLoc = CLLocation(latitude: lineStart.latitude, longitude: lineStart.longitude)
|
||||
let endLoc = CLLocation(latitude: lineEnd.latitude, longitude: lineEnd.longitude)
|
||||
|
||||
let lineLength = startLoc.distance(from: endLoc)
|
||||
|
||||
// Handle degenerate case where start == end
|
||||
if lineLength < 1 {
|
||||
return pointLoc.distance(from: startLoc) / 1609.34 // meters to miles
|
||||
}
|
||||
|
||||
// Calculate projection parameter t
|
||||
// t = ((P - A) · (B - A)) / |B - A|²
|
||||
let dx = endLoc.coordinate.longitude - startLoc.coordinate.longitude
|
||||
let dy = endLoc.coordinate.latitude - startLoc.coordinate.latitude
|
||||
let px = point.longitude - lineStart.longitude
|
||||
let py = point.latitude - lineStart.latitude
|
||||
|
||||
let t = max(0, min(1, (px * dx + py * dy) / (dx * dx + dy * dy)))
|
||||
|
||||
// Calculate closest point on segment
|
||||
let closestLat = lineStart.latitude + t * dy
|
||||
let closestLon = lineStart.longitude + t * dx
|
||||
let closestLoc = CLLocation(latitude: closestLat, longitude: closestLon)
|
||||
|
||||
// Return distance in miles
|
||||
return pointLoc.distance(from: closestLoc) / 1609.34
|
||||
}
|
||||
|
||||
/// Calculates distance between two coordinates in miles.
|
||||
private func distanceInMiles(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> Double {
|
||||
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||
return fromLoc.distance(from: toLoc) / 1609.34 // meters to miles
|
||||
}
|
||||
|
||||
// MARK: - Route Modification
|
||||
|
||||
/// Finds the best position to insert a must-stop location into an itinerary.
|
||||
/// Used when we need to add an explicit stop for a must-stop location.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - mustStop: The location to insert
|
||||
/// - stops: Current stops in order
|
||||
/// - Returns: The index where the stop should be inserted (1-based, between existing stops)
|
||||
func bestInsertionIndex(
|
||||
for mustStop: LocationInput,
|
||||
in stops: [ItineraryStop]
|
||||
) -> Int {
|
||||
guard let mustStopCoord = mustStop.coordinate, stops.count >= 2 else {
|
||||
return 1 // Insert after first stop
|
||||
}
|
||||
|
||||
var bestIndex = 1
|
||||
var minDetour = Double.greatestFiniteMagnitude
|
||||
|
||||
for i in 0..<(stops.count - 1) {
|
||||
guard let fromCoord = stops[i].coordinate,
|
||||
let toCoord = stops[i + 1].coordinate else { continue }
|
||||
|
||||
// Calculate detour: (from→mustStop + mustStop→to) - (from→to)
|
||||
let direct = distanceInMiles(from: fromCoord, to: toCoord)
|
||||
let via = distanceInMiles(from: fromCoord, to: mustStopCoord) +
|
||||
distanceInMiles(from: mustStopCoord, to: toCoord)
|
||||
let detour = via - direct
|
||||
|
||||
if detour < minDetour {
|
||||
minDetour = detour
|
||||
bestIndex = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user