package weather import ( "context" "errors" "fmt" "math" "net/url" "strings" "time" ) // metNo fetches the MET Norway (yr.no) forecast, a free fallback with // global coverage. Outside the Nordics it has no gusts or rain chance, so // gusts are estimated from wind speed and the rain chance is left unknown. func (c *Client) metNo(ctx context.Context, lat, lon float64) (Forecast, error) { q := url.Values{"lat": {fmt.Sprintf("%.2f", lat)}, "lon": {fmt.Sprintf("%.2f", lon)}} var raw struct { Properties struct { Timeseries []struct { Time time.Time `json:"time"` Data struct { Instant struct { Details map[string]float64 `json:"details"` } `json:"instant"` Next1 *struct { Summary struct { Symbol string `json:"symbol_code"` } `json:"summary"` Details map[string]float64 `json:"details"` } `json:"next_1_hours"` } `json:"data"` } `json:"timeseries"` } `json:"properties"` } u := or(c.MetNoURL, "https://api.met.no/weatherapi/locationforecast/2.0/complete") + "?" + q.Encode() if err := c.getJSON(ctx, u, &raw); err != nil { return Forecast{}, err } now := time.Now() if c.now != nil { now = c.now() } f := Forecast{Source: "MET Norway"} f.Sunrise, f.Sunset = sunTimes(now, lat, lon) if f.Sunset.Before(now) { f.Sunrise, f.Sunset = sunTimes(now.Add(24*time.Hour), lat, lon) } for _, t := range raw.Properties.Timeseries { if t.Time.Before(now.Truncate(time.Hour)) || t.Data.Next1 == nil { continue } if len(f.Hours) == 24 { break } d, n := t.Data.Instant.Details, t.Data.Next1.Details h := Hour{ Time: t.Time.Local(), Temp: d["air_temperature"], Feels: d["air_temperature"], Precip: n["precipitation_amount"], PrecipProb: -1, Code: symbolCode(t.Data.Next1.Summary.Symbol), Wind: d["wind_speed"] * 3.6, WindDirection: int(math.Round(d["wind_from_direction"])), IsDay: isDay(t.Time, lat, lon), } if v, ok := d["apparent_air_temperature"]; ok { h.Feels = v } if v, ok := n["probability_of_precipitation"]; ok { h.PrecipProb = int(math.Round(v)) } if v, ok := d["wind_speed_of_gust"]; ok { h.Gusts = v * 3.6 } else { h.Gusts = h.Wind * 1.5 // typical gust factor over open land f.GustsEstimated = true } f.Hours = append(f.Hours, h) } if len(f.Hours) == 0 { return Forecast{}, errors.New("met norway: no hourly forecast") } f.Now = f.Hours[0] f.Now.Time = now.Truncate(time.Minute) return f, nil } // symbolCode maps a MET Norway symbol ("lightrainshowers_day") to the // closest WMO weather code. func symbolCode(sym string) int { base, _, _ := strings.Cut(sym, "_") switch { case strings.Contains(base, "thunder"): return 95 case strings.Contains(base, "sleet"): if strings.HasPrefix(base, "light") { return 66 } return 67 case strings.HasSuffix(base, "snowshowers"): if strings.HasPrefix(base, "heavy") { return 86 } return 85 case strings.HasSuffix(base, "rainshowers"): switch { case strings.HasPrefix(base, "light"): return 80 case strings.HasPrefix(base, "heavy"): return 82 } return 81 } if code, ok := map[string]int{ "clearsky": 0, "fair": 1, "partlycloudy": 2, "cloudy": 3, "fog": 45, "lightrain": 61, "rain": 63, "heavyrain": 65, "lightsnow": 71, "snow": 73, "heavysnow": 75, }[base]; ok { return code } return 3 // unknown: say overcast rather than promise sunshine } func isDay(t time.Time, lat, lon float64) bool { rise, set := sunTimes(t, lat, lon) return !t.Before(rise) && t.Before(set) } // sunTimes computes sunrise and sunset for t's UTC date with the standard // sunrise equation (good to a minute or two), in local time. func sunTimes(t time.Time, lat, lon float64) (rise, set time.Time) { const rad = math.Pi / 180 y, m, d := t.UTC().Date() noon := float64(time.Date(y, m, d, 12, 0, 0, 0, time.UTC).Unix())/86400 + 2440587.5 // Julian date n := math.Round(noon - 2451545.0) // days since J2000 jStar := n - lon/360 M := math.Mod(357.5291+0.98560028*jStar, 360) C := 1.9148*math.Sin(M*rad) + 0.02*math.Sin(2*M*rad) + 0.0003*math.Sin(3*M*rad) lambda := math.Mod(M+C+180+102.9372, 360) transit := 2451545.0 + jStar + 0.0053*math.Sin(M*rad) - 0.0069*math.Sin(2*lambda*rad) sinDec := math.Sin(lambda*rad) * math.Sin(23.4397*rad) cosDec := math.Cos(math.Asin(sinDec)) cosW := (math.Sin(-0.833*rad) - math.Sin(lat*rad)*sinDec) / (math.Cos(lat*rad) * cosDec) cosW = math.Max(-1, math.Min(1, cosW)) // polar day/night: clamp w := math.Acos(cosW) / rad toTime := func(j float64) time.Time { return time.Unix(int64(math.Round((j-2440587.5)*86400)), 0).Local() } return toTime(transit - w/360), toTime(transit + w/360) }