Files
paragliding/backend/internal/service/assessment.go
2026-01-03 14:16:16 -08:00

286 lines
7.2 KiB
Go

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