defmodule Localize.Unit.Conversion do @moduledoc """ Converts numeric values between CLDR units of measure. Two units are convertible if they reduce to the same base unit. The conversion goes through the base unit: `source_value → base_value → target_value`. For simple units: `base_value = source_value * factor + offset`. For compound units (products and per-expressions), the total factor is the product of all component factors raised to their respective powers. """ alias Localize.Unit.{BaseUnit, Parser} @si_prefix_multipliers Localize.Unit.Data.si_prefix_multipliers() @doc """ Checks whether two units are convertible (same dimensional base unit). ### Arguments * `unit_1` is a unit identifier string or parsed AST. * `unit_2` is a unit identifier string or parsed AST. ### Returns * `true` if the units are convertible, `false` otherwise. ### Examples iex> Localize.Unit.Conversion.convertible?("foot", "meter") true iex> Localize.Unit.Conversion.convertible?("foot", "kilogram") false """ @spec convertible?(String.t() | tuple(), String.t() | tuple()) :: boolean() def convertible?(unit_1, unit_2) do with {:ok, base_1} <- BaseUnit.base_unit(unit_1), {:ok, base_2} <- BaseUnit.base_unit(unit_2) do base_1 == base_2 else _ -> false end end @doc """ Converts a numeric value from one unit to another. Both units must be of the same category (convertible). Accepts integers, floats, and Decimal values. ### Arguments * `value` is the numeric value to convert (integer, float, or Decimal). * `from` is the source unit identifier string. * `to` is the target unit identifier string. ### Returns * `{:ok, converted_value}` where the result is a float, or * `{:error, reason}` if the units cannot be parsed or are not convertible. ### Examples iex> Localize.Unit.Conversion.convert(1, "kilometer", "meter") {:ok, 1000.0} iex> Localize.Unit.Conversion.convert(32, "fahrenheit", "celsius") {:ok, 0.0} """ @spec convert(number(), String.t(), String.t()) :: {:ok, float()} | {:error, Exception.t() | String.t()} @dialyzer {:nowarn_function, convert: 3} def convert(value, from, to) do with {:ok, parsed_from} <- Parser.parse(from), {:ok, parsed_to} <- Parser.parse(to), true <- convertible?(parsed_from, parsed_to) do do_convert(to_float(value), parsed_from, parsed_to) else false -> {:error, Localize.UnitConversionError.exception(from: from, to: to, reason: :not_convertible)} {:error, _} = error -> error end end @doc """ Converts a numeric value from one unit to another, raising on error. Same as `convert/3` but returns the value directly or raises `ArgumentError`. ### Arguments * `value` is the numeric value to convert. * `from` is the source unit identifier string. * `to` is the target unit identifier string. ### Returns * The converted value as a float. ### Examples iex> Localize.Unit.Conversion.convert!(1000, "meter", "kilometer") 1.0 """ @spec convert!(number(), String.t(), String.t()) :: float() | no_return() def convert!(value, from, to) do case convert(value, from, to) do {:ok, result} -> result {:error, exception} -> raise exception end end # ── Private ───────────────────────────────────────────────────────── defp do_convert(value, from_ast, to_ast) do case {special_unit(from_ast), special_unit(to_ast)} do {{:special, fwd, _inv}, nil} -> base_value = apply(elem(fwd, 0), elem(fwd, 1), [value]) convert_from_base(base_value, to_ast) {nil, {:special, _fwd, inv}} -> with {:ok, from_params} <- conversion_params(from_ast) do base_value = value * from_params.factor + from_params.offset {:ok, apply(elem(inv, 0), elem(inv, 1), [base_value])} end {{:special, fwd, _}, {:special, _, inv}} -> base_value = apply(elem(fwd, 0), elem(fwd, 1), [value]) {:ok, apply(elem(inv, 0), elem(inv, 1), [base_value])} {nil, nil} -> with {:ok, from_params} <- conversion_params(from_ast), {:ok, to_params} <- conversion_params(to_ast) do base_value = value * from_params.factor + from_params.offset result = (base_value - to_params.offset) / to_params.factor {:ok, result} end end end defp convert_from_base(base_value, to_ast) do with {:ok, to_params} <- conversion_params(to_ast) do result = (base_value - to_params.offset) / to_params.factor {:ok, result} end end # Compute the total factor and offset for a parsed unit AST. # For compound units, offset is always 0 (offsets only apply to # simple temperature-like units). defp conversion_params({:unit, keyword}) do numerator = Keyword.get(keyword, :numerator, []) denominator = Keyword.get(keyword, :denominator, []) with {:ok, num_factor} <- product_factor(numerator), {:ok, den_factor} <- product_factor(denominator) do total_factor = num_factor / den_factor # Offset only applies to single simple units (temperature conversions) offset = single_unit_offset(numerator, denominator) {:ok, %{factor: total_factor, offset: offset}} end end defp conversion_params({:mixed_unit, _units}) do {:error, Localize.UnitConversionError.exception(reason: :mixed_units)} end defp product_factor([]), do: {:ok, 1.0} defp product_factor(units) do Enum.reduce_while(units, {:ok, 1.0}, fn unit, {:ok, acc} -> case single_factor(unit) do {:ok, factor} -> {:cont, {:ok, acc * factor}} {:error, _} = error -> {:halt, error} end end) end defp single_factor({:single_unit, keyword}) do base = Keyword.fetch!(keyword, :base) case base do "curr-" <> _code -> {:ok, 1.0} _ -> prefix = Keyword.get(keyword, :prefix) power = Keyword.get(keyword, :power) case Localize.Unit.Data.conversion_factor(base) do nil -> {:error, Localize.UnknownUnitError.exception(unit: base)} %{factor: :special} -> {:error, Localize.UnitConversionError.exception( from: base, reason: :special_conversion )} %{factor: base_factor} -> prefix_mult = if prefix, do: Map.get(@si_prefix_multipliers, Atom.to_string(prefix), 1.0), else: 1.0 total = base_factor * prefix_mult powered = apply_power_to_factor(total, power) {:ok, powered} end end end defp single_factor({:constant, value_string}) do value = if String.contains?(value_string, "e") do {v, ""} = Float.parse("1" <> String.replace(value_string, "1e", "e")) v else {v, ""} = Float.parse(value_string) v end {:ok, value} end # Offset only applies when there is exactly one simple unit in the # numerator and nothing in the denominator — i.e., a non-compound unit # like "fahrenheit" or "celsius". defp single_unit_offset([{:single_unit, keyword}], []) do base = Keyword.fetch!(keyword, :base) prefix = Keyword.get(keyword, :prefix) if prefix == nil do case Localize.Unit.Data.conversion_factor(base) do %{offset: offset} when offset != 0.0 -> offset _ -> 0.0 end else 0.0 end end defp single_unit_offset(_numerator, _denominator), do: 0.0 defp apply_power_to_factor(factor, nil), do: factor defp apply_power_to_factor(factor, :square), do: factor * factor defp apply_power_to_factor(factor, :cubic), do: factor * factor * factor defp apply_power_to_factor(factor, {:pow, n}), do: :math.pow(factor, n) # ── Special (nonlinear) conversion detection ───────────────────── # Built-in special conversions compiled from CLDR data. These are # always available without requiring CustomRegistry registration. @built_in_special %{ "beaufort" => { {Localize.Unit.Conversion.Beaufort, :forward}, {Localize.Unit.Conversion.Beaufort, :inverse} } } # Detect if a parsed AST represents a unit with a registered special # conversion (forward/inverse functions). Only simple units without # SI prefixes or power prefixes qualify — compound units cannot be special. defp special_unit({:unit, keyword}) do with [{:single_unit, opts}] <- Keyword.get(keyword, :numerator, []), [] <- Keyword.get(keyword, :denominator, []), nil <- Keyword.get(opts, :prefix), nil <- Keyword.get(opts, :power), base when is_binary(base) <- Keyword.get(opts, :base), %{factor: :special} <- Localize.Unit.Data.conversion_factor(base) do lookup_special(base) else _ -> nil end end defp special_unit({:mixed_unit, _}), do: nil defp lookup_special(name) do case Localize.Unit.CustomRegistry.get(name) do %{forward: fwd, inverse: inv} -> {:special, fwd, inv} _ -> case Map.get(@built_in_special, name) do {fwd, inv} -> {:special, fwd, inv} nil -> nil end end end # ── Helpers ───────────────────────────────────────────────────────── defp to_float(value) when is_integer(value), do: value * 1.0 defp to_float(value) when is_float(value), do: value defp to_float(%Decimal{} = value) do Decimal.to_float(value) end end