package service import ( "math" "time" "github.com/scottyah/paragliding/internal/model" ) // AssessmentService evaluates weather conditions for paragliding type AssessmentService struct{} // NewAssessmentService creates a new assessment service func NewAssessmentService() *AssessmentService { return &AssessmentService{} } // Evaluate analyzes weather points against thresholds and returns an assessment func (s *AssessmentService) Evaluate(points []model.WeatherPoint, thresholds model.Thresholds) model.Assessment { if len(points) == 0 { return model.Assessment{ Status: "BAD", Reason: "No weather data available", FlyableNow: false, BestWindow: nil, AllWindows: []model.FlyableWindow{}, } } // Filter for daylight hours (8am-10pm) daylightPoints := s.filterDaylightHours(points) if len(daylightPoints) == 0 { return model.Assessment{ Status: "BAD", Reason: "No data available during daylight flying hours (8am-10pm)", FlyableNow: false, BestWindow: nil, AllWindows: []model.FlyableWindow{}, } } // Find all flyable windows windows := s.FindFlyableWindows(daylightPoints, thresholds) // Check if current conditions are flyable now := time.Now() flyableNow := false for _, point := range daylightPoints { if point.Time.Before(now.Add(30*time.Minute)) && point.Time.After(now.Add(-30*time.Minute)) { flyableNow = s.isPointFlyable(point, thresholds) break } } // Determine best window var bestWindow *model.FlyableWindow if len(windows) > 0 { // Find the longest window longest := windows[0] for _, w := range windows[1:] { if w.Duration > longest.Duration { longest = w } } bestWindow = &longest } // Build assessment assessment := model.Assessment{ FlyableNow: flyableNow, BestWindow: bestWindow, AllWindows: windows, } if bestWindow != nil { assessment.Status = "GOOD" assessment.Reason = formatBestWindowReason(*bestWindow) } else { assessment.Status = "BAD" assessment.Reason = s.determineWhyNotFlyable(daylightPoints, thresholds) } return assessment } // FindFlyableWindows identifies all continuous periods of flyable conditions // A flyable window must have at least 1 hour of continuous flyable conditions func (s *AssessmentService) FindFlyableWindows(points []model.WeatherPoint, thresholds model.Thresholds) []model.FlyableWindow { if len(points) == 0 { return []model.FlyableWindow{} } windows := []model.FlyableWindow{} var windowStart *time.Time var lastFlyableTime *time.Time for i, point := range points { isFlyable := s.isPointFlyable(point, thresholds) if isFlyable { // Start a new window if not already in one if windowStart == nil { windowStart = &point.Time } lastFlyableTime = &point.Time } else { // End current window if we were in one if windowStart != nil && lastFlyableTime != nil { duration := lastFlyableTime.Sub(*windowStart) // Only include windows of at least 1 hour if duration >= time.Hour { windows = append(windows, model.FlyableWindow{ Start: *windowStart, End: *lastFlyableTime, Duration: duration, }) } windowStart = nil lastFlyableTime = nil } } // Handle last point if i == len(points)-1 && windowStart != nil && lastFlyableTime != nil { duration := lastFlyableTime.Sub(*windowStart) if duration >= time.Hour { windows = append(windows, model.FlyableWindow{ Start: *windowStart, End: *lastFlyableTime, Duration: duration, }) } } } return windows } // isPointFlyable checks if a single weather point meets flyable conditions func (s *AssessmentService) isPointFlyable(point model.WeatherPoint, thresholds model.Thresholds) bool { // Check wind speed if point.WindSpeedMPH < thresholds.SpeedMin || point.WindSpeedMPH > thresholds.SpeedMax { return false } // Check wind direction with wraparound handling if !s.isDirectionInRange(point.WindDirection, thresholds.DirCenter, thresholds.DirRange) { return false } return true } // isDirectionInRange checks if a direction is within range of center, handling 0/360 wraparound func (s *AssessmentService) isDirectionInRange(direction, center, rangeVal int) bool { // Normalize all values to 0-359 direction = s.normalizeDegrees(direction) center = s.normalizeDegrees(center) // Calculate the absolute difference diff := s.angleDifference(direction, center) return diff <= float64(rangeVal) } // normalizeDegrees ensures degrees are in 0-359 range func (s *AssessmentService) normalizeDegrees(degrees int) int { normalized := degrees % 360 if normalized < 0 { normalized += 360 } return normalized } // angleDifference calculates the minimum difference between two angles func (s *AssessmentService) angleDifference(angle1, angle2 int) float64 { diff := math.Abs(float64(angle1 - angle2)) if diff > 180 { diff = 360 - diff } return diff } // filterDaylightHours returns only points between 8am and 10pm local time func (s *AssessmentService) filterDaylightHours(points []model.WeatherPoint) []model.WeatherPoint { filtered := make([]model.WeatherPoint, 0, len(points)) for _, point := range points { hour := point.Time.Hour() if hour >= 8 && hour < 22 { filtered = append(filtered, point) } } return filtered } // determineWhyNotFlyable analyzes points to explain why conditions aren't flyable func (s *AssessmentService) determineWhyNotFlyable(points []model.WeatherPoint, thresholds model.Thresholds) string { if len(points) == 0 { return "No data available during flying hours" } tooSlow := 0 tooFast := 0 wrongDirection := 0 for _, point := range points { if point.WindSpeedMPH < thresholds.SpeedMin { tooSlow++ } else if point.WindSpeedMPH > thresholds.SpeedMax { tooFast++ } if !s.isDirectionInRange(point.WindDirection, thresholds.DirCenter, thresholds.DirRange) { wrongDirection++ } } // Determine primary reason if tooSlow > len(points)/2 { return "Wind speeds too low for safe flying" } if tooFast > len(points)/2 { return "Wind speeds too high for safe flying" } if wrongDirection > len(points)/2 { return "Wind direction not favorable" } return "No continuous flyable windows of at least 1 hour found" } // formatBestWindowReason creates a human-readable message about the best window func formatBestWindowReason(window model.FlyableWindow) string { hours := int(window.Duration.Hours()) minutes := int(window.Duration.Minutes()) % 60 timeStr := "" if hours > 0 { timeStr = formatHours(hours) if minutes > 0 { timeStr += " " + formatMinutes(minutes) } } else { timeStr = formatMinutes(minutes) } return "Best flyable window: " + timeStr + " starting at " + window.Start.Format("3:04 PM") } func formatHours(hours int) string { if hours == 1 { return "1 hour" } return formatInt(hours) + " hours" } func formatMinutes(minutes int) string { if minutes == 1 { return "1 minute" } return formatInt(minutes) + " minutes" } func formatInt(n int) string { // Simple int to string conversion if n == 0 { return "0" } negative := n < 0 if negative { n = -n } digits := []byte{} for n > 0 { digits = append([]byte{byte('0' + n%10)}, digits...) n /= 10 } if negative { digits = append([]byte{'-'}, digits...) } return string(digits) }