cloudspin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cloudspin/weather.py ADDED
@@ -0,0 +1,310 @@
1
+ """Weather from Open-Meteo (free, no API key) and how it maps onto the sky."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import asdict, dataclass, field
12
+ from datetime import date, datetime, timedelta, timezone
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from .sky import Daylight, Weather
17
+
18
+ GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
19
+ FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
20
+ TIMEOUT = 8.0
21
+
22
+ _CURRENT = (
23
+ "temperature_2m,apparent_temperature,relative_humidity_2m,is_day,precipitation,"
24
+ "weather_code,cloud_cover,wind_speed_10m,wind_direction_10m"
25
+ )
26
+ _DAILY = "weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max"
27
+
28
+ # WMO weather code -> (description, icon, sky). Sky wind is filled in later.
29
+ _CODES: dict[int, tuple[str, str, Weather]] = {
30
+ 0: ("Clear sky", "clear", Weather(cover=0.0)),
31
+ 1: ("Mostly clear", "clear", Weather(cover=0.14)),
32
+ 2: ("Partly cloudy", "partly", Weather(cover=0.42)),
33
+ 3: ("Overcast", "cloud", Weather(cover=0.96)),
34
+ 45: ("Fog", "fog", Weather(cover=0.5, fog=1.0)),
35
+ 48: ("Freezing fog", "fog", Weather(cover=0.5, fog=1.0)),
36
+ 51: ("Light drizzle", "rain", Weather(cover=0.82, rain=0.16)),
37
+ 53: ("Drizzle", "rain", Weather(cover=0.88, rain=0.26)),
38
+ 55: ("Heavy drizzle", "rain", Weather(cover=0.92, rain=0.36)),
39
+ 56: ("Freezing drizzle", "rain", Weather(cover=0.88, rain=0.22)),
40
+ 57: ("Freezing drizzle", "rain", Weather(cover=0.92, rain=0.34)),
41
+ 61: ("Light rain", "rain", Weather(cover=0.9, rain=0.42)),
42
+ 63: ("Rain", "rain", Weather(cover=0.95, rain=0.68)),
43
+ 65: ("Heavy rain", "rain", Weather(cover=1.0, rain=1.0)),
44
+ 66: ("Freezing rain", "rain", Weather(cover=0.92, rain=0.5)),
45
+ 67: ("Freezing rain", "rain", Weather(cover=1.0, rain=0.9)),
46
+ 71: ("Light snow", "snow", Weather(cover=0.85, snow=0.35)),
47
+ 73: ("Snow", "snow", Weather(cover=0.92, snow=0.65)),
48
+ 75: ("Heavy snow", "snow", Weather(cover=1.0, snow=1.0)),
49
+ 77: ("Snow grains", "snow", Weather(cover=0.85, snow=0.3)),
50
+ 80: ("Light showers", "rain", Weather(cover=0.7, rain=0.48)),
51
+ 81: ("Showers", "rain", Weather(cover=0.8, rain=0.74)),
52
+ 82: ("Heavy showers", "rain", Weather(cover=0.95, rain=1.0)),
53
+ 85: ("Snow showers", "snow", Weather(cover=0.8, snow=0.5)),
54
+ 86: ("Heavy snow showers", "snow", Weather(cover=0.95, snow=0.9)),
55
+ 95: ("Thunderstorm", "storm", Weather(cover=1.0, rain=0.85, storm=1.0)),
56
+ 96: ("Thunderstorm with hail", "storm", Weather(cover=1.0, rain=1.0, storm=1.0)),
57
+ 99: ("Thunderstorm with hail", "storm", Weather(cover=1.0, rain=1.0, storm=1.0)),
58
+ }
59
+
60
+ class WeatherError(Exception):
61
+ """A weather lookup failed; the message is written for the person using the app."""
62
+
63
+
64
+ @dataclass
65
+ class Place:
66
+ name: str
67
+ latitude: float
68
+ longitude: float
69
+ country: str = ""
70
+ region: str = ""
71
+
72
+ @property
73
+ def label(self) -> str:
74
+ return f"{self.name}, {self.country}" if self.country else self.name
75
+
76
+
77
+ @dataclass
78
+ class Day:
79
+ day: date
80
+ code: int
81
+ high: float
82
+ low: float
83
+ rain_chance: int | None = None
84
+
85
+
86
+ @dataclass
87
+ class Report:
88
+ place: Place
89
+ units: str
90
+ temperature: float
91
+ feels_like: float
92
+ humidity: int
93
+ wind_speed: float
94
+ wind_direction: float
95
+ code: int
96
+ cloud_cover: float
97
+ high: float
98
+ low: float
99
+ sunrise: datetime # local wall-clock time at the place
100
+ sunset: datetime
101
+ next_sunrise: datetime
102
+ utc_offset: int # seconds
103
+ days: list[Day] = field(default_factory=list)
104
+ fetched_at: float = 0.0
105
+
106
+ @property
107
+ def description(self) -> str:
108
+ return describe(self.code)
109
+
110
+ @property
111
+ def wind_unit(self) -> str:
112
+ return "mph" if self.units == "imperial" else "km/h"
113
+
114
+ def local_now(self, now: float | None = None) -> datetime:
115
+ stamp = time.time() if now is None else now
116
+ return datetime.fromtimestamp(stamp, timezone.utc).replace(tzinfo=None) + timedelta(seconds=self.utc_offset)
117
+
118
+ def sky(self) -> Weather:
119
+ wind_kmh = self.wind_speed * 1.609 if self.units == "imperial" else self.wind_speed
120
+ return sky_for(self.code, self.cloud_cover, wind_kmh)
121
+
122
+ def daylight(self, now: float | None = None) -> Daylight:
123
+ stamp = time.time() if now is None else now
124
+ return daylight_at(self.local_now(stamp), self.sunrise, self.sunset, self.next_sunrise, moon_phase(stamp))
125
+
126
+
127
+ def describe(code: int) -> str:
128
+ return _CODES.get(code, ("Unsettled", "cloud", Weather()))[0]
129
+
130
+
131
+ def icon_for(code: int) -> str:
132
+ return _CODES.get(code, ("", "cloud", Weather()))[1]
133
+
134
+
135
+ def sky_for(code: int, cloud_cover: float | None = None, wind_kmh: float = 6.0) -> Weather:
136
+ base = _CODES.get(code, ("", "cloud", Weather(cover=0.6)))[2]
137
+ cover = base.cover
138
+ if cloud_cover is not None and code in (0, 1, 2, 3):
139
+ # Trust the measured cover for dry skies, but keep it in the band the code implies.
140
+ cover = min(max(cloud_cover / 100.0, cover - 0.14), cover + 0.2)
141
+ return Weather(
142
+ cover=max(0.0, min(1.0, cover)),
143
+ rain=base.rain,
144
+ snow=base.snow,
145
+ fog=base.fog,
146
+ storm=base.storm,
147
+ wind=max(1.0, wind_kmh),
148
+ )
149
+
150
+
151
+ def compass(degrees: float) -> str:
152
+ names = ("N", "NE", "E", "SE", "S", "SW", "W", "NW")
153
+ return names[int((degrees % 360) / 45.0 + 0.5) % 8]
154
+
155
+
156
+ def moon_phase(stamp: float | None = None) -> float:
157
+ """Fraction through the lunar cycle: 0 new, 0.5 full."""
158
+ stamp = time.time() if stamp is None else stamp
159
+ known_new_moon = 947182440.0 # 2000-01-06 18:14 UTC
160
+ return ((stamp - known_new_moon) / 86400.0 / 29.530588853) % 1.0
161
+
162
+
163
+ def daylight_at(
164
+ now: datetime, sunrise: datetime, sunset: datetime, next_sunrise: datetime, phase: float = 0.5
165
+ ) -> Daylight:
166
+ if sunrise <= now < sunset:
167
+ span = (sunset - sunrise).total_seconds() or 1.0
168
+ p = (now - sunrise).total_seconds() / span
169
+ return Daylight(elevation=math.sin(math.pi * p), sun_x=p, moon_x=1.0 if p < 0.5 else 0.0, moon_phase=phase)
170
+ if now >= sunset:
171
+ start, end = sunset, next_sunrise
172
+ else:
173
+ start, end = sunset - timedelta(days=1), sunrise
174
+ span = (end - start).total_seconds() or 1.0
175
+ p = min(1.0, max(0.0, (now - start).total_seconds() / span))
176
+ return Daylight(elevation=-math.sin(math.pi * p), sun_x=1.0 if p < 0.5 else 0.0, moon_x=p, moon_phase=phase)
177
+
178
+
179
+ def clock_daylight(now: datetime | None = None) -> Daylight:
180
+ """A sensible sky for the local clock when no place is known yet."""
181
+ now = now or datetime.now()
182
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
183
+ sunrise = midnight + timedelta(hours=6, minutes=15)
184
+ sunset = midnight + timedelta(hours=18, minutes=30)
185
+ return daylight_at(now, sunrise, sunset, sunrise + timedelta(days=1), moon_phase())
186
+
187
+
188
+ # -- network -----------------------------------------------------------------
189
+
190
+
191
+ def _get(url: str, params: dict[str, object]) -> dict:
192
+ query = urllib.parse.urlencode(params)
193
+ request = urllib.request.Request(f"{url}?{query}", headers={"User-Agent": f"cloudspin/{__version__}"})
194
+ try:
195
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
196
+ return json.loads(response.read().decode("utf-8"))
197
+ except urllib.error.HTTPError as error:
198
+ raise WeatherError(f"The weather service answered with an error ({error.code}).") from error
199
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
200
+ raise WeatherError("Can't reach the weather service. Check your connection.") from error
201
+ except ValueError as error:
202
+ raise WeatherError("The weather service sent something unreadable.") from error
203
+
204
+
205
+ def geocode(name: str) -> Place:
206
+ data = _get(GEOCODE_URL, {"name": name.strip(), "count": 1, "language": "en", "format": "json"})
207
+ results = data.get("results") or []
208
+ if not results:
209
+ raise WeatherError(f"No place called “{name.strip()}” was found. Try a nearby city.")
210
+ hit = results[0]
211
+ return Place(
212
+ name=str(hit.get("name", name)),
213
+ latitude=float(hit["latitude"]),
214
+ longitude=float(hit["longitude"]),
215
+ country=str(hit.get("country_code", "") or ""),
216
+ region=str(hit.get("admin1", "") or ""),
217
+ )
218
+
219
+
220
+ def parse(data: dict, place: Place, units: str = "metric", fetched_at: float | None = None) -> Report:
221
+ try:
222
+ current = data["current"]
223
+ daily = data["daily"]
224
+ days = [
225
+ Day(
226
+ day=date.fromisoformat(daily["time"][i]),
227
+ code=int(daily["weather_code"][i] or 0),
228
+ high=float(daily["temperature_2m_max"][i]),
229
+ low=float(daily["temperature_2m_min"][i]),
230
+ rain_chance=(
231
+ int(daily["precipitation_probability_max"][i])
232
+ if daily.get("precipitation_probability_max")
233
+ and daily["precipitation_probability_max"][i] is not None
234
+ else None
235
+ ),
236
+ )
237
+ for i in range(len(daily["time"]))
238
+ ]
239
+ sunrise = datetime.fromisoformat(daily["sunrise"][0])
240
+ sunset = datetime.fromisoformat(daily["sunset"][0])
241
+ if len(daily["sunrise"]) > 1:
242
+ next_sunrise = datetime.fromisoformat(daily["sunrise"][1])
243
+ else:
244
+ next_sunrise = sunrise + timedelta(days=1)
245
+ return Report(
246
+ place=place,
247
+ units=units,
248
+ temperature=float(current["temperature_2m"]),
249
+ feels_like=float(current.get("apparent_temperature", current["temperature_2m"])),
250
+ humidity=int(current.get("relative_humidity_2m", 0) or 0),
251
+ wind_speed=float(current.get("wind_speed_10m", 0.0) or 0.0),
252
+ wind_direction=float(current.get("wind_direction_10m", 0.0) or 0.0),
253
+ code=int(current.get("weather_code", 0) or 0),
254
+ cloud_cover=float(current.get("cloud_cover", 0.0) or 0.0),
255
+ high=days[0].high,
256
+ low=days[0].low,
257
+ sunrise=sunrise,
258
+ sunset=sunset,
259
+ next_sunrise=next_sunrise,
260
+ utc_offset=int(data.get("utc_offset_seconds", 0) or 0),
261
+ days=days,
262
+ fetched_at=time.time() if fetched_at is None else fetched_at,
263
+ )
264
+ except (KeyError, IndexError, TypeError, ValueError) as error:
265
+ raise WeatherError("The weather service sent something unreadable.") from error
266
+
267
+
268
+ # -- a small disk cache so the sky is right the moment the app opens ---------
269
+
270
+
271
+ def save_cache(path: Path, data: dict, place: Place, units: str) -> None:
272
+ try:
273
+ path.parent.mkdir(parents=True, exist_ok=True)
274
+ payload = {"saved_at": time.time(), "units": units, "place": asdict(place), "data": data}
275
+ path.write_text(json.dumps(payload), encoding="utf-8")
276
+ except OSError:
277
+ pass
278
+
279
+
280
+ def load_cache(path: Path, place: Place, units: str, max_age: float = 3 * 3600) -> Report | None:
281
+ try:
282
+ payload = json.loads(path.read_text(encoding="utf-8"))
283
+ saved_at = float(payload["saved_at"])
284
+ if time.time() - saved_at > max_age or payload.get("units") != units:
285
+ return None
286
+ cached = Place(**payload["place"])
287
+ if abs(cached.latitude - place.latitude) > 0.01 or abs(cached.longitude - place.longitude) > 0.01:
288
+ return None
289
+ return parse(payload["data"], place, units, fetched_at=saved_at)
290
+ except (OSError, ValueError, KeyError, TypeError, WeatherError):
291
+ return None
292
+
293
+
294
+ def fetch(place: Place, units: str = "metric", cache: Path | None = None) -> Report:
295
+ params: dict[str, object] = {
296
+ "latitude": f"{place.latitude:.4f}",
297
+ "longitude": f"{place.longitude:.4f}",
298
+ "current": _CURRENT,
299
+ "daily": _DAILY,
300
+ "timezone": "auto",
301
+ "forecast_days": 7,
302
+ }
303
+ if units == "imperial":
304
+ params["temperature_unit"] = "fahrenheit"
305
+ params["wind_speed_unit"] = "mph"
306
+ data = _get(FORECAST_URL, params)
307
+ report = parse(data, place, units)
308
+ if cache is not None:
309
+ save_cache(cache, data, place, units)
310
+ return report