all repos

rss-tools @ 63e22ef

get rss feed from sources that(i need and) dont provide one

rss-tools/sources/weather/weather.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
add weather source, 1 month ago
1
package weather
2
3
import (
4
	"context"
5
	"encoding/json"
6
	"errors"
7
	"fmt"
8
	"html"
9
	"math"
10
	"net/http"
11
	"net/url"
12
	"strconv"
13
	"strings"
14
	"time"
15
16
	"olexsmir.xyz/rss-tools/app"
17
)
18
19
const (
20
	forecastAPIURL   = "https://api.open-meteo.com/v1/forecast"
21
	airQualityAPIURL = "https://air-quality-api.open-meteo.com/v1/air-quality"
22
	geocodingAPIURL  = "https://nominatim.openstreetmap.org/reverse"
23
24
	clockLayout = "15:04"
25
	localLayout = "2006-01-02T15:04"
26
)
27
28
var timelineHours = []int{8, 12, 16, 20}
29
30
type weather struct {
31
	client        *http.Client
32
	forecastURL   string
33
	airQualityURL string
34
	geocodingURL  string
35
}
36
37
func Register(a *app.App) error {
38
	w := &weather{
39
		client:        a.Client,
40
		forecastURL:   forecastAPIURL,
41
		airQualityURL: airQualityAPIURL,
42
		geocodingURL:  geocodingAPIURL,
43
	}
44
	a.Route("GET /weather", w.handler)
45
	a.Logger.Info("weather source registered")
46
	return nil
47
}
48
49
func (w *weather) handler(rw http.ResponseWriter, r *http.Request) {
50
	lat, lon, err := parseCoordinates(r)
51
	if err != nil {
52
		http.Error(rw, "invalid latitude/longitude", http.StatusBadRequest)
53
		return
54
	}
55
56
	forecast, err := w.fetchForecast(r.Context(), lat, lon)
57
	if err != nil {
58
		http.Error(rw, "failed to fetch weather forecast", http.StatusBadGateway)
59
		return
60
	}
61
62
	air, err := w.fetchAirQuality(r.Context(), lat, lon)
63
	if err != nil {
64
		http.Error(rw, "failed to fetch air quality", http.StatusBadGateway)
65
		return
66
	}
67
68
	content, updated, err := buildMorningBriefing(forecast, air)
69
	if err != nil {
70
		http.Error(rw, "failed to build weather briefing", http.StatusBadGateway)
71
		return
72
	}
73
74
	place := fmt.Sprintf("%.4f,%.4f", lat, lon)
75
	if town, err := w.fetchTownName(r.Context(), lat, lon); err == nil && town != "" {
76
		place = town
77
	}
78
79
	feedID := weatherFeedID(lat, lon)
80
	feed := app.NewFeed(fmt.Sprintf("Weather forecast for %s", place), feedID).
81
		WithUpdated(updated)
82
83
	feed.Add(app.FeedEntry{
84
		Title:       "Morning weather briefing",
85
		ID:          fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")),
86
		Content:     formatBriefingHTML(content),
87
		ContentType: "html",
88
		Updated:     updated,
89
	})
90
91
	if err := feed.Render(rw); err != nil {
92
		http.Error(rw, "failed to render feed", http.StatusInternalServerError)
93
	}
94
}
95
96
func parseCoordinates(r *http.Request) (float64, float64, error) {
97
	latRaw := strings.TrimSpace(r.URL.Query().Get("latitude"))
98
	lonRaw := strings.TrimSpace(r.URL.Query().Get("longitude"))
99
	if latRaw == "" || lonRaw == "" {
100
		return 0, 0, errors.New("latitude and longitude are required")
101
	}
102
103
	lat, err := strconv.ParseFloat(latRaw, 64)
104
	if err != nil {
105
		return 0, 0, fmt.Errorf("invalid latitude: %w", err)
106
	}
107
	lon, err := strconv.ParseFloat(lonRaw, 64)
108
	if err != nil {
109
		return 0, 0, fmt.Errorf("invalid longitude: %w", err)
110
	}
111
112
	if lat < -90 || lat > 90 {
113
		return 0, 0, errors.New("latitude is out of range")
114
	}
115
	if lon < -180 || lon > 180 {
116
		return 0, 0, errors.New("longitude is out of range")
117
	}
118
	return lat, lon, nil
119
}
120
121
type forecastResponse struct {
122
	Timezone string `json:"timezone"`
123
	Current  struct {
124
		Time                string   `json:"time"`
125
		Temperature2M       *float64 `json:"temperature_2m"`
126
		ApparentTemperature *float64 `json:"apparent_temperature"`
127
	} `json:"current"`
128
	Daily struct {
129
		Time                     []string  `json:"time"`
130
		TemperatureMin           []float64 `json:"temperature_2m_min"`
131
		TemperatureMax           []float64 `json:"temperature_2m_max"`
132
		WeatherCode              []int     `json:"weather_code"`
133
		PrecipitationProbability []float64 `json:"precipitation_probability_max"`
134
		PrecipitationSum         []float64 `json:"precipitation_sum"`
135
		RainSum                  []float64 `json:"rain_sum"`
136
		ShowersSum               []float64 `json:"showers_sum"`
137
		WindSpeedMax             []float64 `json:"wind_speed_10m_max"`
138
		WindDirectionDominant    []float64 `json:"wind_direction_10m_dominant"`
139
	} `json:"daily"`
140
	Hourly struct {
141
		Time                     []string  `json:"time"`
142
		Temperature              []float64 `json:"temperature_2m"`
143
		WeatherCode              []int     `json:"weather_code"`
144
		PrecipitationProbability []float64 `json:"precipitation_probability"`
145
		WindSpeed                []float64 `json:"wind_speed_10m"`
146
		Humidity                 []float64 `json:"relative_humidity_2m"`
147
	} `json:"hourly"`
148
}
149
150
type airQualityResponse struct {
151
	Current struct {
152
		USAQI           *float64 `json:"us_aqi"`
153
		PM2_5           *float64 `json:"pm2_5"`
154
		PM10            *float64 `json:"pm10"`
155
		Ozone           *float64 `json:"ozone"`
156
		NitrogenDioxide *float64 `json:"nitrogen_dioxide"`
157
	} `json:"current"`
158
}
159
160
type geocodingResponse struct {
161
	Name        string `json:"name"`
162
	DisplayName string `json:"display_name"`
163
	Address     struct {
164
		City         string `json:"city"`
165
		Town         string `json:"town"`
166
		Village      string `json:"village"`
167
		Municipality string `json:"municipality"`
168
		Hamlet       string `json:"hamlet"`
169
		County       string `json:"county"`
170
	} `json:"address"`
171
}
172
173
func (w *weather) fetchForecast(ctx context.Context, lat, lon float64) (forecastResponse, error) {
174
	var payload forecastResponse
175
176
	q := url.Values{}
177
	q.Set("latitude", strconv.FormatFloat(lat, 'f', 6, 64))
178
	q.Set("longitude", strconv.FormatFloat(lon, 'f', 6, 64))
179
	q.Set("timezone", "auto")
180
	q.Set("forecast_days", "1")
181
	q.Set("current", strings.Join([]string{
182
		"temperature_2m",
183
		"apparent_temperature",
184
	}, ","))
185
	q.Set("daily", strings.Join([]string{
186
		"temperature_2m_min",
187
		"temperature_2m_max",
188
		"weather_code",
189
		"precipitation_probability_max",
190
		"precipitation_sum",
191
		"rain_sum",
192
		"showers_sum",
193
		"wind_speed_10m_max",
194
		"wind_direction_10m_dominant",
195
	}, ","))
196
	q.Set("hourly", strings.Join([]string{
197
		"temperature_2m",
198
		"weather_code",
199
		"precipitation_probability",
200
		"wind_speed_10m",
201
		"relative_humidity_2m",
202
	}, ","))
203
204
	endpoint := w.forecastURL + "?" + q.Encode()
205
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
206
	if err != nil {
207
		return payload, err
208
	}
209
210
	res, err := w.client.Do(req)
211
	if err != nil {
212
		return payload, err
213
	}
214
	defer res.Body.Close()
215
216
	if res.StatusCode != http.StatusOK {
217
		return payload, fmt.Errorf("forecast API returned %s", res.Status)
218
	}
219
	if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
220
		return payload, err
221
	}
