1// Package weather builds a local weather report from public US sources:2// the National Weather Service (forecast, hourly, observations, alerts,3// radar), NOAA GOES satellite imagery, and Open-Meteo (air quality, sun).4package weather56import (7 "context"8 "encoding/json"9 "fmt"10 "io"11 "math"12 "net/http"13 "net/url"14 "strings"15 "sync"16 "time"1718 "airwaves/internal/geo"19)2021// userAgent identifies the app to the NWS API, which requires one.22const userAgent = "Airwaves home TV (personal use)"2324// Report is everything the weather page and alert crawl need.25type Report struct {26 Updated time.Time `json:"updated"`27 Now *Now `json:"now,omitempty"`28 Periods []Period `json:"periods"` // 7-day, day and night29 Hourly []Hour `json:"hourly"` // next 48 hours30 Alerts []Alert `json:"alerts"`31 Air *Air `json:"air,omitempty"`32 Sun *Sun `json:"sun,omitempty"`33 // Radar and Satellite are server paths serving the latest images.34 Radar string `json:"radar,omitempty"`35 Satellite string `json:"satellite,omitempty"`36 Errors []string `json:"errors,omitempty"` // sources that failed this time37}3839// Now is the latest observation, in US units.40type Now struct {41 Station string `json:"station"`42 Observed time.Time `json:"observed"`43 Description string `json:"description"`44 TempF *float64 `json:"tempF"`45 FeelsLikeF *float64 `json:"feelsLikeF"`46 DewpointF *float64 `json:"dewpointF"`47 Humidity *float64 `json:"humidity"`48 WindMph *float64 `json:"windMph"`49 GustMph *float64 `json:"gustMph"`50 WindDir string `json:"windDir"`51 PressureInHg *float64 `json:"pressureInHg"`52 VisibilityMi *float64 `json:"visibilityMi"`53}5455// Period is one part of the 7-day forecast ("Tonight", "Monday").56type Period struct {57 Name string `json:"name"`58 Start time.Time `json:"start"`59 IsDay bool `json:"isDay"`60 TempF int `json:"tempF"`61 Short string `json:"short"`62 Detailed string `json:"detailed"`63 Precip int `json:"precip"` // chance, percent64 Wind string `json:"wind"`65}6667// Hour is one hour of the hourly forecast.68type Hour struct {69 Start time.Time `json:"start"`70 TempF int `json:"tempF"`71 Precip int `json:"precip"`72 Short string `json:"short"`73 Wind string `json:"wind"`74 IsDay bool `json:"isDay"`75}7677// Alert is an active NWS watch, warning or advisory.78type Alert struct {79 Event string `json:"event"`80 Headline string `json:"headline"`81 Severity string `json:"severity"` // Extreme, Severe, Moderate, Minor82 Urgency string `json:"urgency"`83 Onset time.Time `json:"onset"`84 Ends time.Time `json:"ends"`85 Description string `json:"description"`86 Instruction string `json:"instruction"`87}8889// Air is current air quality.90type Air struct {91 AQI int `json:"aqi"` // US AQI92 Category string `json:"category"`93 PM25 float64 `json:"pm25"`94 Ozone float64 `json:"ozone"`95}9697// Sun is today's sunrise and sunset.98type Sun struct {99 Rise time.Time `json:"rise"`100 Set time.Time `json:"set"`101}102103// Source fetches and caches weather for one location.104type Source struct {105 HTTP *http.Client106107 mu sync.Mutex108 point geo.Point109 meta *pointMeta110 cache map[string]cached111 images map[string]image112}113114type cached struct {115 at time.Time116 data any117}118119type image struct {120 at time.Time121 kind string122 data []byte123}124125// pointMeta is what NWS knows about a location.126type pointMeta struct {127 Forecast string128 Hourly string129 Stations string130 Radar string // radar site, e.g. KFTG131}132133// NewSource returns a weather source.134func NewSource(c *http.Client) *Source {135 return &Source{HTTP: c, cache: map[string]cached{}, images: map[string]image{}}136}137138// Report gathers the weather at p. Each part is cached on its own schedule139// and a failed part keeps its last good value.140func (s *Source) Report(ctx context.Context, p geo.Point) (*Report, error) {141 s.mu.Lock()142 if s.point != p {143 s.point, s.meta = p, nil144 clear(s.cache)145 clear(s.images)146 }147 s.mu.Unlock()148 meta, err := s.pointMeta(ctx, p)149 if err != nil {150 return nil, err151 }152 r := &Report{Updated: time.Now(), Radar: "/wximg/radar.gif", Satellite: "/wximg/satellite.jpg"}153 part := func(key string, ttl time.Duration, fetch func(context.Context) (any, error)) any {154 v, err := s.cached(ctx, key, ttl, fetch)155 if err != nil {156 r.Errors = append(r.Errors, key+": "+err.Error())157 }158 return v159 }160 if v, ok := part("now", 10*time.Minute, func(ctx context.Context) (any, error) { return s.observation(ctx, meta) }).(*Now); ok {161 r.Now = v162 }163 if v, ok := part("periods", 20*time.Minute, func(ctx context.Context) (any, error) { return s.periods(ctx, meta) }).([]Period); ok {164 r.Periods = v165 }166 if v, ok := part("hourly", 20*time.Minute, func(ctx context.Context) (any, error) { return s.hourly(ctx, meta) }).([]Hour); ok {167 r.Hourly = v168 }169 if v, ok := part("alerts", 2*time.Minute, func(ctx context.Context) (any, error) { return s.alerts(ctx, p) }).([]Alert); ok {170 r.Alerts = v171 }172 if v, ok := part("air", 30*time.Minute, func(ctx context.Context) (any, error) { return s.air(ctx, p) }).(*Air); ok {173 r.Air = v174 }175 if v, ok := part("sun", 6*time.Hour, func(ctx context.Context) (any, error) { return s.sun(ctx, p) }).(*Sun); ok {176 r.Sun = v177 }178 // Drop hours already past.179 cut := time.Now().Add(-time.Hour)180 for len(r.Hourly) > 0 && r.Hourly[0].Start.Before(cut) {181 r.Hourly = r.Hourly[1:]182 }183 return r, nil184}185186func (s *Source) cached(ctx context.Context, key string, ttl time.Duration, fetch func(context.Context) (any, error)) (any, error) {187 s.mu.Lock()188 c, ok := s.cache[key]189 s.mu.Unlock()190 if ok && time.Since(c.at) < ttl {191 return c.data, nil192 }193 v, err := fetch(ctx)194 if err != nil {195 if ok {196 return c.data, err197 }198 return nil, err199 }200 s.mu.Lock()201 s.cache[key] = cached{at: time.Now(), data: v}202 s.mu.Unlock()203 return v, nil204}205206func (s *Source) getJSON(ctx context.Context, u string, out any) error {207 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)208 if err != nil {209 return err210 }211 req.Header.Set("User-Agent", userAgent)212 req.Header.Set("Accept", "application/geo+json, application/json")213 resp, err := s.HTTP.Do(req)214 if err != nil {215 return err216 }217 defer resp.Body.Close()218 if resp.StatusCode != http.StatusOK {219 return fmt.Errorf("GET %s: HTTP %d", u, resp.StatusCode)220 }221 return json.NewDecoder(resp.Body).Decode(out)222}223224func (s *Source) pointMeta(ctx context.Context, p geo.Point) (*pointMeta, error) {225 s.mu.Lock()226 m := s.meta227 s.mu.Unlock()228 if m != nil {229 return m, nil230 }231 var body struct {232 Properties struct {233 Forecast string `json:"forecast"`234 ForecastHourly string `json:"forecastHourly"`235 ObservationStations string `json:"observationStations"`236 RadarStation string `json:"radarStation"`237 } `json:"properties"`238 }239 if err := s.getJSON(ctx, fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", p.Lat, p.Lon), &body); err != nil {240 return nil, fmt.Errorf("nws point: %w", err)241 }242 pr := body.Properties243 m = &pointMeta{Forecast: pr.Forecast, Hourly: pr.ForecastHourly, Stations: pr.ObservationStations, Radar: pr.RadarStation}244 s.mu.Lock()245 s.meta = m246 s.mu.Unlock()247 return m, nil248}249250type quantity struct {251 Value *float64 `json:"value"`252}253254func (s *Source) observation(ctx context.Context, m *pointMeta) (*Now, error) {255 var stations struct {256 Features []struct {257 Properties struct {258 ID string `json:"stationIdentifier"`259 Name string `json:"name"`260 } `json:"properties"`261 } `json:"features"`262 }263 if err := s.getJSON(ctx, m.Stations, &stations); err != nil {264 return nil, err265 }266 // The nearest station sometimes has no recent report; try a few.267 for _, f := range stations.Features[:min(3, len(stations.Features))] {268 var obs struct {269 Properties struct {270 Timestamp time.Time `json:"timestamp"`271 TextDescription string `json:"textDescription"`272 Temperature quantity `json:"temperature"`273 Dewpoint quantity `json:"dewpoint"`274 WindDirection quantity `json:"windDirection"`275 WindSpeed quantity `json:"windSpeed"`276 WindGust quantity `json:"windGust"`277 BarometricPressure quantity `json:"barometricPressure"`278 Visibility quantity `json:"visibility"`279 RelativeHumidity quantity `json:"relativeHumidity"`280 WindChill quantity `json:"windChill"`281 HeatIndex quantity `json:"heatIndex"`282 } `json:"properties"`283 }284 if err := s.getJSON(ctx, "https://api.weather.gov/stations/"+f.Properties.ID+"/observations/latest", &obs); err != nil {285 continue286 }287 o := obs.Properties288 if o.Temperature.Value == nil || time.Since(o.Timestamp) > 3*time.Hour {289 continue290 }291 now := &Now{292 Station: f.Properties.Name, Observed: o.Timestamp, Description: o.TextDescription,293 TempF: cToF(o.Temperature.Value), DewpointF: cToF(o.Dewpoint.Value), Humidity: round(o.RelativeHumidity.Value, 0),294 WindMph: scale(o.WindSpeed.Value, 0.621371), GustMph: scale(o.WindGust.Value, 0.621371),295 WindDir: compass(o.WindDirection.Value),296 PressureInHg: scale(o.BarometricPressure.Value, 0.0002953), VisibilityMi: scale(o.Visibility.Value, 0.000621371),297 }298 switch {299 case o.HeatIndex.Value != nil:300 now.FeelsLikeF = cToF(o.HeatIndex.Value)301 case o.WindChill.Value != nil:302 now.FeelsLikeF = cToF(o.WindChill.Value)303 }304 return now, nil305 }306 return nil, fmt.Errorf("no recent observation near here")307}308309type nwsPeriod struct {310 Name string `json:"name"`311 StartTime time.Time `json:"startTime"`312 IsDaytime bool `json:"isDaytime"`313 Temperature int `json:"temperature"`314 WindSpeed string `json:"windSpeed"`315 WindDirection string `json:"windDirection"`316 ShortForecast string `json:"shortForecast"`317 DetailedForecast string `json:"detailedForecast"`318 ProbabilityPrecip quantity `json:"probabilityOfPrecipitation"`319}320321func (s *Source) forecast(ctx context.Context, u string) ([]nwsPeriod, error) {322 var body struct {323 Properties struct {324 Periods []nwsPeriod `json:"periods"`325 } `json:"properties"`326 }327 if err := s.getJSON(ctx, u, &body); err != nil {328 return nil, err329 }330 return body.Properties.Periods, nil331}332333func (s *Source) periods(ctx context.Context, m *pointMeta) ([]Period, error) {334 ps, err := s.forecast(ctx, m.Forecast)335 if err != nil {336 return nil, err337 }338 out := make([]Period, 0, len(ps))339 for _, p := range ps {340 out = append(out, Period{341 Name: p.Name, Start: p.StartTime, IsDay: p.IsDaytime, TempF: p.Temperature,342 Short: p.ShortForecast, Detailed: p.DetailedForecast, Precip: pct(p.ProbabilityPrecip.Value),343 Wind: strings.TrimSpace(p.WindDirection + " " + p.WindSpeed),344 })345 }346 return out, nil347}348349func (s *Source) hourly(ctx context.Context, m *pointMeta) ([]Hour, error) {350 ps, err := s.forecast(ctx, m.Hourly)351 if err != nil {352 return nil, err353 }354 out := make([]Hour, 0, 48)355 for _, p := range ps[:min(48, len(ps))] {356 out = append(out, Hour{357 Start: p.StartTime, TempF: p.Temperature, Precip: pct(p.ProbabilityPrecip.Value),358 Short: p.ShortForecast, Wind: strings.TrimSpace(p.WindDirection + " " + p.WindSpeed), IsDay: p.IsDaytime,359 })360 }361 return out, nil362}363364func (s *Source) alerts(ctx context.Context, p geo.Point) ([]Alert, error) {365 var body struct {366 Features []struct {367 Properties struct {368 Event string `json:"event"`369 Headline string `json:"headline"`370 Severity string `json:"severity"`371 Urgency string `json:"urgency"`372 Onset time.Time `json:"onset"`373 Ends time.Time `json:"ends"`374 Expires time.Time `json:"expires"`375 Description string `json:"description"`376 Instruction string `json:"instruction"`377 } `json:"properties"`378 } `json:"features"`379 }380 if err := s.getJSON(ctx, fmt.Sprintf("https://api.weather.gov/alerts/active?point=%.4f,%.4f", p.Lat, p.Lon), &body); err != nil {381 return nil, err382 }383 out := []Alert{}384 for _, f := range body.Features {385 a := f.Properties386 ends := a.Ends387 if ends.IsZero() {388 ends = a.Expires389 }390 out = append(out, Alert{391 Event: a.Event, Headline: a.Headline, Severity: a.Severity, Urgency: a.Urgency,392 Onset: a.Onset, Ends: ends, Description: a.Description, Instruction: a.Instruction,393 })394 }395 return out, nil396}397398func (s *Source) air(ctx context.Context, p geo.Point) (*Air, error) {399 var body struct {400 Current struct {401 AQI *float64 `json:"us_aqi"`402 PM25 float64 `json:"pm2_5"`403 Ozone float64 `json:"ozone"`404 } `json:"current"`405 }406 q := url.Values{407 "latitude": {fmt.Sprintf("%.4f", p.Lat)}, "longitude": {fmt.Sprintf("%.4f", p.Lon)},408 "current": {"us_aqi,pm2_5,ozone"},409 }410 if err := s.getJSON(ctx, "https://air-quality-api.open-meteo.com/v1/air-quality?"+q.Encode(), &body); err != nil {411 return nil, err412 }413 if body.Current.AQI == nil {414 return nil, fmt.Errorf("no air quality reading")415 }416 aqi := int(math.Round(*body.Current.AQI))417 return &Air{AQI: aqi, Category: aqiCategory(aqi), PM25: body.Current.PM25, Ozone: body.Current.Ozone}, nil418}419420func (s *Source) sun(ctx context.Context, p geo.Point) (*Sun, error) {421 var body struct {422 Timezone string `json:"timezone"`423 Daily struct {424 Sunrise []string `json:"sunrise"`425 Sunset []string `json:"sunset"`426 } `json:"daily"`427 }428 q := url.Values{429 "latitude": {fmt.Sprintf("%.4f", p.Lat)}, "longitude": {fmt.Sprintf("%.4f", p.Lon)},430 "daily": {"sunrise,sunset"}, "timezone": {"auto"}, "forecast_days": {"1"},431 }432 if err := s.getJSON(ctx, "https://api.open-meteo.com/v1/forecast?"+q.Encode(), &body); err != nil {433 return nil, err434 }435 if len(body.Daily.Sunrise) == 0 || len(body.Daily.Sunset) == 0 {436 return nil, fmt.Errorf("no sun times")437 }438 loc, err := time.LoadLocation(body.Timezone)439 if err != nil {440 loc = time.Local441 }442 rise, err1 := time.ParseInLocation("2006-01-02T15:04", body.Daily.Sunrise[0], loc)443 set, err2 := time.ParseInLocation("2006-01-02T15:04", body.Daily.Sunset[0], loc)444 if err1 != nil || err2 != nil {445 return nil, fmt.Errorf("parse sun times")446 }447 return &Sun{Rise: rise, Set: set}, nil448}449450// Image returns the latest radar loop ("radar.gif") or satellite still451// ("satellite.jpg") for the location, cached for a few minutes.452func (s *Source) Image(ctx context.Context, p geo.Point, name string) ([]byte, string, error) {453 meta, err := s.pointMeta(ctx, p)454 if err != nil {455 return nil, "", err456 }457 var u, kind string458 var ttl time.Duration459 switch name {460 case "radar.gif":461 u, kind, ttl = "https://radar.weather.gov/ridge/standard/"+meta.Radar+"_loop.gif", "image/gif", 4*time.Minute462 case "satellite.jpg":463 sat, sector := satelliteSector(p)464 u = fmt.Sprintf("https://cdn.star.nesdis.noaa.gov/%s/ABI/SECTOR/%s/GEOCOLOR/600x600.jpg", sat, sector)465 kind, ttl = "image/jpeg", 10*time.Minute466 default:467 return nil, "", fmt.Errorf("no image %q", name)468 }469 s.mu.Lock()470 img, ok := s.images[name]471 s.mu.Unlock()472 if ok && time.Since(img.at) < ttl {473 return img.data, img.kind, nil474 }475 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)476 if err != nil {477 return nil, "", err478 }479 req.Header.Set("User-Agent", userAgent)480 resp, err := s.HTTP.Do(req)481 if err != nil {482 if ok {483 return img.data, img.kind, nil484 }485 return nil, "", err486 }487 defer resp.Body.Close()488 data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))489 if err != nil {490 return nil, "", err491 }492 if resp.StatusCode != http.StatusOK {493 if ok {494 return img.data, img.kind, nil495 }496 return nil, "", fmt.Errorf("GET %s: HTTP %d", u, resp.StatusCode)497 }498 s.mu.Lock()499 s.images[name] = image{at: time.Now(), kind: kind, data: data}500 s.mu.Unlock()501 return data, kind, nil502}503504// satelliteSector picks the GOES regional view centered nearest p.505func satelliteSector(p geo.Point) (satellite, sector string) {506 sectors := []struct {507 sat, code string508 at geo.Point509 }{510 {"GOES18", "pnw", geo.Point{Lat: 45.5, Lon: -121}},511 {"GOES18", "psw", geo.Point{Lat: 36, Lon: -119}},512 {"GOES19", "nr", geo.Point{Lat: 45, Lon: -109}},513 {"GOES19", "sr", geo.Point{Lat: 37.5, Lon: -107}},514 {"GOES19", "sp", geo.Point{Lat: 33, Lon: -99}},515 {"GOES19", "umv", geo.Point{Lat: 44, Lon: -93}},516 {"GOES19", "smv", geo.Point{Lat: 33, Lon: -91}},517 {"GOES19", "cgl", geo.Point{Lat: 43, Lon: -84}},518 {"GOES19", "ne", geo.Point{Lat: 43, Lon: -73}},519 {"GOES19", "se", geo.Point{Lat: 31, Lon: -83}},520 }521 best := sectors[0]522 for _, s := range sectors[1:] {523 if geo.DistanceKm(p, s.at) < geo.DistanceKm(p, best.at) {524 best = s525 }526 }527 return best.sat, best.code528}529530func aqiCategory(aqi int) string {531 switch {532 case aqi <= 50:533 return "Good"534 case aqi <= 100:535 return "Moderate"536 case aqi <= 150:537 return "Unhealthy for sensitive groups"538 case aqi <= 200:539 return "Unhealthy"540 case aqi <= 300:541 return "Very unhealthy"542 default:543 return "Hazardous"544 }545}546547func cToF(c *float64) *float64 {548 if c == nil {549 return nil550 }551 f := math.Round(*c*9/5 + 32)552 return &f553}554555func scale(v *float64, k float64) *float64 {556 if v == nil {557 return nil558 }559 x := math.Round(*v*k*10) / 10560 return &x561}562563func round(v *float64, places int) *float64 {564 if v == nil {565 return nil566 }567 p := math.Pow(10, float64(places))568 x := math.Round(*v*p) / p569 return &x570}571572func pct(v *float64) int {573 if v == nil {574 return 0575 }576 return int(math.Round(*v))577}578579func compass(deg *float64) string {580 if deg == nil {581 return ""582 }583 dirs := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}584 return dirs[int(math.Round(*deg/22.5))%16]585}