summaryrefslogtreecommitdiff
path: root/searx
diff options
context:
space:
mode:
Diffstat (limited to 'searx')
-rw-r--r--searx/babel_extract.py14
-rw-r--r--searx/cache.py2
-rw-r--r--searx/engines/open_meteo.py184
-rw-r--r--searx/plugins/unit_converter.py2
-rw-r--r--searx/result_types/__init__.py6
-rw-r--r--searx/result_types/answer.py97
-rw-r--r--searx/searxng.msg12
-rw-r--r--searx/templates/simple/answer/weather.html117
-rw-r--r--searx/weather.py605
-rw-r--r--searx/wikidata_units.py79
10 files changed, 935 insertions, 183 deletions
diff --git a/searx/babel_extract.py b/searx/babel_extract.py
index f50756a48..65705efd6 100644
--- a/searx/babel_extract.py
+++ b/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")
diff --git a/searx/cache.py b/searx/cache.py
index 96644419b..7ba5c8886 100644
--- a/searx/cache.py
+++ b/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
diff --git a/searx/engines/open_meteo.py b/searx/engines/open_meteo.py
index d6680f697..31ada12b0 100644
--- a/searx/engines/open_meteo.py
+++ b/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)
diff --git a/searx/plugins/unit_converter.py b/searx/plugins/unit_converter.py
index 0072afe55..8cefd1760 100644
--- a/searx/plugins/unit_converter.py
+++ b/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
diff --git a/searx/result_types/__init__.py b/searx/result_types/__init__.py
index 8a82cf8d4..6d47d3a4f 100644
--- a/searx/result_types/__init__.py
+++ b/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
diff --git a/searx/result_types/answer.py b/searx/result_types/answer.py
index d5793fac3..7ea0787a1 100644
--- a/searx/result_types/answer.py
+++ b/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)
diff --git a/searx/searxng.msg b/searx/searxng.msg
index a4bfb038a..7401b8313 100644
--- a/searx/searxng.msg
+++ b/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',
diff --git a/searx/templates/simple/answer/weather.html b/searx/templates/simple/answer/weather.html
index 4cea9b683..bd59d4cfe 100644
--- a/searx/templates/simple/answer/weather.html
+++ b/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 -%}
diff --git a/searx/weather.py b/searx/weather.py
new file mode 100644
index 000000000..fb62515b6
--- /dev/null
+++ b/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
diff --git a/searx/wikidata_units.py b/searx/wikidata_units.py
index 9fc94585f..b05ded220 100644
--- a/searx/wikidata_units.py
+++ b/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: