defmodule Measurements do
@moduledoc """
Documentation for `Measurements`.
A measurement is a quantity represented, by a value, a unit and an error.
The value is usually an integer to maintain maximum precision,
but can also be a float if required.
## Examples
iex> Measurements.time(42, :second)
%Measurements{
value: 42,
unit: :second,
error: 0
}
"""
## only for now, for ratio...
require Measurements.Scale
alias Measurements.Unit
@enforce_keys [:value, :unit]
defstruct value: nil,
unit: nil,
error: 0
@typedoc "Measurement Type"
@type t :: %__MODULE__{
value: integer,
unit: Unit.t(),
error: non_neg_integer
}
@doc """
Time Measurement.
## Examples
iex> Measurements.time(42, :second)
%Measurements{
value: 42,
unit: :second
}
"""
@spec time(integer, Unit.t()) :: t
@spec time(integer, Unit.t(), integer) :: t
def time(v, unit, err \\ 0) do
# normalize the unit
case Unit.time(unit) do
{:ok, nu} ->
%__MODULE__{value: v, unit: nu, error: abs(err)}
{:error, conversion, nu} ->
%__MODULE__{value: conversion.(v), unit: nu, error: abs(err)}
{:error, :not_a_supported_time_unit} ->
raise ArgumentError, message: "#{unit} is not a supported time unit"
end
end
@doc """
Length Measurement.
## Examples
iex> Measurements.length(42, :meter)
%Measurements{
value: 42,
unit: :meter
}
"""
@spec length(integer, Unit.t()) :: t
@spec length(integer, Unit.t(), integer) :: t
def length(v, unit, err \\ 0) do
# normalize the unit
case Unit.length(unit) do
{:ok, nu} ->
%__MODULE__{value: v, unit: nu, error: abs(err)}
{:error, conversion, nu} ->
%__MODULE__{value: conversion.(v), unit: nu, error: abs(err)}
{:error, :not_a_supported_length_unit} ->
raise ArgumentError, message: "#{unit} is not a supported length unit"
end
end
@doc """
Generic Measurement. Unit indicates the dimension.
## Examples
iex> Measurements.new(42, :meter)
%Measurements{
value: 42,
unit: :meter
}
"""
@spec new(integer, Unit.t()) :: t
@spec new(integer, Unit.t(), integer) :: t
def new(v, unit, err \\ 0) do
# normalize the unit
case Unit.new(unit) do
# TODO : while new/2 seems the more intuitive approach,
# we might need a way to pass unknown units to Unit.new/2 somehow...
# maybe create them with time/1, length/1 ??
{:ok, nu} ->
%__MODULE__{value: v, unit: nu, error: abs(err)}
{:error, conversion, nu} ->
%__MODULE__{value: conversion.(v), unit: nu, error: abs(err)}
end
end
@doc """
Add error to a Measurement.
The error is symmetric and always represented by a positive number.
The measurement unit is converted if needed to not loose precision.
## Examples
iex> Measurements.time(42, :second) |> Measurements.add_error(-4, :millisecond)
%Measurements{
value: 42_000,
unit: :millisecond,
error: 4
}
"""
@spec add_error(t(), integer, Unit.t()) :: t
def add_error(%__MODULE__{} = value, err, unit) when value.unit == unit do
%{value | error: value.error + abs(err)}
end
def add_error(%__MODULE__{} = value, err, unit) do
case Unit.convert(value.unit, unit) do
{:ok, converter} ->
%__MODULE__{
value: converter.(value.value),
unit: unit
}
|> add_error(err, unit)
{:error, reason} ->
raise reason
# time(Unit.convert(same, unit), unit)
# |> with_error(pos_int, unit)
end
end
@doc """
Convert the measurement to the new unit, if the new unit is more precise.
This will pick the most precise between the measurement's unit and the new unit.
Then it will convert the measurement to the chosen unit.
If no conversion is possible, the original measurement is returned.
## Examples
iex> Measurements.time(42, :second) |> Measurements.add_error(1, :second) |> Measurements.best_convert(:millisecond)
%Measurements{value: 42_000, unit: :millisecond, error: 1_000}
iex> Measurements.time(42, :millisecond) |> Measurements.add_error(1, :millisecond) |> Measurements.best_convert(:second)
%Measurements{value: 42, unit: :millisecond, error: 1}
"""
@spec best_convert(t, Unit.t()) :: t
def best_convert(%__MODULE__{unit: u} = m, unit) when u == unit, do: m
def best_convert(%__MODULE__{} = m, unit) do
case Unit.min(m.unit, unit) do
{:ok, min_unit} ->
# if Unit.min is successful, conversion will always work.
{:ok, converter} = Unit.convert(m.unit, min_unit)
%__MODULE__{
value: converter.(m.value),
unit: min_unit,
error: converter.(m.error)
}
# no conversion possible, just ignore it
{:error, :incompatible_dimension} ->
m
end
end
@doc """
The sum of multiple measurements, with implicit unit conversion.
Only measurements with the same unit dimension will work.
Error will be propagated.
## Examples
iex> m1 = Measurements.time(42, :second) |> Measurements.add_error(1, :second)
iex> m2 = Measurements.time(543, :millisecond) |> Measurements.add_error(3, :millisecond)
iex> Measurements.sum(m1, m2)
%Measurements{
value: 42_543,
unit: :millisecond,
error: 1_003
}
"""
def sum(%__MODULE__{} = m1, %__MODULE__{} = m2) when m1.unit == m2.unit do
%{m1 | value: m1.value + m2.value, error: m1.error + m2.error}
end
def sum(%__MODULE__{} = m1, %__MODULE__{} = m2) do
if Unit.dimension(m1.unit) == Unit.dimension(m2.unit) do
m1 = best_convert(m1, m2.unit)
m2 = best_convert(m2, m1.unit)
sum(m1, m2)
else
raise ArgumentError, message: "#{m1} and #{m2} have incompatible unit dimension"
end
end
@doc """
Scales a measurement by a number.
No unit conversion happens at this stage for simplicity, and to keep the scale of the resulting value obvious.
Error will be scaled by the same number, but always remains positive.
## Examples
iex> m1 = Measurements.time(543, :millisecond) |> Measurements.add_error(3, :millisecond)
iex> Measurements.scale(m1, 10)
%Measurements{
value: 5430,
unit: :millisecond,
error: 30
}
"""
def scale(%__MODULE__{} = m1, n) when is_integer(n) do
%{m1 | value: m1.value * n, error: abs(m1.error * n)}
end
@doc """
The difference of two measurements, with implicit unit conversion.
Only measurements with the same unit dimension will work.
Error will be propagated (ie compounded).
## Examples
iex> m1 = Measurements.time(42, :second) |> Measurements.add_error(1, :second)
iex> m2 = Measurements.time(543, :millisecond) |> Measurements.add_error(3, :millisecond)
iex> Measurements.delta(m1, m2)
%Measurements{
value: 41_457,
unit: :millisecond,
error: 1_003
}
"""
def delta(%__MODULE__{} = m1, %__MODULE__{} = m2) when m1.unit == m2.unit do
%{m1 | value: m1.value - m2.value, error: m1.error + m2.error}
end
def delta(%__MODULE__{} = m1, %__MODULE__{} = m2) do
if Unit.dimension(m1.unit) == Unit.dimension(m2.unit) do
m1 = best_convert(m1, m2.unit)
m2 = best_convert(m2, m1.unit)
delta(m1, m2)
else
raise ArgumentError, message: "#{m1} and #{m2} have incompatible unit dimension"
end
end
@doc """
The ratio of two measurements, with implicit unit conversion.
Only measurements with the same unit dimension will work, currently.
Error will be propagated (ie relatively compounded) as an int if possible.
## Examples
iex> m1 = Measurements.time(300, :second) |> Measurements.add_error(1, :second)
iex> m2 = Measurements.time(60_000, :millisecond) |> Measurements.add_error(3, :millisecond)
iex> Measurements.ratio(m1, m2)
%Measurements{
value: 5,
unit: nil,
error: 0.01691666666666667
}
"""
def ratio(%__MODULE__{} = m1, %__MODULE__{} = m2) when m1.unit == m2.unit do
# note: relative error is computed as float temporarily (quotient is supposed to always be small)
# For error we rely on float precision. Other approximations are already made in Error propagation theory anyway.
m1_rel_err = m1.error / m1.value
m2_rel_err = m2.error / m2.value
value =
if rem(m1.value, m2.value) == 0 do
div(m1.value, m2.value)
else
m1.value / m2.value
end
error = abs(value * (m1_rel_err + m2_rel_err))
# TODO : unit conversion via ratio...
# TODO : maybe unit is still there, but only with a scale ??
# TMP: force to scale 0 if unit is nil -> constant
%__MODULE__{value: value, unit: nil, error: error}
end
def ratio(%__MODULE__{} = m1, %__MODULE__{} = m2) do
if Unit.dimension(m1.unit) == Unit.dimension(m2.unit) do
m1 = best_convert(m1, m2.unit)
m2 = best_convert(m2, m1.unit)
ratio(m1, m2)
else
raise ArgumentError, message: "#{m1} and #{m2} have incompatible unit dimension"
end
end
# TODO : ratio of different units, with adjustment of dimension
# TODO : product with increase of dimension
end
defimpl String.Chars, for: Measurements do
def to_string(%Measurements{
value: v,
unit: unit,
error: 0
}) do
u = Measurements.Unit.to_string(unit)
"#{v} #{u}"
end
def to_string(%Measurements{
value: v,
unit: unit,
error: err
}) do
u = Measurements.Unit.to_string(unit)
"#{v} ±#{err} #{u}"
end
end