2016-01-14 15:22:15 -06:00
|
|
|
module UnitConversion
|
|
|
|
|
2016-01-20 15:07:37 -06:00
|
|
|
INTEGER_REGEX = /-?[0-9]+/
|
2016-01-15 13:49:08 -06:00
|
|
|
DECIMAL_REGEX = /(?:-?[0-9]+)?\.[0-9]+/
|
2016-01-20 15:07:37 -06:00
|
|
|
RATIONAL_REGEX = /-?(?:[0-9]+ )?[0-9]+\/[0-9]+/
|
2016-01-15 13:49:08 -06:00
|
|
|
UNIT_REGEX = /[\[\]a-zA-Z][\[\]a-zA-Z\/.\-()0-9]*/
|
2016-01-20 15:07:37 -06:00
|
|
|
UNIT_PARSING_REGEX = /^(?<value>#{INTEGER_REGEX}|#{DECIMAL_REGEX}|#{RATIONAL_REGEX})\s+(?<unit>#{UNIT_REGEX})$/
|
2016-01-14 15:22:15 -06:00
|
|
|
|
2016-01-15 09:41:24 -06:00
|
|
|
UNIT_ALIASES = {
|
|
|
|
cup: %w(cups c),
|
|
|
|
tablespoon: %w(tbsp tbs tablespoons),
|
|
|
|
teaspoon: %w(tsp teaspoons),
|
|
|
|
ounce: %w(oz ounces),
|
|
|
|
pound: %w(pounds lb lbs),
|
|
|
|
pint: %w(pints),
|
2016-01-20 15:07:37 -06:00
|
|
|
quart: %w(quarts qt),
|
2016-01-15 09:41:24 -06:00
|
|
|
gallon: %w(gallons ga),
|
|
|
|
|
|
|
|
gram: %w(grams g),
|
2016-01-18 20:50:19 -06:00
|
|
|
kilogram: %w(kilograms kg),
|
2016-01-20 15:07:37 -06:00
|
|
|
milliliter: %w(ml milliliters),
|
|
|
|
liter: %w(l liters)
|
2016-01-15 09:41:24 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
UNIT_ALIAS_MAP = Hash[UNIT_ALIASES.map { |unit, values| values.map { |v| [v, unit] } }.flatten(1)]
|
|
|
|
|
2016-01-20 15:07:37 -06:00
|
|
|
# map that gives comfortable ranges for a given set of units
|
|
|
|
UNIT_RANGES = {
|
|
|
|
teaspoon: (0.125...3.0),
|
|
|
|
tablespoon: (0.5...4.0),
|
|
|
|
cup: (0.25...4.0),
|
|
|
|
quart: (1.0...4.0),
|
|
|
|
gallon: (0.5..9999),
|
|
|
|
|
|
|
|
ounce: (0..16),
|
|
|
|
pound: (0.5..9999),
|
|
|
|
|
|
|
|
gram: (1.0..750),
|
|
|
|
kilogram: (0.5..9999),
|
|
|
|
|
|
|
|
milliliter: (1.0..750),
|
|
|
|
liter: (0.5..9999)
|
|
|
|
}
|
|
|
|
|
|
|
|
UNIT_ORDERS = {
|
|
|
|
standard_volume: [
|
|
|
|
:teaspoon,
|
|
|
|
:tablespoon,
|
|
|
|
:cup,
|
|
|
|
:quart,
|
|
|
|
:gallon
|
|
|
|
],
|
|
|
|
|
|
|
|
metric_volume: [
|
|
|
|
:milliliter,
|
|
|
|
:liter
|
|
|
|
],
|
|
|
|
|
|
|
|
standard_mass: [
|
|
|
|
:ounce,
|
|
|
|
:pound
|
|
|
|
],
|
|
|
|
|
|
|
|
metric_mass: [
|
|
|
|
:gram,
|
|
|
|
:kilogram
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
2016-01-14 15:22:15 -06:00
|
|
|
class UnparseableUnitError < StandardError
|
|
|
|
end
|
|
|
|
|
|
|
|
class UnknownUnitError < UnparseableUnitError
|
|
|
|
end
|
|
|
|
|
|
|
|
class << self
|
|
|
|
|
|
|
|
def parse(unit_string)
|
|
|
|
match = UNIT_PARSING_REGEX.match(unit_string.to_s.strip)
|
|
|
|
|
|
|
|
if match && match[:value].present? && match[:unit].present?
|
|
|
|
begin
|
2016-01-15 13:49:08 -06:00
|
|
|
value = get_value(match[:value])
|
|
|
|
Unitwise(value, normalize_unit_names(match[:unit]))
|
2016-01-14 15:22:15 -06:00
|
|
|
rescue Unitwise::ExpressionError => err
|
|
|
|
raise UnknownUnitError, err.message
|
|
|
|
end
|
|
|
|
else
|
|
|
|
raise UnparseableUnitError, "'#{unit_string}' does not appear to be a valid measurement of the form <value> <units> (ie '5 cup' or '223 gram/cup')"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def density?(unit)
|
|
|
|
unit.compatible_with? Unitwise(1, 'g/ml')
|
|
|
|
end
|
|
|
|
|
2016-01-15 16:09:34 -06:00
|
|
|
def volume?(unit)
|
|
|
|
unit.compatible_with? Unitwise(1, 'ml')
|
|
|
|
end
|
|
|
|
|
|
|
|
def mass?(unit)
|
|
|
|
unit.compatible_with? Unitwise(1, 'g')
|
|
|
|
end
|
|
|
|
|
2016-01-18 15:10:25 -06:00
|
|
|
# Returns a Unitwise representation of the density. Raises an exception if the value is anything other than a
|
|
|
|
# valid density
|
2016-01-15 16:09:34 -06:00
|
|
|
def get_density(str)
|
2016-01-18 15:10:25 -06:00
|
|
|
raise UnknownUnitError, 'No density provided' if str.blank?
|
|
|
|
begin
|
|
|
|
unit = parse(str)
|
|
|
|
rescue UnparseableUnitError => err
|
|
|
|
raise UnknownUnitError "Invalid density: #{err.message}"
|
|
|
|
end
|
|
|
|
|
|
|
|
raise UnknownUnitError, "Invalid density: #{str} is not a density" unless density?(unit)
|
2016-01-15 16:09:34 -06:00
|
|
|
unit
|
|
|
|
end
|
|
|
|
|
2016-01-15 09:41:24 -06:00
|
|
|
def normalize_unit_names(unit_description)
|
2016-01-15 13:49:08 -06:00
|
|
|
unit_description.downcase.gsub(/[a-z]+/) do |match|
|
2016-01-15 09:41:24 -06:00
|
|
|
UNIT_ALIAS_MAP[match] || match
|
|
|
|
end
|
2016-01-15 13:49:08 -06:00
|
|
|
end
|
|
|
|
|
|
|
|
def get_value(str)
|
2016-01-20 15:07:37 -06:00
|
|
|
if str =~ /^#{INTEGER_REGEX}$/
|
|
|
|
str.to_i
|
|
|
|
elsif str =~ /^#{RATIONAL_REGEX}$/
|
2016-01-15 13:49:08 -06:00
|
|
|
parts = str.split(' ')
|
|
|
|
if parts.length == 2
|
|
|
|
whole = parts.first.to_r
|
|
|
|
fractional = parts.last.to_r
|
|
|
|
(whole.abs + fractional) * (whole < 0 ? -1.to_r : 1.to_r)
|
|
|
|
else
|
|
|
|
str.to_r
|
|
|
|
end
|
|
|
|
elsif str =~ /^#{DECIMAL_REGEX}$/
|
|
|
|
str.to_d
|
|
|
|
else
|
|
|
|
raise UnparseableUnitError, "str (#{str}) is not a valid number"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2016-01-15 16:09:34 -06:00
|
|
|
def convert(quantity, factor, input_unit, output_unit, density = nil)
|
2016-01-15 13:49:08 -06:00
|
|
|
|
|
|
|
value = get_value(quantity)
|
|
|
|
factor = get_value(factor)
|
2016-01-15 09:41:24 -06:00
|
|
|
|
2016-01-15 13:49:08 -06:00
|
|
|
if value.is_a?(BigDecimal) && factor.is_a?(Rational)
|
|
|
|
factor = factor.to_d(10)
|
|
|
|
end
|
|
|
|
|
2016-01-15 16:09:34 -06:00
|
|
|
converted = value * factor
|
|
|
|
|
|
|
|
input_unit = normalize_unit_names(input_unit) unless input_unit.nil?
|
|
|
|
output_unit = normalize_unit_names(output_unit) unless output_unit.nil?
|
2016-01-15 13:49:08 -06:00
|
|
|
|
2016-01-18 12:58:54 -06:00
|
|
|
if input_unit.present? && output_unit.present? && input_unit != output_unit
|
2016-01-15 16:09:34 -06:00
|
|
|
in_unit = Unitwise(1, input_unit)
|
|
|
|
out_unit = Unitwise(1, output_unit)
|
|
|
|
unit = Unitwise(converted, input_unit)
|
2016-01-15 13:49:08 -06:00
|
|
|
|
2016-01-15 16:09:34 -06:00
|
|
|
if volume?(in_unit) && mass?(out_unit)
|
|
|
|
unit_density = get_density(density)
|
|
|
|
unit = unit * unit_density
|
|
|
|
elsif mass?(in_unit) && volume?(out_unit)
|
|
|
|
unit_density = get_density(density)
|
|
|
|
unit = unit / unit_density
|
|
|
|
end
|
|
|
|
|
|
|
|
converted = unit.convert_to(output_unit).value
|
|
|
|
end
|
2016-01-15 13:49:08 -06:00
|
|
|
|
2016-01-20 15:07:37 -06:00
|
|
|
quantity_format(converted, value)
|
|
|
|
end
|
|
|
|
|
|
|
|
def quantity_format(quantity, original_quantity)
|
|
|
|
if original_quantity.is_a?(Rational) || original_quantity.is_a?(Integer)
|
|
|
|
rational_val = rationalize(quantity)
|
2016-01-15 13:49:08 -06:00
|
|
|
if rational_val.denominator == 1
|
|
|
|
rational_val.to_i.to_s
|
2016-01-15 16:15:50 -06:00
|
|
|
elsif rational_val.denominator < rational_val.numerator.abs
|
|
|
|
whole = rational_val.floor
|
|
|
|
fraction = rational_val - whole
|
|
|
|
"#{whole} #{fraction}"
|
2016-01-15 13:49:08 -06:00
|
|
|
else
|
|
|
|
rational_val.to_s
|
|
|
|
end
|
|
|
|
else
|
2016-01-20 15:07:37 -06:00
|
|
|
"%g" % ("%.3f" % quantity)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def rationalize(value)
|
|
|
|
|
|
|
|
if value.is_a? Integer
|
|
|
|
return value
|
|
|
|
end
|
|
|
|
|
|
|
|
if value.floor == value
|
|
|
|
return value
|
|
|
|
end
|
|
|
|
|
|
|
|
useful_denominators = [2, 3, 4, 8, 16]
|
|
|
|
|
|
|
|
whole = value.floor
|
|
|
|
fraction = value - whole
|
|
|
|
|
|
|
|
approximations = useful_denominators.map do |d|
|
|
|
|
best_n = (1...d).each.map { |n| {delta: (fraction - Rational(n, d)).abs, numerator: n} }.sort { |a, b| a[:delta] <=> b[:delta] }.first
|
|
|
|
{denominator: d, numerator: best_n[:numerator], delta: best_n[:delta]}
|
|
|
|
end
|
|
|
|
|
|
|
|
# Add 0 and 1
|
|
|
|
approximations << { denominator: 1, numerator: 0, delta: (fraction - Rational(0,1)).abs }
|
|
|
|
approximations << { denominator: 1, numerator: 1, delta: (fraction - Rational(1,1)).abs }
|
|
|
|
|
|
|
|
best = approximations.sort { |a, b| [a[:delta], a[:denominator]] <=> [b[:delta], b[:denominator]] }.first
|
|
|
|
|
|
|
|
Rational((whole * best[:denominator]) + best[:numerator], best[:denominator])
|
|
|
|
end
|
|
|
|
|
|
|
|
# Given an awkward measurement such as '18 9/10 oz' or '5/24 cup' or '0.09434 cup',
|
|
|
|
# it will round the rational and scale the unit to give a more reasonable, useful measurement, ie
|
|
|
|
# 19 oz, 3 Tbsp, 1 1/2 Tbsp
|
|
|
|
def auto_unit(quantity, units)
|
|
|
|
# First scale the unit if it seems to use the wrong unit
|
|
|
|
normalized_unit = normalize_unit_names(units)
|
|
|
|
value = initial_value = get_value(quantity)
|
|
|
|
type_key, unit_orders = UNIT_ORDERS.detect { |key, orders| orders.include(normalized_unit.to_sym) }
|
|
|
|
new_unit = normalized_unit
|
|
|
|
|
|
|
|
if unit_orders && (unit_range = UNIT_RANGE[normalized_unit.to_sym])
|
|
|
|
|
|
|
|
if value < unit_range.first
|
|
|
|
unit_orders = unit_orders.reverse
|
|
|
|
end
|
|
|
|
|
|
|
|
idx = unit_orders.index(new_unit.to_sym)
|
|
|
|
|
|
|
|
while !unit_range.include?(value) && idx < (unit_orders.length - 1)
|
|
|
|
idx += 1
|
|
|
|
next_unit = unit_orders[idx]
|
|
|
|
value = Unitwise(value, new_unit).convert_to(next_unit).value
|
|
|
|
new_unit = next_unit
|
|
|
|
unit_range = UNIT_RANGE[new_unit.to_sym]
|
|
|
|
end
|
2016-01-15 13:49:08 -06:00
|
|
|
end
|
2016-01-20 15:07:37 -06:00
|
|
|
|
|
|
|
quantity_format(value, initial_value)
|
2016-01-15 09:41:24 -06:00
|
|
|
end
|
|
|
|
|
2016-01-14 15:22:15 -06:00
|
|
|
end
|
|
|
|
end
|