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