Packages
localize
0.27.0
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.41.3
0.41.2
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.1
0.30.0
retired
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
0.1.0-alpha.1
Localization (parsing, formatting) of numbers, dates/time/calendar, units of measure, messages and lists. Includes localized collation.
Current section
Files
Jump to
Current section
Files
lib/localize/unit/preference.ex
defmodule Localize.Unit.Preference do
@moduledoc """
Returns the preferred units for a given unit, territory, and usage.
In many cultures, the common unit of measure for some unit categories
varies based upon the usage. For example, when describing length in
the US, the units vary by context:
* road distance — miles for larger distances, feet for smaller
* person height — feet and inches
* snowfall — inches
This module provides functions to look up CLDR preference data for
different use cases in different territories.
"""
alias Localize.Unit
alias Localize.Unit.{Conversion, Data, Parser, BaseUnit}
@unit_preferences (
geq_to_base = fn unit_name, geq_string ->
case geq_string do
nil ->
0.0
"" ->
0.0
s when is_binary(s) ->
case Float.parse(s) do
{+0.0, _} ->
+0.0
{geq_value, _} ->
primary = unit_name |> String.split("-and-") |> hd()
case Parser.parse(primary) do
{:ok, parsed} ->
case BaseUnit.base_unit(parsed) do
{:ok, base} ->
case Conversion.convert(geq_value, primary, base) do
{:ok, v} -> v
_ -> 0.0
end
_ ->
0.0
end
_ ->
0.0
end
:error ->
0.0
end
end
end
Data.unit_preferences()
|> Enum.group_by(& &1.category)
|> Map.new(fn {category, entries} ->
by_usage =
entries
|> Enum.group_by(& &1.usage)
|> Map.new(fn {usage, usage_entries} ->
prefs =
usage_entries
|> Enum.flat_map(fn entry ->
Enum.map(entry.preferences, fn p ->
%{
unit: p.unit,
geq: geq_to_base.(p.unit, p[:geq]),
regions: String.split(p.regions, " ", trim: true),
skeleton: p[:skeleton] || ""
}
end)
end)
{usage, prefs}
end)
{category, by_usage}
end)
)
@doc """
Returns the preferred units for a given unit, territory, and usage.
The unit's value is converted to the base unit for its category and
compared against the `geq` (greater-than-or-equal) thresholds in the
preference data to select the appropriate unit set.
### Arguments
* `unit` is a `t:Localize.Unit.t/0` struct.
* `options` is a keyword list of options.
### Options
* `:usage` is the unit usage context as an atom (e.g., `:person_height`,
`:road`). The default is `:default`.
* `:territory` is a territory code atom (e.g., `:US`, `:GB`). The
default is derived from the current locale.
* `:locale` is a locale identifier. Used to derive the territory when
`:territory` is not provided.
### Returns
* `{:ok, units, options}` where `units` is a list of unit name atoms
and `options` is a keyword list of formatting hints (e.g.,
`[round_nearest: 1]`).
* `{:error, exception}` if preferences cannot be resolved.
### Examples
iex> meter = Localize.Unit.new!(1, "meter")
iex> {:ok, units, _opts} = Localize.Unit.Preference.preferred_units(meter, usage: :person, territory: :US)
iex> units
[:inch]
iex> many_meters = Localize.Unit.new!(10_000, "meter")
iex> {:ok, units, _opts} = Localize.Unit.Preference.preferred_units(many_meters, usage: :road, territory: :US)
iex> units
[:mile]
"""
@spec preferred_units(Unit.t(), Keyword.t()) ::
{:ok, [atom()], Keyword.t()} | {:error, Exception.t()}
def preferred_units(%Unit{} = unit, options \\ []) do
usage = Keyword.get(options, :usage, :default)
territory = resolve_territory(options)
with {:ok, category} <- unit_category(unit),
{:ok, base_value} <- base_unit_value(unit),
{:ok, territory_chain} <- Localize.Territory.territory_chain(territory) do
usage_chain = build_usage_chain(usage)
find_preference(category, usage_chain, territory_chain, abs(base_value))
end
end
@doc """
Same as `preferred_units/2` but raises on error.
"""
@spec preferred_units!(Unit.t(), Keyword.t()) :: [atom()] | no_return()
def preferred_units!(%Unit{} = unit, options \\ []) do
case preferred_units(unit, options) do
{:ok, units, _opts} -> units
{:error, exception} -> raise exception
end
end
# ── Category resolution ─────────────────────────────────────
defp unit_category(%Unit{name: name}) do
with {:ok, parsed} <- Parser.parse(name),
{:ok, base} <- BaseUnit.base_unit(parsed) do
btq = Data.base_unit_to_quantity()
# Try the original unit name first (handles compound units like
# cubic-meter-per-meter → consumption), then fall back to base unit
case Map.get(btq, name) || Map.get(btq, base) do
nil ->
{:error,
Localize.InvalidValueError.exception(
value: name,
expected: "a unit with a known category"
)}
category ->
{:ok, category}
end
end
end
# ── Base unit value ─────────────────────────────────────────
defp base_unit_value(%Unit{value: value, name: name}) do
with {:ok, parsed} <- Parser.parse(name),
{:ok, base} <- BaseUnit.base_unit(parsed) do
Conversion.convert(to_float(value), name, base)
end
end
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
defp to_float(n) when is_number(n), do: n * 1.0
defp to_float(nil), do: 0.0
# ── Territory resolution ────────────────────────────────────
defp resolve_territory(options) do
case Keyword.get(options, :territory) do
nil ->
locale = Keyword.get(options, :locale, Localize.get_locale())
case Localize.Territory.territory_from_locale(locale) do
{:ok, territory} -> territory
_ -> :US
end
territory ->
territory
end
end
# ── Usage chain ─────────────────────────────────────────────
# Build a chain of usages to try, from most specific to least.
# For example, :person_height → [:person_height, :person, :default]
defp build_usage_chain(:default), do: [:default]
defp build_usage_chain(usage) when is_atom(usage) do
parts = usage |> Atom.to_string() |> String.split("_")
chain =
parts
|> Enum.scan(fn part, acc -> acc <> "_" <> part end)
|> Enum.reverse()
|> Enum.map(&String.to_atom/1)
chain ++ [:default]
end
# ── Preference lookup ───────────────────────────────────────
defp find_preference(category, usage_chain, territory_chain, base_value) do
Enum.find_value(usage_chain, fn usage ->
find_for_usage(category, usage, territory_chain, base_value)
end) ||
{:error,
Localize.InvalidValueError.exception(
value: {category, hd(usage_chain)},
expected: "a known unit preference"
)}
end
defp find_for_usage(category, usage, territory_chain, base_value) do
usage_str = Atom.to_string(usage) |> String.replace("_", "-")
case get_in(@unit_preferences, [category, usage_str]) do
nil -> nil
preferences -> find_for_territory(preferences, territory_chain, base_value)
end
end
defp find_for_territory(preferences, territory_chain, base_value) do
Enum.find_value(territory_chain, fn territory ->
territory_str = Atom.to_string(territory)
matching = Enum.filter(preferences, fn p -> territory_str in p.regions end)
case find_by_geq(matching, base_value) do
nil -> nil
result -> result
end
end)
end
defp find_by_geq([], _value), do: nil
defp find_by_geq(preferences, base_value) do
# Process preferences in order. For each:
# - If geq > 0: check if base_value >= geq (threshold match)
# - If geq == 0: convert to this unit, check if value >= 1 (size cascade)
# First match wins. If none match, use the last preference.
match =
Enum.find(preferences, fn pref ->
if pref.geq > 0.0 do
# Use a small epsilon for floating-point comparison to handle
# IEEE 754 rounding (e.g., 3.0 feet = 0.9144000000000001 meters)
base_value >= pref.geq * (1.0 - 1.0e-12)
else
# Size cascade: convert base value to this unit, check >= 1
primary_unit = pref.unit |> String.split("-and-") |> hd()
converted_value_gte_1?(base_value, primary_unit)
end
end)
case match do
nil ->
# Fall back to the last (smallest) preference
case List.last(preferences) do
nil -> nil
pref -> format_result(pref)
end
pref ->
format_result(pref)
end
end
@dialyzer {:nowarn_function, converted_value_gte_1?: 2}
defp converted_value_gte_1?(base_value, unit_name) do
case Parser.parse(unit_name) do
{:ok, parsed} ->
case BaseUnit.base_unit(parsed) do
{:ok, base} ->
case Conversion.convert(base_value, base, unit_name) do
{:ok, converted} -> abs(converted) >= 1.0 - 1.0e-8
_ -> false
end
_ ->
false
end
_ ->
false
end
end
defp format_result(pref) do
units =
pref.unit
|> String.split("-and-")
|> Enum.map(fn u -> u |> String.replace("-", "_") |> String.to_atom() end)
skeleton = parse_skeleton(pref)
{:ok, units, skeleton}
end
defp parse_skeleton(%{skeleton: skeleton}) when is_binary(skeleton) and skeleton != "" do
skeleton
|> String.split("/")
|> Enum.flat_map(fn part ->
case part do
"1" -> [round_nearest: 1]
"5" -> [round_nearest: 5]
"10" -> [round_nearest: 10]
"50" -> [round_nearest: 50]
"100" -> [round_nearest: 100]
_ -> []
end
end)
end
defp parse_skeleton(_), do: []
end