lib/quantity.ex

defmodule Quantity do
  @moduledoc """
  A data structure that encapsulates a decimal value with a unit.
  """

  alias Quantity.Math

  @type t :: %__MODULE__{
          value: Decimal.t(),
          unit: unit
        }

  @type unit :: base_unit | {:div | :mult, unit, unit}
  @type base_unit :: String.t() | 1

  defstruct [
    :value,
    :unit
  ]

  defdelegate add!(quantity_1, quantity_2), to: Math
  defdelegate add(quantity_1, quantity_2), to: Math
  defdelegate div(dividend, divisor), to: Math
  defdelegate inverse(quantity), to: Math
  defdelegate mult(quantity, quantity_or_scalar), to: Math
  defdelegate round(quantity, decimals), to: Math
  defdelegate sub!(quantity_1, quantity_2), to: Math
  defdelegate sub(quantity_1, quantity_2), to: Math
  defdelegate sum!(quantities), to: Math
  defdelegate sum!(quantities, exp, unit), to: Math
  defdelegate sum(quantities), to: Math
  defdelegate sum(quantities, exp, unit), to: Math
  defdelegate abs(quantity), to: Math

  @doc """
  Builds a new Quantity from a Decimal and a unit
  """
  @spec new(Decimal.t(), unit) :: t
  def new(value, unit) do
    case try_new(value, unit) do
      {:ok, quantity} -> quantity
      {:error, reason} -> raise ArgumentError, reason
    end
  end

  @doc """
  Builds a new Quantity from a base value, exponent and unit
  """
  @spec new(integer, integer, unit) :: t
  def new(base_value, exponent, unit) do
    sign = if base_value < 0, do: -1, else: 1
    positive_base_value = Kernel.abs(base_value)
    value = Decimal.new(sign, positive_base_value, exponent)
    new(value, unit)
  end

  @doc """
  Tries to create a new Quantity. If it fails because of infinity or NaN decimal, will return an error tuple.
  This could be used instead of new/2 when creating a Quantity from user input or other thirdparty input.
  """
  @spec try_new(Decimal.t(), unit) :: {:ok, t()} | {:error, reason :: String.t()}
  def try_new(value, unit) do
    cond do
      Decimal.inf?(value) ->
        {:error, "Infinity not supported by Quantity"}

      Decimal.nan?(value) ->
        {:error, "NaN not supported by Quantity"}

      true ->
        unit = normalize_unit(unit)

        quantity = %__MODULE__{
          value: value,
          unit: unit
        }

        {:ok, quantity}
    end
  end

  @doc """
  Parses a string representation of a quantity (perhaps generated with to_string/1)

  iex> Quantity.parse("99.0 red_balloons")
  {:ok, Quantity.new(~d[99.0], "red_balloons")}

  iex> Quantity.parse("15 bananas/monkey")
  {:ok, Quantity.new(~d[15], {:div, "bananas", "monkey"})}

  iex> Quantity.parse("15 m*m")
  {:ok, Quantity.new(~d[15], {:mult, "m", "m"})}

  iex> Quantity.parse("bogus")
  :error
  """
  @spec parse(String.t()) :: {:ok, t} | :error
  def parse(input) do
    with {:ok, value_string, unit_string} <- parse_split_value_and_unit(input),
         {value, ""} <- Decimal.parse(value_string) do
      unit = parse_unit(unit_string)

      case try_new(value, unit) do
        {:ok, quantity} -> {:ok, quantity}
        {:error, _reason} -> :error
      end
    else
      _ -> :error
    end
  end

  defp parse_split_value_and_unit(input) do
    case String.split(input, " ", parts: 2) do
      [value] -> {:ok, value, "1"}
      [value, unit] -> {:ok, value, unit}
      _ -> :error
    end
  end

  defp parse_unit(unit_string) do
    if unit_string =~ "/" do
      [:div | unit_string |> String.split("/", parts: 2) |> Enum.map(&parse_mult_unit/1)] |> List.to_tuple()
    else
      parse_mult_unit(unit_string)
    end
  end

  defp parse_mult_unit(unit_string) do
    unit_string
    |> String.split("*")
    |> Enum.map(&parse_base_unit/1)
    |> Enum.reduce(&{:mult, &1, &2})
  end

  defp parse_base_unit("1"), do: 1
  defp parse_base_unit(unit_string), do: unit_string

  @doc """
  Same as parse/1, but raises if it could not parse
  """
  @spec parse!(String.t()) :: t
  def parse!(input) do
    {:ok, quantity} = parse(input)
    quantity
  end

  @doc """
  Encodes the quantity as a string. The result is parsable with parse/1
  If the exponent is positive, encode usinge the "raw" format to preserve precision

  iex> Quantity.new(42, -1, "db") |> Quantity.to_string()
  "4.2 db"
  iex> Quantity.new(42, 1, "db") |> Quantity.to_string()
  "42E1 db"
  iex> Quantity.new(~d[3600], {:div, "seconds", "hour"}) |> Quantity.to_string()
  "3600 seconds/hour"
  iex> Quantity.new(~d[34], {:mult, "m", "m"}) |> Quantity.to_string()
  "34 m*m"
  """
  @spec to_string(t) :: String.t()
  def to_string(quantity) do
    decimal_string = decimal_to_string(quantity.value)

    unit_string =
      case quantity.unit do
        1 -> ""
        unit -> " #{unit_to_string(unit)}"
      end

    "#{decimal_string}#{unit_string}"
  end

  defp unit_to_string(1), do: "1"
  defp unit_to_string(unit) when is_binary(unit), do: unit
  defp unit_to_string({:div, a, b}), do: "#{unit_to_string(a)}/#{unit_to_string(b)}"
  defp unit_to_string({:mult, a, b}), do: "#{unit_to_string(a)}*#{unit_to_string(b)}"

  @doc """
  Encodes a decimal as string. Uses either :raw (E-notation) or :normal based on exponent, so that precision is not
  lost

  iex> Quantity.decimal_to_string(~d[1.234])
  "1.234"

  iex> Quantity.decimal_to_string(~d[1E3])
  "1E3"
  """
  @spec decimal_to_string(Decimal.t()) :: String.t()
  def decimal_to_string(%Decimal{} = decimal) do
    if decimal.exp > 0 do
      Decimal.to_string(decimal, :raw)
    else
      Decimal.to_string(decimal, :normal)
    end
  end

  @doc """
  Tests if a quantity has zero value

  iex> Quantity.zero?(~Q[0.00 m^2])
  true

  iex> Quantity.zero?(~Q[0E7 m^2])
  true

  iex> Quantity.zero?(~Q[10 m^2])
  false
  """
  @spec zero?(t) :: boolean
  def zero?(quantity), do: quantity.value.coef == 0

  @doc """
  Test whether a Quantity is negative

  iex> ~Q[100.00 DKK] |> Quantity.negative?()
  false

  iex> ~Q[0.00 DKK] |> Quantity.negative?()
  false

  iex> ~Q[-1.93 DKK] |> Quantity.negative?()
  true
  """
  @spec negative?(t) :: boolean()
  def negative?(%{value: value}), do: Decimal.negative?(value)

  @doc """
  Test whether a Quantity is positive

  iex> ~Q[100.00 DKK] |> Quantity.positive?()
  true

  iex> ~Q[0.00 DKK] |> Quantity.positive?()
  false

  iex> ~Q[-1.93 DKK] |> Quantity.positive?()
  false
  """
  @spec positive?(t) :: boolean()
  def positive?(%{value: value}), do: Decimal.positive?(value)

  @doc """
  Returns true if the two quantities are numerically equal

  iex> Quantity.equals?(~Q[5 bananas], ~Q[5.0 bananas])
  true

  iex> Quantity.equals?(~Q[5 bananas], ~Q[5 apples])
  false
  """
  @spec equals?(t, t) :: boolean
  def equals?(q1, q2) do
    reduce(q1) == reduce(q2)
  end

  @doc """
  Reduces the value to the largest possible exponent without altering the numerical value

  iex> Quantity.reduce(~Q[1.200 m])
  ~Q[1.2 m]
  """
  @spec reduce(t) :: t
  def reduce(quantity) do
    %{quantity | value: Decimal.normalize(quantity.value)}
  end

  @doc """
  Return a quantity with a zero value and the same unit and precision as another Quantity

  iex> ~Q[123.99 EUR] |> Quantity.to_zero()
  ~Q[0.00 EUR]

  iex> ~Q[1 person] |> Quantity.to_zero()
  ~Q[0 person]

  iex> ~Q[-123 seconds] |> Quantity.to_zero()
  ~Q[0 seconds]
  """
  @spec to_zero(t) :: t
  def to_zero(%{unit: unit, value: %Decimal{exp: exp}}), do: Quantity.new(0, exp, unit)

  @doc """
  Converts the quantity to have a new unit.
  The new unit must be a whole 10-exponent more or less than the original unit.

  The exponent given is the difference in exponents (new-exponent - old-exponent).
  For example when converting from kWh to MWh: 6 (MWh) - 3 (kWh) = 3

  iex> ~Q[1234E3 Wh] |> Quantity.convert_unit("MWh", 6)
  ~Q[1.234 MWh]

  iex> ~Q[25.2 m] |> Quantity.convert_unit("mm", -3)
  ~Q[252E2 mm]
  """
  @spec convert_unit(t, String.t(), integer) :: t
  def convert_unit(quantity, new_unit, exponent) do
    new(Decimal.new(quantity.value.sign, quantity.value.coef, quantity.value.exp - exponent), new_unit)
  end

  @doc """
  Compares two quantities with the same unit numerically

  iex> Quantity.compare(~Q[1.00 m], ~Q[2.00 m])
  :lt
  iex> Quantity.compare(~Q[1.00 m], ~Q[1 m])
  :eq
  iex> Quantity.compare(~Q[3.00 m], ~Q[2.9999999 m])
  :gt
  """
  @spec compare(t, t) :: :lt | :eq | :gt
  def compare(%{unit: unit} = q1, %{unit: unit} = q2) do
    Decimal.compare(q1.value, q2.value)
  end

  @doc """
  Extracts the base value from the quantity
  """
  @spec base_value(t) :: integer
  def base_value(quantity), do: quantity.value.coef * quantity.value.sign

  @doc """
  Extracts the exponent from the quantity
  """
  @spec exponent(t) :: integer
  def exponent(quantity), do: quantity.value.exp

  @doc """
  Converts a 1-unit quantity to a decimal. If the quantity does not represent a decimal (a unit other than 1) it fails.

  iex> Quantity.to_decimal!(~Q[42])
  ~d[42]
  """
  @spec to_decimal!(t) :: Decimal.t()
  def to_decimal!(%{unit: 1} = quantity), do: quantity.value

  @doc """
  Extracts the unit from the quantity
  """
  @spec unit(t) :: unit
  def unit(quantity), do: quantity.unit

  defimpl String.Chars, for: __MODULE__ do
    def to_string(quantity) do
      @for.to_string(quantity)
    end
  end

  defimpl Inspect, for: __MODULE__ do
    def inspect(quantity, _options) do
      "~Q[#{@for.to_string(quantity)}]"
    end
  end

  # Normalizes unit to a standard form:
  # * Shorten unit as much as possible
  # * At most one :div (with possibly many :mults on each side)
  # * All :mult units are sorted
  # * Extra 1-units are removed
  defp normalize_unit(unit) do
    [numerators, denominators] =
      unit
      |> isolate_units([[], []])
      |> shorten()
      |> Enum.map(&Enum.sort/1)

    case {numerators, denominators} do
      {[], []} -> 1
      {nums, []} -> reduce_mults(nums)
      {[], dens} -> {:div, 1, reduce_mults(dens)}
      {nums, dens} -> {:div, reduce_mults(nums), reduce_mults(dens)}
    end
  end

  defp reduce_mults(units) do
    units
    |> Enum.reverse()
    |> Enum.reduce(&{:mult, &1, &2})
  end

  # Remove common elements in numerator and denominator
  defp shorten([numerators, denominators]) do
    [numerators, denominators] =
      [numerators, denominators]
      # Can be replaced with Enum.frequencies/1 when we no longer support Elixir 1.9
      |> Enum.map(fn list ->
        list |> Enum.group_by(& &1) |> Enum.into(%{}, fn {unit, count_list} -> {unit, length(count_list)} end)
      end)

    numerators
    |> Map.keys()
    |> Enum.reduce([numerators, denominators], fn key, [num, den] ->
      common = min(Map.fetch!(num, key), Map.get(den, key, 0))
      num = Map.update!(num, key, &(&1 - common))
      den = Map.update(den, key, 0, &(&1 - common))
      [num, den]
    end)
    |> Enum.map(fn map ->
      map |> Enum.flat_map(fn {key, count} -> List.duplicate(key, count) end)
    end)
  end

  # Splits units in numerators and denominators, so they are of the form (a * b * ...) / (c * d * ...)
  defp isolate_units({:div, a, b}, [acc_n, acc_d]) do
    [acc_n, acc_d] = isolate_units(a, [acc_n, acc_d])
    [acc_d, acc_n] = isolate_units(b, [acc_d, acc_n])
    [acc_n, acc_d]
  end

  defp isolate_units({:mult, a, b}, acc), do: Enum.reduce([a, b], acc, &isolate_units/2)
  defp isolate_units(a, [acc_n, acc_d]) when is_binary(a), do: [[a | acc_n], acc_d]
  defp isolate_units(1, acc), do: acc
end