weather.py 21 KB

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