Browse Source

[mod] weather results: add types, i18n/l10n, symbols & unit conversions

The types necessary for weather information such as GeoLocation, DateTime,
Temperature,Pressure, WindSpeed, RelativeHumidity, Compass (wind direction) and
symbols for the weather have been implemented.

There are unit conversions and translations for weather property labels.

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
Markus Heiser 1 week ago
parent
commit
ff206e9679

+ 1 - 0
client/simple/src/less/style.less

@@ -19,6 +19,7 @@
 @import "new_issue.less";
 @import "stats.less";
 @import "result_templates.less";
+@import "weather.less";
 
 // for index.html template
 @import "index.less";

+ 38 - 0
client/simple/src/less/weather.less

@@ -0,0 +1,38 @@
+#answers .weather {
+  summary {
+    display: block;
+    list-style: none;
+  }
+
+  div.summary {
+    margin: 0;
+    padding: 0.5rem 1rem;
+    background-color: var(--color-header-background);
+    .rounded-corners-tiny;
+  }
+
+  table {
+    font-size: 0.9rem;
+    table-layout: fixed;
+    margin-top: 0.5rem;
+    margin-bottom: 0.5rem;
+  }
+
+  td {
+    padding: 0;
+  }
+
+  img.symbol {
+    width: 5rem;
+    margin: auto;
+    display: block;
+  }
+
+  .title {
+    // background-color: var(--color-result-keyvalue-even);
+  }
+
+  .measured {
+    // background-color: var(--color-result-keyvalue-odd);
+  }
+}

+ 8 - 0
docs/src/searx.weather.rst

@@ -0,0 +1,8 @@
+.. _weather:
+
+=======
+Weather
+=======
+
+.. automodule:: searx.weather
+   :members:

+ 11 - 3
searx/babel_extract.py

