Remove unused planning files

Deleted files replaced by new scenario-based planners:
- RouteCandidateBuilder.swift
- RouteOptimizer.swift (TSP solver)
- ScheduleMatcher.swift (corridor logic)
- LegacyPlanningTypes.swift
- TripScorer.swift
- DateRangeValidator.swift
- DrivingFeasibilityValidator.swift
- GeographicSanityChecker.swift
- MustStopValidator.swift

These were superseded by ScenarioA/B/C planners, GeographicRouteExplorer,
and ItineraryBuilder.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 08:53:43 -06:00
parent 9088b46563
commit 405ebe68eb
9 changed files with 0 additions and 2015 deletions

View File

@@ -1,232 +0,0 @@
//
// 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)"
)]
}
}

View File

@@ -1,283 +0,0 @@
//
// 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
}
}
}

View File

@@ -1,396 +0,0 @@
//
// 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
}
}

View File

@@ -1,99 +0,0 @@
//
// 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
}

View File

@@ -1,196 +0,0 @@
//
// 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

View File

@@ -1,127 +0,0 @@
//
// 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)
}
}
}

View File

@@ -1,200 +0,0 @@
//
// 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
}
}

View File

@@ -1,229 +0,0 @@
//
// 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
}
}

View File

@@ -1,253 +0,0 @@
//
// 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: (frommustStop + mustStopto) - (fromto)
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
}
}