.oO SearXNG Developer Documentation Oo.
Loading...
Searching...
No Matches
weather.py
Go to the documentation of this file.
1# SPDX-License-Identifier: AGPL-3.0-or-later
2"""Implementations used for weather conditions and forecast."""
3# pylint: disable=too-few-public-methods
4
5__all__ = [
6 "symbol_url",
7 "Temperature",
8 "Pressure",
9 "WindSpeed",
10 "RelativeHumidity",
11 "Compass",
12 "WeatherConditionType",
13 "DateTime",
14 "GeoLocation",
15]
16
17import typing
18
19import base64
20import datetime
21import dataclasses
22import zoneinfo
23
24from urllib.parse import quote_plus
25
26import babel
27import babel.numbers
28import babel.dates
29import babel.languages
30
31from searx import network
32from searx.cache import ExpireCache, ExpireCacheCfg
33from searx.extended_types import sxng_request
34from searx.wikidata_units import convert_to_si, convert_from_si
35
36WEATHER_DATA_CACHE: ExpireCache | None = None
37"""A simple cache for weather data (geo-locations, icons, ..)"""
38
39YR_WEATHER_SYMBOL_URL = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols/outline"
40
41
43
44 global WEATHER_DATA_CACHE # pylint: disable=global-statement
45
46 if WEATHER_DATA_CACHE is None:
47 WEATHER_DATA_CACHE = ExpireCache.build_cache(
49 name="WEATHER_DATA_CACHE",
50 MAX_VALUE_LEN=1024 * 200, # max. 200kB per icon (icons have most often 10-20kB)
51 MAXHOLD_TIME=60 * 60 * 24 * 7 * 4, # 4 weeks
52 )
53 )
54 return WEATHER_DATA_CACHE
55
56
58 # The function should return a locale (the sxng-tag: de-DE.en-US, ..) that
59 # can later be used to format and convert measured values for the output of
60 # weather data to the user.
61 #
62 # In principle, SearXNG only has two possible parameters for determining
63 # the locale: the UI language or the search- language/region. Since the
64 # conversion of weather data and time information is usually
65 # region-specific, the UI language is not suitable.
66 #
67 # It would probably be ideal to use the user's geolocation, but this will
68 # probably never be available in SearXNG (privacy critical).
69 #
70 # Therefore, as long as no "better" parameters are available, this function
71 # returns a locale based on the search region.
72
73 # pylint: disable=import-outside-toplevel,disable=cyclic-import
74 from searx import query
75 from searx.preferences import ClientPref
76
77 query = query.RawTextQuery(sxng_request.form.get("q", ""), [])
78 if query.languages and query.languages[0] not in ["all", "auto"]:
79 return query.languages[0]
80
81 search_lang = sxng_request.form.get("language")
82 if search_lang and search_lang not in ["all", "auto"]:
83 return search_lang
84
85 client_pref = ClientPref.from_http_request(sxng_request)
86 search_lang = client_pref.locale_tag
87 if search_lang and search_lang not in ["all", "auto"]:
88 return search_lang
89 return "en"
90
91
92def symbol_url(condition: "WeatherConditionType") -> str | None:
93 """Returns ``data:`` URL for the weather condition symbol or ``None`` if
94 the condition is not of type :py:obj:`WeatherConditionType`.
95
96 If symbol (SVG) is not already in the :py:obj:`WEATHER_DATA_CACHE` its
97 fetched from https://github.com/nrkno/yr-weather-symbols
98 """
99 # Symbols for darkmode/lightmode? .. and day/night symbols? .. for the
100 # latter we need a geopoint (critical in sense of privacy)
101
102 fname = YR_WEATHER_SYMBOL_MAP.get(condition)
103 if fname is None:
104 return None
105
106 ctx = "weather_symbol_url"
107 cache = get_WEATHER_DATA_CACHE()
108 origin_url = f"{YR_WEATHER_SYMBOL_URL}/{fname}.svg"
109
110 data_url = cache.get(origin_url, ctx=ctx)
111 if data_url is not None:
112 return data_url
113
114 response = network.get(origin_url, timeout=3)
115 if response.status_code == 200:
116 mimetype = response.headers['Content-Type']
117 data_url = f"data:{mimetype};base64,{str(base64.b64encode(response.content), 'utf-8')}"
118 cache.set(key=origin_url, value=data_url, expire=None, ctx=ctx)
119 return data_url
120
121
122@dataclasses.dataclass
124 """Minimal implementation of Geocoding."""
125
126 # The type definition was based on the properties from the geocoding API of
127 # open-meteo.
128 #
129 # - https://open-meteo.com/en/docs/geocoding-api
130 # - https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
131 # - https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
132
133 name: str
134 latitude: float # Geographical WGS84 coordinates of this location
135 longitude: float
136 elevation: float # Elevation above mean sea level of this location
137 country_code: str # 2-Character ISO-3166-1 alpha2 country code. E.g. DE for Germany
138 timezone: str # Time zone using time zone database definitions
139
140 @property
141 def zoneinfo(self) -> zoneinfo.ZoneInfo:
142 return zoneinfo.ZoneInfo(self.timezone)
143
144 def __str__(self):
145 return self.name
146
147 def locale(self) -> babel.Locale:
148
149 # by region of the search language
150 sxng_tag = _get_sxng_locale_tag()
151 if "-" in sxng_tag:
152 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
153 return locale
154
155 # by most popular language in the region (country code)
156 for lang in babel.languages.get_official_languages(self.country_code):
157 try:
158 locale = babel.Locale.parse(f"{lang}_{self.country_code}")
159 return locale
160 except babel.UnknownLocaleError:
161 continue
162
163 # No locale could be determined. This does not actually occur, but if
164 # it does, the English language is used by default. But not region US.
165 # US has some units that are only used in US but not in the rest of the
166 # world (e.g. °F instead of °C)
167 return babel.Locale("en", territory="DE")
168
169 @classmethod
170 def by_query(cls, search_term: str) -> "GeoLocation":
171 """Factory method to get a GeoLocation object by a search term. If no
172 location can be determined for the search term, a :py:obj:`ValueError`
173 is thrown.
174 """
175
176 ctx = "weather_geolocation_by_query"
177 cache = get_WEATHER_DATA_CACHE()
178 geo_props = cache.get(search_term, ctx=ctx)
179
180 if not geo_props:
181 geo_props = cls._query_open_meteo(search_term=search_term)
182 cache.set(key=search_term, value=geo_props, expire=None, ctx=ctx)
183
184 return cls(**geo_props) # type: ignore
185
186 @classmethod
187 def _query_open_meteo(cls, search_term: str) -> dict[str, str]:
188 url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote_plus(search_term)}"
189 resp = network.get(url, timeout=3)
190 if resp.status_code != 200:
191 raise ValueError(f"unknown geo location: '{search_term}'")
192 results = resp.json().get("results")
193 if not results:
194 raise ValueError(f"unknown geo location: '{search_term}'")
195 location = results[0]
196 return {field.name: location[field.name] for field in dataclasses.fields(cls)}
197
198
199DateTimeFormats = typing.Literal["full", "long", "medium", "short"]
200
201
202@typing.final
204 """Class to represent date & time. Essentially, it is a wrapper that
205 conveniently combines :py:obj:`datetime.datetime` and
206 :py:obj:`babel.dates.format_datetime`. A conversion of time zones is not
207 provided (in the current version).
208 """
209
210 def __init__(self, time: datetime.datetime):
211 self.datetime = time
212
213 def __str__(self):
214 return self.l10n()
215
216 def l10n(
217 self,
218 fmt: DateTimeFormats | str = "medium",
219 locale: babel.Locale | GeoLocation | None = None,
220 ) -> str:
221 """Localized representation of date & time."""
222 if isinstance(locale, GeoLocation):
223 locale = locale.locale()
224 elif locale is None:
225 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
226 return babel.dates.format_datetime(self.datetime, format=fmt, locale=locale)
227
228
229@typing.final
231 """Class for converting temperature units and for string representation of
232 measured values."""
233
234 si_name = "Q11579"
235
236 Units = typing.Literal["°C", "°F", "K"]
237 """Supported temperature units."""
238
239 units = list(typing.get_args(Units))
240
241 def __init__(self, value: float, unit: Units):
242 if unit not in self.units:
243 raise ValueError(f"invalid unit: {unit}")
244 self.si: float = convert_to_si( # pylint: disable=invalid-name
245 si_name=self.si_name,
246 symbol=unit,
247 value=value,
248 )
249
250 def __str__(self):
251 return self.l10n()
252
253 def value(self, unit: Units) -> float:
254 return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
255
256 def l10n(
257 self,
258 unit: Units | None = None,
259 locale: babel.Locale | GeoLocation | None = None,
260 template: str = "{value} {unit}",
261 num_pattern: str = "#,##0",
262 ) -> str:
263 """Localized representation of a measured value.
264
265 If the ``unit`` is not set, an attempt is made to determine a ``unit``
266 matching the territory of the ``locale``. If the locale is not set, an
267 attempt is made to determine it from the HTTP request.
268
269 The value is converted into the respective unit before formatting.
270
271 The argument ``num_pattern`` is used to determine the string formatting
272 of the numerical value:
273
274 - https://babel.pocoo.org/en/latest/numbers.html#pattern-syntax
275 - https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns
276
277 The argument ``template`` specifies how the **string formatted** value
278 and unit are to be arranged.
279
280 - `Format Specification Mini-Language
281 <https://docs.python.org/3/library/string.html#format-specification-mini-language>`.
282 """
283
284 if isinstance(locale, GeoLocation):
285 locale = locale.locale()
286 elif locale is None:
287 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
288
289 if unit is None: # unit by territory
290 unit = "°C"
291 if locale.territory in ["US"]:
292 unit = "°F"
293 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
294 return template.format(value=val_str, unit=unit)
295
296
297@typing.final
299 """Class for converting pressure units and for string representation of
300 measured values."""
301
302 si_name = "Q44395"
303
304 Units = typing.Literal["Pa", "hPa", "cm Hg", "bar"]
305 """Supported units."""
306
307 units = list(typing.get_args(Units))
308
309 def __init__(self, value: float, unit: Units):
310 if unit not in self.units:
311 raise ValueError(f"invalid unit: {unit}")
312 # pylint: disable=invalid-name
313 self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
314
315 def __str__(self):
316 return self.l10n()
317
318 def value(self, unit: Units) -> float:
319 return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
320
321 def l10n(
322 self,
323 unit: Units | None = None,
324 locale: babel.Locale | GeoLocation | None = None,
325 template: str = "{value} {unit}",
326 num_pattern: str = "#,##0",
327 ) -> str:
328 if isinstance(locale, GeoLocation):
329 locale = locale.locale()
330 elif locale is None:
331 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
332
333 if unit is None: # unit by territory?
334 unit = "hPa"
335
336 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
337 return template.format(value=val_str, unit=unit)
338
339
340@typing.final
342 """Class for converting speed or velocity units and for string
343 representation of measured values.
344
345 .. hint::
346
347 Working with unit ``Bft`` (:py:obj:`searx.wikidata_units.Beaufort`) will
348 throw a :py:obj:`ValueError` for egative values or values greater 16 Bft
349 (55.6 m/s)
350 """
351
352 si_name = "Q182429"
353
354 Units = typing.Literal["m/s", "km/h", "kn", "mph", "mi/h", "Bft"]
355 """Supported units."""
356
357 units = list(typing.get_args(Units))
358
359 def __init__(self, value: float, unit: Units):
360 if unit not in self.units:
361 raise ValueError(f"invalid unit: {unit}")
362 # pylint: disable=invalid-name
363 self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
364
365 def __str__(self):
366 return self.l10n()
367
368 def value(self, unit: Units) -> float:
369 return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
370
371 def l10n(
372 self,
373 unit: Units | None = None,
374 locale: babel.Locale | GeoLocation | None = None,
375 template: str = "{value} {unit}",
376 num_pattern: str = "#,##0",
377 ) -> str:
378 if isinstance(locale, GeoLocation):
379 locale = locale.locale()
380 elif locale is None:
381 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
382
383 if unit is None: # unit by territory?
384 unit = "m/s"
385
386 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
387 return template.format(value=val_str, unit=unit)
388
389
390@typing.final
392 """Amount of relative humidity in the air. The unit is ``%``"""
393
394 Units = typing.Literal["%"]
395 """Supported unit."""
396
397 units = list(typing.get_args(Units))
398
399 def __init__(self, humidity: float):
400 self.humidity = humidity
401
402 def __str__(self):
403 return self.l10n()
404
405 def value(self) -> float:
406 return self.humidity
407
408 def l10n(
409 self,
410 locale: babel.Locale | GeoLocation | None = None,
411 template: str = "{value}{unit}",
412 num_pattern: str = "#,##0",
413 ) -> str:
414 if isinstance(locale, GeoLocation):
415 locale = locale.locale()
416 elif locale is None:
417 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
418
419 unit = "%"
420 val_str = babel.numbers.format_decimal(self.value(), locale=locale, format=num_pattern)
421 return template.format(value=val_str, unit=unit)
422
423
424@typing.final
426 """Class for converting compass points and azimuth values (360°)"""
427
428 Units = typing.Literal["°", "Point"]
429
430 Point = typing.Literal[
431 "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
432 ]
433 """Compass point type definition"""
434
435 TURN = 360.0
436 """Full turn (360°)"""
437
438 POINTS = list(typing.get_args(Point))
439 """Compass points."""
440
441 RANGE = TURN / len(POINTS)
442 """Angle sector of a compass point"""
443
444 def __init__(self, azimuth: float | int | Point):
445 if isinstance(azimuth, str):
446 if azimuth not in self.POINTS:
447 raise ValueError(f"Invalid compass point: {azimuth}")
448 azimuth = self.POINTS.index(azimuth) * self.RANGE
449 self.azimuth = azimuth % self.TURN
450
451 def __str__(self):
452 return self.l10n()
453
454 def value(self, unit: Units):
455 if unit == "Point":
456 return self.point(self.azimuth)
457 if unit == "°":
458 return self.azimuth
459 raise ValueError(f"unknown unit: {unit}")
460
461 @classmethod
462 def point(cls, azimuth: float | int) -> Point:
463 """Returns the compass point to an azimuth value."""
464 azimuth = azimuth % cls.TURN
465 # The angle sector of a compass point starts 1/2 sector range before
466 # and after compass point (example: "N" goes from -11.25° to +11.25°)
467 azimuth = azimuth - cls.RANGE / 2
468 idx = int(azimuth // cls.RANGE)
469 return cls.POINTS[idx]
470
471 def l10n(
472 self,
473 unit: Units = "Point",
474 locale: babel.Locale | GeoLocation | None = None,
475 template: str = "{value}{unit}",
476 num_pattern: str = "#,##0",
477 ) -> str:
478 if isinstance(locale, GeoLocation):
479 locale = locale.locale()
480 elif locale is None:
481 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
482
483 if unit == "Point":
484 val_str = self.value(unit)
485 return template.format(value=val_str, unit="")
486
487 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
488 return template.format(value=val_str, unit=unit)
489
490
491WeatherConditionType = typing.Literal[
492 # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
493 "clear sky",
494 "partly cloudy",
495 "cloudy",
496 "fair",
497 "fog",
498 # rain
499 "light rain and thunder",
500 "light rain showers and thunder",
501 "light rain showers",
502 "light rain",
503 "rain and thunder",
504 "rain showers and thunder",
505 "rain showers",
506 "rain",
507 "heavy rain and thunder",
508 "heavy rain showers and thunder",
509 "heavy rain showers",
510 "heavy rain",
511 # sleet
512 "light sleet and thunder",
513 "light sleet showers and thunder",
514 "light sleet showers",
515 "light sleet",
516 "sleet and thunder",
517 "sleet showers and thunder",
518 "sleet showers",
519 "sleet",
520 "heavy sleet and thunder",
521 "heavy sleet showers and thunder",
522 "heavy sleet showers",
523 "heavy sleet",
524 # snow
525 "light snow and thunder",
526 "light snow showers and thunder",
527 "light snow showers",
528 "light snow",
529 "snow and thunder",
530 "snow showers and thunder",
531 "snow showers",
532 "snow",
533 "heavy snow and thunder",
534 "heavy snow showers and thunder",
535 "heavy snow showers",
536 "heavy snow",
537]
538"""Standardized designations for weather conditions. The designators were
539taken from a collaboration between NRK and Norwegian Meteorological Institute
540(yr.no_). `Weather symbols`_ can be assigned to the identifiers
541(weathericons_) and they are included in the translation (i18n/l10n
542:origin:`searx/searxng.msg`).
543
544.. _yr.no: https://www.yr.no/en
545.. _Weather symbols: https://github.com/nrkno/yr-weather-symbols
546.. _weathericons: https://github.com/metno/weathericons
547"""
548
549YR_WEATHER_SYMBOL_MAP = {
550 "clear sky": "01d", # 01d clearsky_day
551 "partly cloudy": "03d", # 03d partlycloudy_day
552 "cloudy": "04", # 04 cloudy
553 "fair": "02d", # 02d fair_day
554 "fog": "15", # 15 fog
555 # rain
556 "light rain and thunder": "30", # 30 lightrainandthunder
557 "light rain showers and thunder": "24d", # 24d lightrainshowersandthunder_day
558 "light rain showers": "40d", # 40d lightrainshowers_day
559 "light rain": "46", # 46 lightrain
560 "rain and thunder": "22", # 22 rainandthunder
561 "rain showers and thunder": "06d", # 06d rainshowersandthunder_day
562 "rain showers": "05d", # 05d rainshowers_day
563 "rain": "09", # 09 rain
564 "heavy rain and thunder": "11", # 11 heavyrainandthunder
565 "heavy rain showers and thunder": "25d", # 25d heavyrainshowersandthunder_day
566 "heavy rain showers": "41d", # 41d heavyrainshowers_day
567 "heavy rain": "10", # 10 heavyrain
568 # sleet
569 "light sleet and thunder": "31", # 31 lightsleetandthunder
570 "light sleet showers and thunder": "26d", # 26d lightssleetshowersandthunder_day
571 "light sleet showers": "42d", # 42d lightsleetshowers_day
572 "light sleet": "47", # 47 lightsleet
573 "sleet and thunder": "23", # 23 sleetandthunder
574 "sleet showers and thunder": "20d", # 20d sleetshowersandthunder_day
575 "sleet showers": "07d", # 07d sleetshowers_day
576 "sleet": "12", # 12 sleet
577 "heavy sleet and thunder": "32", # 32 heavysleetandthunder
578 "heavy sleet showers and thunder": "27d", # 27d heavysleetshowersandthunder_day
579 "heavy sleet showers": "43d", # 43d heavysleetshowers_day
580 "heavy sleet": "48", # 48 heavysleet
581 # snow
582 "light snow and thunder": "33", # 33 lightsnowandthunder
583 "light snow showers and thunder": "28d", # 28d lightssnowshowersandthunder_day
584 "light snow showers": "44d", # 44d lightsnowshowers_day
585 "light snow": "49", # 49 lightsnow
586 "snow and thunder": "14", # 14 snowandthunder
587 "snow showers and thunder": "21d", # 21d snowshowersandthunder_day
588 "snow showers": "08d", # 08d snowshowers_day
589 "snow": "13", # 13 snow
590 "heavy snow and thunder": "34", # 34 heavysnowandthunder
591 "heavy snow showers and thunder": "29d", # 29d heavysnowshowersandthunder_day
592 "heavy snow showers": "45d", # 45d heavysnowshowers_day
593 "heavy snow": "50", # 50 heavysnow
594}
595"""Map a :py:obj:`WeatherConditionType` to a `YR weather symbol`_
596
597.. code::
598
599 base_url = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols"
600 icon_url = f"{base_url}/outline/{YR_WEATHER_SYMBOL_MAP['sleet showers']}.svg"
601
602.. _YR weather symbol: https://github.com/nrkno/yr-weather-symbols/blob/master/locales/en.json
603
604"""
605
606if __name__ == "__main__":
607
608 # test: fetch all symbols of the type catalog ..
609 for c in typing.get_args(WeatherConditionType):
610 symbol_url(condition=c)
611
613 title = "cached weather condition symbols"
614 print(title)
615 print("=" * len(title))
616 print(_cache.state().report())
617 print()
618 title = f"properties of {_cache.cfg.name}"
619 print(title)
620 print("=" * len(title))
621 print(str(_cache.properties)) # type: ignore
__init__(self, float|int|Point azimuth)
Definition weather.py:444
str l10n(self, Units unit="Point", babel.Locale|GeoLocation|None locale=None, str template="{value}{unit}", str num_pattern="#,##0")
Definition weather.py:477
Point point(cls, float|int azimuth)
Definition weather.py:462
value(self, Units unit)
Definition weather.py:454
str l10n(self, DateTimeFormats|str fmt="medium", babel.Locale|GeoLocation|None locale=None)
Definition weather.py:220
__init__(self, datetime.datetime time)
Definition weather.py:210
"GeoLocation" by_query(cls, str search_term)
Definition weather.py:170
dict[str, str] _query_open_meteo(cls, str search_term)
Definition weather.py:187
babel.Locale locale(self)
Definition weather.py:147
zoneinfo.ZoneInfo zoneinfo(self)
Definition weather.py:141
str l10n(self, Units|None unit=None, babel.Locale|GeoLocation|None locale=None, str template="{value} {unit}", str num_pattern="#,##0")
Definition weather.py:327
float value(self, Units unit)
Definition weather.py:318
__init__(self, float value, Units unit)
Definition weather.py:309
__init__(self, float humidity)
Definition weather.py:399
str l10n(self, babel.Locale|GeoLocation|None locale=None, str template="{value}{unit}", str num_pattern="#,##0")
Definition weather.py:413
str l10n(self, Units|None unit=None, babel.Locale|GeoLocation|None locale=None, str template="{value} {unit}", str num_pattern="#,##0")
Definition weather.py:262
__init__(self, float value, Units unit)
Definition weather.py:241
float value(self, Units unit)
Definition weather.py:253
str l10n(self, Units|None unit=None, babel.Locale|GeoLocation|None locale=None, str template="{value} {unit}", str num_pattern="#,##0")
Definition weather.py:377
__init__(self, float value, Units unit)
Definition weather.py:359
float value(self, Units unit)
Definition weather.py:368
get_WEATHER_DATA_CACHE()
Definition weather.py:42
str _get_sxng_locale_tag()
Definition weather.py:57
str|None symbol_url("WeatherConditionType" condition)
Definition weather.py:92