222
	if len(payload.Daily.Time) == 0 || len(payload.Daily.TemperatureMin) == 0 || len(payload.Daily.TemperatureMax) == 0 {
223
		return payload, errors.New("forecast payload missing daily data")
224
	}
225
	if len(payload.Hourly.Time) == 0 || len(payload.Hourly.Temperature) == 0 {
226
		return payload, errors.New("forecast payload missing hourly data")
227
	}
228
	return payload, nil
229
}
230
231
func (w *weather) fetchAirQuality(ctx context.Context, lat, lon float64) (airQualityResponse, error) {
232
	var payload airQualityResponse
233
234
	q := url.Values{}
235
	q.Set("latitude", strconv.FormatFloat(lat, 'f', 6, 64))
236
	q.Set("longitude", strconv.FormatFloat(lon, 'f', 6, 64))
237
	q.Set("timezone", "auto")
238
	q.Set("current", strings.Join([]string{
239
		"us_aqi",
240
		"pm2_5",
241
		"pm10",
242
		"ozone",
243
		"nitrogen_dioxide",
244
	}, ","))
245
246
	endpoint := w.airQualityURL + "?" + q.Encode()
247
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
248
	if err != nil {
249
		return payload, err
250
	}
251
252
	res, err := w.client.Do(req)
253
	if err != nil {
254
		return payload, err
255
	}
256
	defer res.Body.Close()
257
258
	if res.StatusCode != http.StatusOK {
259
		return payload, fmt.Errorf("air quality API returned %s", res.Status)
260
	}
261
	if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
262
		return payload, err
263
	}
