elixir-cldr / cldr_units

Unit formatting (volume, area, length, ...) functions for the Common Locale Data Repository (CLDR)
Other
16 stars 13 forks source link

Cldr for Units

Hex.pm Hex.pm Hex.pm

Installation

Note that :ex_cldr_units requires Elixir 1.6 or later.

Add ex_cldr_units as a dependency to your mix project:

defp deps do
  [
    {:ex_cldr_units, "~> 3.0"}
  ]
end

then retrieve ex_cldr_units from hex:

mix deps.get
mix deps.compile

Documentation can be found at https://hexdocs.pm/ex_cldr_units.

Getting Started

ex_cldr_units is an add-on library for ex_cldr that provides localisation and formatting for units such as weights, lengths, areas, volumes and so on. It also provides unit conversion and simple arithmetic for compatible units.

Configuration

From ex_cldr version 2.0, a backend module must be defined into which the public API and the CLDR data is compiled. See the ex_cldr documentation for further information on configuration.

In the following examples we assume the presence of a module called MyApp.Cldr defined as:

defmodule MyApp.Cldr do
  use Cldr,
    locales: ["en", "fr"],
    default_locale: "en",
    providers: [Cldr.Number, Cldr.Unit, Cldr.List]
end

Supporting the String.Chars protocol

The String.Chars protocol underpins Kernel.to_string/1 and is also used in string interpolation such as #{my_unit}. In order for this to be supported by Cldr.Unit, a default backend module must be configured in config.exs. For example:

config :ex_cldr_units,
  default_backend: MyApp.Cldr

Public API

The primary api is defined by three functions:

Creating a new unit

A Cldr.Unit.t() struct is created with the Cldr.Unit.new/2 function. The two parameters are a unit name and a number (expressed as a float, integer, Decimal or Ratio) in either order.

Naming units is quite flexible combining:

Names can be expressed as strings with any of -, _ or as separators between words.

Some examples:

