repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

6.6 KB · 243 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/url"
  7	"time"
  8)
  9
 10// Is the weekend worth going out in. Isaac camps and hikes, and described his
 11// optimal day as a cool dry one with firm ground and no wind, so that is what
 12// the scoring is built around rather than around a generic idea of nice
 13// weather. A hot still day scores badly here and would score well anywhere else.
 14const (
 15	outdoorsURL   = "https://api.open-meteo.com/v1/forecast"
 16	outdoorsEvery = 3 * time.Hour
 17
 18	// Three days back is enough to know whether the ground has dried out, and
 19	// it is what the same request returns for free.
 20	groundDays = 3
 21
 22	// Friday through Sunday, which is the shape of a trip rather than the
 23	// shape of a weekend.
 24	outlookDays = 3
 25)
 26
 27type OutlookDay struct {
 28	Day     string `json:"day"`
 29	Date    string `json:"date"`
 30	Verdict string `json:"verdict"`
 31	Score   int    `json:"score"`
 32
 33	High string `json:"high"`
 34	Low  string `json:"low"`
 35
 36	// The four things being graded, each with the word and the nought to three
 37	// it scored. The word alone said what the weather was doing and left which
 38	// of the four ruined the day to be worked out by comparing four adjectives.
 39	Factors []Factor `json:"factors"`
 40}
 41
 42type Factor struct {
 43	Label string `json:"label"`
 44	Value string `json:"value"`
 45	Score int    `json:"score"`
 46
 47	// The reading the word came from. FIRM and MUDDY are a judgement about
 48	// three days of rain and the judgement is the useful part, but the number
 49	// behind it is what says whether a call was close.
 50	Detail string `json:"detail"`
 51}
 52
 53type Outlook struct {
 54	Days []OutlookDay `json:"days"`
 55}
 56
 57type outdoorsPayload struct {
 58	Daily struct {
 59		Time         []string  `json:"time"`
 60		Code         []int     `json:"weather_code"`
 61		Max          []float64 `json:"temperature_2m_max"`
 62		Min          []float64 `json:"temperature_2m_min"`
 63		Precip       []float64 `json:"precipitation_sum"`
 64		PrecipChance []int     `json:"precipitation_probability_max"`
 65		Gusts        []float64 `json:"wind_gusts_10m_max"`
 66	} `json:"daily"`
 67}
 68
 69func fetchOutlook(ctx context.Context, g *Guard, now time.Time) (Outlook, error) {
 70	q := url.Values{}
 71	q.Set("latitude", fmt.Sprintf("%.2f", weatherLat))
 72	q.Set("longitude", fmt.Sprintf("%.2f", weatherLon))
 73	q.Set("daily", "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_gusts_10m_max")
 74	q.Set("past_days", fmt.Sprint(groundDays))
 75	q.Set("forecast_days", "10")
 76	q.Set("temperature_unit", "fahrenheit")
 77	q.Set("wind_speed_unit", "mph")
 78	q.Set("precipitation_unit", "inch")
 79	q.Set("timezone", "America/New_York")
 80
 81	var payload outdoorsPayload
 82	if err := getJSON(ctx, g, "openmeteo", outdoorsURL+"?"+q.Encode(), &payload); err != nil {
 83		return Outlook{}, err
 84	}
 85
 86	d := payload.Daily
 87	if len(d.Time) == 0 {
 88		return Outlook{}, fmt.Errorf("open-meteo: no daily series")
 89	}
 90
 91	today := now.In(easternTime()).Format("2006-01-02")
 92	var out Outlook
 93
 94	for i, day := range d.Time {
 95		if len(out.Days) == outlookDays || day < today {
 96			continue
 97		}
 98		parsed, err := time.ParseInLocation("2006-01-02", day, easternTime())
 99		if err != nil {
100			continue
101		}
102		// Friday counts, because a weekend trip leaves on one.
103		switch parsed.Weekday() {
104		case time.Friday, time.Saturday, time.Sunday:
105		default:
106			continue
107		}
108
109		// Rain over the days before this one, which is what decides whether the
110		// ground is firm or a swamp. The window walks back through the past
111		// days the same request returned.
112		var before float64
113		for j := i - groundDays; j < i; j++ {
114			if j >= 0 && j < len(d.Precip) {
115				before += d.Precip[j]
116			}
117		}
118
119		out.Days = append(out.Days, scoreDay(parsed, nth(d.Max, i), nth(d.Min, i), nth(d.Precip, i), chance(d.PrecipChance, i), nth(d.Gusts, i), before))
120	}
121
122	if len(out.Days) == 0 {
123		return Outlook{}, fmt.Errorf("open-meteo: no weekend in the forecast window")
124	}
125	return out, nil
126}
127
128func nth(xs []float64, i int) float64 {
129	if i < len(xs) {
130		return xs[i]
131	}
132	return 0
133}
134
135func chance(xs []int, i int) int {
136	if i < len(xs) {
137		return xs[i]
138	}
139	return 0
140}
141
142// scoreDay grades the four things Isaac named. Each contributes nought to three
143// and the total decides the verdict, so one washout ruins a day on its own
144// while two merely mediocre readings only make it middling.
145func scoreDay(day time.Time, high, low, precip float64, pop int, gust, groundRain float64) OutlookDay {
146	o := OutlookDay{
147		Day:  day.Format("Mon"),
148		Date: day.Format("2 Jan"),
149		High: fmt.Sprintf("%.0f", high),
150		Low:  fmt.Sprintf("%.0f", low),
151	}
152
153	tempScore, tempWord := gradeTemp(high)
154	rainScore, rainWord := gradeRain(precip, pop)
155	groundScore, groundWord := gradeGround(groundRain)
156	windScore, windWord := gradeWind(gust)
157
158	o.Factors = []Factor{
159		{"TEMP", tempWord, tempScore, fmt.Sprintf("%.0f°", high)},
160		{"RAIN", rainWord, rainScore, fmt.Sprintf("%d%%", pop)},
161		{"GRND", groundWord, groundScore, fmt.Sprintf("%.1f\"", groundRain)},
162		{"WIND", windWord, windScore, fmt.Sprintf("%.0fmph", gust)},
163	}
164	o.Score = tempScore + rainScore + groundScore + windScore
165
166	switch {
167	case o.Score <= 1:
168		o.Verdict = "OPTIMAL"
169	case o.Score <= 3:
170		o.Verdict = "GOOD"
171	case o.Score <= 6:
172		o.Verdict = "MARGINAL"
173	default:
174		o.Verdict = "POOR"
175	}
176	return o
177}
178
179// A cool day is the good one here. Isaac's stated optimum is a fall day, so the
180// band that scores zero is 55 to 75 and anything in the nineties is penalised
181// as hard as anything near freezing.
182func gradeTemp(high float64) (int, string) {
183	switch {
184	case high >= 55 && high <= 75:
185		return 0, "COOL"
186	case high > 75 && high <= 85:
187		return 1, "WARM"
188	case high >= 45 && high < 55:
189		return 1, "CHILLY"
190	case high > 85 && high <= 95:
191		return 2, "HOT"
192	case high >= 32 && high < 45:
193		return 2, "COLD"
194	case high > 95:
195		return 3, "BAKING"
196	default:
197		return 3, "FREEZING"
198	}
199}
200
201func gradeRain(precip float64, pop int) (int, string) {
202	switch {
203	case precip < 0.02 && pop < 25:
204		return 0, "DRY"
205	case precip < 0.1 && pop < 50:
206		return 1, "MAYBE"
207	case precip < 0.3:
208		return 2, "WET"
209	default:
210		return 3, "POURING"
211	}
212}
213
214// The one nobody forecasts, and the one that decides whether a trail is a trail
215// or a bog. Measured as rain over the three days before, not on the day.
216func gradeGround(before float64) (int, string) {
217	switch {
218	case before < 0.1:
219		return 0, "FIRM"
220	case before < 0.5:
221		return 1, "DAMP"
222	case before < 1.0:
223		return 2, "MUDDY"
224	default:
225		return 3, "SOAKED"
226	}
227}
228
229// Gusts rather than the average, since an average of ten with gusts to thirty
230// is the day the tent goes flat.
231func gradeWind(gust float64) (int, string) {
232	switch {
233	case gust < 15:
234		return 0, "CALM"
235	case gust < 25:
236		return 1, "BREEZY"
237	case gust < 35:
238		return 2, "WINDY"
239	default:
240		return 3, "GALE"
241	}
242}