defmodule ExUc.Units do @moduledoc """ # Units and conversion accessor Allow efficient access to units and conversions defined in config. """ # ETS table to store map of aliases by unit. @units_table :ex_uc_units_map # ETS table to store a map per kind with the conversions graph. @graphs_table :ex_uc_units_graphs # This kinds be used by default @default_kinds ~w(length mass memory pressure speed temperature time) @doc """ Gets the defined precision for outputs. By default is 2 decimals. ## Examples ``` iex>ExUc.Units.precision 2 ``` """ def precision, do: Application.get_env(:ex_uc, :precision, 2) @doc """ Gets the enabled unit modules defined in config. If there is none defined, will include only the defaults """ def units_modules, do: Application.get_env(:ex_uc, :units_modules, @default_kinds) @doc """ Gets the defined flag for when to trim decimal zeros. By default is disabled. ## Examples ``` iex>ExUc.Units.allow_exact_results false ``` """ def allow_exact_results, do: Application.get_env(:ex_uc, :allow_exact_results, false) @doc """ Gets all defined units as a Keyword. Units can be defined in config files or modules under Units namespace. Returns Keyword/List """ def all do defaults = units_modules() |> Enum.map(&String.capitalize/1) |> Enum.flat_map(&get_module_definitions/1) overrides = Application.get_all_env(:ex_uc) |> Enum.filter(fn {kind, _opts} -> kind_str = kind |> Atom.to_string() String.ends_with?(kind_str, "_units") || String.ends_with?(kind_str, "_conversions") end) Enum.reduce(overrides, defaults, fn {key, value}, acc -> with default_value <- Keyword.get(acc, key), _is_override when not is_nil(default_value) <- default_value, overridden_value <- Keyword.merge(default_value, value) do Keyword.put(acc, key, overridden_value) else # New values nil -> Keyword.put(acc, key, value) _ -> acc end end) end defp get_module_definitions(module_name) do mod = Module.safe_concat(["ExUc", "Units", module_name]) mod.definitions end @doc """ Gets all the conversions for the given kind. Returns Map ## Parameters - kind: Atom for the kind of units. """ def all_conversions(kind) do conversion_key = "#{kind}_conversions" |> String.to_atom() all() |> Keyword.get_values(conversion_key) |> List.first() |> Enum.into(%{}) end @doc """ Gets a map with every kind of unit defined in config. The result has a very traversable structure as: ``` %{ kind_of_unit: [ alias_0: :main, alias_N: :main, ], ... } ``` Returns Map """ def map do stored_map = cond do :ets.info(@units_table) == :undefined -> # Initialize units map table :ets.new(@units_table, [:named_table]) # Empty table :"$end_of_table" true -> :ets.first(@units_table) end get_map(stored_map) end defp get_map(:"$end_of_table") do parsed_map = all() |> Enum.filter(fn {kind, _opts} -> Atom.to_string(kind) |> String.ends_with?("_units") end) |> Enum.map(fn {kind, units} -> units_map = units |> Enum.flat_map(fn {main, aliases} -> cond do is_list(aliases) -> [{main, main} | for(alias <- aliases, do: {alias, main})] is_binary(aliases) -> [{main, main}, {aliases, main}] true -> [{main, main}, {aliases, main}] end end) {kind, units_map} end) |> Enum.into(%{}) :ets.insert(@units_table, {parsed_map}) parsed_map end defp get_map(parsed_map), do: parsed_map # Creates a ETS table to store a graph reference per kind def init_graphs do :ets.new(@graphs_table, [:named_table]) graphs_map = all() |> Enum.filter(fn {kind, _opts} -> Atom.to_string(kind) |> String.ends_with?("_conversions") end) |> Enum.map(fn {kind_conversion, conversions} -> kind = kind_conversion |> Atom.to_string() |> String.replace_suffix("_conversions", "") |> String.to_atom() {^kind, g, _} = make_graph(kind, conversions) {kind, g} end) |> Enum.into(%{}) :ets.insert(@graphs_table, {graphs_map}) graphs_map end # Creates a graph representing all conversions in a kind # Returns a tuple as {, , } defp make_graph(kind, conversions) do g = :digraph.new() conversions |> Enum.map(fn {edge, val} -> {v0, v1} = edge |> Atom.to_string() |> String.split("_to_") |> Enum.map(&String.to_atom/1) |> List.to_tuple() :digraph.add_vertex(g, v0) :digraph.add_vertex(g, v1) :digraph.add_edge(g, v0, v1) # Only when the conversion is a factor the reverse edge is added. if is_number(val), do: :digraph.add_edge(g, v1, v0) end) {kind, g, :digraph.no_vertices(g)} end @doc """ Gets a list of units as path for conversion. Returns List of units. ## Parameters - kind: Atom as the kind of unit. - from: Atom as initial unit. - to: Atom as target unit. ## Examples ``` iex>ExUc.Units.get_path_in(:length, :km, :ft) [:km, :m, :ft] iex>ExUc.Units.get_path_in(:length, :km, :zzx) false ``` """ def get_path_in(kind, from, to) do stored = cond do :ets.info(@graphs_table) == :undefined -> init_graphs() true -> :ets.first(@graphs_table) end g = stored |> Map.get(kind) :digraph.get_short_path(g, from, to) end @doc """ Gets the kind of unit for their given unit. ## Parameters - unit: Atom representing the unit to find the kind. ## Examples ``` iex>ExUc.Units.get_kind(:kg) "mass" iex>ExUc.Units.get_kind(:meter) "length" ``` """ def get_kind(unit) do kind_kw = map() |> Enum.find(fn {_kind, units} -> units |> Enum.any?(fn {alias, _main} -> "#{alias}" == "#{unit}" end) end) case kind_kw do {kind_name, _units} -> kind_name |> Atom.to_string() |> String.replace_suffix("_units", "") _ -> nil end end @doc """ Gets the unit among its aliases that can be used as a key in conversions ## Parameters - alias: Atom with a unit aliases. - kind: Atom or String for the kind where the alias is. ## Examples ``` iex>ExUc.Units.get_key_alias(:meter, "length") :m iex>ExUc.Units.get_key_alias(:pounds, :mass) :lb iex>ExUc.Units.get_key_alias(:r4R3, :mass) nil ``` """ def get_key_alias(alias, kind) do kind_token = "#{kind}_units" |> String.to_atom() with aliases <- Map.get(map(), kind_token), {_alias, main} <- Enum.find(aliases, fn {a, _key} -> "#{a}" == "#{alias}" end), do: main end @doc """ Gets the conversion factor for the units If can find inverse relation when the conversion is a factor. Returns Atom.t, Integer.t, Float.t ## Parameters - from: Atom representing the unit to convert from - to: Atom representing the unit to convert to ## Examples ``` iex>ExUc.Units.get_conversion(:g, :mg) {:ok, 1000} iex>ExUc.Units.get_conversion(:g, :zz) {:error, "undetermined conversion"} # This relation has not been defined but # the inverse is based on a factor, so is valid. iex>ExUc.Units.get_conversion(:km, :m) {:ok, 1.0e3} # This relation has not been defined either but # there is a traversable path among units. iex>ExUc.Units.get_conversion(:km, :ft) {:ok, [:km, :m, :ft]} ``` """ def get_conversion(from_alias, to_alias) do kind = get_kind(from_alias) |> String.to_atom() conversions = all_conversions(kind) {from, to} = {get_key_alias(from_alias, kind), get_key_alias(to_alias, kind)} regular_key = "#{from}_to_#{to}" |> String.to_atom() inverted_key = "#{to}_to_#{from}" |> String.to_atom() conversion = cond do Map.has_key?(conversions, regular_key) -> {:ok, Map.get(conversions, regular_key)} Map.has_key?(conversions, inverted_key) && is_number(Map.get(conversions, inverted_key)) -> {:ok, 1 / Map.get(conversions, inverted_key)} true -> {:ok, get_path_in(kind, from, to)} end cond do conversion == {:ok, false} -> {:error, "undetermined conversion"} true -> conversion end end end