This commit is contained in:
2026-01-03 14:16:16 -08:00
commit 1f0e678d47
71 changed files with 16127 additions and 0 deletions

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

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

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