286 lines
7.2 KiB
Go
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)
|
|
}
|