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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user