264
	return payload, nil
265
}
266
267
func (w *weather) fetchTownName(ctx context.Context, lat, lon float64) (string, error) {
268
	var payload geocodingResponse
269
270
	geocodingURL := w.geocodingURL
271
	if geocodingURL == "" {
272
		geocodingURL = geocodingAPIURL
273
	}
274
275
	q := url.Values{}
276
	q.Set("lat", strconv.FormatFloat(lat, 'f', 6, 64))
277
	q.Set("lon", strconv.FormatFloat(lon, 'f', 6, 64))
278
	q.Set("format", "jsonv2")
279
	q.Set("accept-language", "en")
280
	q.Set("zoom", "12")
281
	q.Set("addressdetails", "1")
282
283
	endpoint := geocodingURL + "?" + q.Encode()
284
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
285
	if err != nil {
286
		return "", err
287
	}
288
	req.Header.Set("User-Agent", "rss-tools/1.0 (+https://github.com/olexsmir/rss-tools)")
289
290
	res, err := w.client.Do(req)
291
	if err != nil {
292
		return "", err
293
	}
294
	defer res.Body.Close()
295
296
	if res.StatusCode != http.StatusOK {
297
		return "", fmt.Errorf("geocoding API returned %s", res.Status)
298
	}
299
	if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
300
		return "", err
301
	}
302
303
	for _, candidate := range []string{
304
		payload.Address.City,
305
		payload.Address.Town,
306
		payload.Address.Village,
307
		payload.Address.Municipality,
308
		payload.Address.Hamlet,
309
		payload.Address.County,
310
		payload.Name,
311
		firstDisplayNamePart(payload.DisplayName),
312
	} {
313
		if town := strings.TrimSpace(candidate); town != "" {
314
			return town, nil
315
		}
316
	}
317
	return "", errors.New("town name not found")
