init
This commit is contained in:
285
backend/internal/service/assessment.go
Normal file
285
backend/internal/service/assessment.go
Normal file
@@ -0,0 +1,285 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user