module UnitConversion INTEGER_REGEX = /-?[0-9]+/ DECIMAL_REGEX = /(?:-?[0-9]+)?\.[0-9]+/ RATIONAL_REGEX = /-?(?:[0-9]+ )?[0-9]+\/[0-9]+/ UNIT_REGEX = /[\[\]a-zA-Z][\[\]a-zA-Z\/.\-()0-9]*/ UNIT_PARSING_REGEX = /^(?#{INTEGER_REGEX}|#{DECIMAL_REGEX}|#{RATIONAL_REGEX})\s+(?#{UNIT_REGEX})$/ UNIT_ALIASES = { '[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) } UNIT_ALIAS_MAP = Hash[UNIT_ALIASES.map { |unit, values| values.map { |v| [v, unit] } }.flatten(1)] # map that gives comfortable ranges for a given set of units UNIT_RANGES = { '[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), '[oz_av]': (0..16), '[lb_av]': (1.0..9999), g: (1.0..750), kg: (0.5..9999), ml: (1.0..750), l: (0.5..9999) } UNIT_ORDERS = { standard_volume: [ :'[tsp_us]', :'[tbs_us]', :'[cup_us]', :'[qt_us]', :'[gal_us]' ], metric_volume: [ :ml, :l ], standard_mass: [ :'[oz_av]', :'[lb_av]' ], metric_mass: [ :g, :kg ] } 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 value = get_value(match[:value]) Unitwise(value, normalize_unit_names(match[:unit])) 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 (ie '5 cup' or '223 gram/cup')" end end def density?(unit) unit.compatible_with? Unitwise(1, 'g/ml') end def volume?(unit) unit.compatible_with? Unitwise(1, 'ml') end def mass?(unit) unit.compatible_with? Unitwise(1, 'g') end # Returns a Unitwise representation of the density. Raises an exception if the value is anything other than a # valid density def get_density(str) 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) unit end def normalize_unit_names(unit_description) unit_description.downcase.gsub(/[a-z]+/) do |match| UNIT_ALIAS_MAP[match] || match end end def get_value(str) if str =~ /^#{INTEGER_REGEX}$/ str.to_i elsif str =~ /^#{RATIONAL_REGEX}$/ 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 def convert(quantity, factor, input_unit, output_unit, density = nil) value = get_value(quantity) factor = get_value(factor) if value.is_a?(BigDecimal) && factor.is_a?(Rational) factor = factor.to_d(10) end 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? if input_unit.present? && output_unit.present? && input_unit != output_unit in_unit = Unitwise(1, input_unit) out_unit = Unitwise(1, output_unit) unit = Unitwise(converted, input_unit) 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 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) if rational_val.denominator == 1 rational_val.to_i.to_s elsif rational_val.denominator < rational_val.numerator.abs whole = rational_val.floor fraction = rational_val - whole "#{whole} #{fraction}" else rational_val.to_s end else "%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] if value.is_a?(Rational) && useful_denominators.include?(value.denominator) return value end 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) 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_RANGES[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.round(4) new_unit = next_unit.to_s unit_range = UNIT_RANGES[new_unit.to_sym] end end 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 end end end