318
}
319
320
func firstDisplayNamePart(displayName string) string {
321
	part, _, _ := strings.Cut(displayName, ",")
322
	return strings.TrimSpace(part)
323
}
324
325
func formatBriefingHTML(content string) string {
326
	return "<pre>" + html.EscapeString(content) + "</pre>"
327
}
328
329
func buildMorningBriefing(forecast forecastResponse, air airQualityResponse) (string, time.Time, error) {
330
	loc := time.Local
331
	if tz := strings.TrimSpace(forecast.Timezone); tz != "" {
332
		zone, err := time.LoadLocation(tz)
333
		if err == nil {
334
			loc = zone
335
		}
336
	}
337
338
	day := firstEl(forecast.Daily.Time)
339
	if day == "" {
340
		return "", time.Time{}, errors.New("missing day")
341
	}
342
343
	updated := time.Now().In(loc)
344
	if parsed, err := time.ParseInLocation(localLayout, forecast.Current.Time, loc); err == nil {
345
		updated = parsed
346
	}
347
348
	minTemp := firstEl(forecast.Daily.TemperatureMin)
349
	maxTemp := firstEl(forecast.Daily.TemperatureMax)
350
	code := firstEl(forecast.Daily.WeatherCode)
351
	_, dayText := weatherCodeLabel(code)
352
353
	peakPrecipHour := peakPrecipitationHour(forecast, day, loc)
354
	if maxProbability := firstEl(forecast.Daily.PrecipitationProbability); maxProbability >= 50 {
355
		switch {
356
		case peakPrecipHour >= 12 && peakPrecipHour <= 18:
357
			dayText += " with showers in the afternoon"
358
		case peakPrecipHour >= 5 && peakPrecipHour < 12:
359
			dayText += " with rain in the morning"
360
		default:
361
			dayText += " with possible rain"
362
		}
363
	}
364
365
	rainLine := fmt.Sprintf(
366
		"ā˜‚ %d%% chance of rain (%s)",
367
		int(math.Round(firstEl(forecast.Daily.PrecipitationProbability))),
368
		rainAmount(forecast.Daily.PrecipitationSum, forecast.Daily.RainSum, forecast.Daily.ShowersSum),
369
	)
370
371
	windMin, windMax := minMaxForDay(forecast.Hourly.Time, forecast.Hourly.WindSpeed, day)
372
	if windMin == 0 && windMax == 0 {
373
		windMax = firstEl(forecast.Daily.WindSpeedMax)
374
		windMin = windMax
375
	}
376
	windDirection := windDirectionFromDegrees(firstEl(forecast.Daily.WindDirectionDominant))
377
	windLine := formatWindLine(windMin, windMax, windDirection)
378
379
	lowTemp, lowTime, highTemp, highTime, ok := dayExtremes(forecast.Hourly.Time, forecast.Hourly.Temperature, day, loc)
380
	extremesLine := ""
381
	if ok {
382
		extremesLine = fmt.Sprintf(
383
			"šŸ“ˆ High: %s at %s  •  šŸ“‰ Low: %s at %s",
384
			formatSignedTemperature(highTemp),
385
			highTime.Format(clockLayout),
386
			formatSignedTemperature(lowTemp),
387
			lowTime.Format(clockLayout),
388
		)
389
	}
390
391
	nowLine := ""
392
	if forecast.Current.Temperature2M != nil && forecast.Current.ApparentTemperature != nil {
393
		nowLine = fmt.Sprintf(
394
			"🌔 Now: %s (feels like %s)",
395
			formatSignedTemperature(*forecast.Current.Temperature2M),
396
			formatSignedTemperature(*forecast.Current.ApparentTemperature),
397
		)
398
	}
399
400
	humidityMin, humidityMax := minMaxForDay(forecast.Hourly.Time, forecast.Hourly.Humidity, day)
401
	humidityLine := ""
402
	if humidityMin != 0 || humidityMax != 0 {
403
		humidityLine = fmt.Sprintf("šŸ’§ Humidity: %d-%d%%", int(math.Round(humidityMin)), int(math.Round(humidityMax)))
404
	}
405
406
	airLine := formatAirQualityLine(air)
407
	timelineLines := timelineSummary(forecast, day, loc)
408
	if len(timelineLines) == 0 {
409
		return "", time.Time{}, errors.New("missing timeline data")
410
	}
411
412
	lines := []string{
413
		fmt.Sprintf("%s / %s  |  %s", formatSignedTemperature(minTemp), formatSignedTemperature(maxTemp), dayText),
414
		rainLine,
415
		windLine,
416
	}
417
	if extremesLine != "" {
418
		lines = append(lines, extremesLine)
419
	}
420
	if nowLine != "" {
421
		lines = append(lines, nowLine)
422
	}
423
	if humidityLine != "" {
424
		lines = append(lines, humidityLine)
425
	}
426
	if airLine != "" {
427
		lines = append(lines, airLine)
428
	}
429
	lines = append(lines, "")
430
	lines = append(lines, timelineLines...)
431
	lines = append(lines, "", "Data: Open-Meteo")
432
433
	return strings.Join(lines, "\n"), updated, nil
434
}
435
436
func peakPrecipitationHour(forecast forecastResponse, day string, loc *time.Location) int {
437
	peakHour := -1
438
	maxProb := -1.0
439
	for i, raw := range forecast.Hourly.Time {
440
		if i >= len(forecast.Hourly.PrecipitationProbability) || !strings.HasPrefix(raw, day+"T") {
441
			continue
442
		}
443
		t, err := time.ParseInLocation(localLayout, raw, loc)
444
		if err != nil {
445
			continue
446
		}
447
		prob := forecast.Hourly.PrecipitationProbability[i]
448
		if prob > maxProb {
449
			maxProb = prob
450
			peakHour = t.Hour()
451
		}
452
	}
453
	return peakHour
454
}
455
456
func dayExtremes(times []string, values []float64, day string, loc *time.Location) (float64, time.Time, float64, time.Time, bool) {
457
	if len(times) == 0 || len(values) == 0 {
458
		return 0, time.Time{}, 0, time.Time{}, false
459
	}
460
	minVal, maxVal := 0.0, 0.0
461
	var minTime, maxTime time.Time
462
	found := false
463
	for i, raw := range times {
464
		if i >= len(values) || !strings.HasPrefix(raw, day+"T") {
465
			continue
466
		}
467
		t, err := time.ParseInLocation(localLayout, raw, loc)
468
		if err != nil {
469
			continue
470
		}
471
		v := values[i]
472
		if !found {
473
			minVal, maxVal = v, v
474
			minTime, maxTime = t, t
475
			found = true
476
			continue
477
		}
478
		if v < minVal {
479
			minVal = v
480
			minTime = t
481
		}
482
		if v > maxVal {
483
			maxVal = v
484
			maxTime = t
485
		}
486
	}
487
	return minVal, minTime, maxVal, maxTime, found
488
}
489
490
func minMaxForDay(times []string, values []float64, day string) (float64, float64) {
491
	if len(times) == 0 || len(values) == 0 {
492
		return 0, 0
493
	}
494
	minV, maxV := 0.0, 0.0
495
	found := false
496
	for i, raw := range times {
497
		if i >= len(values) || !strings.HasPrefix(raw, day+"T") {
498
			continue
499
		}
500
		v := values[i]
501
		if !found {
502
			minV, maxV = v, v
503
			found = true
504
			continue
505
		}
506
		if v < minV {
507
			minV = v
508
		}
509
		if v > maxV {
510
			maxV = v
511
		}
512
	}
513
	if !found {
514
		return 0, 0
515
	}
516
	return minV, maxV
517
}
518
519
func timelineSummary(forecast forecastResponse, day string, loc *time.Location) []string {
520
	out := make([]string, 0, len(timelineHours))
521
522
	type point struct {
523
		time  time.Time
524
		temp  float64
525
		code  int
526
		valid bool
527
	}
528
529
	points := make([]point, 0, len(forecast.Hourly.Time))
530
	for i, raw := range forecast.Hourly.Time {
531
		if i >= len(forecast.Hourly.Temperature) || !strings.HasPrefix(raw, day+"T") {
532
			continue
533
		}
534
		t, err := time.ParseInLocation(localLayout, raw, loc)
535
		if err != nil {
536
			continue
537
		}
538
		code := 0
539
		if i < len(forecast.Hourly.WeatherCode) {
540
			code = forecast.Hourly.WeatherCode[i]
541
		}
542
		points = append(points, point{
543
			time:  t,
544
			temp:  forecast.Hourly.Temperature[i],
545
			code:  code,
546
			valid: true,
547
		})
548
	}
549
	if len(points) == 0 {
550
		return nil
551
	}
552
553
	for _, targetHour := range timelineHours {
554
		bestIdx := -1
555
		bestDelta := 24
556
		for i, p := range points {
557
			delta := p.time.Hour() - targetHour
558
			if delta < 0 {
559
				delta = -delta
560
			}
561
			if delta < bestDelta {
562
				bestDelta = delta
563
				bestIdx = i
564
			}
565
		}
566
		if bestIdx < 0 {
567
			continue
568
		}
569
		icon, text := weatherCodeLabel(points[bestIdx].code)
570
		out = append(out, fmt.Sprintf("%02d:00  %s  %s %s", targetHour, formatSignedTemperature(points[bestIdx].temp), icon, text))
571
	}
572
	return out
573
}
574
575
func weatherCodeLabel(code int) (string, string) {
576
	switch code {
577
	case 0:
578
		return "ā˜€", "Clear sky"
579
	case 1:
580
		return "ā›…", "Mostly clear"
581
	case 2:
582
		return "🌄", "Partly cloudy"
583
	case 3:
584
		return "☁", "Cloudy"
585
	case 45, 48:
586
		return "🌫", "Fog"
587
	case 51, 53, 55, 56, 57:
588
		return "🌦", "Drizzle"
589
	case 61, 63, 65, 66, 67:
590
		return "🌧", "Rain"
591
	case 71, 73, 75, 77:
592
		return "🌨", "Snow"
593
	case 80, 81, 82:
594
		return "🌧", "Rain showers"
595
	case 85, 86:
596
		return "🌨", "Snow showers"
597
	case 95, 96, 99:
598
		return "ā›ˆ", "Thunderstorm"
599
	default:
600
		return "🌤", "Variable clouds"
601
	}
602
}
603
604
func windDirectionFromDegrees(degrees float64) string {
605
	directions := []string{
606
		"N", "NNE", "NE", "ENE",
607
		"E", "ESE", "SE", "SSE",
608
		"S", "SSW", "SW", "WSW",
609
		"W", "WNW", "NW", "NNW",
610
	}
611
	normalized := math.Mod(degrees, 360)
612
	if normalized < 0 {
613
		normalized += 360
614
	}
615
	idx := int(math.Round(normalized/22.5)) % len(directions)
616
	return directions[idx]
617
}
618
619
func rainAmount(precipitationSum, rainSum, showersSum []float64) string {
620
	precip := firstEl(precipitationSum)
621
	rain := firstEl(rainSum)
622
	showers := firstEl(showersSum)
623
624
	low := rain
625
	if low <= 0 {
626
		low = precip
627
	}
628
	high := precip
629
	if showers > 0 && rain > 0 {
630
		high = math.Max(high, rain+showers)
631
	}
632
	if high < low {
633
		high = low
634
	}
635
	if high <= 0 {
636
		return "0 mm"
637
	}
638
639
	lowMM := int(math.Round(low))
640
	highMM := int(math.Round(high))
641
	if lowMM == highMM {
642
		return fmt.Sprintf("%d mm", highMM)
643
	}
644
	return fmt.Sprintf("%d-%d mm", lowMM, highMM)
645
}
646
647
func formatAirQualityLine(air airQualityResponse) string {
648
	if air.Current.USAQI == nil && air.Current.PM2_5 == nil && air.Current.PM10 == nil {
649
		return ""
650
	}
651
652
	parts := make([]string, 0, 4)
653
	if air.Current.USAQI != nil {
654
		aqi := int(math.Round(*air.Current.USAQI))
655
		parts = append(parts, fmt.Sprintf("AQI %d (%s)", aqi, usAQILevel(aqi)))
656
	}
657
	if air.Current.PM2_5 != nil {
658
		parts = append(parts, fmt.Sprintf("PM2.5 %.1f", *air.Current.PM2_5))
659
	}
660
	if air.Current.PM10 != nil {
661
		parts = append(parts, fmt.Sprintf("PM10 %.1f", *air.Current.PM10))
662
	}
663
	if air.Current.Ozone != nil {
664
		parts = append(parts, fmt.Sprintf("O3 %.1f", *air.Current.Ozone))
665
	}
666
	if air.Current.NitrogenDioxide != nil {
667
		parts = append(parts, fmt.Sprintf("NO2 %.1f", *air.Current.NitrogenDioxide))
668
	}
669
	return "🌫 Air: " + strings.Join(parts, ", ")
670
}
671
672
func usAQILevel(v int) string {
673
	switch {
674
	case v <= 50:
675
		return "Good"
676
	case v <= 100:
677
		return "Moderate"
678
	case v <= 150:
679
		return "Unhealthy for sensitive groups"
680
	case v <= 200:
681
		return "Unhealthy"
682
	case v <= 300:
683
		return "Very unhealthy"
684
	default:
685
		return "Hazardous"
686
	}
687
}
688
689
func weatherFeedID(lat, lon float64) string {
690
	return fmt.Sprintf("weather-lat-%s-lon-%s", normalizeCoord(lat), normalizeCoord(lon))
691
}
692
693
func normalizeCoord(v float64) string {
694
	s := strconv.FormatFloat(v, 'f', 4, 64)
695
	s = strings.ReplaceAll(s, "-", "m")
696
	return strings.ReplaceAll(s, ".", "_")
697
}
698
699
func formatSignedTemperature(v float64) string {
700
	n := int(math.Round(v))
701
	if n > 0 {
702
		return fmt.Sprintf("+%d°", n)
703
	}
704
	return fmt.Sprintf("%d°", n)
705
}
706
707
func formatWindLine(minWind, maxWind float64, direction string) string {
708
	minV := int(math.Round(minWind))
709
	maxV := int(math.Round(maxWind))
710
	if maxV < minV {
711
		minV, maxV = maxV, minV
712
	}
713
	if minV == maxV {
714
		return fmt.Sprintf("🌬 Wind: %d km/h from %s", minV, direction)
715
	}
716
	return fmt.Sprintf("🌬 Wind: %d-%d km/h from %s", minV, maxV, direction)
717
}
718
719
func firstEl[T comparable](values []T) T {
720
	if len(values) == 0 {
721
		var zero T
722
		return zero
723
	}
724
	return values[0]
725
}