parsley/app/models/unit_conversion.rb

263 lines
7.5 KiB
Ruby
Raw Normal View History

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 = {
2016-01-20 18:37:28 -06:00
'[cup_us]': %w(cup cups c),
'[tbs_us]': %w(tablespoon tablespoons tbsp tbs),
'[tsp_us]': %w(teaspoon teaspoons tsp),
'[oz_av]': %w(ounce ounces oz),
'[lb_av]': %w(pound pounds lb lbs),
'[pt_us]': %w(pint pints),
'[qt_us]': %w(quart quarts qt),
'[gal_us]': %w(gallon gallons ga),
g: %w(gram grams),
kg: %w(kilograms kilogram),
ml: %w(milliliter milliliters),
l: %w(liter 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 = {
2016-01-20 18:37:28 -06:00
'[tsp_us]': (0.125...3.0),
'[tbs_us]': (0.5...4.0),
'[cup_us]': (0.25...4.0),
'[qt_us]': (1.0...4.0),
'[gal_us]': (0.5..9999),
2016-01-20 15:07:37 -06:00
2016-01-20 18:37:28 -06:00
'[oz_av]': (0..16),
'[lb_av]': (1.0..9999),
2016-01-20 15:07:37 -06:00
2016-01-20 18:37:28 -06:00
g: (1.0..750),
kg: (0.5..9999),
2016-01-20 15:07:37 -06:00
2016-01-20 18:37:28 -06:00
ml: (1.0..750),
l: (0.5..9999)
2016-01-20 15:07:37 -06:00
}
UNIT_ORDERS = {
standard_volume: [
2016-01-20 18:37:28 -06:00
:'[tsp_us]',
:'[tbs_us]',
:'[cup_us]',
:'[qt_us]',
:'[gal_us]'
2016-01-20 15:07:37 -06:00
],
metric_volume: [
2016-01-20 18:37:28 -06:00
:ml,
:l
2016-01-20 15:07:37 -06:00
],
standard_mass: [
2016-01-20 18:37:28 -06:00
:'[oz_av]',
:'[lb_av]'
2016-01-20 15:07:37 -06:00
],
metric_mass: [
2016-01-20 18:37:28 -06:00
:g,
:kg
2016-01-20 15:07:37 -06:00
]
}
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]
2016-01-20 18:37:28 -06:00
if value.is_a?(Rational) && useful_denominators.include?(value.denominator)
return value
end
2016-01-20 15:07:37 -06:00
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)
normalized_unit = normalize_unit_names(units)
value = initial_value = get_value(quantity)
2016-01-20 18:37:28 -06:00
type_key, unit_orders = UNIT_ORDERS.detect { |key, orders| orders.include?(normalized_unit.to_sym) }
2016-01-20 15:07:37 -06:00
new_unit = normalized_unit
2016-01-20 18:37:28 -06:00
if unit_orders && (unit_range = UNIT_RANGES[normalized_unit.to_sym])
2016-01-20 15:07:37 -06:00
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]
2016-01-20 18:37:28 -06:00
value = Unitwise(value, new_unit).convert_to(next_unit).value.round(4)
new_unit = next_unit.to_s
unit_range = UNIT_RANGES[new_unit.to_sym]
2016-01-20 15:07:37 -06:00
end
2016-01-15 13:49:08 -06:00
end
2016-01-20 15:07:37 -06:00
2016-01-20 18:37:28 -06:00
if normalized_unit == new_unit
new_unit = units
else
new_unit = (UNIT_ALIASES[new_unit.to_sym] || []).first || new_unit
end
return quantity_format(value, initial_value), new_unit
2016-01-15 09:41:24 -06:00
end
2016-01-14 15:22:15 -06:00
end
end