@@ -45,6 +45,14 @@ def extract(
     namespace = {}
     exec(fileobj.read(), {}, namespace)  # pylint: disable=exec-used
 
-    for name in namespace['__all__']:
-        for k, v in namespace[name].items():
-            yield 0, '_', v, ["%s['%s']" % (name, k)]
+    for obj_name in namespace['__all__']:
+        obj = namespace[obj_name]
+        if isinstance(obj, list):
+            for msg in obj:
+                # (lineno, funcname, message, comments)
+                yield 0, '_', msg, [f"{obj_name}"]
+        elif isinstance(obj, dict):
+            for k, msg in obj.items():
+                yield 0, '_', msg, [f"{obj_name}['{k}']"]
+        else:
+            raise ValueError(f"{obj_name} should be list or dict")

+ 1 - 1
searx/cache.py

@@ -226,7 +226,7 @@ class ExpireCacheSQLite(sqlitedb.SQLiteAppl, ExpireCache):
     # The key/value tables will be created on demand by self.create_table
     DDL_CREATE_TABLES = {}
 
-    CACHE_TABLE_PREFIX = "CACHE-TABLE-"
+    CACHE_TABLE_PREFIX = "CACHE-TABLE"
 
     def __init__(self, cfg: ExpireCacheCfg):
         """An instance of the SQLite expire cache is build up from a

+ 107 - 77
searx/engines/open_meteo.py

@@ -1,18 +1,17 @@
 # SPDX-License-Identifier: AGPL-3.0-or-later
 """Open Meteo (weather)"""
 
-from urllib.parse import urlencode, quote_plus
+from urllib.parse import urlencode
 from datetime import datetime
-from flask_babel import gettext
 
-from searx.network import get
-from searx.exceptions import SearxEngineAPIException
-from searx.result_types import EngineResults, Weather
+from searx.result_types import EngineResults, WeatherAnswer
+from searx import weather
+
 
 about = {
-    "website": 'https://open-meteo.com',
+    "website": "https://open-meteo.com",
     "wikidata_id": None,
-    "official_api_documentation": 'https://open-meteo.com/en/docs',
+    "official_api_documentation": "https://open-meteo.com/en/docs",
     "use_official_api": True,
     "require_api_key": False,
     "results": "JSON",
@@ -23,98 +22,129 @@ categories = ["weather"]
 geo_url = "https://geocoding-api.open-meteo.com"
 api_url = "https://api.open-meteo.com"
 
-data_of_interest = "temperature_2m,relative_humidity_2m,apparent_temperature,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m"  # pylint: disable=line-too-long
+data_of_interest = (
+    "temperature_2m",
+    "apparent_temperature",
+    "relative_humidity_2m",
+    "apparent_temperature",
+    "cloud_cover",
+    "pressure_msl",
+    "wind_speed_10m",
+    "wind_direction_10m",
+    "weather_code",
+    # "visibility",
+    # "is_day",
+)
 
 
 def request(query, params):
-    location_url = f"{geo_url}/v1/search?name={quote_plus(query)}"
-
-    resp = get(location_url)
-    if resp.status_code != 200:
-        raise SearxEngineAPIException("invalid geo location response code")
 
-    json_locations = resp.json().get("results", [])
-    if len(json_locations) == 0:
-        raise SearxEngineAPIException("location not found")
+    try:
+        location = weather.GeoLocation.by_query(query)
+    except ValueError:
+        return
 
-    location = json_locations[0]
     args = {
-        'latitude': location['latitude'],
-        'longitude': location['longitude'],
-        'timeformat': 'unixtime',
-        'format': 'json',
-        'current': data_of_interest,
-        'forecast_days': 7,
-        'hourly': data_of_interest,
+        "latitude": location.latitude,
+        "longitude": location.longitude,
+        "timeformat": "unixtime",
+        "timezone": "auto",  # use timezone of the location
+        "format": "json",
+        "current": ",".join(data_of_interest),
+        "forecast_days": 3,
+        "hourly": ",".join(data_of_interest),
     }
 
-    params['url'] = f"{api_url}/v1/forecast?{urlencode(args)}"
-    params['location'] = location['name']
-
-    return params
-
-
-def c_to_f(temperature):
-    return "%.2f" % ((temperature * 1.8) + 32)
-
-
-def get_direction(degrees):
-    if degrees < 45 or degrees >= 315:
-        return "N"
-
-    if 45 <= degrees < 135:
-        return "O"
-
-    if 135 <= degrees < 225:
-        return "S"
-
-    return "W"
-
-
-def build_condition_string(data):
-    if data['relative_humidity_2m'] > 50:
-        return "rainy"
-
-    if data['cloud_cover'] > 30:
-        return 'cloudy'
+    params["url"] = f"{api_url}/v1/forecast?{urlencode(args)}"
+
+
+# https://open-meteo.com/en/docs#weather_variable_documentation
+# https://nrkno.github.io/yr-weather-symbols/
+
+WMO_TO_CONDITION: dict[int, weather.WeatherConditionType] = {
+    # 0	Clear sky
+    0: "clear sky",
+    # 1, 2, 3     Mainly clear, partly cloudy, and overcast
+    1: "fair",
+    2: "partly cloudy",
+    3: "cloudy",
+    # 45, 48      Fog and depositing rime fog
+    45: "fog",
+    48: "fog",
+    # 51, 53, 55  Drizzle: Light, moderate, and dense intensity
+    51: "light rain",
+    53: "light rain",
+    55: "light rain",
+    # 56, 57      Freezing Drizzle: Light and dense intensity
+    56: "light sleet showers",
+    57: "light sleet",
+    # 61, 63, 65  Rain: Slight, moderate and heavy intensity
+    61: "light rain",
+    63: "rain",
+    65: "heavy rain",
+    # 66, 67    Freezing Rain: Light and heavy intensity
+    66: "light sleet showers",
+    67: "light sleet",
+    # 71, 73, 75  Snow fall: Slight, moderate, and heavy intensity
+    71: "light sleet",
+    73: "sleet",
+    75: "heavy sleet",
+    # 77    Snow grains
+    77: "snow",
+    # 80, 81, 82  Rain showers: Slight, moderate, and violent
+    80: "light rain showers",
+    81: "rain showers",
+    82: "heavy rain showers",
+    # 85, 86      Snow showers slight and heavy
+    85: "snow showers",
+    86: "heavy snow showers",
+    # 95          Thunderstorm: Slight or moderate
+    95: "rain and thunder",
+    # 96, 99      Thunderstorm with slight and heavy hail
+    96: "light snow and thunder",
+    99: "heavy snow and thunder",
+}
 
-    return 'clear sky'
 
+def _weather_data(location: weather.GeoLocation, data: dict):
 
-def generate_weather_data(data):
-    return Weather.DataItem(
-        condition=build_condition_string(data),
-        temperature=f"{data['temperature_2m']}°C / {c_to_f(data['temperature_2m'])}°F",
-        feelsLike=f"{data['apparent_temperature']}°C / {c_to_f(data['apparent_temperature'])}°F",
-        wind=(
-            f"{get_direction(data['wind_direction_10m'])}, "
-            f"{data['wind_direction_10m']}° — "
-            f"{data['wind_speed_10m']} km/h"
-        ),
-        pressure=f"{data['pressure_msl']}hPa",
-        humidity=f"{data['relative_humidity_2m']}hPa",
-        attributes={gettext('Cloud cover'): f"{data['cloud_cover']}%"},
+    return WeatherAnswer.Item(
+        location=location,
+        temperature=weather.Temperature(unit="°C", value=data["temperature_2m"]),
+        condition=WMO_TO_CONDITION[data["weather_code"]],
+        feels_like=weather.Temperature(unit="°C", value=data["apparent_temperature"]),
+        wind_from=weather.Compass(data["wind_direction_10m"]),
+        wind_speed=weather.WindSpeed(data["wind_speed_10m"], unit="km/h"),
+        pressure=weather.Pressure(data["pressure_msl"], unit="hPa"),
+        humidity=weather.RelativeHumidity(data["relative_humidity_2m"]),
+        cloud_cover=data["cloud_cover"],
     )
 
 
 def response(resp):
+    location = weather.GeoLocation.by_query(resp.search_params["query"])
+
     res = EngineResults()
     json_data = resp.json()
 
-    current_weather = generate_weather_data(json_data['current'])
-    weather_answer = Weather(
-        location=resp.search_params['location'],
-        current=current_weather,
+    weather_answer = WeatherAnswer(
+        current=_weather_data(location, json_data["current"]),
+        service="Open-meteo",
+        # url="https://open-meteo.com/en/docs",
     )
 
-    for index, time in enumerate(json_data['hourly']['time']):
-        hourly_data = {}
+    for index, time in enumerate(json_data["hourly"]["time"]):
 
-        for key in data_of_interest.split(","):
-            hourly_data[key] = json_data['hourly'][key][index]
+        if time < json_data["current"]["time"]:
+            # Cut off the hours that are already in the past
+            continue
+
+        hourly_data = {}
+        for key in data_of_interest:
+            hourly_data[key] = json_data["hourly"][key][index]
 
-        forecast_data = generate_weather_data(hourly_data)
-        forecast_data.time = datetime.fromtimestamp(time).strftime('%Y-%m-%d %H:%M')
+        forecast_data = _weather_data(location, hourly_data)
+        forecast_data.datetime = weather.DateTime(datetime.fromtimestamp(time))
         weather_answer.forecasts.append(forecast_data)
 
     res.add(weather_answer)

+ 1 - 1
searx/plugins/unit_converter.py

@@ -15,7 +15,7 @@ import babel.numbers
 
 from flask_babel import gettext, get_locale
 
-from searx.units import symbol_to_si
+from searx.wikidata_units import symbol_to_si
 from searx.plugins import Plugin, PluginInfo
 from searx.result_types import EngineResults
 

+ 3 - 3
searx/result_types/__init__.py

@@ -13,14 +13,14 @@
 
 from __future__ import annotations
 
-__all__ = ["Result", "MainResult", "KeyValue", "EngineResults", "AnswerSet", "Answer", "Translations", "Weather"]
+__all__ = ["Result", "MainResult", "KeyValue", "EngineResults", "AnswerSet", "Answer", "Translations", "WeatherAnswer"]
 
 import abc
 
 from searx import enginelib
 
 from ._base import Result, MainResult, LegacyResult
-from .answer import AnswerSet, Answer, Translations, Weather
+from .answer import AnswerSet, Answer, Translations, WeatherAnswer
 from .keyvalue import KeyValue
 
 
@@ -35,7 +35,7 @@ class ResultList(list, abc.ABC):
         MainResult = MainResult
         Result = Result
         Translations = Translations
-        Weather = Weather
+        WeatherAnswer = WeatherAnswer
 
         # for backward compatibility
         LegacyResult = LegacyResult

+ 69 - 28
searx/result_types/answer.py

@@ -18,7 +18,7 @@ template.
    :members:
    :show-inheritance:
 
-.. autoclass:: Weather
+.. autoclass:: WeatherAnswer
    :members:
    :show-inheritance:
 
@@ -30,10 +30,12 @@ template.
 
 from __future__ import annotations
 
-__all__ = ["AnswerSet", "Answer", "Translations", "Weather"]
+__all__ = ["AnswerSet", "Answer", "Translations", "WeatherAnswer"]
 
+from flask_babel import gettext
 import msgspec
 
+from searx import weather
 from ._base import Result
 
 
@@ -149,49 +151,88 @@ class Translations(BaseAnswer, kw_only=True):
         """List of synonyms for the requested translation."""
 
 
-class Weather(BaseAnswer, kw_only=True):
+class WeatherAnswer(BaseAnswer, kw_only=True):
     """Answer type for weather data."""
 
     template: str = "answer/weather.html"
     """The template is located at :origin:`answer/weather.html
     <searx/templates/simple/answer/weather.html>`"""
 
-    location: str
-    """The geo-location the weather data is from (e.g. `Berlin, Germany`)."""
-
-    current: Weather.DataItem
+    current: WeatherAnswer.Item
     """Current weather at ``location``."""
 
-    forecasts: list[Weather.DataItem] = []
+    forecasts: list[WeatherAnswer.Item] = []
     """Weather forecasts for ``location``."""
 
-    def __post_init__(self):
-        if not self.location:
-            raise ValueError("Weather answer is missing a location")
+    service: str = ""
+    """Weather service from which this information was provided."""
+
+    class Item(msgspec.Struct, kw_only=True):
+        """Weather parameters valid for a specific point in time."""
+
+        location: weather.GeoLocation
+        """The geo-location the weather data is from (e.g. `Berlin, Germany`)."""
+
+        temperature: weather.Temperature
+        """Air temperature at 2m above the ground."""
+
+        condition: weather.WeatherConditionType
+        """Standardized designations that summarize the weather situation
+        (e.g. ``light sleet showers and thunder``)."""
 
-    class DataItem(msgspec.Struct, kw_only=True):
-        """A container for weather data such as temperature, humidity, ..."""
+        # optional fields
 
-        time: str | None = None
+        datetime: weather.DateTime | None = None
         """Time of the forecast - not needed for the current weather."""
 
-        condition: str
-        """Weather condition, e.g. `cloudy`, `rainy`, `sunny` ..."""
+        summary: str | None = None
+        """One-liner about the weather forecast / current weather conditions.
+        If unset, a summary is build up from temperature and current weather
+        conditions.
+        """
+
+        feels_like: weather.Temperature | None = None
+        """Apparent temperature, the temperature equivalent perceived by
+        humans, caused by the combined effects of air temperature, relative
+        humidity and wind speed.  The measure is most commonly applied to the
+        perceived outdoor temperature.
+        """
+
+        pressure: weather.Pressure | None = None
+        """Air pressure at sea level (e.g. 1030 hPa) """
+
+        humidity: weather.RelativeHumidity | None = None
+        """Amount of relative humidity in the air at 2m above the ground. The
+        unit is ``%``, e.g. 60%)
+        """
+
+        wind_from: weather.Compass
+        """The directon which moves towards / direction the wind is coming from."""
 
-        temperature: str
-        """Temperature string, e.g. `17°C`"""
+        wind_speed: weather.WindSpeed | None = None
+        """Speed of wind / wind speed at 10m above the ground (10 min average)."""
 
-        feelsLike: str | None = None
-        """Felt temperature string, should be formatted like ``temperature``"""
+        cloud_cover: int | None = None
+        """Amount of sky covered by clouds / total cloud cover for all heights
+        (cloudiness, unit: %)"""
 
-        humidity: str | None = None
-        """Humidity percentage string, e.g. `60%`"""
+        # attributes: dict[str, str | int] = {}
+        # """Key-Value dict of additional typeless weather attributes."""
 
-        pressure: str | None = None
-        """Pressure string, e.g. `1030hPa`"""
+        def __post_init__(self):
+            if not self.summary:
+                self.summary = gettext("{location}: {temperature}, {condition}").format(
+                    location=self.location,
+                    temperature=self.temperature,
+                    condition=gettext(self.condition.capitalize()),
+                )
 
-        wind: str | None = None
-        """Information about the wind, e.g. `W, 231°, 10 m/s`"""
+        @property
+        def url(self) -> str | None:
+            """Determines a `data URL`_ with a symbol for the weather
+            conditions.  If no symbol can be assigned, ``None`` is returned.
 