iex> Cldr.Unit.new :meter, 1
{:ok, #Cldr.Unit<:meter, 1>}

iex> Cldr.Unit.new "square meter", 1
{:ok, #Cldr.Unit<:square_meter, 1>}

iex> Cldr.Unit.new "square liter", 1
{:ok, #Cldr.Unit<"square_liter", 1>}

iex> Cldr.Unit.new "square yottaliter", 1
{:ok, #Cldr.Unit<"square_yottaliter", 1>}

iex> Cldr.Unit.new "cubic light year", 1
{:ok, #Cldr.Unit<"cubic_light_year", 1>}

iex> Cldr.Unit.new "squre meter", 1
{:error,
 {Cldr.UnknownUnitError, "Unknown unit was detected at \"squre_meter\""}}

You will note that the unit make not make logical sense (cubic light-year?) but they do make mathematical sense.

Units can also be described as the product of one or more base units. For example:

iex> Cldr.Unit.new "liter ampere", 1
{:ok, #Cldr.Unit<"ampere_liter", 1>}

iex> Cldr.Unit.new "mile lux", 1
{:ok, #Cldr.Unit<"mile_lux", 1>}

Again, this may not have a logical meaning but they do have an arithmetic meaning and they can be formatted as strings:

iex> Cldr.Unit.new!("liter ampere", 1) |> Cldr.Unit.to_string
{:ok, "1 ampere⋅litre"}

iex> Cldr.Unit.new!("mile lux", 3) |> Cldr.Unit.to_string
{:ok, "3 miles⋅lux"}

Lastly, there are units formed by division where are called "per" units. For example:

iex> Cldr.Unit.new "mile per hour", 1
{:ok, #Cldr.Unit<:mile_per_hour, 1>}

iex> Cldr.Unit.new "liter per second", 1
{:ok, #Cldr.Unit<"liter_per_second", 1>}

iex> Cldr.Unit.new "cubic gigalux per inch", 1
{:ok, #Cldr.Unit<"cubic_gigalux_per_inch", 1>}

Unit formatting and localization

MyApp.Cldr.Unit.to_string/2 provides localized unit formatting. It supports two arguments:

iex> MyApp.Cldr.Unit.to_string 123, unit: :gallon
{:ok, "123 gallons"}

iex> MyApp.Cldr.Unit.to_string 1234, unit: :gallon, format: :long
{:ok, "1 thousand gallons"}

iex> MyApp.Cldr.Unit.to_string 1234, unit: :gallon, format: :short
{:ok, "1K gallons"}

iex> MyApp.Cldr.Unit.to_string 1234, unit: :megahertz
{:ok, "1,234 megahertz"}

iex> MyApp.Cldr.Unit.to_string 1234, unit: :foot, locale: "fr"
{:ok, "1 234 pieds"}

iex> MyApp.Cldr.Unit.to_string Cldr.Unit.new(:ampere, 42), locale: "fr"
{:ok, "42 ampères"}

iex> Cldr.Unit.to_string 1234, MyApp.Cldr, unit: "foot_per_second", style: :narrow, per: :second
{:ok, "1,234′/s"}

iex> Cldr.Unit.to_string 1234, MyApp.Cldr, unit: "foot_per_second"
{:ok, "1,234 feet per second"}

Unit decomposition

Sometimes its a requirement to decompose a unit into one or more subunits. For example, if someone is 6.3 feet height we would normally say "6 feet, 4 inches". This can be achieved with Cldr.Unit.decompose/2. Using our example:

iex> height = Cldr.Unit.new(:foot, 6.3)
#Cldr.Unit<:foot, 6.3>
iex(2)> Cldr.Unit.decompose height, [:foot, :inch]
[#Cldr.Unit<:foot, 6.0>, #Cldr.Unit<:inch, 4.0>]

A localised string representing this decomposition can also be produced. Cldr.Unit.to_string/3 will process a unit list, using the function Cldr.List.to_string/2 to perform the list combination. Again using the example:

iex> c = Cldr.Unit.decompose height, [:foot, :inch]
[#Cldr.Unit<:foot, 6.0>, #Cldr.Unit<:inch, 4.0>]

iex> Cldr.Unit.to_string c, MyApp.Cldr
"6 feet and 4 inches"

iex> Cldr.Unit.to_string c, MyApp.Cldr, list_options: [format: :unit_short]
"6 feet, 4 inches"
# And of course full localisation is supported
iex> Cldr.Unit.to_string c, MyApp.Cldr, locale: "fr"
"6 pieds et 4 pouces"

Converting Units

t:Unit structs can be converted to other compatible units. For example, feet can be converted to meters since they are both of the length unit type.

# Test for unit compatibility
iex> Cldr.Unit.compatible? :foot, :meter
true
iex> Cldr.Unit.compatible? :foot, :liter
false

# Convert a unit
iex> Cldr.Unit.convert Cldr.Unit.new!(:foot, 3), :meter
{:ok, #Cldr.Unit<:meter, 16472365997070327 <|> 18014398509481984>}

Localising units for a given locale or territory

Different locales or territories use different measurement systems and sometimes different measurement scales that also vary based upon usage. For example, in the US a person's height is considered in inches up to a certain point and feet and inches after that. For distances when driving, the length is considered in yards for certain distances and miles after that. For most other countries the same quantity would be expressed in centimeters or meters or kilometers.

ex_cldr_units makes it easy to take a unit and convert it to the units appropriate for a given locale and usage.

Consider this example:

iex> height = Cldr.Unit.new!(1.81, :meter)
#Cldr.Unit<:meter, 1.81>

iex> us_height = Cldr.Unit.localize height, usage: :person_height, territory: :US
[#Cldr.Unit<:foot, 5>,
 #Cldr.Unit<:inch, 1545635392113553812 <|> 137269716642252725>]

iex> Cldr.Unit.to_string us_height
{:ok, "5 feet and 11.26 inches"}

Note that conversion is dependent on context. The context above is :person_height reflecting that we are referring to the height of a person. For units of length category, the other contexts available are :rainfall, :snowfall, :vehicle, :visibility and :road. Using the above example with the context of :rainfall we see

iex> length = Cldr.Unit.localize height, usage: :rainfall, territory: :US
[#Cldr.Unit<:inch, 9781818390648717312 <|> 137269716642252725>]

iex> Cldr.Unit.to_string length
{:ok, "71.26 inches"}

See Cldr.Unit.preferred_units/3 to see what mappings are available, in particular what context usage is supported for conversion.

Unit arithmetic

Basic arithmetic is provided by Cldr.Unit.add/2, Cldr.Unit.sub/2, Cldr.Unit.mult/2, Cldr.Unit.div/2 as well as Cldr.Unit.round/3

iex> Cldr.Unit.Math.add Cldr.Unit.new!(:foot, 1), Cldr.Unit.new!(:foot, 1)
#Cldr.Unit<:foot, 2>

iex> Cldr.Unit.Math.add Cldr.Unit.new!(:foot, 1), Cldr.Unit.new!(:mile, 1)
#Cldr.Unit<:foot, 5280.945925937846>

iex> Cldr.Unit.Math.add Cldr.Unit.new!(:foot, 1), Cldr.Unit.new!(:gallon, 1)
{:error, {Cldr.Unit.IncompatibleUnitError,
 "Operations can only be performed between units of the same type. Received #Cldr.Unit<:foot, 1> and #Cldr.Unit<:gallon, 1>"}}

iex> Cldr.Unit.round Cldr.Unit.new(:yard, 1031.61), 1
#Cldr.Unit<:yard, 1031.6>

iex> Cldr.Unit.round Cldr.Unit.new(:yard, 1031.61), 1, :up
#Cldr.Unit<:yard, 1031.7>

Available units

Available units are returned by Cldr.Unit.known_units/0.

iex> Cldr.Unit.known_units
[:acre, :acre_foot, :ampere, :arc_minute, :arc_second, :astronomical_unit, :bit,
 :bushel, :byte, :calorie, :carat, :celsius, :centiliter, :centimeter, :century,
 :cubic_centimeter, :cubic_foot, :cubic_inch, :cubic_kilometer, :cubic_meter,
 :cubic_mile, :cubic_yard, :cup, :cup_metric, :day, :deciliter, :decimeter,
 :degree, :fahrenheit, :fathom, :fluid_ounce, :foodcalorie, :foot, :furlong,
 :g_force, :gallon, :gallon_imperial, :generic, :gigabit, :gigabyte, :gigahertz,
 :gigawatt, :gram, :hectare, :hectoliter, :hectopascal, :hertz, :horsepower,
 :hour, :inch, ...]

Unit categories

Units are grouped by unit category which defines the convertibility of different types. In general, units of the same category are convertible to each other. The function Cldr.Unit.known_unit_categories/0 returns the unit categories.

iex> Cldr.Unit.known_unit_categories
[:acceleration, :angle, :area, :concentr, :consumption, :coordinate, :digital,
 :duration, :electric, :energy, :frequency, :length, :light, :mass, :power,
 :pressure, :speed, :temperature, :volume]

See also Cldr.Unit.known_units_by_category/0 and Cldr.Unit.known_units_for_category/1.

Measurement systems

Units generally fall into one of three measurement systems in use around the world. In CLDR these are known as :metric, :ussystem and :uksystem. The following functions allow identifying measurement systems for units, territories and locales.

Localisation with measurement systems

Knowledge of the measurement system in place for a given user helps create a better user experience. For example, a user who prefers units of measure in the US system can be shown different but compatible units from a user who prefers metric units.

In this example, the list of units in the volume category are filtered based upon the users preference as expressed by their locale.

# For a user preferring US english
iex> system = Cldr.Unit.measurement_system_from_locale "en"
:ussystem

iex> {:ok, units} = Cldr.Unit.known_units_for_category(:volume)
iex> Enum.filter(units, &Cldr.Unit.measurement_system?(&1, system))
[:dessert_spoon, :cup, :drop, :dram, :cubic_foot, :teaspoon, :tablespoon,
 :cubic_inch, :bushel, :quart, :pint, :cubic_yard, :cubic_mile, :fluid_ounce,
 :pinch, :barrel, :jigger, :gallon, :acre_foot]

# For a user preferring australian english
iex> system = Cldr.Unit.measurement_system_from_locale "en-AU"
:metric

iex> Enum.filter(units, &Cldr.Unit.measurement_system?(&1, system))
[:cubic_centimeter, :centiliter, :cubic_meter, :pint_metric, :megaliter,
 :cubic_kilometer, :hectoliter, :milliliter, :deciliter, :liter, :cup_metric]

# For a user expressing an explicit measurement system
iex> system = Cldr.Unit.measurement_system_from_locale "en-AU-u-ms-uksystem"
:uksystem

iex> Enum.filter(units, &Cldr.Unit.measurement_system?(&1, system))
[:quart_imperial, :cubic_foot, :cubic_inch, :dessert_spoon_imperial,
 :cubic_yard, :cubic_mile, :fluid_ounce_imperial, :acre_foot, :gallon_imperial]

Additional units (custom units)

Additional domain-specific units can be defined to suit application requirements. In the context of ex_cldr there are two parts to configuring additional units.

  1. Configure the unit, base unit and conversion in config.exs. This is a requirement since units are compiled into code.

  2. Configure the localizations for the additional unit in a CLDR backend module. Once configured, additional units act and behave like any of the predefined units of measure defined by CLDR.

Configuring a unit in config.exs

Under the application :ex_cldr_units, define a key :additional_units with the required unit definitions.

For example:

config :ex_cldr_units,  :additional_units,
  vehicle: [base_unit: :unit, factor: 1,  offset: 0, sort_before: :all],
  person: [base_unit: :unit, factor:  1, offset: 0, sort_before: :all]

This example defines two additional units: :vehicle and :person.

Configuration keys

Defining localizations

Although defining a unit in config.exs is enough to create, operate on and serialize an additional unit, it cannot be localised without defining localizations in an ex_cldr backend module. For example:

defmodule MyApp.Cldr do
  # Note that this line should come before the `use Cldr` line
  use Cldr.Unit.Additional

  use Cldr,
    locales: ["en", "fr", "de", "bs", "af", "af-NA", "se-SE"],
    default_locale: "en",
    providers: [Cldr.Number, Cldr.Unit, Cldr.List]

  unit_localization(:person, "en", :long,
    nominative: %{
      one: "{0} person",
      other: "{0} people"
    },
    display_name: "people"
  )
end

Note the additions to a typical ex_cldr backend module:

One invocation of Cldr.Unit.Additional.unit_localization/4 should made for each combination of unit, locale and style.

Parameters to unit_localization/4

Localisation definition

Localization keyword list defines localizations that match the plural rules for a given locale. Plural rules for a given number in a given locale resolve to one of six keys, and they must be placed under the proper declension to be used:

Only the :nominative key is required, and at minimum, it has to provide the :other key. For english, providing keys for :one and :other is enough. Other languages have different grammatical requirements.

The key :display_name is used by the function Cldr.Unit.display_name/1 which is primarily used to support UI applications.

Sorting Units

From Elixir 1.10, Enum.sort/2 supports module-based comparisons to provide a simpler API for sorting structs. ex_cldr_units supports Elixir 1.10 as the following example shows:

iex> alias Cldr.Unit
Cldr.Unit

iex> unit_list = [Unit.new!(:millimeter, 100), Unit.new!(:centimeter, 100), Unit.new!(:meter, 100), Unit.new!(:kilometer, 100)]
[#Unit<:millimeter, 100>, #Unit<:centimeter, 100>, #Unit<:meter, 100>,
 #Unit<:kilometer, 100>]

iex> Enum.sort unit_list, Cldr.Unit
[#Unit<:millimeter, 100>, #Unit<:centimeter, 100>, #Unit<:meter, 100>,
 #Unit<:kilometer, 100>]

iex> Enum.sort unit_list, {:desc, Cldr.Unit}
[#Unit<:kilometer, 100>, #Unit<:meter, 100>, #Unit<:centimeter, 100>,
 #Unit<:millimeter, 100>]

iex> Enum.sort unit_list, {:asc, Cldr.Unit}
[#Unit<:millimeter, 100>, #Unit<:centimeter, 100>, #Unit<:meter, 100>,
 #Unit<:kilometer, 100>]

Note that the items being sorted must be all of the same unit category (length, volume, ...). Where units are of the same category but different units, conversion to a common unit will occur before the comparison. If units of different categories are encountered an exception will be raised as the following example shows:

iex> unit_list = [Unit.new!(:millimeter, 100), Unit.new!(:centimeter, 100), Unit.new!(:meter, 100), Unit.new!(:liter, 100)]
[#Cldr.Unit<:millimeter, 100>, #Cldr.Unit<:centimeter, 100>,
 #Cldr.Unit<:meter, 100>, #Cldr.Unit<:liter, 100>]

iex> Enum.sort unit_list, Cldr.Unit
** (Cldr.Unit.IncompatibleUnitsError) Operations can only be performed between units with the same category and base unit. Received :liter and :meter

Serializing to a database with Ecto

The companion package ex_cldr_units_sql provides functions for the serialization of Unit data. See the README for further information.