orchard
mirrorEvery 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
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}