-        attributes: dict[str] = []
-        """Key-Value dict of additional weather attributes that are not available above"""
+            .. _data URL:
+               https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data
+            """
+            return weather.symbol_url(self.condition)

+ 12 - 0
searx/searxng.msg

@@ -3,8 +3,12 @@
 """A SearXNG message file, see :py:obj:`searx.babel`
 """
 
+import typing
+
 from searx import webutils
 from searx import engines
+from searx.weather import WeatherConditionType
+
 
 __all__ = [
     'CONSTANT_NAMES',
@@ -13,6 +17,7 @@ __all__ = [
     'STYLE_NAMES',
     'BRAND_CUSTOM_LINKS',
     'WEATHER_TERMS',
+    'WEATHER_CONDITIONS',
     'SOCIAL_MEDIA_TERMS',
 ]
 
@@ -85,6 +90,13 @@ WEATHER_TERMS = {
     'WIND': 'Wind',
 }
 
+
+WEATHER_CONDITIONS = [
+    # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
+    msg.capitalize()
+    for msg in typing.get_args(WeatherConditionType)
+]
+
 SOCIAL_MEDIA_TERMS = {
     'SUBSCRIBERS': 'subscribers',
     'POSTS': 'posts',

+ 56 - 61
searx/templates/simple/answer/weather.html

@@ -1,67 +1,62 @@
-{% macro show_weather_data(data) %}
-    <table>
-      <tbody>
-        {%- if data.condition -%}
-        <tr>
-          <td>{{ _("Condition") }}</td>
-          <td>{{ data.condition }}</td>
-        </tr>
-        {%- endif -%}
-        {%- if data.temperature -%}
-        <tr>
-          <td>{{ _("Temperature") }}</td>
-          <td>{{ data.temperature }}</td>
-        </tr>
-        {%- endif -%}
-        {%- if data.feelsLike -%}
-        <tr>
-          <td>{{ _("Feels Like") }}</td>
-          <td>{{ data.feelsLike }}</td>
-        </tr>
-        {%- endif -%}
-        {%- if data.wind -%}
-        <tr>
-          <td>{{ _("Wind") }}</td>
-          <td>{{ data.wind }}</td>
-        </tr>
-        {%- endif -%}
-        {%- if data.humidity -%}
-        <tr>
-          <td>{{ _("Humidity") }}</td>
-          <td>{{ data.humidity }}</td>
-        </tr>
-        {%- endif -%}
-        {%- if data.pressure -%}
-        <tr>
-          <td>{{ _("Pressure") }}</td>
-          <td>{{ data.pressure }}</td>
-        </tr>
-        {%- endif -%}
-        <tr>
-        {%- for name, value in data.attributes.items() -%}
-        <tr>
-          <td>{{ name }}</td>
-          <td>{{ value }}</td>
-        </tr>
-        {%- endfor -%}
-      </tbody>
-    </table>
+{% macro show_weather_data(answer, data) %}
+  <table>
+    <colgroup>
+      <col span="1" class="thumbnail">
+      <col span="1" class="title">
+      <col span="1" class="measured">
+      <col span="1" class="title">
+      <col span="1" class="measured">
+    </colgroup>
+    <tbody>
+      <tr>
+        <td rowspan="4">
+          {%- if data.url %}<img class="symbol" src="{{ data.url }}" title="{{ data.summary }}">{% endif -%}
+        </td>
+      </tr>
+      <tr>
+        <td>{{ _("Temperature") }}:</td>
+        <td>{{ data.temperature.l10n(locale=data.location) }}</td>
+        <td>{{ _("Feels Like") }}:</td>
+        <td>{{ data.feels_like.l10n(locale=data.location) }}</td>
+      </tr>
+      <tr>
+        <td>{{ _("Wind") }}:</td>
+        <td>{{ data.wind_from.l10n(locale=data.location) }}: {{ data.wind_speed.l10n(locale=data.location) }}</td>
+        <td>{{ _("Pressure") }}:</td>
+        <td>{{ data.pressure.l10n(locale=data.location) }}</td>
+      </tr>
+      <tr>
+        <td>{{_("Humidity")}}:</td>
+        <td>{{ data.humidity.l10n(locale=data.location) }}</td>
+        <td></td>
+        <td></td>
+      </tr>
+    </tbody>
+  </table>
 {% endmacro %}
 
-<details class="answer-weather">
-  <summary>It's currently {{ answer.current.condition }}, {{ answer.current.temperature }} in {{ answer.location }}</summary>
-  <div>
-    <h2 class="title">{{ answer.location }}</h2>
-    <h3>{{ _("Current condition") }}</h3>
-    {{ show_weather_data(answer.current) }}
-
+<details class="weather">
+  <summary>
+    <div class="summary"> {{ answer.current.summary }}</div>
+    {{ show_weather_data(answer, answer.current) }}
+  </summary>
+  <div class="weather-forecast">
     {%- if answer.forecasts -%}
-    <div class="answer-weather-forecasts">
-      {%- for forecast in answer.forecasts -%}
-      <h3>{{ forecast.time }}</h3>
-      {{ show_weather_data(forecast) }}
-      {%- endfor -%}
-    </div>
+      <div class="answer-weather-forecasts">
+        {%- for forecast in answer.forecasts -%}
+            <div class="summary">{{ forecast.datetime.l10n(locale=answer.current.location,fmt="short") }} {{ forecast.summary }}</div>
+            {{ show_weather_data(answer, forecast) }}
+        {%- endfor -%}
+      </div>
     {%- endif -%}
   </div>
 </details>
+
+{%- if answer.url -%}
+    <a href="{{ answer.url }}" class="answer-url"
+        {%- if results_on_new_tab %}target="_blank" rel="noopener noreferrer"{%- else -%}rel="noreferrer"{%- endif -%}>
+        {{ answer.service }}
+    </a>
+{%- else -%}
+    <span class="answer-url">{{ answer.service }}</span>
+{% endif -%}

+ 605 - 0
searx/weather.py

@@ -0,0 +1,605 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Implementations used for weather conditions and forecast."""
+# pylint: disable=too-few-public-methods
+from __future__ import annotations
+
+__all__ = [
+    "symbol_url",
+    "Temperature",
+    "Pressure",
+    "WindSpeed",
+    "RelativeHumidity",
+    "Compass",
+    "WeatherConditionType",
+    "DateTime",
+    "GeoLocation",
+]
+
+import typing
+
+import base64
+import datetime
+import dataclasses
+
+from urllib.parse import quote_plus
+
+import babel
+import babel.numbers
+import babel.dates
+import babel.languages
+
+from searx import network
+from searx.cache import ExpireCache, ExpireCacheCfg
+from searx.extended_types import sxng_request
+from searx.wikidata_units import convert_to_si, convert_from_si
+
+WEATHER_DATA_CACHE: ExpireCache = None  # type: ignore
+"""A simple cache for weather data (geo-locations, icons, ..)"""
+
+YR_WEATHER_SYMBOL_URL = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols/outline"
+
+
+def get_WEATHER_DATA_CACHE():
+
+    global WEATHER_DATA_CACHE  # pylint: disable=global-statement
+
+    if WEATHER_DATA_CACHE is None:
+        WEATHER_DATA_CACHE = ExpireCache.build_cache(
+            ExpireCacheCfg(
+                name="WEATHER_DATA_CACHE",
+                MAX_VALUE_LEN=1024 * 200,  # max. 200kB per icon (icons have most often 10-20kB)
+                MAXHOLD_TIME=60 * 60 * 24 * 7 * 4,  # 4 weeks
+            )
+        )
+    return WEATHER_DATA_CACHE
+
+
+def _get_sxng_locale_tag() -> str:
+    # The function should return a locale (the sxng-tag: de-DE.en-US, ..) that
+    # can later be used to format and convert measured values for the output of
+    # weather data to the user.
+    #
+    # In principle, SearXNG only has two possible parameters for determining
+    # the locale: the UI language or the search- language/region.  Since the
+    # conversion of weather data and time information is usually
+    # region-specific, the UI language is not suitable.
+    #
+    # It would probably be ideal to use the user's geolocation, but this will
+    # probably never be available in SearXNG (privacy critical).
+    #
+    # Therefore, as long as no "better" parameters are available, this function
+    # returns a locale based on the search region.
+
+    # pylint: disable=import-outside-toplevel,disable=cyclic-import
+    from searx import query
+    from searx.preferences import ClientPref
+
+    query = query.RawTextQuery(sxng_request.form.get("q", ""), [])
+    if query.languages and query.languages[0] not in ["all", "auto"]:
+        return query.languages[0]
+
+    search_lang = sxng_request.form.get("language")
+    if search_lang and search_lang not in ["all", "auto"]:
+        return search_lang
+
+    client_pref = ClientPref.from_http_request(sxng_request)
+    search_lang = client_pref.locale_tag
+    if search_lang and search_lang not in ["all", "auto"]:
+        return search_lang
+    return "en"
+
+
+def symbol_url(condition: WeatherConditionType) -> str | None:
+    """Returns ``data:`` URL for the weather condition symbol or ``None`` if
+    the condition is not of type :py:obj:`WeatherConditionType`.
+
+    If symbol (SVG) is not already in the :py:obj:`WEATHER_DATA_CACHE` its
+    fetched from https://github.com/nrkno/yr-weather-symbols
+    """
+    # Symbols for darkmode/lightmode? .. and day/night symbols? .. for the
+    # latter we need a geopoint (critical in sense of privacy)
+
+    fname = YR_WEATHER_SYMBOL_MAP.get(condition)
+    if fname is None:
+        return None
+
+    ctx = "weather_symbol_url"
+    cache = get_WEATHER_DATA_CACHE()
+    origin_url = f"{YR_WEATHER_SYMBOL_URL}/{fname}.svg"
+
+    data_url = cache.get(origin_url, ctx=ctx)
+    if data_url is not None:
+        return data_url
+
+    response = network.get(origin_url, timeout=3)
+    if response.status_code == 200:
+        mimetype = response.headers['Content-Type']
+        data_url = f"data:{mimetype};base64,{str(base64.b64encode(response.content), 'utf-8')}"
+        cache.set(key=origin_url, value=data_url, expire=None, ctx=ctx)
+    return data_url
+
+
+@dataclasses.dataclass
+class GeoLocation:
+    """Minimal implementation of Geocoding."""
+
+    # The type definition was based on the properties from the geocoding API of
+    # open-meteo.
+    #
+    # - https://open-meteo.com/en/docs/geocoding-api
+    # - https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
+    # - https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
+
+    name: str
+    latitude: float  # Geographical WGS84 coordinates of this location
+    longitude: float
+    elevation: float  # Elevation above mean sea level of this location
+    country_code: str  # 2-Character ISO-3166-1 alpha2 country code. E.g. DE for Germany
+    timezone: str  # Time zone using time zone database definitions
+
+    def __str__(self):
+        return self.name
+
+    def locale(self) -> babel.Locale:
+
+        # by region of the search language
+        sxng_tag = _get_sxng_locale_tag()
+        if "-" in sxng_tag:
+            locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
+            return locale
+
+        # by most popular language in the region (country code)
+        for lang in babel.languages.get_official_languages(self.country_code):
+            try:
+                locale = babel.Locale.parse(f"{lang}_{self.country_code}")
+                return locale
+            except babel.UnknownLocaleError:
+                continue
+
+        # No locale could be determined.  This does not actually occur, but if
+        # it does, the English language is used by default.  But not region US.
+        # US has some units that are only used in US but not in the rest of the
+        # world (e.g. °F instead of °C)
+        return babel.Locale("en", territory="DE")
+
+    @classmethod
+    def by_query(cls, search_term: str) -> GeoLocation:
+        """Factory method to get a GeoLocation object by a search term.  If no
+        location can be determined for the search term, a :py:obj:`ValueError`
+        is thrown.
+        """
+
+        ctx = "weather_geolocation_by_query"
+        cache = get_WEATHER_DATA_CACHE()
+        geo_props = cache.get(search_term, ctx=ctx)
+
+        if not geo_props:
+            geo_props = cls._query_open_meteo(search_term=search_term)
+            cache.set(key=search_term, value=geo_props, expire=None, ctx=ctx)
+
+        return cls(**geo_props)
+
+    @classmethod
+    def _query_open_meteo(cls, search_term: str) -> dict:
+        url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote_plus(search_term)}"
+        resp = network.get(url, timeout=3)
+        if resp.status_code != 200:
+            raise ValueError(f"unknown geo location: '{search_term}'")
+        results = resp.json().get("results")
+        if not results:
+            raise ValueError(f"unknown geo location: '{search_term}'")
+        location = results[0]
+        return {field.name: location[field.name] for field in dataclasses.fields(cls)}
+
+
+DateTimeFormats = typing.Literal["full", "long", "medium", "short"]
+
+
+class DateTime:
+    """Class to represent date & time.  Essentially, it is a wrapper that
+    conveniently combines :py:obj:`datetime.datetime` and
+    :py:obj:`babel.dates.format_datetime`.  A conversion of time zones is not
+    provided (in the current version).
+    """
+
+    def __init__(self, time: datetime.datetime):
+        self.datetime = time
+
+    def __str__(self):
+        return self.l10n()
+
+    def l10n(
+        self,
+        fmt: DateTimeFormats | str = "medium",
+        locale: babel.Locale | GeoLocation | None = None,
+    ) -> str:
+        """Localized representation of date & time."""
+        if isinstance(locale, GeoLocation):
+            locale = locale.locale()
+        elif locale is None:
+            locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
+        return babel.dates.format_datetime(self.datetime, format=fmt, locale=locale)
+
+
+class Temperature:
+    """Class for converting temperature units and for string representation of
+    measured values."""
+
+    si_name = "Q11579"
+
+    Units = typing.Literal["°C", "°F", "K"]
+    """Supported temperature units."""
+
+    units = list(typing.get_args(Units))
+
+    def __init__(self, value: float, unit: Units):
+        if unit not in self.units:
+            raise ValueError(f"invalid unit: {unit}")
+        self.si: float = convert_to_si(  # pylint: disable=invalid-name
+            si_name=self.si_name,
+            symbol=unit,
+            value=value,
+        )
+
+    def __str__(self):
+        return self.l10n()
+
+    def value(self, unit: Units) -> float:
+        return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
+
+    def l10n(
+        self,
+        unit: Units | None = None,
+        locale: babel.Locale | GeoLocation | None = None,
+        template: str = "{value} {unit}",
+        num_pattern: str = "#,##0",
+    ) -> str:
+        """Localized representation of a measured value.
+
+        If the ``unit`` is not set, an attempt is made to determine a ``unit``
+        matching the territory of the ``locale``.  If the locale is not set, an
+        attempt is made to determine it from the HTTP request.
+
+        The value is converted into the respective unit before formatting.
+
+        The argument ``num_pattern`` is used to determine the string formatting
+        of the numerical value:
+
+        - https://babel.pocoo.org/en/latest/numbers.html#pattern-syntax
+        - https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns
+
+        The argument ``template`` specifies how the **string formatted** value
+        and unit are to be arranged.
+
+        - `Format Specification Mini-Language
+          <https://docs.python.org/3/library/string.html#format-specification-mini-language>`.
+        """
+
+        if isinstance(locale, GeoLocation):
+            locale = locale.locale()
+        elif locale is None:
+            locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
+
+        if unit is None:  # unit by territory
+            unit = "°C"
+            if locale.territory in ["US"]:
+                unit = "°F"
+        val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
+        return template.format(value=val_str, unit=unit)
+
+
+class Pressure:
+    """Class for converting pressure units and for string representation of
+    measured values."""
+
+    si_name = "Q44395"
+
+    Units = typing.Literal["Pa", "hPa", "cm Hg", "bar"]
+    """Supported units."""
+
+    units = list(typing.get_args(Units))
+
+    def __init__(self, value: float, unit: Units):
+        if unit not in self.units:
+            raise ValueError(f"invalid unit: {unit}")
+        # pylint: disable=invalid-name
+        self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
+
+    def __str__(self):
+        return self.l10n()
+
+    def value(self, unit: Units) -> float:
+        return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
+
+    def l10n(
+        self,
+        unit: Units | None = None,
+        locale: babel.Locale | GeoLocation | None = None,
+        template: str = "{value} {unit}",
+        num_pattern: str = "#,##0",
+    ) -> str:
+        if isinstance(locale, GeoLocation):
+            locale = locale.locale()
+        elif locale is None:
+            locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
+
+        if unit is None:  # unit by territory?
+            unit = "hPa"
+
+        val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
+        return template.format(value=val_str, unit=unit)
+
+
+class WindSpeed:
+    """Class for converting speed or velocity units and for string
+    representation of measured values.
+
+    .. hint::
+
+       Working with unit ``Bft`` (:py:obj:`searx.wikidata_units.Beaufort`) will
+       throw a :py:obj:`ValueError` for egative values or values greater 16 Bft
+       (55.6 m/s)
+    """
+
+    si_name = "Q182429"
+
+    Units = typing.Literal["m/s", "km/h", "kn", "mph", "mi/h", "Bft"]
+    """Supported units."""
+
+    units = list(typing.get_args(Units))
+
+    def __init__(self, value: float, unit: Units):
+        if unit not in self.units:
+            raise ValueError(f"invalid unit: {unit}")
+        # pylint: disable=invalid-name
+        self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
+
+    def __str__(self):
+        return self.l10n()
+
+    def value(self, unit: Units) -> float:
+        return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
+
+    def l10n(
+        self,
+        unit: Units | None = None,
+        locale: babel.Locale | GeoLocation | None = None,
+        template: str = "{value} {unit}",
+        num_pattern: str = "#,##0",
+    ) -> str:
+        if isinstance(locale, GeoLocation):
+            locale = locale.locale()
+        elif locale is None:
+            locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
+
+        if unit is None:  # unit by territory?
+            unit = "m/s"
+
+        val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
+        return template.format(value=val_str, unit=unit)
+
+
+class RelativeHumidity:
+    """Amount of relative humidity in the air. The unit is ``%``"""
+
+    Units = typing.Literal["%"]
+    """Supported unit."""
+
+    units = list(typing.get_args(Units))
+
+    def __init__(self, humidity: float):
+        self.humidity = humidity
+
+    def __str__(self):
+        return self.l10n()
+
+    def value(self) -> float:
+        return self.humidity
+
+    def l10n(
+        self,
+        locale: babel.Locale | GeoLocation | None = None,
+        template: str = "{value}{unit}",
+        num_pattern: str = "#,##0",
+    ) -> str:
+        if isinstance(locale, GeoLocation):
+            locale = locale.locale()
+        elif locale is None:
+            locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
+
+        unit = "%"
+        val_str = babel.numbers.format_decimal(self.value(), locale=locale, format=num_pattern)
+        return template.format(value=val_str, unit=unit)
+
+
+class Compass:
+    """Class for converting compass points and azimuth values (360°)"""
+
+    Units = typing.Literal["°", "Point"]
+
+    Point = typing.Literal[
+        "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+    ]
+    """Compass point type definition"""
+
+    TURN = 360.0
+    """Full turn (360°)"""
+
+    POINTS = list(typing.get_args(Point))
+    """Compass points."""
+
+    RANGE = TURN / len(POINTS)
+    """Angle sector of a compass point"""
+
+    def __init__(self, azimuth: float | int | Point):
+        if isinstance(azimuth, str):
+            if azimuth not in self.POINTS:
+                raise ValueError(f"Invalid compass point: {azimuth}")
+            azimuth = self.POINTS.index(azimuth) * self.RANGE
+        self.azimuth = azimuth % self.TURN
+
+    def __str__(self):
+        return self.l10n()
+
+    def value(self, unit: Units):
+        if unit == "Point":
+            return self.point(self.azimuth)
+        if unit == "°":
+            return self.azimuth
+        raise ValueError(f"unknown unit: {unit}")
+
+    @classmethod
+    def point(cls, azimuth: float | int) -> Point:
+        """Returns the compass point to an azimuth value."""
+        azimuth = azimuth % cls.TURN
+        # The angle sector of a compass point starts 1/2 sector range before
+        # and after compass point (example: "N" goes from -11.25° to +11.25°)
+        azimuth = azimuth - cls.RANGE / 2
+        idx = int(azimuth // cls.RANGE)
+        return cls.POINTS[idx]
+
+    def l10n(
+        self,
+        unit: Units = "Point",
+        locale: babel.Locale | GeoLocation | None = None,
+        template: str = "{value}{unit}",
+        num_pattern: str = "#,##0",
+    ) -> str:
+        if isinstance(locale, GeoLocation):
+            locale = locale.locale()
+        elif locale is None:
+            locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-')
+
+        if unit == "Point":
+            val_str = self.value(unit)
+            return template.format(value=val_str, unit="")
+
+        val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
+        return template.format(value=val_str, unit=unit)
+
+
+WeatherConditionType = typing.Literal[
+    # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
+    "clear sky",
+    "cloudy",
+    "fair",
+    "fog",
+    "heavy rain and thunder",
+    "heavy rain showers and thunder",
+    "heavy rain showers",
+    "heavy rain",
+    "heavy sleet and thunder",
+    "heavy sleet showers and thunder",
+    "heavy sleet showers",
+    "heavy sleet",
+    "heavy snow and thunder",
+    "heavy snow showers and thunder",
+    "heavy snow showers",
+    "heavy snow",
+    "light rain and thunder",
+    "light rain showers and thunder",
+    "light rain showers",
+    "light rain",
+    "light sleet and thunder",
+    "light sleet showers and thunder",
+    "light sleet showers",
+    "light sleet",
+    "light snow and thunder",
+    "light snow showers and thunder",
+    "light snow showers",
+    "light snow",
+    "partly cloudy",
+    "rain and thunder",
+    "rain showers and thunder",
+    "rain showers",
+    "rain",
+    "sleet and thunder",
+    "sleet showers and thunder",
+    "sleet showers",
+    "sleet",
+    "snow and thunder",
+    "snow showers and thunder",
+    "snow showers",
+    "snow",
+]
+"""Standardized designations for weather conditions.  The designators were
+taken from a collaboration between NRK and Norwegian Meteorological Institute
+(yr.no_).  `Weather symbols`_ can be assigned to the identifiers
+(weathericons_) and they are included in the translation (i18n/l10n
+:origin:`searx/searxng.msg`).
+
+.. _yr.no: https://www.yr.no/en
+.. _Weather symbols: https://github.com/nrkno/yr-weather-symbols
+.. _weathericons: https://github.com/metno/weathericons
+"""
+
+YR_WEATHER_SYMBOL_MAP = {
+    "clear sky": "01d",  # 01d clearsky_day
+    "fair": "02d",  # 02d fair_day
+    "partly cloudy": "03d",  # 03d partlycloudy_day
+    "cloudy": "04",  # 04 cloudy
+    "light rain showers": "40d",  # 40d lightrainshowers_day
+    "rain showers": "05d",  # 05d rainshowers_day
+    "heavy rain showers": "41d",  # 41d heavyrainshowers_day
+    "light rain showers and thunder": "24d",  # 24d lightrainshowersandthunder_day
+    "rain showers and thunder": "06d",  # 06d rainshowersandthunder_day
+    "heavy rain showers and thunder": "25d",  # 25d heavyrainshowersandthunder_day
+    "light sleet showers": "42d",  # 42d lightsleetshowers_day
+    "sleet showers": "07d",  # 07d sleetshowers_day
+    "heavy sleet showers": "43d",  # 43d heavysleetshowers_day
+    "light sleet showers and thunder": "26d",  # 26d lightssleetshowersandthunder_day
+    "sleet showers and thunder": "20d",  # 20d sleetshowersandthunder_day
+    "heavy sleet showers and thunder": "27d",  # 27d heavysleetshowersandthunder_day
+    "light snow showers": "44d",  # 44d lightsnowshowers_day
+    "snow showers": "08d",  # 08d snowshowers_day
+    "heavy snow showers": "45d",  # 45d heavysnowshowers_day
+    "light snow showers and thunder": "28d",  # 28d lightssnowshowersandthunder_day
+    "snow showers and thunder": "21d",  # 21d snowshowersandthunder_day
+    "heavy snow showers and thunder": "29d",  # 29d heavysnowshowersandthunder_day
+    "light rain": "46",  # 46 lightrain
+    "rain": "09",  # 09 rain
+    "heavy rain": "10",  # 10 heavyrain
+    "light rain and thunder": "30",  # 30 lightrainandthunder
+    "rain and thunder": "22",  # 22 rainandthunder
+    "heavy rain and thunder": "11",  # 11 heavyrainandthunder
+    "light sleet": "47",  # 47 lightsleet
+    "sleet": "12",  # 12 sleet
+    "heavy sleet": "48",  # 48 heavysleet
+    "light sleet and thunder": "31",  # 31 lightsleetandthunder
+    "sleet and thunder": "23",  # 23 sleetandthunder
+    "heavy sleet and thunder": "32",  # 32 heavysleetandthunder
+    "light snow": "49",  # 49 lightsnow
+    "snow": "13",  # 13 snow
+    "heavy snow": "50",  # 50 heavysnow
+    "light snow and thunder": "33",  # 33 lightsnowandthunder
+    "snow and thunder": "14",  # 14 snowandthunder
+    "heavy snow and thunder": "34",  # 34 heavysnowandthunder
+    "fog": "15",  # 15 fog
+}
+"""Map a :py:obj:`WeatherConditionType` to a `YR weather symbol`_
+
+.. code::
+
+   base_url = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols"
+   icon_url = f"{base_url}/outline/{YR_WEATHER_SYMBOL_MAP['sleet showers']}.svg"
+
+.. _YR weather symbol: https://github.com/nrkno/yr-weather-symbols/blob/master/locales/en.json
+
+"""
+
+if __name__ == "__main__":
+
+    # test: fetch all symbols of the type catalog ..
+    for c in typing.get_args(WeatherConditionType):
+        symbol_url(condition=c)
+
+    _cache = get_WEATHER_DATA_CACHE()
+    title = "cached weather condition symbols"
+    print(title)
+    print("=" * len(title))
+    print(_cache.state().report())
+    print()
+    title = f"properties of {_cache.cfg.name}"
+    print(title)
+    print("=" * len(title))
+    print(str(_cache.properties))  # type: ignore

+ 70 - 9
searx/wikidata_units.py

@@ -5,6 +5,7 @@ Coordinates`_
 .. _SPARQL/WIKIDATA Precision, Units and Coordinates:
    https://en.wikibooks.org/wiki/SPARQL/WIKIDATA_Precision,_Units_and_Coordinates#Quantities
 """
+from __future__ import annotations
 
 __all__ = ["convert_from_si", "convert_to_si", "symbol_to_si"]
 
@@ -13,6 +14,47 @@ import collections
 from searx import data
 from searx.engines import wikidata
 
+
+class Beaufort:
+    """The mapping of the Beaufort_ contains values from 0 to 16 (55.6 m/s),
+    wind speeds greater than 200km/h (55.6 m/s) are given as 17 Bft. Thats why
+    a value of 17 Bft cannot be converted to SI.
+
+    .. hint::
+
+       Negative values or values greater 16 Bft (55.6 m/s) will throw a
+       :py:obj:`ValueError`.
+
+    _Beaufort: https://en.wikipedia.org/wiki/Beaufort_scale
+    """
+
+    # fmt: off
+    scale: list[float] = [
+         0.2,  1.5,  3.3,  5.4,  7.9,
+        10.7, 13.8, 17.1, 20.7, 24.4,
+        28.4, 32.6, 32.7, 41.1, 45.8,
+        50.8, 55.6
+    ]
+    # fmt: on
+
+    @classmethod
+    def from_si(cls, value) -> float:
+        if value < 0 or value > 55.6:
+            raise ValueError(f"invalid value {value} / the Beaufort scales from 0 to 16 (55.6 m/s)")
+        bft = 0
+        for bft, mps in enumerate(cls.scale):
+            if mps >= value:
+                break
+        return bft
+
+    @classmethod
+    def to_si(cls, value) -> float:
+        idx = round(value)
+        if idx < 0 or idx > 16:
+            raise ValueError(f"invalid value {value} / the Beaufort scales from 0 to 16 (55.6 m/s)")
+        return cls.scale[idx]
+
+
 ADDITIONAL_UNITS = [
     {
         "si_name": "Q11579",
@@ -26,6 +68,12 @@ ADDITIONAL_UNITS = [
         "to_si": lambda val: (val + 459.67) * 5 / 9,
         "from_si": lambda val: (val * 9 / 5) - 459.67,
     },
+    {
+        "si_name": "Q182429",
+        "symbol": "Bft",
+        "to_si": Beaufort.to_si,
+        "from_si": Beaufort.from_si,
+    },
 ]
 """Additional items to convert from a measure unit to a SI unit (vice versa).
 
@@ -55,6 +103,7 @@ ALIAS_SYMBOLS = {
     '°C': ('C',),
     '°F': ('F',),
     'mi': ('L',),
+    'Bft': ('bft',),
 }
 """Alias symbols for known unit of measure symbols / by example::
 
@@ -65,11 +114,11 @@ ALIAS_SYMBOLS = {
 
 
 SYMBOL_TO_SI = []
-UNITS_BY_SI_NAME: dict | None = None
+UNITS_BY_SI_NAME: dict = {}
 
 
 def convert_from_si(si_name: str, symbol: str, value: float | int) -> float:
-    from_si = units_by_si_name(si_name)[symbol][symbol]["from_si"]
+    from_si = units_by_si_name(si_name)[symbol][pos_from_si]
     if isinstance(from_si, (float, int)):
         value = float(value) * from_si
     else:
@@ -78,7 +127,7 @@ def convert_from_si(si_name: str, symbol: str, value: float | int) -> float:
 
 
 def convert_to_si(si_name: str, symbol: str, value: float | int) -> float:
-    to_si = units_by_si_name(si_name)[symbol][symbol]["to_si"]
+    to_si = units_by_si_name(si_name)[symbol][pos_to_si]
     if isinstance(to_si, (float, int)):
         value = float(value) * to_si
     else:
@@ -88,20 +137,32 @@ def convert_to_si(si_name: str, symbol: str, value: float | int) -> float:
 
 def units_by_si_name(si_name):
 
-    global UNITS_BY_SI_NAME
-    if UNITS_BY_SI_NAME is not None:
+    global UNITS_BY_SI_NAME  # pylint: disable=global-statement,global-variable-not-assigned
+    if UNITS_BY_SI_NAME:
         return UNITS_BY_SI_NAME[si_name]
 
-    UNITS_BY_SI_NAME = {}
+    # build the catalog ..
     for item in symbol_to_si():
-        by_symbol = UNITS_BY_SI_NAME.get(si_name)
+
+        item_si_name = item[pos_si_name]
+        item_symbol = item[pos_symbol]
+
+        by_symbol = UNITS_BY_SI_NAME.get(item_si_name)
         if by_symbol is None:
             by_symbol = {}
-            UNITS_BY_SI_NAME[si_name] = by_symbol
-        by_symbol[item["symbol"]] = item
+            UNITS_BY_SI_NAME[item_si_name] = by_symbol
+        by_symbol[item_symbol] = item
+
     return UNITS_BY_SI_NAME[si_name]
 
 
+pos_symbol = 0  # (alias) symbol
+pos_si_name = 1  # si_name
+pos_from_si = 2  # from_si
+pos_to_si = 3  # to_si
+pos_symbol = 4  # standardized symbol
+
+
 def symbol_to_si():
     """Generates a list of tuples, each tuple is a measure unit and the fields
     in the tuple are: