| Name | Message | Date |
|---|---|---|
| 📄 ClientOptions.cs | 1 month ago | |
| 📄 Dtos.cs | 1 month ago | |
| 📄 packages.lock.json | 1 month ago | |
| 📄 ServiceCollectionExtensions.cs | 1 month ago | |
| 📄 Smhi.csproj | 1 month ago | |
| 📄 SmhiClient.cs | 1 month ago |
📄
src/Integrations/Smhi/SmhiClient.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net.Http; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; namespace MMirror.Integrations.Smhi; public sealed class SmhiClient(HttpClient httpClient, TimeProvider timeProvider) { private const double MissingValue = 9999; private static readonly DayPeriod[] PeriodOrder = [ DayPeriod.Morgon, DayPeriod.Middag, DayPeriod.Eftermiddag, DayPeriod.Natt, ]; public async Task<PointForecast> GetPointForecast( double longitude, double latitude, CancellationToken cancellationToken ) { var response = await httpClient.GetFromJsonAsync( $"/api/category/snow1g/version/1/geotype/point/lon/{longitude.ToString("F6", CultureInfo.InvariantCulture)}/lat/{latitude.ToString("F6", CultureInfo.InvariantCulture)}/data.json?parameters=air_temperature,symbol_code,precipitation_amount_mean", SmhiJsonSerializerContext.Default.ForecastResponse, cancellationToken ) ?? throw new Exception("Received literal null from SMHI point forecast endpoint."); var periods = response.TimeSeries.Select(TryMapPeriod).OfType<ForecastPeriod>().ToArray(); if (periods.Length < 3) { throw new Exception("SMHI point forecast returned fewer than 3 usable time series entries."); } var localNow = timeProvider.GetLocalNow(); var currentPeriod = GetCurrentPeriod(localNow); var currentAnchor = localNow; var nextPeriod = PeriodAtOffset(currentPeriod, 1); var nextAnchor = GetFollowingPeriodAnchor(nextPeriod, currentAnchor); var followingPeriod = PeriodAtOffset(currentPeriod, 2); var followingAnchor = GetFollowingPeriodAnchor(followingPeriod, nextAnchor); return new PointForecast( CreateLabeledPeriod(currentPeriod, currentAnchor, periods, preferFuture: true), CreateLabeledPeriod(nextPeriod, nextAnchor, periods), CreateLabeledPeriod(followingPeriod, followingAnchor, periods) ); } private static LabeledForecastPeriod CreateLabeledPeriod( DayPeriod period, DateTimeOffset anchor, ForecastPeriod[] periods, bool preferFuture = false ) => new(period, GetPeriodName(period), FindClosestPeriod(periods, anchor.ToUniversalTime(), preferFuture)); private static DayPeriod GetCurrentPeriod(DateTimeOffset localNow) => localNow.Hour switch { >= 3 and < 9 => DayPeriod.Morgon, >= 9 and < 15 => DayPeriod.Middag, >= 15 and < 21 => DayPeriod.Eftermiddag, _ => DayPeriod.Natt, }; private static DateTimeOffset GetFollowingPeriodAnchor(DayPeriod period, DateTimeOffset previousAnchor) { var day = DateOnly.FromDateTime(previousAnchor.LocalDateTime); for (var dayOffset = 0; dayOffset <= 2; dayOffset++) { var candidate = MakeAnchor(day.AddDays(dayOffset), period, previousAnchor.Offset); if (candidate > previousAnchor) { return candidate; } } throw new Exception($"No upcoming anchor found for {period} after {previousAnchor}."); } private static DateTimeOffset MakeAnchor(DateOnly day, DayPeriod period, TimeSpan offset) => new( day.ToDateTime( new TimeOnly( period switch { DayPeriod.Morgon => 6, DayPeriod.Middag => 12, DayPeriod.Eftermiddag => 18, DayPeriod.Natt => 0, _ => throw new ArgumentOutOfRangeException(nameof(period)), }, 0 ) ), offset ); private static DayPeriod PeriodAtOffset(DayPeriod current, int offset) => PeriodOrder[(Array.IndexOf(PeriodOrder, current) + offset) % PeriodOrder.Length]; private static string GetPeriodName(DayPeriod period) => period switch { DayPeriod.Morgon => "Morgon", DayPeriod.Middag => "Middag", DayPeriod.Eftermiddag => "Eftermiddag", DayPeriod.Natt => "Natt", _ => throw new ArgumentOutOfRangeException(nameof(period)), }; private static ForecastPeriod? TryMapPeriod(TimeSeriesEntry entry) { if ( entry.Data.AirTemperature is not double temperature || IsMissing(temperature) || entry.Data.SymbolCode is not int symbolCode || symbolCode is < 1 or > 27 || entry.Data.PrecipitationAmountMean is not double precipitation || IsMissing(precipitation) ) { return null; } return new ForecastPeriod(entry.Time, temperature, precipitation, (WeatherSymbol)symbolCode); } private static bool IsMissing(double value) => double.Abs(value - MissingValue) < 0.001; private static ForecastPeriod FindClosestPeriod(ForecastPeriod[] periods, DateTimeOffset target, bool preferFuture) { IEnumerable<ForecastPeriod> filteredPeriods = periods; if (preferFuture) { filteredPeriods = filteredPeriods.Where(period => period.ValidTime >= target); } return filteredPeriods.MinBy(period => double.Abs((period.ValidTime - target).TotalMinutes)) ?? throw new Exception($"No forecast found for {target}."); } } public sealed record PointForecast( LabeledForecastPeriod Now, LabeledForecastPeriod Next, LabeledForecastPeriod Following ); public sealed record LabeledForecastPeriod(DayPeriod Period, string PeriodName, ForecastPeriod Forecast); public enum DayPeriod { Morgon, Middag, Eftermiddag, Natt, } public sealed record ForecastPeriod( DateTimeOffset ValidTime, double TemperatureCelsius, double PrecipitationMillimeters, WeatherSymbol Symbol ); public enum WeatherSymbol { ClearSky = 1, NearlyClearSky = 2, VariableCloudiness = 3, HalfclearSky = 4, CloudySky = 5, Overcast = 6, Fog = 7, LightRainShowers = 8, ModerateRainShowers = 9, HeavyRainShowers = 10, Thunderstorm = 11, LightSleetShowers = 12, ModerateSleetShowers = 13, HeavySleetShowers = 14, LightSnowShowers = 15, ModerateSnowShowers = 16, HeavySnowShowers = 17, LightRain = 18, ModerateRain = 19, HeavyRain = 20, Thunder = 21, LightSleet = 22, ModerateSleet = 23, HeavySleet = 24, LightSnowfall = 25, ModerateSnowfall = 26, HeavySnowfall = 27, }