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)
|
||||
}
|
||||
554
backend/internal/service/assessment_test.go
Normal file
554
backend/internal/service/assessment_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
)
|
||||
|
||||
func TestAssessmentService_Evaluate(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
t.Run("empty points", func(t *testing.T) {
|
||||
result := service.Evaluate([]model.WeatherPoint{}, thresholds)
|
||||
|
||||
if result.Status != "BAD" {
|
||||
t.Errorf("expected status BAD, got %s", result.Status)
|
||||
}
|
||||
if result.FlyableNow {
|
||||
t.Error("expected FlyableNow to be false")
|
||||
}
|
||||
if result.BestWindow != nil {
|
||||
t.Error("expected no best window")
|
||||
}
|
||||
if len(result.AllWindows) != 0 {
|
||||
t.Errorf("expected 0 windows, got %d", len(result.AllWindows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("good conditions", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(now, 10, 270), // Good
|
||||
createPoint(now.Add(1*time.Hour), 10, 270),
|
||||
createPoint(now.Add(2*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
if result.Status != "GOOD" {
|
||||
t.Errorf("expected status GOOD, got %s", result.Status)
|
||||
}
|
||||
if result.BestWindow == nil {
|
||||
t.Fatal("expected a best window")
|
||||
}
|
||||
if result.BestWindow.Duration < 2*time.Hour {
|
||||
t.Errorf("expected window duration >= 2h, got %v", result.BestWindow.Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no daylight hours", func(t *testing.T) {
|
||||
// Create points at 2am
|
||||
midnight := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(midnight.Add(2*time.Hour), 10, 270),
|
||||
createPoint(midnight.Add(3*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
if result.Status != "BAD" {
|
||||
t.Errorf("expected status BAD, got %s", result.Status)
|
||||
}
|
||||
if !contains(result.Reason, "daylight") {
|
||||
t.Errorf("expected reason to mention daylight, got: %s", result.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssessmentService_FindFlyableWindows(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
t.Run("single continuous window", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window, got %d", len(windows))
|
||||
}
|
||||
|
||||
if windows[0].Duration < 2*time.Hour {
|
||||
t.Errorf("expected duration >= 2h, got %v", windows[0].Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple windows", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
// First window (2 hours)
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 10, 270),
|
||||
// Break (bad wind)
|
||||
createPoint(start.Add(3*time.Hour), 20, 270), // Too fast
|
||||
createPoint(start.Add(4*time.Hour), 20, 270),
|
||||
// Second window (3 hours)
|
||||
createPoint(start.Add(5*time.Hour), 10, 270),
|
||||
createPoint(start.Add(6*time.Hour), 10, 270),
|
||||
createPoint(start.Add(7*time.Hour), 10, 270),
|
||||
createPoint(start.Add(8*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 2 {
|
||||
t.Fatalf("expected 2 windows, got %d", len(windows))
|
||||
}
|
||||
|
||||
// First window should be ~2 hours
|
||||
if windows[0].Duration < 2*time.Hour {
|
||||
t.Errorf("first window duration should be >= 2h, got %v", windows[0].Duration)
|
||||
}
|
||||
|
||||
// Second window should be ~3 hours
|
||||
if windows[1].Duration < 3*time.Hour {
|
||||
t.Errorf("second window duration should be >= 3h, got %v", windows[1].Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("window less than 1 hour excluded", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(30*time.Minute), 10, 270), // Only 30 minutes
|
||||
createPoint(start.Add(1*time.Hour), 20, 270), // Bad wind
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 0 {
|
||||
t.Errorf("expected 0 windows (< 1h), got %d", len(windows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly 1 hour window", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 20, 270), // Bad wind
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window, got %d", len(windows))
|
||||
}
|
||||
|
||||
if windows[0].Duration < time.Hour {
|
||||
t.Errorf("window should be at least 1 hour, got %v", windows[0].Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no flyable conditions", func(t *testing.T) {
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 20, 270), // Too fast
|
||||
createPoint(start.Add(1*time.Hour), 5, 270), // Too slow
|
||||
createPoint(start.Add(2*time.Hour), 10, 180), // Wrong direction
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
if len(windows) != 0 {
|
||||
t.Errorf("expected 0 windows, got %d", len(windows))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssessmentService_isPointFlyable(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
point model.WeatherPoint
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "perfect conditions",
|
||||
point: createPoint(time.Now(), 10, 270),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wind too slow",
|
||||
point: createPoint(time.Now(), 5, 270),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wind too fast",
|
||||
point: createPoint(time.Now(), 20, 270),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wrong direction",
|
||||
point: createPoint(time.Now(), 10, 180), // South, not west
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "edge of speed range - min",
|
||||
point: createPoint(time.Now(), 7, 270),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "edge of speed range - max",
|
||||
point: createPoint(time.Now(), 14, 270),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "edge of direction range - low",
|
||||
point: createPoint(time.Now(), 10, 255), // 270 - 15
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "edge of direction range - high",
|
||||
point: createPoint(time.Now(), 10, 285), // 270 + 15
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := service.isPointFlyable(tt.point, thresholds)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_DirectionWraparound(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
direction int
|
||||
center int
|
||||
rangeVal int
|
||||
shouldMatch bool
|
||||
}{
|
||||
{
|
||||
name: "wraparound - north center, matches 350",
|
||||
direction: 350,
|
||||
center: 0,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - north center, matches 10",
|
||||
direction: 10,
|
||||
center: 0,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, matches 0",
|
||||
direction: 0,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, matches 5",
|
||||
direction: 5,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, matches 340",
|
||||
direction: 340,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wraparound - 350 center, rejects 30",
|
||||
direction: 30,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "no wraparound - normal case",
|
||||
direction: 180,
|
||||
center: 180,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "no wraparound - within range",
|
||||
direction: 190,
|
||||
center: 180,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "no wraparound - out of range",
|
||||
direction: 200,
|
||||
center: 180,
|
||||
rangeVal: 15,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negative direction normalized",
|
||||
direction: -10,
|
||||
center: 350,
|
||||
rangeVal: 20,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "direction over 360 normalized",
|
||||
direction: 370,
|
||||
center: 10,
|
||||
rangeVal: 15,
|
||||
shouldMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := service.isDirectionInRange(tt.direction, tt.center, tt.rangeVal)
|
||||
if result != tt.shouldMatch {
|
||||
t.Errorf("expected %v, got %v for direction=%d, center=%d, range=%d",
|
||||
tt.shouldMatch, result, tt.direction, tt.center, tt.rangeVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_DaylightFiltering(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
baseDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
included bool
|
||||
}{
|
||||
{"7am - before daylight", 7, false},
|
||||
{"8am - start of daylight", 8, true},
|
||||
{"12pm - middle of day", 12, true},
|
||||
{"6pm - evening", 18, true},
|
||||
{"9pm - late evening", 21, true},
|
||||
{"10pm - end of daylight", 22, false},
|
||||
{"11pm - night", 23, false},
|
||||
{"2am - night", 2, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(baseDate.Add(time.Duration(tt.hour)*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
filtered := service.filterDaylightHours(points)
|
||||
|
||||
if tt.included && len(filtered) != 1 {
|
||||
t.Errorf("expected point at %d:00 to be included", tt.hour)
|
||||
}
|
||||
if !tt.included && len(filtered) != 0 {
|
||||
t.Errorf("expected point at %d:00 to be excluded", tt.hour)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_BestWindowSelection(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
// First window (2 hours)
|
||||
createPoint(start, 10, 270),
|
||||
createPoint(start.Add(1*time.Hour), 10, 270),
|
||||
createPoint(start.Add(2*time.Hour), 10, 270),
|
||||
// Break
|
||||
createPoint(start.Add(3*time.Hour), 20, 270),
|
||||
// Second window (4 hours) - this should be the best
|
||||
createPoint(start.Add(4*time.Hour), 10, 270),
|
||||
createPoint(start.Add(5*time.Hour), 10, 270),
|
||||
createPoint(start.Add(6*time.Hour), 10, 270),
|
||||
createPoint(start.Add(7*time.Hour), 10, 270),
|
||||
createPoint(start.Add(8*time.Hour), 10, 270),
|
||||
// Break
|
||||
createPoint(start.Add(9*time.Hour), 5, 270),
|
||||
// Third window (1 hour)
|
||||
createPoint(start.Add(10*time.Hour), 10, 270),
|
||||
createPoint(start.Add(11*time.Hour), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
if result.BestWindow == nil {
|
||||
t.Fatal("expected a best window")
|
||||
}
|
||||
|
||||
// Best window should be the 4-hour window
|
||||
if result.BestWindow.Duration < 4*time.Hour {
|
||||
t.Errorf("expected best window to be >= 4 hours, got %v", result.BestWindow.Duration)
|
||||
}
|
||||
|
||||
// Should have found 3 windows total
|
||||
if len(result.AllWindows) != 3 {
|
||||
t.Errorf("expected 3 windows, got %d", len(result.AllWindows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_EdgeCases(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
thresholds := getDefaultThresholds()
|
||||
|
||||
t.Run("single point - flyable", func(t *testing.T) {
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), 10, 270),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, thresholds)
|
||||
|
||||
// Single point can't form a 1-hour window
|
||||
if result.BestWindow != nil {
|
||||
t.Error("single point should not create a window")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all points same time", func(t *testing.T) {
|
||||
sameTime := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(sameTime, 10, 270),
|
||||
createPoint(sameTime, 10, 270),
|
||||
createPoint(sameTime, 10, 270),
|
||||
}
|
||||
|
||||
windows := service.FindFlyableWindows(points, thresholds)
|
||||
|
||||
// Should handle gracefully
|
||||
if len(windows) > 1 {
|
||||
t.Errorf("expected at most 1 window for same-time points, got %d", len(windows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("custom thresholds", func(t *testing.T) {
|
||||
customThresholds := model.Thresholds{
|
||||
SpeedMin: 5,
|
||||
SpeedMax: 10,
|
||||
DirCenter: 180,
|
||||
DirRange: 30,
|
||||
}
|
||||
|
||||
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
points := []model.WeatherPoint{
|
||||
createPoint(start, 7, 170), // Within custom range
|
||||
createPoint(start.Add(1*time.Hour), 7, 170),
|
||||
createPoint(start.Add(2*time.Hour), 7, 170),
|
||||
}
|
||||
|
||||
result := service.Evaluate(points, customThresholds)
|
||||
|
||||
if result.Status != "GOOD" {
|
||||
t.Errorf("expected GOOD status with custom thresholds, got %s", result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssessmentService_NormalizeDegrees(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
tests := []struct {
|
||||
input int
|
||||
expected int
|
||||
}{
|
||||
{0, 0},
|
||||
{180, 180},
|
||||
{360, 0},
|
||||
{361, 1},
|
||||
{720, 0},
|
||||
{-10, 350},
|
||||
{-90, 270},
|
||||
{-360, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := service.normalizeDegrees(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("normalizeDegrees(%d) = %d, expected %d", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessmentService_AngleDifference(t *testing.T) {
|
||||
service := NewAssessmentService()
|
||||
|
||||
tests := []struct {
|
||||
angle1 int
|
||||
angle2 int
|
||||
expected float64
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{0, 180, 180},
|
||||
{0, 10, 10},
|
||||
{10, 0, 10},
|
||||
{350, 10, 20}, // Wraparound
|
||||
{10, 350, 20}, // Wraparound
|
||||
{270, 90, 180}, // Opposite
|
||||
{359, 1, 2}, // Close wraparound
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := service.angleDifference(tt.angle1, tt.angle2)
|
||||
if result != tt.expected {
|
||||
t.Errorf("angleDifference(%d, %d) = %.1f, expected %.1f",
|
||||
tt.angle1, tt.angle2, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getDefaultThresholds() model.Thresholds {
|
||||
return model.Thresholds{
|
||||
SpeedMin: 7,
|
||||
SpeedMax: 14,
|
||||
DirCenter: 270,
|
||||
DirRange: 15,
|
||||
}
|
||||
}
|
||||
|
||||
func createPoint(t time.Time, speed float64, direction int) model.WeatherPoint {
|
||||
return model.WeatherPoint{
|
||||
Time: t,
|
||||
WindSpeedMPH: speed,
|
||||
WindDirection: direction,
|
||||
WindGustMPH: speed + 2, // Arbitrary gust value
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
415
backend/internal/service/weather.go
Normal file
415
backend/internal/service/weather.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/paragliding/internal/client"
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
"github.com/scottyah/paragliding/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// Cache keys
|
||||
cacheKeyCurrentWeather = "weather:current"
|
||||
cacheKeyForecast = "weather:forecast"
|
||||
cacheKeyHistorical = "weather:historical:%s"
|
||||
cacheKeyLastAPIFetch = "weather:last_api_fetch"
|
||||
|
||||
// Cache TTLs
|
||||
cacheTTLCurrent = 5 * time.Minute
|
||||
cacheTTLForecast = 10 * time.Minute
|
||||
cacheTTLHistorical = 24 * time.Hour
|
||||
|
||||
// API rate limiting - minimum time between API calls
|
||||
minAPIFetchInterval = 15 * time.Minute
|
||||
|
||||
// Data staleness threshold - if DB data is older than this, consider fetching fresh
|
||||
dataStaleThreshold = 30 * time.Minute
|
||||
)
|
||||
|
||||
// WeatherData holds weather data with metadata
|
||||
type WeatherData struct {
|
||||
Points []model.WeatherPoint
|
||||
FetchedAt time.Time
|
||||
Source string // "cache", "database", "api"
|
||||
}
|
||||
|
||||
// WeatherService provides weather data with DB-first access and rate-limited API fallback
|
||||
type WeatherService struct {
|
||||
client *client.OpenMeteoClient
|
||||
repo *repository.WeatherRepository
|
||||
logger *slog.Logger
|
||||
|
||||
// In-memory cache
|
||||
cache map[string]cacheEntry
|
||||
cacheMu sync.RWMutex
|
||||
|
||||
// API rate limiting
|
||||
lastAPIFetch time.Time
|
||||
lastAPIFetchMu sync.RWMutex
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// WeatherServiceConfig contains configuration for the weather service
|
||||
type WeatherServiceConfig struct {
|
||||
Client *client.OpenMeteoClient
|
||||
Repo *repository.WeatherRepository
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWeatherService creates a new weather service
|
||||
func NewWeatherService(config WeatherServiceConfig) *WeatherService {
|
||||
return &WeatherService{
|
||||
client: config.Client,
|
||||
repo: config.Repo,
|
||||
logger: config.Logger,
|
||||
cache: make(map[string]cacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentWeather returns current weather conditions
|
||||
// Priority: Cache → DB → API (rate-limited)
|
||||
func (s *WeatherService) GetCurrentWeather(ctx context.Context) (*WeatherData, error) {
|
||||
// 1. Try cache first
|
||||
if cached := s.getFromCache(cacheKeyCurrentWeather); cached != nil {
|
||||
if data, ok := cached.(*WeatherData); ok {
|
||||
s.logger.Debug("current weather cache hit")
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try database
|
||||
now := time.Now()
|
||||
start := now.Add(-1 * time.Hour)
|
||||
end := now.Add(1 * time.Hour)
|
||||
|
||||
points, err := s.repo.GetForecast(ctx, start, end)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get current weather from DB", "error", err)
|
||||
}
|
||||
|
||||
// Find closest point to now
|
||||
if len(points) > 0 {
|
||||
current := s.findClosestPoint(points, now)
|
||||
|
||||
// Check if data is fresh enough
|
||||
if now.Sub(current.Time) < dataStaleThreshold {
|
||||
data := &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "database",
|
||||
}
|
||||
s.setCache(cacheKeyCurrentWeather, data, cacheTTLCurrent)
|
||||
s.logger.Debug("current weather from DB", "time", current.Time)
|
||||
return data, nil
|
||||
}
|
||||
s.logger.Debug("DB data is stale", "data_time", current.Time, "threshold", dataStaleThreshold)
|
||||
}
|
||||
|
||||
// 3. Try API (rate-limited)
|
||||
if s.canFetchFromAPI() {
|
||||
s.logger.Info("fetching current weather from API (DB data stale or missing)")
|
||||
apiPoints, err := s.fetchAndStoreFromAPI(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch from API", "error", err)
|
||||
// If we have stale DB data, return it
|
||||
if len(points) > 0 {
|
||||
current := s.findClosestPoint(points, now)
|
||||
return &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "database (stale)",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no weather data available: %w", err)
|
||||
}
|
||||
|
||||
current := s.findClosestPoint(apiPoints, now)
|
||||
data := &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "api",
|
||||
}
|
||||
s.setCache(cacheKeyCurrentWeather, data, cacheTTLCurrent)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// 4. Return stale data if available, or error
|
||||
if len(points) > 0 {
|
||||
current := s.findClosestPoint(points, now)
|
||||
s.logger.Warn("returning stale data (API rate limited)", "data_time", current.Time)
|
||||
return &WeatherData{
|
||||
Points: []model.WeatherPoint{current},
|
||||
FetchedAt: now,
|
||||
Source: "database (stale, API rate limited)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no weather data available and API rate limited")
|
||||
}
|
||||
|
||||
// GetForecast returns weather forecast data
|
||||
// Priority: Cache → DB → API (rate-limited)
|
||||
func (s *WeatherService) GetForecast(ctx context.Context) (*WeatherData, error) {
|
||||
// 1. Try cache first
|
||||
if cached := s.getFromCache(cacheKeyForecast); cached != nil {
|
||||
if data, ok := cached.(*WeatherData); ok {
|
||||
s.logger.Debug("forecast cache hit")
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try database
|
||||
now := time.Now()
|
||||
end := now.Add(7 * 24 * time.Hour) // 7 days ahead
|
||||
|
||||
points, err := s.repo.GetForecast(ctx, now, end)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get forecast from DB", "error", err)
|
||||
}
|
||||
|
||||
if len(points) > 0 {
|
||||
// Check if we have recent enough data (at least some points in the next few hours)
|
||||
hasRecentData := false
|
||||
for _, p := range points {
|
||||
if p.Time.After(now) && p.Time.Before(now.Add(6*time.Hour)) {
|
||||
hasRecentData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasRecentData {
|
||||
data := &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: now,
|
||||
Source: "database",
|
||||
}
|
||||
s.setCache(cacheKeyForecast, data, cacheTTLForecast)
|
||||
s.logger.Debug("forecast from DB", "points", len(points))
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try API (rate-limited)
|
||||
if s.canFetchFromAPI() {
|
||||
s.logger.Info("fetching forecast from API (DB data stale or missing)")
|
||||
apiPoints, err := s.fetchAndStoreFromAPI(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch from API", "error", err)
|
||||
if len(points) > 0 {
|
||||
return &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: now,
|
||||
Source: "database (stale)",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no forecast data available: %w", err)
|
||||
}
|
||||
|
||||
// Filter for future points
|
||||
forecast := make([]model.WeatherPoint, 0, len(apiPoints))
|
||||
for _, p := range apiPoints {
|
||||
if p.Time.After(now) {
|
||||
forecast = append(forecast, p)
|
||||
}
|
||||
}
|
||||
|
||||
data := &WeatherData{
|
||||
Points: forecast,
|
||||
FetchedAt: now,
|
||||
Source: "api",
|
||||
}
|
||||
s.setCache(cacheKeyForecast, data, cacheTTLForecast)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// 4. Return stale data if available
|
||||
if len(points) > 0 {
|
||||
s.logger.Warn("returning stale forecast (API rate limited)", "points", len(points))
|
||||
return &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: now,
|
||||
Source: "database (stale, API rate limited)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no forecast data available and API rate limited")
|
||||
}
|
||||
|
||||
// GetHistorical returns historical weather data for a specific date
|
||||
// Historical data is primarily from DB (populated by background fetcher)
|
||||
func (s *WeatherService) GetHistorical(ctx context.Context, date time.Time) (*WeatherData, error) {
|
||||
cacheKey := fmt.Sprintf(cacheKeyHistorical, date.Format("2006-01-02"))
|
||||
|
||||
// 1. Try cache first
|
||||
if cached := s.getFromCache(cacheKey); cached != nil {
|
||||
if data, ok := cached.(*WeatherData); ok {
|
||||
s.logger.Debug("historical cache hit", "date", date.Format("2006-01-02"))
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get from database (historical data should always be from DB)
|
||||
points, err := s.repo.GetHistorical(ctx, date)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get historical data from DB", "error", err, "date", date.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
if len(points) > 0 {
|
||||
data := &WeatherData{
|
||||
Points: points,
|
||||
FetchedAt: time.Now(),
|
||||
Source: "database",
|
||||
}
|
||||
s.setCache(cacheKey, data, cacheTTLHistorical)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Historical data not available - don't try API for past dates
|
||||
// The background fetcher should have this data
|
||||
return &WeatherData{
|
||||
Points: []model.WeatherPoint{},
|
||||
FetchedAt: time.Now(),
|
||||
Source: "none",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllPoints returns all available weather points (for assessment)
|
||||
// This reads from DB/cache only, never triggers API calls
|
||||
func (s *WeatherService) GetAllPoints(ctx context.Context) ([]model.WeatherPoint, error) {
|
||||
// Try to get forecast data which includes recent + future points
|
||||
data, err := s.GetForecast(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Points, nil
|
||||
}
|
||||
|
||||
// FetchFromAPI forces an API fetch (used by background fetcher)
|
||||
// This bypasses rate limiting as it's called on a schedule
|
||||
func (s *WeatherService) FetchFromAPI(ctx context.Context) ([]model.WeatherPoint, error) {
|
||||
points, err := s.client.GetWeatherForecast(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from API: %w", err)
|
||||
}
|
||||
|
||||
// Store in database
|
||||
if err := s.repo.SaveObservations(ctx, points); err != nil {
|
||||
s.logger.Error("failed to save observations to DB", "error", err)
|
||||
// Continue anyway - return the data
|
||||
}
|
||||
|
||||
// Update last fetch time
|
||||
s.lastAPIFetchMu.Lock()
|
||||
s.lastAPIFetch = time.Now()
|
||||
s.lastAPIFetchMu.Unlock()
|
||||
|
||||
// Clear caches so next request gets fresh data
|
||||
s.clearCache()
|
||||
|
||||
s.logger.Info("API fetch complete", "points", len(points))
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// canFetchFromAPI checks if enough time has passed since last API fetch
|
||||
func (s *WeatherService) canFetchFromAPI() bool {
|
||||
s.lastAPIFetchMu.RLock()
|
||||
defer s.lastAPIFetchMu.RUnlock()
|
||||
|
||||
if s.lastAPIFetch.IsZero() {
|
||||
return true
|
||||
}
|
||||
return time.Since(s.lastAPIFetch) >= minAPIFetchInterval
|
||||
}
|
||||
|
||||
// fetchAndStoreFromAPI fetches from API and stores in DB
|
||||
func (s *WeatherService) fetchAndStoreFromAPI(ctx context.Context) ([]model.WeatherPoint, error) {
|
||||
points, err := s.client.GetWeatherForecast(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from API: %w", err)
|
||||
}
|
||||
|
||||
// Store in database
|
||||
if err := s.repo.SaveObservations(ctx, points); err != nil {
|
||||
s.logger.Error("failed to save observations to DB", "error", err)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Update last fetch time
|
||||
s.lastAPIFetchMu.Lock()
|
||||
s.lastAPIFetch = time.Now()
|
||||
s.lastAPIFetchMu.Unlock()
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// findClosestPoint finds the weather point closest to the target time
|
||||
func (s *WeatherService) findClosestPoint(points []model.WeatherPoint, target time.Time) model.WeatherPoint {
|
||||
if len(points) == 0 {
|
||||
return model.WeatherPoint{}
|
||||
}
|
||||
|
||||
closest := points[0]
|
||||
minDiff := absDuration(points[0].Time.Sub(target))
|
||||
|
||||
for _, point := range points[1:] {
|
||||
diff := absDuration(point.Time.Sub(target))
|
||||
if diff < minDiff {
|
||||
minDiff = diff
|
||||
closest = point
|
||||
}
|
||||
}
|
||||
|
||||
return closest
|
||||
}
|
||||
|
||||
// Cache helpers
|
||||
|
||||
func (s *WeatherService) getFromCache(key string) interface{} {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
|
||||
entry, exists := s.cache[key]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return entry.data
|
||||
}
|
||||
|
||||
func (s *WeatherService) setCache(key string, data interface{}, ttl time.Duration) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache[key] = cacheEntry{
|
||||
data: data,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WeatherService) clearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
|
||||
// absDuration returns the absolute value of a duration
|
||||
func absDuration(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
Reference in New Issue
Block a user