unit_converter.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. # SPDX-License-Identifier: AGPL-3.0-or-later
  2. """A plugin for converting measured values from one unit to another unit (a
  3. unit converter).
  4. The plugin looks up the symbols (given in the query term) in a list of
  5. converters, each converter is one item in the list (compare
  6. :py:obj:`ADDITIONAL_UNITS`). If the symbols are ambiguous, the matching units
  7. of measurement are evaluated. The weighting in the evaluation results from the
  8. sorting of the :py:obj:`list of unit converters<symbol_to_si>`.
  9. """
  10. from __future__ import annotations
  11. import typing
  12. import re
  13. import babel.numbers
  14. from flask_babel import gettext, get_locale
  15. from searx import data
  16. from searx.plugins import Plugin, PluginInfo
  17. from searx.result_types import EngineResults
  18. if typing.TYPE_CHECKING:
  19. from searx.search import SearchWithPlugins
  20. from searx.extended_types import SXNG_Request
  21. from searx.plugins import PluginCfg
  22. name = ""
  23. description = gettext("")
  24. plugin_id = ""
  25. preference_section = ""
  26. CONVERT_KEYWORDS = ["in", "to", "as"]
  27. class SXNGPlugin(Plugin):
  28. """Convert between units. The result is displayed in area for the
  29. "answers".
  30. """
  31. id = "unit_converter"
  32. def __init__(self, plg_cfg: "PluginCfg") -> None:
  33. super().__init__(plg_cfg)
  34. self.info = PluginInfo(
  35. id=self.id,
  36. name=gettext("Unit converter plugin"),
  37. description=gettext("Convert between units"),
  38. preference_section="general",
  39. )
  40. def post_search(self, request: "SXNG_Request", search: "SearchWithPlugins") -> EngineResults:
  41. results = EngineResults()
  42. # only convert between units on the first page
  43. if search.search_query.pageno > 1:
  44. return results
  45. query = search.search_query.query
  46. query_parts = query.split(" ")
  47. if len(query_parts) < 3:
  48. return results
  49. for query_part in query_parts:
  50. for keyword in CONVERT_KEYWORDS:
  51. if query_part == keyword:
  52. from_query, to_query = query.split(keyword, 1)
  53. target_val = _parse_text_and_convert(from_query.strip(), to_query.strip())
  54. if target_val:
  55. results.add(results.types.Answer(answer=target_val))
  56. return results
  57. # inspired from https://stackoverflow.com/a/42475086
  58. RE_MEASURE = r'''
  59. (?P<sign>[-+]?) # +/- or nothing for positive
  60. (\s*) # separator: white space or nothing
  61. (?P<number>[\d\.,]*) # number: 1,000.00 (en) or 1.000,00 (de)
  62. (?P<E>[eE][-+]?\d+)? # scientific notation: e(+/-)2 (*10^2)
  63. (\s*) # separator: white space or nothing
  64. (?P<unit>\S+) # unit of measure
  65. '''
  66. ADDITIONAL_UNITS = [
  67. {
  68. "si_name": "Q11579",
  69. "symbol": "°C",
  70. "to_si": lambda val: val + 273.15,
  71. "from_si": lambda val: val - 273.15,
  72. },
  73. {
  74. "si_name": "Q11579",
  75. "symbol": "°F",
  76. "to_si": lambda val: (val + 459.67) * 5 / 9,
  77. "from_si": lambda val: (val * 9 / 5) - 459.67,
  78. },
  79. ]
  80. """Additional items to convert from a measure unit to a SI unit (vice versa).
  81. .. code:: python
  82. {
  83. "si_name": "Q11579", # Wikidata item ID of the SI unit (Kelvin)
  84. "symbol": "°C", # symbol of the measure unit
  85. "to_si": lambda val: val + 273.15, # convert measure value (val) to SI unit
  86. "from_si": lambda val: val - 273.15, # convert SI value (val) measure unit
  87. },
  88. {
  89. "si_name": "Q11573",
  90. "symbol": "mi",
  91. "to_si": 1609.344, # convert measure value (val) to SI unit
  92. "from_si": 1 / 1609.344 # convert SI value (val) measure unit
  93. },
  94. The values of ``to_si`` and ``from_si`` can be of :py:obj:`float` (a multiplier)
  95. or a callable_ (val in / converted value returned).
  96. .. _callable: https://docs.python.org/3/glossary.html#term-callable
  97. """
  98. ALIAS_SYMBOLS = {
  99. '°C': ('C',),
  100. '°F': ('F',),
  101. 'mi': ('L',),
  102. }
  103. """Alias symbols for known unit of measure symbols / by example::
  104. '°C': ('C', ...), # list of alias symbols for °C (Q69362731)
  105. '°F': ('F', ...), # list of alias symbols for °F (Q99490479)
  106. 'mi': ('L',), # list of alias symbols for mi (Q253276)
  107. """
  108. SYMBOL_TO_SI = []
  109. def symbol_to_si():
  110. """Generates a list of tuples, each tuple is a measure unit and the fields
  111. in the tuple are:
  112. 0. Symbol of the measure unit (e.g. 'mi' for measure unit 'miles' Q253276)
  113. 1. SI name of the measure unit (e.g. Q11573 for SI unit 'metre')
  114. 2. Factor to get SI value from measure unit (e.g. 1mi is equal to SI 1m
  115. multiplied by 1609.344)
  116. 3. Factor to get measure value from from SI value (e.g. SI 100m is equal to
  117. 100mi divided by 1609.344)
  118. The returned list is sorted, the first items are created from
  119. ``WIKIDATA_UNITS``, the second group of items is build from
  120. :py:obj:`ADDITIONAL_UNITS` and items created from :py:obj:`ALIAS_SYMBOLS`.
  121. If you search this list for a symbol, then a match with a symbol from
  122. Wikidata has the highest weighting (first hit in the list), followed by the
  123. symbols from the :py:obj:`ADDITIONAL_UNITS` and the lowest weighting is
  124. given to the symbols resulting from the aliases :py:obj:`ALIAS_SYMBOLS`.
  125. """
  126. global SYMBOL_TO_SI # pylint: disable=global-statement
  127. if SYMBOL_TO_SI:
  128. return SYMBOL_TO_SI
  129. # filter out units which can't be normalized to a SI unit and filter out
  130. # units without a symbol / arcsecond does not have a symbol
  131. # https://www.wikidata.org/wiki/Q829073
  132. for item in data.WIKIDATA_UNITS.values():
  133. if item['to_si_factor'] and item['symbol']:
  134. SYMBOL_TO_SI.append(
  135. (
  136. item['symbol'],
  137. item['si_name'],
  138. 1 / item['to_si_factor'], # from_si
  139. item['to_si_factor'], # to_si
  140. item['symbol'],
  141. )
  142. )
  143. for item in ADDITIONAL_UNITS:
  144. SYMBOL_TO_SI.append(
  145. (
  146. item['symbol'],
  147. item['si_name'],
  148. item['from_si'],
  149. item['to_si'],
  150. item['symbol'],
  151. )
  152. )
  153. alias_items = []
  154. for item in SYMBOL_TO_SI:
  155. for alias in ALIAS_SYMBOLS.get(item[0], ()):
  156. alias_items.append(
  157. (
  158. alias,
  159. item[1],
  160. item[2], # from_si
  161. item[3], # to_si
  162. item[0], # origin unit
  163. )
  164. )
  165. SYMBOL_TO_SI = SYMBOL_TO_SI + alias_items
  166. return SYMBOL_TO_SI
  167. def _parse_text_and_convert(from_query, to_query) -> str | None:
  168. # pylint: disable=too-many-branches, too-many-locals
  169. if not (from_query and to_query):
  170. return None
  171. measured = re.match(RE_MEASURE, from_query, re.VERBOSE)
  172. if not (measured and measured.group('number'), measured.group('unit')):
  173. return None
  174. # Symbols are not unique, if there are several hits for the from-unit, then
  175. # the correct one must be determined by comparing it with the to-unit
  176. # https://github.com/searxng/searxng/pull/3378#issuecomment-2080974863
  177. # first: collecting possible units
  178. source_list, target_list = [], []
  179. for symbol, si_name, from_si, to_si, orig_symbol in symbol_to_si():
  180. if symbol == measured.group('unit'):
  181. source_list.append((si_name, to_si))
  182. if symbol == to_query:
  183. target_list.append((si_name, from_si, orig_symbol))
  184. if not (source_list and target_list):
  185. return None
  186. source_to_si = target_from_si = target_symbol = None
  187. # second: find the right unit by comparing list of from-units with list of to-units
  188. for source in source_list:
  189. for target in target_list:
  190. if source[0] == target[0]: # compare si_name
  191. source_to_si = source[1]
  192. target_from_si = target[1]
  193. target_symbol = target[2]
  194. if not (source_to_si and target_from_si):
  195. return None
  196. _locale = get_locale() or 'en_US'
  197. value = measured.group('sign') + measured.group('number') + (measured.group('E') or '')
  198. value = babel.numbers.parse_decimal(value, locale=_locale)
  199. # convert value to SI unit
  200. if isinstance(source_to_si, (float, int)):
  201. value = float(value) * source_to_si
  202. else:
  203. value = source_to_si(float(value))
  204. # convert value from SI unit to target unit
  205. if isinstance(target_from_si, (float, int)):
  206. value = float(value) * target_from_si
  207. else:
  208. value = target_from_si(float(value))
  209. if measured.group('E'):
  210. # when incoming notation is scientific, outgoing notation is scientific
  211. result = babel.numbers.format_scientific(value, locale=_locale)
  212. else:
  213. result = babel.numbers.format_decimal(value, locale=_locale, format='#,##0.##########;-#')
  214. return f'{result} {target_symbol}'