.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
4from __future__ import annotations
5
6__all__ = [
7 "symbol_url",
8 "Temperature",
9 "Pressure",
10 "WindSpeed",
11 "RelativeHumidity",
12 "Compass",
13 "WeatherConditionType",
14 "DateTime",
15 "GeoLocation",
16]
17
18import typing
19
20import base64
21import datetime
22import dataclasses
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 # type: ignore
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 def __str__(self):
141 return self.name
142
143 def locale(self) -> babel.Locale:
144
145 # by region of the search language
146 sxng_tag = _get_sxng_locale_tag()
147 if "-" in sxng_tag:
148 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
149 return locale
150
151 # by most popular language in the region (country code)
152 for lang in babel.languages.get_official_languages(self.country_code):
153 try:
154 locale = babel.Locale.parse(f"{lang}_{self.country_code}")
155 return locale
156 except babel.UnknownLocaleError:
157 continue
158
159 # No locale could be determined. This does not actually occur, but if
160 # it does, the English language is used by default. But not region US.
161 # US has some units that are only used in US but not in the rest of the
162 # world (e.g. °F instead of °C)
163 return babel.Locale("en", territory="DE")
164
165 @classmethod
166 def by_query(cls, search_term: str) -> GeoLocation:
167 """Factory method to get a GeoLocation object by a search term. If no
168 location can be determined for the search term, a :py:obj:`ValueError`
169 is thrown.
170 """
171
172 ctx = "weather_geolocation_by_query"
173 cache = get_WEATHER_DATA_CACHE()
174 geo_props = cache.get(search_term, ctx=ctx)
175
176 if not geo_props:
177 geo_props = cls._query_open_meteo(search_term=search_term)
178 cache.set(key=search_term, value=geo_props, expire=None, ctx=ctx)
179
180 return cls(**geo_props)
181
182 @classmethod
183 def _query_open_meteo(cls, search_term: str) -> dict:
184 url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote_plus(search_term)}"
185 resp = network.get(url, timeout=3)
186 if resp.status_code != 200:
187 raise ValueError(f"unknown geo location: '{search_term}'")
188 results = resp.json().get("results")
189 if not results:
190 raise ValueError(f"unknown geo location: '{search_term}'")
191 location = results[0]
192 return {field.name: location[field.name] for field in dataclasses.fields(cls)}
193
194
195DateTimeFormats = typing.Literal["full", "long", "medium", "short"]
196
197
199 """Class to represent date & time. Essentially, it is a wrapper that
200 conveniently combines :py:obj:`datetime.datetime` and
201 :py:obj:`babel.dates.format_datetime`. A conversion of time zones is not
202 provided (in the current version).
203 """
204
205 def __init__(self, time: datetime.datetime):
206 self.datetime = time
207
208 def __str__(self):
209 return self.l10n()
210
211 def l10n(
212 self,
213 fmt: DateTimeFormats | str = "medium",
214 locale: babel.Locale | GeoLocation | None = None,
215 ) -> str:
216 """Localized representation of date & time."""
217 if isinstance(locale, GeoLocation):
218 locale = locale.locale()
219 elif locale is None:
220 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
221 return babel.dates.format_datetime(self.datetime, format=fmt, locale=locale)
222
223
225 """Class for converting temperature units and for string representation of
226 measured values."""
227
228 si_name = "Q11579"
229
230 Units = typing.Literal["°C", "°F", "K"]
231 """Supported temperature units."""
232
233 units = list(typing.get_args(Units))
234
235 def __init__(self, value: float, unit: Units):
236 if unit not in self.units:
237 raise ValueError(f"invalid unit: {unit}")
238 self.si: float = convert_to_si( # pylint: disable=invalid-name
239 si_name=self.si_name,
240 symbol=unit,
241 value=value,
242 )
243
244 def __str__(self):
245 return self.l10n()
246
247 def value(self, unit: Units) -> float:
248 return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
249
250 def l10n(
251 self,
252 unit: Units | None = None,
253 locale: babel.Locale | GeoLocation | None = None,
254 template: str = "{value} {unit}",
255 num_pattern: str = "#,##0",
256 ) -> str:
257 """Localized representation of a measured value.
258
259 If the ``unit`` is not set, an attempt is made to determine a ``unit``
260 matching the territory of the ``locale``. If the locale is not set, an
261 attempt is made to determine it from the HTTP request.
262
263 The value is converted into the respective unit before formatting.
264
265 The argument ``num_pattern`` is used to determine the string formatting
266 of the numerical value:
267
268 - https://babel.pocoo.org/en/latest/numbers.html#pattern-syntax
269 - https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns
270
271 The argument ``template`` specifies how the **string formatted** value
272 and unit are to be arranged.
273
274 - `Format Specification Mini-Language
275 <https://docs.python.org/3/library/string.html#format-specification-mini-language>`.
276 """
277
278 if isinstance(locale, GeoLocation):
279 locale = locale.locale()
280 elif locale is None:
281 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
282
283 if unit is None: # unit by territory
284 unit = "°C"
285 if locale.territory in ["US"]:
286 unit = "°F"
287 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
288 return template.format(value=val_str, unit=unit)
289
290
292 """Class for converting pressure units and for string representation of
293 measured values."""
294
295 si_name = "Q44395"
296
297 Units = typing.Literal["Pa", "hPa", "cm Hg", "bar"]
298 """Supported units."""
299
300 units = list(typing.get_args(Units))
301
302 def __init__(self, value: float, unit: Units):
303 if unit not in self.units:
304 raise ValueError(f"invalid unit: {unit}")
305 # pylint: disable=invalid-name
306 self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
307
308 def __str__(self):
309 return self.l10n()
310
311 def value(self, unit: Units) -> float:
312 return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
313
314 def l10n(
315 self,
316 unit: Units | None = None,
317 locale: babel.Locale | GeoLocation | None = None,
318 template: str = "{value} {unit}",
319 num_pattern: str = "#,##0",
320 ) -> str:
321 if isinstance(locale, GeoLocation):
322 locale = locale.locale()
323 elif locale is None:
324 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
325
326 if unit is None: # unit by territory?
327 unit = "hPa"
328
329 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
330 return template.format(value=val_str, unit=unit)
331
332
334 """Class for converting speed or velocity units and for string
335 representation of measured values.
336
337 .. hint::
338
339 Working with unit ``Bft`` (:py:obj:`searx.wikidata_units.Beaufort`) will
340 throw a :py:obj:`ValueError` for egative values or values greater 16 Bft
341 (55.6 m/s)
342 """
343
344 si_name = "Q182429"
345
346 Units = typing.Literal["m/s", "km/h", "kn", "mph", "mi/h", "Bft"]
347 """Supported units."""
348
349 units = list(typing.get_args(Units))
350
351 def __init__(self, value: float, unit: Units):
352 if unit not in self.units:
353 raise ValueError(f"invalid unit: {unit}")
354 # pylint: disable=invalid-name
355 self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
356
357 def __str__(self):
358 return self.l10n()
359
360 def value(self, unit: Units) -> float:
361 return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
362
363 def l10n(
364 self,
365 unit: Units | None = None,
366 locale: babel.Locale | GeoLocation | None = None,
367 template: str = "{value} {unit}",
368 num_pattern: str = "#,##0",
369 ) -> str:
370 if isinstance(locale, GeoLocation):
371 locale = locale.locale()
372 elif locale is None:
373 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
374
375 if unit is None: # unit by territory?
376 unit = "m/s"
377
378 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
379 return template.format(value=val_str, unit=unit)
380
381
383 """Amount of relative humidity in the air. The unit is ``%``"""
384
385 Units = typing.Literal["%"]
386 """Supported unit."""
387
388 units = list(typing.get_args(Units))
389
390 def __init__(self, humidity: float):
391 self.humidity = humidity
392
393 def __str__(self):
394 return self.l10n()
395
396 def value(self) -> float:
397 return self.humidity
398
399 def l10n(
400 self,
401 locale: babel.Locale | GeoLocation | None = None,
402 template: str = "{value}{unit}",
403 num_pattern: str = "#,##0",
404 ) -> str:
405 if isinstance(locale, GeoLocation):
406 locale = locale.locale()
407 elif locale is None:
408 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
409
410 unit = "%"
411 val_str = babel.numbers.format_decimal(self.value(), locale=locale, format=num_pattern)
412 return template.format(value=val_str, unit=unit)
413
414
416 """Class for converting compass points and azimuth values (360°)"""
417
418 Units = typing.Literal["°", "Point"]
419
420 Point = typing.Literal[
421 "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
422 ]
423 """Compass point type definition"""
424
425 TURN = 360.0
426 """Full turn (360°)"""
427
428 POINTS = list(typing.get_args(Point))
429 """Compass points."""
430
431 RANGE = TURN / len(POINTS)
432 """Angle sector of a compass point"""
433
434 def __init__(self, azimuth: float | int | Point):
435 if isinstance(azimuth, str):
436 if azimuth not in self.POINTS:
437 raise ValueError(f"Invalid compass point: {azimuth}")
438 azimuth = self.POINTS.index(azimuth) * self.RANGE
439 self.azimuth = azimuth % self.TURN
440
441 def __str__(self):
442 return self.l10n()
443
444 def value(self, unit: Units):
445 if unit == "Point":
446 return self.point(self.azimuth)
447 if unit == "°":
448 return self.azimuth
449 raise ValueError(f"unknown unit: {unit}")
450
451 @classmethod
452 def point(cls, azimuth: float | int) -> Point:
453 """Returns the compass point to an azimuth value."""
454 azimuth = azimuth % cls.TURN
455 # The angle sector of a compass point starts 1/2 sector range before
456 # and after compass point (example: "N" goes from -11.25° to +11.25°)
457 azimuth = azimuth - cls.RANGE / 2
458 idx = int(azimuth // cls.RANGE)
459 return cls.POINTS[idx]
460
461 def l10n(
462 self,
463 unit: Units = "Point",
464 locale: babel.Locale | GeoLocation | None = None,
465 template: str = "{value}{unit}",
466 num_pattern: str = "#,##0",
467 ) -> str:
468 if isinstance(locale, GeoLocation):
469 locale = locale.locale()
470 elif locale is None:
471 locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
472
473 if unit == "Point":
474 val_str = self.value(unit)
475 return template.format(value=val_str, unit="")
476
477 val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
478 return template.format(value=val_str, unit=unit)
479
480
481WeatherConditionType = typing.Literal[
482 # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
483 "clear sky",
484 "cloudy",
485 "fair",
486 "fog",
487 "heavy rain and thunder",
488 "heavy rain showers and thunder",
489 "heavy rain showers",
490 "heavy rain",
491 "heavy sleet and thunder",
492 "heavy sleet showers and thunder",
493 "heavy sleet showers",
494 "heavy sleet",
495 "heavy snow and thunder",
496 "heavy snow showers and thunder",
497 "heavy snow showers",
498 "heavy snow",
499 "light rain and thunder",
500 "light rain showers and thunder",
501 "light rain showers",
502 "light rain",
503 "light sleet and thunder",
504 "light sleet showers and thunder",
505 "light sleet showers",
506 "light sleet",
507 "light snow and thunder",
508 "light snow showers and thunder",
509 "light snow showers",
510 "light snow",
511 "partly cloudy",
512 "rain and thunder",
513 "rain showers and thunder",
514 "rain showers",
515 "rain",
516 "sleet and thunder",
517 "sleet showers and thunder",
518 "sleet showers",
519 "sleet",
520 "snow and thunder",
521 "snow showers and thunder",
522 "snow showers",
523 "snow",
524]
525"""Standardized designations for weather conditions. The designators were
526taken from a collaboration between NRK and Norwegian Meteorological Institute
527(yr.no_). `Weather symbols`_ can be assigned to the identifiers
528(weathericons_) and they are included in the translation (i18n/l10n
529:origin:`searx/searxng.msg`).
530
531.. _yr.no: https://www.yr.no/en
532.. _Weather symbols: https://github.com/nrkno/yr-weather-symbols
533.. _weathericons: https://github.com/metno/weathericons
534"""
535
536YR_WEATHER_SYMBOL_MAP = {
537 "clear sky": "01d", # 01d clearsky_day
538 "fair": "02d", # 02d fair_day
539 "partly cloudy": "03d", # 03d partlycloudy_day
540 "cloudy": "04", # 04 cloudy
541 "light rain showers": "40d", # 40d lightrainshowers_day
542 "rain showers": "05d", # 05d rainshowers_day
543 "heavy rain showers": "41d", # 41d heavyrainshowers_day
544 "light rain showers and thunder": "24d", # 24d lightrainshowersandthunder_day
545 "rain showers and thunder": "06d", # 06d rainshowersandthunder_day
546 "heavy rain showers and thunder": "25d", # 25d heavyrainshowersandthunder_day
547 "light sleet showers": "42d", # 42d lightsleetshowers_day
548 "sleet showers": "07d", # 07d sleetshowers_day
549 "heavy sleet showers": "43d", # 43d heavysleetshowers_day
550 "light sleet showers and thunder": "26d", # 26d lightssleetshowersandthunder_day
551 "sleet showers and thunder": "20d", # 20d sleetshowersandthunder_day
552 "heavy sleet showers and thunder": "27d", # 27d heavysleetshowersandthunder_day
553 "light snow showers": "44d", # 44d lightsnowshowers_day
554 "snow showers": "08d", # 08d snowshowers_day
555 "heavy snow showers": "45d", # 45d heavysnowshowers_day
556 "light snow showers and thunder": "28d", # 28d lightssnowshowersandthunder_day
557 "snow showers and thunder": "21d", # 21d snowshowersandthunder_day
558 "heavy snow showers and thunder": "29d", # 29d heavysnowshowersandthunder_day
559 "light rain": "46", # 46 lightrain
560 "rain": "09", # 09 rain
561 "heavy rain": "10", # 10 heavyrain
562 "light rain and thunder": "30", # 30 lightrainandthunder
563 "rain and thunder": "22", # 22 rainandthunder
564 "heavy rain and thunder": "11", # 11 heavyrainandthunder
565 "light sleet": "47", # 47 lightsleet
566 "sleet": "12", # 12 sleet
567 "heavy sleet": "48", # 48 heavysleet
568 "light sleet and thunder": "31", # 31 lightsleetandthunder
569 "sleet and thunder": "23", # 23 sleetandthunder
570 "heavy sleet and thunder": "32", # 32 heavysleetandthunder
571 "light snow": "49", # 49 lightsnow
572 "snow": "13", # 13 snow
573 "heavy snow": "50", # 50 heavysnow
574 "light snow and thunder": "33", # 33 lightsnowandthunder
575 "snow and thunder": "14", # 14 snowandthunder
576 "heavy snow and thunder": "34", # 34 heavysnowandthunder
577 "fog": "15", # 15 fog
578}
579"""Map a :py:obj:`WeatherConditionType` to a `YR weather symbol`_
580
581.. code::
582
583 base_url = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols"
584 icon_url = f"{base_url}/outline/{YR_WEATHER_SYMBOL_MAP['sleet showers']}.svg"
585
586.. _YR weather symbol: https://github.com/nrkno/yr-weather-symbols/blob/master/locales/en.json
587
588"""
589
590if __name__ == "__main__":
591
592 # test: fetch all symbols of the type catalog ..
593 for c in typing.get_args(WeatherConditionType):
594 symbol_url(condition=c)
595
597 title = "cached weather condition symbols"
598 print(title)
599 print("=" * len(title))
600 print(_cache.state().report())
601 print()
602 title = f"properties of {_cache.cfg.name}"
603 print(title)
604 print("=" * len(title))
605 print(str(_cache.properties)) # type: ignore
__init__(self, float|int|Point azimuth)
Definition weather.py:434
str l10n(self, Units unit="Point", babel.Locale|GeoLocation|None locale=None, str template="{value}{unit}", str num_pattern="#,##0")
Definition weather.py:467
Point point(cls, float|int azimuth)
Definition weather.py:452
value(self, Units unit)
Definition weather.py:444
str l10n(self, DateTimeFormats|str fmt="medium", babel.Locale|GeoLocation|None locale=None)
Definition weather.py:215
__init__(self, datetime.datetime time)
Definition weather.py:205
babel.Locale locale(self)
Definition weather.py:143
dict _query_open_meteo(cls, str search_term)
Definition weather.py:183
GeoLocation by_query(cls, str search_term)
Definition weather.py:166
str l10n(self, Units|None unit=None, babel.Locale|GeoLocation|None locale=None, str template="{value} {unit}", str num_pattern="#,##0")
Definition weather.py:320
float value(self, Units unit)
Definition weather.py:311
__init__(self, float value, Units unit)
Definition weather.py:302
__init__(self, float humidity)
Definition weather.py:390
str l10n(self, babel.Locale|GeoLocation|None locale=None, str template="{value}{unit}", str num_pattern="#,##0")
Definition weather.py:404
str l10n(self, Units|None unit=None, babel.Locale|GeoLocation|None locale=None, str template="{value} {unit}", str num_pattern="#,##0")
Definition weather.py:256
__init__(self, float value, Units unit)
Definition weather.py:235
float value(self, Units unit)
Definition weather.py:247
str l10n(self, Units|None unit=None, babel.Locale|GeoLocation|None locale=None, str template="{value} {unit}", str num_pattern="#,##0")
Definition weather.py:369
__init__(self, float value, Units unit)
Definition weather.py:351
float value(self, Units unit)
Definition weather.py:360
get_WEATHER_DATA_CACHE()
Definition weather.py:42
str|None symbol_url(WeatherConditionType condition)
Definition weather.py:92
str _get_sxng_locale_tag()
Definition weather.py:57