Files
Sportstime/SportsTime/Planning/Validators/GeographicSanityChecker.swift
Trey t 9088b46563 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>
2026-01-07 00:46:40 -06:00

230 lines
8.4 KiB
Swift

//
// 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
}
}