lib/ruby_units/unit.rb
# frozen_string_literal: true
require "date"
module RubyUnits
# Copyright 2006-2024
# @author Kevin C. Olbrich, Ph.D.
# @see https://github.com/olbrich/ruby-units
#
# @note The accuracy of unit conversions depends on the precision of the conversion factor.
# If you have more accurate estimates for particular conversion factors, please send them
# to me and I will incorporate them into the next release. It is also incumbent on the end-user
# to ensure that the accuracy of any conversions is sufficient for their intended application.
#
# While there are a large number of unit specified in the base package,
# there are also a large number of units that are not included.
# This package covers nearly all SI, Imperial, and units commonly used
# in the United States. If your favorite units are not listed here, file an issue on GitHub.
#
# To add or override a unit definition, add a code block like this..
# @example Define a new unit
# RubyUnits::Unit.define("foobar") do |unit|
# unit.aliases = %w{foo fb foo-bar}
# unit.definition = RubyUnits::Unit.new("1 baz")
# end
#
class Unit < ::Numeric
class << self
# return a list of all defined units
# @return [Hash{Symbol=>RubyUnits::Units::Definition}]
attr_accessor :definitions
# @return [Hash{Symbol => String}] the list of units and their prefixes
attr_accessor :prefix_values
# @return [Hash{Symbol => String}]
attr_accessor :prefix_map
# @return [Hash{Symbol => String}]
attr_accessor :unit_map
# @return [Hash{Symbol => String}]
attr_accessor :unit_values
# @return [Hash{Integer => Symbol}]
attr_reader :kinds
end
self.definitions = {}
self.prefix_values = {}
self.prefix_map = {}
self.unit_map = {}
self.unit_values = {}
@unit_regex = nil
@unit_match_regex = nil
UNITY = "<1>"
UNITY_ARRAY = [UNITY].freeze
SIGN_REGEX = /(?:[+-])?/.freeze # +, -, or nothing
# regex for matching an integer number but not a fraction
INTEGER_DIGITS_REGEX = %r{(?<!/)\d+(?!/)}.freeze # 1, 2, 3, but not 1/2 or -1
INTEGER_REGEX = /(#{SIGN_REGEX}#{INTEGER_DIGITS_REGEX})/.freeze # -1, 1, +1, but not 1/2
UNSIGNED_INTEGER_REGEX = /((?<!-)#{INTEGER_DIGITS_REGEX})/.freeze # 1, 2, 3, but not -1
DIGITS_REGEX = /\d+/.freeze # 0, 1, 2, 3
DECIMAL_REGEX = /\d*[.]?#{DIGITS_REGEX}/.freeze # 1, 0.1, .1
# Rational number, including improper fractions: 1 2/3, -1 2/3, 5/3, etc.
RATIONAL_NUMBER = %r{\(?(?:(?<proper>#{SIGN_REGEX}#{DECIMAL_REGEX})[ -])?(?<numerator>#{SIGN_REGEX}#{DECIMAL_REGEX})/(?<denominator>#{SIGN_REGEX}#{DECIMAL_REGEX})\)?}.freeze # 1 2/3, -1 2/3, 5/3, 1-2/3, (1/2) etc.
# Scientific notation: 1, -1, +1, 1.2, +1.2, -1.2, 123.4E5, +123.4e5,
# -123.4E+5, -123.4e-5, etc.
SCI_NUMBER = /([+-]?\d*[.]?\d+(?:[Ee][+-]?\d+(?![.]))?)/.freeze
# ideally we would like to generate this regex from the alias for a 'feet'
# and 'inches', but they aren't defined at the point in the code where we
# need this regex.
FEET_INCH_UNITS_REGEX = /(?:'|ft|feet)\s*(?<inches>#{RATIONAL_NUMBER}|#{SCI_NUMBER})\s*(?:"|in|inch(?:es)?)/.freeze
FEET_INCH_REGEX = /(?<feet>#{INTEGER_REGEX})\s*#{FEET_INCH_UNITS_REGEX}/.freeze
# ideally we would like to generate this regex from the alias for a 'pound'
# and 'ounce', but they aren't defined at the point in the code where we
# need this regex.
LBS_OZ_UNIT_REGEX = /(?:#|lbs?|pounds?|pound-mass)+[\s,]*(?<oz>#{RATIONAL_NUMBER}|#{UNSIGNED_INTEGER_REGEX})\s*(?:ozs?|ounces?)/.freeze
LBS_OZ_REGEX = /(?<pounds>#{INTEGER_REGEX})\s*#{LBS_OZ_UNIT_REGEX}/.freeze
# ideally we would like to generate this regex from the alias for a 'stone'
# and 'pound', but they aren't defined at the point in the code where we
# need this regex. also note that the plural of 'stone' is still 'stone',
# but we accept 'stones' anyway.
STONE_LB_UNIT_REGEX = /(?:sts?|stones?)+[\s,]*(?<pounds>#{RATIONAL_NUMBER}|#{UNSIGNED_INTEGER_REGEX})\s*(?:#|lbs?|pounds?|pound-mass)*/.freeze
STONE_LB_REGEX = /(?<stone>#{INTEGER_REGEX})\s*#{STONE_LB_UNIT_REGEX}/.freeze
# Time formats: 12:34:56,78, (hh:mm:ss,msec) etc.
TIME_REGEX = /(?<hour>\d+):(?<min>\d+):?(?:(?<sec>\d+))?(?:[.](?<msec>\d+))?/.freeze
# Complex numbers: 1+2i, 1.0+2.0i, -1-1i, etc.
COMPLEX_NUMBER = /(?<real>#{SCI_NUMBER})?(?<imaginary>#{SCI_NUMBER})i\b/.freeze
# Any Complex, Rational, or scientific number
ANY_NUMBER = /(#{COMPLEX_NUMBER}|#{RATIONAL_NUMBER}|#{SCI_NUMBER})/.freeze
ANY_NUMBER_REGEX = /(?:#{ANY_NUMBER})?\s?([^-\d.].*)?/.freeze
NUMBER_REGEX = /(?<scalar>#{SCI_NUMBER}*)\s*(?<unit>.+)?/.freeze # a number followed by a unit
UNIT_STRING_REGEX = %r{#{SCI_NUMBER}*\s*([^/]*)/*(.+)*}.freeze
TOP_REGEX = /([^ *]+)(?:\^|\*\*)([\d-]+)/.freeze
BOTTOM_REGEX = /([^* ]+)(?:\^|\*\*)(\d+)/.freeze
NUMBER_UNIT_REGEX = /#{SCI_NUMBER}?(.*)/.freeze
COMPLEX_REGEX = /#{COMPLEX_NUMBER}\s?(?<unit>.+)?/.freeze
RATIONAL_REGEX = /#{RATIONAL_NUMBER}\s?(?<unit>.+)?/.freeze
KELVIN = ["<kelvin>"].freeze
FAHRENHEIT = ["<fahrenheit>"].freeze
RANKINE = ["<rankine>"].freeze
CELSIUS = ["<celsius>"].freeze
@temp_regex = nil
SIGNATURE_VECTOR = %i[
length
time
temperature
mass
current
substance
luminosity
currency
information
angle
].freeze
@kinds = {
-312_078 => :elastance,
-312_058 => :resistance,
-312_038 => :inductance,
-152_040 => :magnetism,
-152_038 => :magnetism,
-152_058 => :potential,
-7997 => :specific_volume,
-79 => :snap,
-59 => :jolt,
-39 => :acceleration,
-38 => :radiation,
-20 => :frequency,
-19 => :speed,
-18 => :viscosity,
-17 => :volumetric_flow,
-1 => :wavenumber,
0 => :unitless,
1 => :length,
2 => :area,
3 => :volume,
20 => :time,
400 => :temperature,
7941 => :yank,
7942 => :power,
7959 => :pressure,
7962 => :energy,
7979 => :viscosity,
7961 => :force,
7981 => :momentum,
7982 => :angular_momentum,
7997 => :density,
7998 => :area_density,
8000 => :mass,
152_020 => :radiation_exposure,
159_999 => :magnetism,
160_000 => :current,
160_020 => :charge,
312_058 => :conductance,
312_078 => :capacitance,
3_199_980 => :activity,
3_199_997 => :molar_concentration,
3_200_000 => :substance,
63_999_998 => :illuminance,
64_000_000 => :luminous_power,
1_280_000_000 => :currency,
25_600_000_000 => :information,
511_999_999_980 => :angular_velocity,
512_000_000_000 => :angle
}.freeze
# Class Methods
# Callback triggered when a subclass is created. This properly sets up the internal variables, and copies
# definitions from the parent class.
#
# @param [Class] subclass
def self.inherited(subclass)
super
subclass.definitions = definitions.dup
subclass.instance_variable_set(:@kinds, @kinds.dup)
subclass.setup
end
# setup internal arrays and hashes
# @return [Boolean]
def self.setup
clear_cache
self.prefix_values = {}
self.prefix_map = {}
self.unit_map = {}
self.unit_values = {}
@unit_regex = nil
@unit_match_regex = nil
@prefix_regex = nil
definitions.each_value do |definition|
use_definition(definition)
end
new(1)
true
end
# determine if a unit is already defined
# @param [String] unit
# @return [Boolean]
def self.defined?(unit)
definitions.values.any? { _1.aliases.include?(unit) }
end
# return the unit definition for a unit
# @param unit_name [String]
# @return [RubyUnits::Unit::Definition, nil]
def self.definition(unit_name)
unit = unit_name =~ /^<.+>$/ ? unit_name : "<#{unit_name}>"
definitions[unit]
end
# @param [RubyUnits::Unit::Definition, String] unit_definition
# @param [Proc] block
# @return [RubyUnits::Unit::Definition]
# @raise [ArgumentError] when passed a non-string if using the block form
# Unpack a unit definition and add it to the array of defined units
#
# @example Block form
# RubyUnits::Unit.define('foobar') do |foobar|
# foobar.definition = RubyUnits::Unit.new("1 baz")
# end
#
# @example RubyUnits::Unit::Definition form
# unit_definition = RubyUnits::Unit::Definition.new("foobar") {|foobar| foobar.definition = RubyUnits::Unit.new("1 baz")}
# RubyUnits::Unit.define(unit_definition)
def self.define(unit_definition, &block)
if block_given?
raise ArgumentError, "When using the block form of RubyUnits::Unit.define, pass the name of the unit" unless unit_definition.is_a?(String)
unit_definition = RubyUnits::Unit::Definition.new(unit_definition, &block)
end
definitions[unit_definition.name] = unit_definition
use_definition(unit_definition)
unit_definition
end
# Get the definition for a unit and allow it to be redefined
#
# @param [String] name Name of unit to redefine
# @param [Proc] _block
# @raise [ArgumentError] if a block is not given
# @yieldparam [RubyUnits::Unit::Definition] the definition of the unit being
# redefined
# @return (see RubyUnits::Unit.define)
def self.redefine!(name, &_block)
raise ArgumentError, "A block is required to redefine a unit" unless block_given?
unit_definition = definition(name)
raise(ArgumentError, "'#{name}' Unit not recognized") unless unit_definition
yield unit_definition
definitions.delete("<#{name}>")
define(unit_definition)
setup
end
# Undefine a unit. Will not raise an exception for unknown units.
#
# @param unit [String] name of unit to undefine
# @return (see RubyUnits::Unit.setup)
def self.undefine!(unit)
definitions.delete("<#{unit}>")
setup
end
# Unit cache
#
# @return [RubyUnits::Cache]
def self.cached
@cached ||= RubyUnits::Cache.new
end
# @return [Boolean]
def self.clear_cache
cached.clear
base_unit_cache.clear
new(1)
true
end
# @return [RubyUnits::Cache]
def self.base_unit_cache
@base_unit_cache ||= RubyUnits::Cache.new
end
# @example parse strings
# "1 minute in seconds"
# @param [String] input
# @return [Unit]
def self.parse(input)
first, second = input.scan(/(.+)\s(?:in|to|as)\s(.+)/i).first
second.nil? ? new(first) : new(first).convert_to(second)
end
# @param q [Numeric] quantity
# @param n [Array] numerator
# @param d [Array] denominator
# @return [Hash]
def self.eliminate_terms(q, n, d)
num = n.dup
den = d.dup
num.delete(UNITY)
den.delete(UNITY)
combined = ::Hash.new(0)
[[num, 1], [den, -1]].each do |array, increment|
array.chunk_while { |elt_before, _| definition(elt_before).prefix? }
.to_a
.each { combined[_1] += increment }
end
num = []
den = []
combined.each do |key, value|
if value.positive?
value.times { num << key }
elsif value.negative?
value.abs.times { den << key }
end
end
num = UNITY_ARRAY if num.empty?
den = UNITY_ARRAY if den.empty?
{ scalar: q, numerator: num.flatten, denominator: den.flatten }
end
# Creates a new unit from the current one with all common terms eliminated.
#
# @return [RubyUnits::Unit]
def eliminate_terms
self.class.new(self.class.eliminate_terms(@scalar, @numerator, @denominator))
end
# return an array of base units
# @return [Array]
def self.base_units
@base_units ||= definitions.dup.select { |_, definition| definition.base? }.keys.map { new(_1) }
end
# Parse a string consisting of a number and a unit string
# NOTE: This does not properly handle units formatted like '12mg/6ml'
#
# @param [String] string
# @return [Array(Numeric, String)] consisting of [number, "unit"]
def self.parse_into_numbers_and_units(string)
num, unit = string.scan(ANY_NUMBER_REGEX).first
[
case num
when nil # This happens when no number is passed and we are parsing a pure unit string
1
when COMPLEX_NUMBER
num.to_c
when RATIONAL_NUMBER
# We use this method instead of relying on `to_r` because it does not
# handle improper fractions correctly.
sign = Regexp.last_match(1) == "-" ? -1 : 1
n = Regexp.last_match(2).to_i
f = Rational(Regexp.last_match(3).to_i, Regexp.last_match(4).to_i)
sign * (n + f)
else
num.to_f
end,
unit.to_s.strip
]
end
# return a fragment of a regex to be used for matching units or reconstruct it if hasn't been used yet.
# Unit names are reverse sorted by length so the regexp matcher will prefer longer and more specific names
# @return [String]
def self.unit_regex
@unit_regex ||= unit_map.keys.sort_by { [_1.length, _1] }.reverse.join("|")
end
# return a regex used to match units
# @return [Regexp]
def self.unit_match_regex
@unit_match_regex ||= /(#{prefix_regex})??(#{unit_regex})\b/
end
# return a regexp fragment used to match prefixes
# @return [String]
# @private
def self.prefix_regex
@prefix_regex ||= prefix_map.keys.sort_by { [_1.length, _1] }.reverse.join("|")
end
# Generates (and memoizes) a regexp matching any of the temperature units or their aliases.
#
# @return [Regexp]
def self.temp_regex
@temp_regex ||= begin
temp_units = %w[tempK tempC tempF tempR degK degC degF degR]
aliases = temp_units.map do |unit|
d = definition(unit)
d && d.aliases
end.flatten.compact
regex_str = aliases.empty? ? "(?!x)x" : aliases.join("|")
Regexp.new "(?:#{regex_str})"
end
end
# inject a definition into the internal array and set it up for use
#
# @param definition [RubyUnits::Unit::Definition]
def self.use_definition(definition)
@unit_match_regex = nil # invalidate the unit match regex
@temp_regex = nil # invalidate the temp regex
if definition.prefix?
prefix_values[definition.name] = definition.scalar
definition.aliases.each { prefix_map[_1] = definition.name }
@prefix_regex = nil # invalidate the prefix regex
else
unit_values[definition.name] = {}
unit_values[definition.name][:scalar] = definition.scalar
unit_values[definition.name][:numerator] = definition.numerator if definition.numerator
unit_values[definition.name][:denominator] = definition.denominator if definition.denominator
definition.aliases.each { unit_map[_1] = definition.name }
@unit_regex = nil # invalidate the unit regex
end
end
include Comparable
# @return [Numeric]
attr_accessor :scalar
# @return [Array]
attr_accessor :numerator
# @return [Array]
attr_accessor :denominator
# @return [Integer]
attr_accessor :signature
# @return [Numeric]
attr_accessor :base_scalar
# @return [Array]
attr_accessor :base_numerator
# @return [Array]
attr_accessor :base_denominator
# @return [String]
attr_accessor :output
# @return [String]
attr_accessor :unit_name
# Used to copy one unit to another
# @param from [RubyUnits::Unit] Unit to copy definition from
# @return [RubyUnits::Unit]
def copy(from)
@scalar = from.scalar
@numerator = from.numerator
@denominator = from.denominator
@base = from.base?
@signature = from.signature
@base_scalar = from.base_scalar
@unit_name = from.unit_name
self
end
# Create a new Unit object. Can be initialized using a String, a Hash, an Array, Time, DateTime
#
# @example Valid options include:
# "5.6 kg*m/s^2"
# "5.6 kg*m*s^-2"
# "5.6 kilogram*meter*second^-2"
# "2.2 kPa"
# "37 degC"
# "1" -- creates a unitless constant with value 1
# "GPa" -- creates a unit with scalar 1 with units 'GPa'
# "6'4\""" -- recognized as 6 feet + 4 inches
# "8 lbs 8 oz" -- recognized as 8 lbs + 8 ounces
# [1, 'kg']
# {scalar: 1, numerator: 'kg'}
#
# @param [Unit,String,Hash,Array,Date,Time,DateTime] options
# @return [Unit]
# @raise [ArgumentError] if absolute value of a temperature is less than absolute zero
# @raise [ArgumentError] if no unit is specified
# @raise [ArgumentError] if an invalid unit is specified
def initialize(*options)
@scalar = nil
@base_scalar = nil
@unit_name = nil
@signature = nil
@output = {}
raise ArgumentError, "Invalid Unit Format" if options[0].nil?
if options.size == 2
# options[0] is the scalar
# options[1] is a unit string
cached = self.class.cached.get(options[1])
if cached.nil?
initialize("#{options[0]} #{options[1]}")
else
copy(cached * options[0])
end
return
end
if options.size == 3
options[1] = options[1].join if options[1].is_a?(Array)
options[2] = options[2].join if options[2].is_a?(Array)
cached = self.class.cached.get("#{options[1]}/#{options[2]}")
if cached.nil?
initialize("#{options[0]} #{options[1]}/#{options[2]}")
else
copy(cached) * options[0]
end
return
end
case options[0]
when Unit
copy(options[0])
return
when Hash
@scalar = options[0][:scalar] || 1
@numerator = options[0][:numerator] || UNITY_ARRAY
@denominator = options[0][:denominator] || UNITY_ARRAY
@signature = options[0][:signature]
when Array
initialize(*options[0])
return
when Numeric
@scalar = options[0]
@numerator = @denominator = UNITY_ARRAY
when Time
@scalar = options[0].to_f
@numerator = ["<second>"]
@denominator = UNITY_ARRAY
when DateTime, Date
@scalar = options[0].ajd
@numerator = ["<day>"]
@denominator = UNITY_ARRAY
when /^\s*$/
raise ArgumentError, "No Unit Specified"
when String
parse(options[0])
else
raise ArgumentError, "Invalid Unit Format"
end
update_base_scalar
raise ArgumentError, "Temperatures must not be less than absolute zero" if temperature? && base_scalar.negative?
unary_unit = units || ""
if options.first.instance_of?(String)
_opt_scalar, opt_units = self.class.parse_into_numbers_and_units(options[0])
if !(self.class.cached.keys.include?(opt_units) ||
(opt_units =~ %r{\D/[\d+.]+}) ||
(opt_units =~ %r{(#{self.class.temp_regex})|(#{STONE_LB_UNIT_REGEX})|(#{LBS_OZ_UNIT_REGEX})|(#{FEET_INCH_UNITS_REGEX})|%|(#{TIME_REGEX})|i\s?(.+)?|±|\+/-})) && (opt_units && !opt_units.empty?)
self.class.cached.set(opt_units, scalar == 1 ? self : opt_units.to_unit)
end
end
unless self.class.cached.keys.include?(unary_unit) || (unary_unit =~ self.class.temp_regex)
self.class.cached.set(unary_unit, scalar == 1 ? self : unary_unit.to_unit)
end
[@scalar, @numerator, @denominator, @base_scalar, @signature, @base].each(&:freeze)
super()
end
# @todo: figure out how to handle :counting units. This method should probably return :counting instead of :unitless for 'each'
# return the kind of the unit (:mass, :length, etc...)
# @return [Symbol]
def kind
self.class.kinds[signature]
end
# Convert the unit to a Unit, possibly performing a conversion.
# > The ability to pass a Unit to convert to was added in v3.0.0 for
# > consistency with other uses of #to_unit.
#
# @param other [RubyUnits::Unit, String] unit to convert to
# @return [RubyUnits::Unit]
def to_unit(other = nil)
other ? convert_to(other) : self
end
alias unit to_unit
# Is this unit in base form?
# @return [Boolean]
def base?
return @base if defined? @base
@base = (@numerator + @denominator)
.compact
.uniq
.map { self.class.definition(_1) }
.all? { _1.unity? || _1.base? }
@base
end
alias is_base? base?
# convert to base SI units
# results of the conversion are cached so subsequent calls to this will be fast
# @return [Unit]
# @todo this is brittle as it depends on the display_name of a unit, which can be changed
def to_base
return self if base?
if self.class.unit_map[units] =~ /\A<(?:temp|deg)[CRF]>\Z/
@signature = self.class.kinds.key(:temperature)
base = if temperature?
convert_to("tempK")
elsif degree?
convert_to("degK")
end
return base
end
cached_unit = self.class.base_unit_cache.get(units)
return cached_unit * scalar unless cached_unit.nil?
num = []
den = []
q = Rational(1)
@numerator.compact.each do |num_unit|
if self.class.prefix_values[num_unit]
q *= self.class.prefix_values[num_unit]
else
q *= self.class.unit_values[num_unit][:scalar] if self.class.unit_values[num_unit]
num << self.class.unit_values[num_unit][:numerator] if self.class.unit_values[num_unit] && self.class.unit_values[num_unit][:numerator]
den << self.class.unit_values[num_unit][:denominator] if self.class.unit_values[num_unit] && self.class.unit_values[num_unit][:denominator]
end
end
@denominator.compact.each do |num_unit|
if self.class.prefix_values[num_unit]
q /= self.class.prefix_values[num_unit]
else
q /= self.class.unit_values[num_unit][:scalar] if self.class.unit_values[num_unit]
den << self.class.unit_values[num_unit][:numerator] if self.class.unit_values[num_unit] && self.class.unit_values[num_unit][:numerator]
num << self.class.unit_values[num_unit][:denominator] if self.class.unit_values[num_unit] && self.class.unit_values[num_unit][:denominator]
end
end
num = num.flatten.compact
den = den.flatten.compact
num = UNITY_ARRAY if num.empty?
base = self.class.new(self.class.eliminate_terms(q, num, den))
self.class.base_unit_cache.set(units, base)
base * @scalar
end
alias base to_base
# Generate human readable output.
# If the name of a unit is passed, the unit will first be converted to the target unit before output.
# some named conversions are available
#
# @example
# unit.to_s(:ft) - outputs in feet and inches (e.g., 6'4")
# unit.to_s(:lbs) - outputs in pounds and ounces (e.g, 8 lbs, 8 oz)
#
# You can also pass a standard format string (i.e., '%0.2f')
# or a strftime format string.
#
# output is cached so subsequent calls for the same format will be fast
#
# @note Rational scalars that are equal to an integer will be represented as integers (i.e, 6/1 => 6, 4/2 => 2, etc..)
# @param [Symbol] target_units
# @param [Float] precision - the precision to use when converting to a rational
# @param format [Symbol] Set to :exponential to force all units to be displayed in exponential format
#
# @return [String]
def to_s(target_units = nil, precision: 0.0001, format: RubyUnits.configuration.format)
out = @output[target_units]
return out if out
separator = RubyUnits.configuration.separator
case target_units
when :ft
feet, inches = convert_to("in").scalar.abs.divmod(12)
improper, frac = inches.divmod(1)
frac = frac.zero? ? "" : "-#{frac.rationalize(precision)}"
out = "#{negative? ? '-' : nil}#{feet}'#{improper}#{frac}\""
when :lbs
pounds, ounces = convert_to("oz").scalar.abs.divmod(16)
improper, frac = ounces.divmod(1)
frac = frac.zero? ? "" : "-#{frac.rationalize(precision)}"
out = "#{negative? ? '-' : nil}#{pounds}#{separator}lbs #{improper}#{frac}#{separator}oz"
when :stone
stone, pounds = convert_to("lbs").scalar.abs.divmod(14)
improper, frac = pounds.divmod(1)
frac = frac.zero? ? "" : "-#{frac.rationalize(precision)}"
out = "#{negative? ? '-' : nil}#{stone}#{separator}stone #{improper}#{frac}#{separator}lbs"
when String
out = case target_units.strip
when /\A\s*\Z/ # whitespace only
""
when /(%[-+.\w#]+)\s*(.+)*/ # format string like '%0.2f in'
begin
if Regexp.last_match(2) # unit specified, need to convert
convert_to(Regexp.last_match(2)).to_s(Regexp.last_match(1), format: format)
else
"#{Regexp.last_match(1) % @scalar}#{separator}#{Regexp.last_match(2) || units(format: format)}".strip
end
rescue StandardError # parse it like a strftime format string
(DateTime.new(0) + self).strftime(target_units)
end
when /(\S+)/ # unit only 'mm' or '1/mm'
convert_to(Regexp.last_match(1)).to_s(format: format)
else
raise "unhandled case"
end
else
out = case @scalar
when Complex
"#{@scalar}#{separator}#{units(format: format)}"
when Rational
"#{@scalar == @scalar.to_i ? @scalar.to_i : @scalar}#{separator}#{units(format: format)}"
else
"#{'%g' % @scalar}#{separator}#{units(format: format)}"
end.strip
end
@output[target_units] = out
out
end
# Normally pretty prints the unit, but if you really want to see the guts of it, pass ':dump'
# @deprecated
# @return [String]
def inspect(dump = nil)
return super() if dump
to_s
end
# true if unit is a 'temperature', false if a 'degree' or anything else
# @return [Boolean]
# @todo use unit definition to determine if it's a temperature instead of a regex
def temperature?
degree? && units.match?(self.class.temp_regex)
end
alias is_temperature? temperature?
# true if a degree unit or equivalent.
# @return [Boolean]
def degree?
kind == :temperature
end
alias is_degree? degree?
# returns the 'degree' unit associated with a temperature unit
# @example '100 tempC'.to_unit.temperature_scale #=> 'degC'
# @return [String] possible values: degC, degF, degR, or degK
def temperature_scale
return nil unless temperature?
"deg#{self.class.unit_map[units][/temp([CFRK])/, 1]}"
end
# returns true if no associated units
# false, even if the units are "unitless" like 'radians, each, etc'
# @return [Boolean]
def unitless?
@numerator == UNITY_ARRAY && @denominator == UNITY_ARRAY
end
# Compare two Unit objects. Throws an exception if they are not of compatible types.
# Comparisons are done based on the value of the unit in base SI units.
# @param [Object] other
# @return [Integer,nil]
# @raise [NoMethodError] when other does not define <=>
# @raise [ArgumentError] when units are not compatible
def <=>(other)
raise NoMethodError, "undefined method `<=>' for #{base_scalar.inspect}" unless base_scalar.respond_to?(:<=>)
if other.nil?
base_scalar <=> nil
elsif !temperature? && other.respond_to?(:zero?) && other.zero?
base_scalar <=> 0
elsif other.instance_of?(Unit)
raise ArgumentError, "Incompatible Units ('#{units}' not compatible with '#{other.units}')" unless self =~ other
base_scalar <=> other.base_scalar
else
x, y = coerce(other)
y <=> x
end
end
# Compare Units for equality
# this is necessary mostly for Complex units. Complex units do not have a <=> operator
# so we define this one here so that we can properly check complex units for equality.
# Units of incompatible types are not equal, except when they are both zero and neither is a temperature
# Equality checks can be tricky since round off errors may make essentially equivalent units
# appear to be different.
# @param [Object] other
# @return [Boolean]
def ==(other)
if other.respond_to?(:zero?) && other.zero?
zero?
elsif other.instance_of?(Unit)
return false unless self =~ other
base_scalar == other.base_scalar
else
begin
x, y = coerce(other)
x == y
rescue ArgumentError # return false when object cannot be coerced
false
end
end
end
# Check to see if units are compatible, ignoring the scalar part. This check is done by comparing unit signatures
# for performance reasons. If passed a string, this will create a [Unit] object with the string and then do the
# comparison.
#
# @example this permits a syntax like:
# unit =~ "mm"
# @note if you want to do a regexp comparison of the unit string do this ...
# unit.units =~ /regexp/
# @param [Object] other
# @return [Boolean]
def =~(other)
return signature == other.signature if other.is_a?(Unit)
x, y = coerce(other)
x =~ y
rescue ArgumentError # return false when `other` cannot be converted to a [Unit]
false
end
alias compatible? =~
alias compatible_with? =~
# Compare two units. Returns true if quantities and units match
# @example
# RubyUnits::Unit.new("100 cm") === RubyUnits::Unit.new("100 cm") # => true
# RubyUnits::Unit.new("100 cm") === RubyUnits::Unit.new("1 m") # => false
# @param [Object] other
# @return [Boolean]
def ===(other)
case other
when Unit
(scalar == other.scalar) && (units == other.units)
else
begin
x, y = coerce(other)
x.same_as?(y)
rescue ArgumentError
false
end
end
end
alias same? ===
alias same_as? ===
# Add two units together. Result is same units as receiver and scalar and base_scalar are updated appropriately
# throws an exception if the units are not compatible.
# It is possible to add Time objects to units of time
# @param [Object] other
# @return [Unit]
# @raise [ArgumentError] when two temperatures are added
# @raise [ArgumentError] when units are not compatible
# @raise [ArgumentError] when adding a fixed time or date to a time span
def +(other)
case other
when Unit
if zero?
other.dup
elsif self =~ other
raise ArgumentError, "Cannot add two temperatures" if [self, other].all?(&:temperature?)
if temperature?
self.class.new(scalar: (scalar + other.convert_to(temperature_scale).scalar), numerator: @numerator, denominator: @denominator, signature: @signature)
elsif other.temperature?
self.class.new(scalar: (other.scalar + convert_to(other.temperature_scale).scalar), numerator: other.numerator, denominator: other.denominator, signature: other.signature)
else
self.class.new(scalar: (base_scalar + other.base_scalar), numerator: base.numerator, denominator: base.denominator, signature: @signature).convert_to(self)
end
else
raise ArgumentError, "Incompatible Units ('#{self}' not compatible with '#{other}')"
end
when Date, Time
raise ArgumentError, "Date and Time objects represent fixed points in time and cannot be added to a Unit"
else
x, y = coerce(other)
y + x
end
end
# Subtract two units. Result is same units as receiver and scalar and base_scalar are updated appropriately
# @param [Numeric] other
# @return [Unit]
# @raise [ArgumentError] when subtracting a temperature from a degree
# @raise [ArgumentError] when units are not compatible
# @raise [ArgumentError] when subtracting a fixed time from a time span
def -(other)
case other
when Unit
if zero?
if other.zero?
other.dup * -1 # preserve Units class
else
-other.dup
end
elsif self =~ other
if [self, other].all?(&:temperature?)
self.class.new(scalar: (base_scalar - other.base_scalar), numerator: KELVIN, denominator: UNITY_ARRAY, signature: @signature).convert_to(temperature_scale)
elsif temperature?
self.class.new(scalar: (base_scalar - other.base_scalar), numerator: ["<tempK>"], denominator: UNITY_ARRAY, signature: @signature).convert_to(self)
elsif other.temperature?
raise ArgumentError, "Cannot subtract a temperature from a differential degree unit"
else
self.class.new(scalar: (base_scalar - other.base_scalar), numerator: base.numerator, denominator: base.denominator, signature: @signature).convert_to(self)
end
else
raise ArgumentError, "Incompatible Units ('#{self}' not compatible with '#{other}')"
end
when Time
raise ArgumentError, "Date and Time objects represent fixed points in time and cannot be subtracted from a Unit"
else
x, y = coerce(other)
y - x
end
end
# Multiply two units.
# @param [Numeric] other
# @return [Unit]
# @raise [ArgumentError] when attempting to multiply two temperatures
def *(other)
case other
when Unit
raise ArgumentError, "Cannot multiply by temperatures" if [other, self].any?(&:temperature?)
opts = self.class.eliminate_terms(@scalar * other.scalar, @numerator + other.numerator, @denominator + other.denominator)
opts[:signature] = @signature + other.signature
self.class.new(opts)
when Numeric
self.class.new(scalar: @scalar * other, numerator: @numerator, denominator: @denominator, signature: @signature)
else
x, y = coerce(other)
x * y
end
end
# Divide two units.
# Throws an exception if divisor is 0
# @param [Numeric] other
# @return [Unit]
# @raise [ZeroDivisionError] if divisor is zero
# @raise [ArgumentError] if attempting to divide a temperature by another temperature
def /(other)
case other
when Unit
raise ZeroDivisionError if other.zero?
raise ArgumentError, "Cannot divide with temperatures" if [other, self].any?(&:temperature?)
sc = Rational(@scalar, other.scalar)
sc = sc.numerator if sc.denominator == 1
opts = self.class.eliminate_terms(sc, @numerator + other.denominator, @denominator + other.numerator)
opts[:signature] = @signature - other.signature
self.class.new(opts)
when Numeric
raise ZeroDivisionError if other.zero?
sc = Rational(@scalar, other)
sc = sc.numerator if sc.denominator == 1
self.class.new(scalar: sc, numerator: @numerator, denominator: @denominator, signature: @signature)
else
x, y = coerce(other)
y / x
end
end
# Returns the remainder when one unit is divided by another
#
# @param [Unit] other
# @return [Unit]
# @raise [ArgumentError] if units are not compatible
def remainder(other)
raise ArgumentError, "Incompatible Units ('#{self}' not compatible with '#{other}')" unless compatible_with?(other)
self.class.new(base_scalar.remainder(other.to_unit.base_scalar), to_base.units).convert_to(self)
end
# Divide two units and return quotient and remainder
#
# @param [Unit] other
# @return [Array(Integer, Unit)]
# @raise [ArgumentError] if units are not compatible
def divmod(other)
raise ArgumentError, "Incompatible Units ('#{self}' not compatible with '#{other}')" unless compatible_with?(other)
[quo(other).to_base.floor, self % other]
end
# Perform a modulo on a unit, will raise an exception if the units are not compatible
#
# @param [Unit] other
# @return [Integer]
# @raise [ArgumentError] if units are not compatible
def %(other)
raise ArgumentError, "Incompatible Units ('#{self}' not compatible with '#{other}')" unless compatible_with?(other)
self.class.new(base_scalar % other.to_unit.base_scalar, to_base.units).convert_to(self)
end
alias modulo %
# @param [Object] other
# @return [Unit]
# @raise [ZeroDivisionError] if other is zero
def quo(other)
self / other
end
alias fdiv quo
# Exponentiation. Only takes integer powers.
# Note that anything raised to the power of 0 results in a [Unit] object with a scalar of 1, and no units.
# Throws an exception if exponent is not an integer.
# Ideally this routine should accept a float for the exponent
# It should then convert the float to a rational and raise the unit by the numerator and root it by the denominator
# but, sadly, floats can't be converted to rationals.
#
# For now, if a rational is passed in, it will be used, otherwise we are stuck with integers and certain floats < 1
# @param [Numeric] other
# @return [Unit]
# @raise [ArgumentError] when raising a temperature to a power
# @raise [ArgumentError] when n not in the set integers from (1..9)
# @raise [ArgumentError] when attempting to raise to a complex number
# @raise [ArgumentError] when an invalid exponent is passed
def **(other)
raise ArgumentError, "Cannot raise a temperature to a power" if temperature?
if other.is_a?(Numeric)
return inverse if other == -1
return self if other == 1
return 1 if other.zero?
end
case other
when Rational
power(other.numerator).root(other.denominator)
when Integer
power(other)
when Float
return self**other.to_i if other == other.to_i
valid = (1..9).map { Rational(1, _1) }
raise ArgumentError, "Not a n-th root (1..9), use 1/n" unless valid.include? other.abs
root(Rational(1, other).to_int)
when Complex
raise ArgumentError, "exponentiation of complex numbers is not supported."
else
raise ArgumentError, "Invalid Exponent"
end
end
# returns the unit raised to the n-th power
# @param [Integer] n
# @return [Unit]
# @raise [ArgumentError] when attempting to raise a temperature to a power
# @raise [ArgumentError] when n is not an integer
def power(n)
raise ArgumentError, "Cannot raise a temperature to a power" if temperature?
raise ArgumentError, "Exponent must an Integer" unless n.is_a?(Integer)
return inverse if n == -1
return 1 if n.zero?
return self if n == 1
return (1..(n - 1).to_i).inject(self) { |acc, _elem| acc * self } if n >= 0
(1..-(n - 1).to_i).inject(self) { |acc, _elem| acc / self }
end
# Calculates the n-th root of a unit
# if n < 0, returns 1/unit^(1/n)
# @param [Integer] n
# @return [Unit]
# @raise [ArgumentError] when attempting to take the root of a temperature
# @raise [ArgumentError] when n is not an integer
# @raise [ArgumentError] when n is 0
def root(n)
raise ArgumentError, "Cannot take the root of a temperature" if temperature?
raise ArgumentError, "Exponent must an Integer" unless n.is_a?(Integer)
raise ArgumentError, "0th root undefined" if n.zero?
return self if n == 1
return root(n.abs).inverse if n.negative?
vec = unit_signature_vector
vec = vec.map { _1 % n }
raise ArgumentError, "Illegal root" unless vec.max.zero?
num = @numerator.dup
den = @denominator.dup
@numerator.uniq.each do |item|
x = num.find_all { _1 == item }.size
r = ((x / n) * (n - 1)).to_int
r.times { num.delete_at(num.index(item)) }
end
@denominator.uniq.each do |item|
x = den.find_all { _1 == item }.size
r = ((x / n) * (n - 1)).to_int
r.times { den.delete_at(den.index(item)) }
end
self.class.new(scalar: @scalar**Rational(1, n), numerator: num, denominator: den)
end
# returns inverse of Unit (1/unit)
# @return [Unit]
def inverse
self.class.new("1") / self
end
# convert to a specified unit string or to the same units as another Unit
#
# unit.convert_to "kg" will covert to kilograms
# unit1.convert_to unit2 converts to same units as unit2 object
#
# To convert a Unit object to match another Unit object, use:
# unit1 >>= unit2
#
# Special handling for temperature conversions is supported. If the Unit
# object is converted from one temperature unit to another, the proper
# temperature offsets will be used. Supports Kelvin, Celsius, Fahrenheit,
# and Rankine scales.
#
# @note If temperature is part of a compound unit, the temperature will be
# treated as a differential and the units will be scaled appropriately.
# @note When converting units with Integer scalars, the scalar will be
# converted to a Rational to avoid unexpected behavior caused by Integer
# division.
# @param other [Unit, String]
# @return [Unit]
# @raise [ArgumentError] when attempting to convert a degree to a temperature
# @raise [ArgumentError] when target unit is unknown
# @raise [ArgumentError] when target unit is incompatible
def convert_to(other)
return self if other.nil?
return self if other.is_a?(TrueClass)
return self if other.is_a?(FalseClass)
if (other.is_a?(Unit) && other.temperature?) || (other.is_a?(String) && other =~ self.class.temp_regex)
raise ArgumentError, "Receiver is not a temperature unit" unless degree?
start_unit = units
# @type [String]
target_unit = case other
when Unit
other.units
when String
other
else
raise ArgumentError, "Unknown target units"
end
return self if target_unit == start_unit
# @type [Numeric]
@base_scalar ||= case self.class.unit_map[start_unit]
when "<tempC>"
@scalar + 273.15
when "<tempK>"
@scalar
when "<tempF>"
(@scalar + 459.67).to_r * Rational(5, 9)
when "<tempR>"
@scalar.to_r * Rational(5, 9)
end
# @type [Numeric]
q = case self.class.unit_map[target_unit]
when "<tempC>"
@base_scalar - 273.15
when "<tempK>"
@base_scalar
when "<tempF>"
(@base_scalar.to_r * Rational(9, 5)) - 459.67r
when "<tempR>"
@base_scalar.to_r * Rational(9, 5)
end
self.class.new("#{q} #{target_unit}")
else
# @type [Unit]
target = case other
when Unit
other
when String
self.class.new(other)
else
raise ArgumentError, "Unknown target units"
end
return self if target.units == units
raise ArgumentError, "Incompatible Units ('#{self}' not compatible with '#{other}')" unless self =~ target
numerator1 = @numerator.map { self.class.prefix_values[_1] || _1 }.map { _1.is_a?(Numeric) ? _1 : self.class.unit_values[_1][:scalar] }.compact
denominator1 = @denominator.map { self.class.prefix_values[_1] || _1 }.map { _1.is_a?(Numeric) ? _1 : self.class.unit_values[_1][:scalar] }.compact
numerator2 = target.numerator.map { self.class.prefix_values[_1] || _1 }.map { _1.is_a?(Numeric) ? _1 : self.class.unit_values[_1][:scalar] }.compact
denominator2 = target.denominator.map { self.class.prefix_values[_1] || _1 }.map { _1.is_a?(Numeric) ? _1 : self.class.unit_values[_1][:scalar] }.compact
# If the scalar is an Integer, convert it to a Rational number so that
# if the value is scaled during conversion, resolution is not lost due
# to integer math
# @type [Rational, Numeric]
conversion_scalar = @scalar.is_a?(Integer) ? @scalar.to_r : @scalar
q = conversion_scalar * (numerator1 + denominator2).reduce(1, :*) / (numerator2 + denominator1).reduce(1, :*)
# Convert the scalar to an Integer if the result is equivalent to an
# integer
q = q.to_i if @scalar.is_a?(Integer) && q.to_i == q
self.class.new(scalar: q, numerator: target.numerator, denominator: target.denominator, signature: target.signature)
end
end
alias >> convert_to
alias to convert_to
# converts the unit back to a float if it is unitless. Otherwise raises an exception
# @return [Float]
# @raise [RuntimeError] when not unitless
def to_f
return @scalar.to_f if unitless?
raise "Cannot convert '#{self}' to Float unless unitless. Use Unit#scalar"
end
# converts the unit back to a complex if it is unitless. Otherwise raises an exception
# @return [Complex]
# @raise [RuntimeError] when not unitless
def to_c
return Complex(@scalar) if unitless?
raise "Cannot convert '#{self}' to Complex unless unitless. Use Unit#scalar"
end
# if unitless, returns an int, otherwise raises an error
# @return [Integer]
# @raise [RuntimeError] when not unitless
def to_i
return @scalar.to_int if unitless?
raise "Cannot convert '#{self}' to Integer unless unitless. Use Unit#scalar"
end
alias to_int to_i
# if unitless, returns a Rational, otherwise raises an error
# @return [Rational]
# @raise [RuntimeError] when not unitless
def to_r
return @scalar.to_r if unitless?
raise "Cannot convert '#{self}' to Rational unless unitless. Use Unit#scalar"
end
# Returns string formatted for json
# @return [String]
def as_json(*)
to_s
end
# Returns the 'unit' part of the Unit object without the scalar
#
# @param with_prefix [Boolean] include prefixes in output
# @param format [Symbol] Set to :exponential to force all units to be displayed in exponential format
#
# @return [String]
def units(with_prefix: true, format: nil)
return "" if @numerator == UNITY_ARRAY && @denominator == UNITY_ARRAY
output_numerator = ["1"]
output_denominator = []
num = @numerator.clone.compact
den = @denominator.clone.compact
unless num == UNITY_ARRAY
definitions = num.map { self.class.definition(_1) }
definitions.reject!(&:prefix?) unless with_prefix
definitions = definitions.chunk_while { |definition, _| definition.prefix? }.to_a
output_numerator = definitions.map { _1.map(&:display_name).join }
end
unless den == UNITY_ARRAY
definitions = den.map { self.class.definition(_1) }
definitions.reject!(&:prefix?) unless with_prefix
definitions = definitions.chunk_while { |definition, _| definition.prefix? }.to_a
output_denominator = definitions.map { _1.map(&:display_name).join }
end
on = output_numerator
.uniq
.map { [_1, output_numerator.count(_1)] }
.map { |element, power| (element.to_s.strip + (power > 1 ? "^#{power}" : "")) }
if format == :exponential
od = output_denominator
.uniq
.map { [_1, output_denominator.count(_1)] }
.map { |element, power| (element.to_s.strip + (power.positive? ? "^#{-power}" : "")) }
(on + od).join("*").strip
else
od = output_denominator
.uniq
.map { [_1, output_denominator.count(_1)] }
.map { |element, power| (element.to_s.strip + (power > 1 ? "^#{power}" : "")) }
"#{on.join('*')}#{od.empty? ? '' : "/#{od.join('*')}"}".strip
end
end
# negates the scalar of the Unit
# @return [Numeric,Unit]
def -@
return -@scalar if unitless?
dup * -1
end
# absolute value of a unit
# @return [Numeric,Unit]
def abs
return @scalar.abs if unitless?
self.class.new(@scalar.abs, @numerator, @denominator)
end
# ceil of a unit
# @return [Numeric,Unit]
def ceil(*args)
return @scalar.ceil(*args) if unitless?
self.class.new(@scalar.ceil(*args), @numerator, @denominator)
end
# @return [Numeric,Unit]
def floor(*args)
return @scalar.floor(*args) if unitless?
self.class.new(@scalar.floor(*args), @numerator, @denominator)
end
# Round the unit according to the rules of the scalar's class. Call this
# with the arguments appropriate for the scalar's class (e.g., Integer,
# Rational, etc..). Because unit conversions can often result in Rational
# scalars (to preserve precision), it may be advisable to use +to_s+ to
# format output instead of using +round+.
# @example
# RubyUnits::Unit.new('21870 mm/min').convert_to('m/min').round(1) #=> 2187/100 m/min
# RubyUnits::Unit.new('21870 mm/min').convert_to('m/min').to_s('%0.1f') #=> 21.9 m/min
#
# @return [Numeric,Unit]
def round(*args, **kwargs)
return @scalar.round(*args, **kwargs) if unitless?
self.class.new(@scalar.round(*args, **kwargs), @numerator, @denominator)
end
# @return [Numeric, Unit]
def truncate(*args)
return @scalar.truncate(*args) if unitless?
self.class.new(@scalar.truncate(*args), @numerator, @denominator)
end
# returns next unit in a range. '1 mm'.to_unit.succ #=> '2 mm'.to_unit
# only works when the scalar is an integer
# @return [Unit]
# @raise [ArgumentError] when scalar is not equal to an integer
def succ
raise ArgumentError, "Non Integer Scalar" unless @scalar == @scalar.to_i
self.class.new(@scalar.to_i.succ, @numerator, @denominator)
end
alias next succ
# returns previous unit in a range. '2 mm'.to_unit.pred #=> '1 mm'.to_unit
# only works when the scalar is an integer
# @return [Unit]
# @raise [ArgumentError] when scalar is not equal to an integer
def pred
raise ArgumentError, "Non Integer Scalar" unless @scalar == @scalar.to_i
self.class.new(@scalar.to_i.pred, @numerator, @denominator)
end
# Tries to make a Time object from current unit. Assumes the current unit hold the duration in seconds from the epoch.
# @return [Time]
def to_time
Time.at(self)
end
alias time to_time
# convert a duration to a DateTime. This will work so long as the duration is the duration from the zero date
# defined by DateTime
# @return [::DateTime]
def to_datetime
DateTime.new!(convert_to("d").scalar)
end
# @return [Date]
def to_date
Date.new0(convert_to("d").scalar)
end
# true if scalar is zero
# @return [Boolean]
def zero?
base_scalar.zero?
end
# @example '5 min'.to_unit.ago
# @return [Unit]
def ago
before
end
# @example '5 min'.before(time)
# @return [Unit]
def before(time_point = ::Time.now)
case time_point
when Time, Date, DateTime
(begin
time_point - self
rescue StandardError
time_point.to_datetime - self
end)
else
raise ArgumentError, "Must specify a Time, Date, or DateTime"
end
end
alias before_now before
# @example 'min'.since(time)
# @param [Time, Date, DateTime] time_point
# @return [Unit]
# @raise [ArgumentError] when time point is not a Time, Date, or DateTime
def since(time_point)
case time_point
when Time
self.class.new(::Time.now - time_point, "second").convert_to(self)
when DateTime, Date
self.class.new(::DateTime.now - time_point, "day").convert_to(self)
else
raise ArgumentError, "Must specify a Time, Date, or DateTime"
end
end
# @example 'min'.until(time)
# @param [Time, Date, DateTime] time_point
# @return [Unit]
def until(time_point)
case time_point
when Time
self.class.new(time_point - ::Time.now, "second").convert_to(self)
when DateTime, Date
self.class.new(time_point - ::DateTime.now, "day").convert_to(self)
else
raise ArgumentError, "Must specify a Time, Date, or DateTime"
end
end
# @example '5 min'.from(time)
# @param [Time, Date, DateTime] time_point
# @return [Time, Date, DateTime]
# @raise [ArgumentError] when passed argument is not a Time, Date, or DateTime
def from(time_point)
case time_point
when Time, DateTime, Date
(begin
time_point + self
rescue StandardError
time_point.to_datetime + self
end)
else
raise ArgumentError, "Must specify a Time, Date, or DateTime"
end
end
alias after from
alias from_now from
# Automatically coerce objects to [Unit] when possible. If an object defines a '#to_unit' method, it will be coerced
# using that method.
#
# @param other [Object, #to_unit]
# @return [Array(Unit, Unit)]
# @raise [ArgumentError] when `other` cannot be converted to a [Unit]
def coerce(other)
return [other.to_unit, self] if other.respond_to?(:to_unit)
[self.class.new(other), self]
end
# Returns a new unit that has been scaled to be more in line with typical usage. This is highly opinionated and not
# based on any standard. It is intended to be used to make the units more human readable.
#
# Some key points:
# * Units containing 'kg' will be returned as is. The prefix in 'kg' makes this an odd case.
# * It will use `centi` instead of `milli` when the scalar is between 0.01 and 0.001
#
# @return [Unit]
def best_prefix
return to_base if scalar.zero?
return self if units.include?("kg")
best_prefix = if kind == :information
self.class.prefix_values.key(2**((::Math.log(base_scalar, 2) / 10.0).floor * 10))
elsif ((1/100r)..(1/10r)).cover?(base_scalar)
self.class.prefix_values.key(1/100r)
else
self.class.prefix_values.key(10**((::Math.log10(base_scalar) / 3.0).floor * 3))
end
to(self.class.new(self.class.prefix_map.key(best_prefix) + units(with_prefix: false)))
end
# override hash method so objects with same values are considered equal
def hash
[
@scalar,
@numerator,
@denominator,
@base,
@signature,
@base_scalar,
@unit_name
].hash
end
# Protected and Private Functions that should only be called from this class
protected
# figure out what the scalar part of the base unit for this unit is
# @return [nil]
def update_base_scalar
if base?
@base_scalar = @scalar
@signature = unit_signature
else
base = to_base
@base_scalar = base.scalar
@signature = base.signature
end
end
# calculates the unit signature vector used by unit_signature
# @return [Array]
# @raise [ArgumentError] when exponent associated with a unit is > 20 or < -20
def unit_signature_vector
return to_base.unit_signature_vector unless base?
vector = ::Array.new(SIGNATURE_VECTOR.size, 0)
# it's possible to have a kind that misses the array... kinds like :counting
# are more like prefixes, so don't use them to calculate the vector
@numerator.map { self.class.definition(_1) }.each do |definition|
index = SIGNATURE_VECTOR.index(definition.kind)
vector[index] += 1 if index
end
@denominator.map { self.class.definition(_1) }.each do |definition|
index = SIGNATURE_VECTOR.index(definition.kind)
vector[index] -= 1 if index
end
raise ArgumentError, "Power out of range (-20 < net power of a unit < 20)" if vector.any? { _1.abs >= 20 }
vector
end
private
# used by #dup to duplicate a Unit
# @param [Unit] other
# @private
def initialize_copy(other)
@numerator = other.numerator.dup
@denominator = other.denominator.dup
end
# calculates the unit signature id for use in comparing compatible units and simplification
# the signature is based on a simple classification of units and is based on the following publication
#
# Novak, G.S., Jr. "Conversion of units of measurement", IEEE Transactions on Software Engineering, 21(8), Aug 1995, pp.651-661
# @see http://doi.ieeecomputersociety.org/10.1109/32.403789
# @return [Array]
def unit_signature
return @signature unless @signature.nil?
vector = unit_signature_vector
vector.each_with_index { |item, index| vector[index] = item * (20**index) }
@signature = vector.inject(0) { |acc, elem| acc + elem }
@signature
end
# parse a string into a unit object.
# Typical formats like :
# "5.6 kg*m/s^2"
# "5.6 kg*m*s^-2"
# "5.6 kilogram*meter*second^-2"
# "2.2 kPa"
# "37 degC"
# "1" -- creates a unitless constant with value 1
# "GPa" -- creates a unit with scalar 1 with units 'GPa'
# 6'4" -- recognized as 6 feet + 4 inches
# 8 lbs 8 oz -- recognized as 8 lbs + 8 ounces
# @return [nil,RubyUnits::Unit]
# @todo This should either be a separate class or at least a class method
def parse(passed_unit_string = "0")
unit_string = passed_unit_string.dup
unit_string = "#{Regexp.last_match(1)} USD" if unit_string =~ /\$\s*(#{NUMBER_REGEX})/
unit_string.gsub!("\u00b0".encode("utf-8"), "deg") if unit_string.encoding == Encoding::UTF_8
unit_string.gsub!(/(\d)[_,](\d)/, '\1\2') # remove underscores and commas in numbers
unit_string.gsub!(/[%'"#]/, "%" => "percent", "'" => "feet", '"' => "inch", "#" => "pound")
if unit_string.start_with?(COMPLEX_NUMBER)
match = unit_string.match(COMPLEX_REGEX)
real = Float(match[:real]) if match[:real]
imaginary = Float(match[:imaginary])
unit_s = match[:unit]
real = real.to_i if real.to_i == real
imaginary = imaginary.to_i if imaginary.to_i == imaginary
complex = Complex(real || 0, imaginary)
complex = complex.to_i if complex.imaginary.zero? && complex.real == complex.real.to_i
result = self.class.new(unit_s || 1) * complex
copy(result)
return
end
if unit_string.start_with?(RATIONAL_NUMBER)
match = unit_string.match(RATIONAL_REGEX)
numerator = Integer(match[:numerator])
denominator = Integer(match[:denominator])
raise ArgumentError, "Improper fractions must have a whole number part" if !match[:proper].nil? && !match[:proper].match?(/^#{INTEGER_REGEX}$/)
proper = match[:proper].to_i
unit_s = match[:unit]
rational = if proper.negative?
(proper - Rational(numerator, denominator))
else
(proper + Rational(numerator, denominator))
end
rational = rational.to_int if rational.to_int == rational
result = self.class.new(unit_s || 1) * rational
copy(result)
return
end
match = unit_string.match(NUMBER_REGEX)
unit = self.class.cached.get(match[:unit])
mult = match[:scalar] == "" ? 1.0 : match[:scalar].to_f
mult = mult.to_int if mult.to_int == mult
if unit
copy(unit)
@scalar *= mult
@base_scalar *= mult
return self
end
while unit_string.gsub!(/<(#{self.class.prefix_regex})><(#{self.class.unit_regex})>/, '<\1\2>')
# replace <prefix><unit> with <prefixunit>
end
while unit_string.gsub!(/<#{self.class.unit_match_regex}><#{self.class.unit_match_regex}>/, '<\1\2>*<\3\4>')
# collapse <prefixunit><prefixunit> into <prefixunit>*<prefixunit>...
end
# ... and then strip the remaining brackets for x*y*z
unit_string.gsub!(/[<>]/, "")
if (match = unit_string.match(TIME_REGEX))
hours = match[:hour]
minutes = match[:min]
seconds = match[:sec]
milliseconds = match[:msec]
raise ArgumentError, "Invalid Duration" if [hours, minutes, seconds, milliseconds].all?(&:nil?)
result = self.class.new("#{hours || 0} hours") +
self.class.new("#{minutes || 0} minutes") +
self.class.new("#{seconds || 0} seconds") +
self.class.new("#{milliseconds || 0} milliseconds")
copy(result)
return
end
# Special processing for unusual unit strings
# feet -- 6'5"
if (match = unit_string.match(FEET_INCH_REGEX))
feet = Integer(match[:feet])
inches = match[:inches]
result = if feet.negative?
self.class.new("#{feet} ft") - self.class.new("#{inches} inches")
else
self.class.new("#{feet} ft") + self.class.new("#{inches} inches")
end
copy(result)
return
end
# weight -- 8 lbs 12 oz
if (match = unit_string.match(LBS_OZ_REGEX))
pounds = Integer(match[:pounds])
oz = match[:oz]
result = if pounds.negative?
self.class.new("#{pounds} lbs") - self.class.new("#{oz} oz")
else
self.class.new("#{pounds} lbs") + self.class.new("#{oz} oz")
end
copy(result)
return
end
# stone -- 3 stone 5, 2 stone, 14 stone 3 pounds, etc.
if (match = unit_string.match(STONE_LB_REGEX))
stone = Integer(match[:stone])
pounds = match[:pounds]
result = if stone.negative?
self.class.new("#{stone} stone") - self.class.new("#{pounds} lbs")
else
self.class.new("#{stone} stone") + self.class.new("#{pounds} lbs")
end
copy(result)
return
end
# more than one per. I.e., "1 m/s/s"
raise(ArgumentError, "'#{passed_unit_string}' Unit not recognized") if unit_string.count("/") > 1
raise(ArgumentError, "'#{passed_unit_string}' Unit not recognized #{unit_string}") if unit_string =~ /\s[02-9]/
@scalar, top, bottom = unit_string.scan(UNIT_STRING_REGEX)[0] # parse the string into parts
top.scan(TOP_REGEX).each do |item|
n = item[1].to_i
x = "#{item[0]} "
if n >= 0
top.gsub!(/#{item[0]}(\^|\*\*)#{n}/) { x * n }
elsif n.negative?
bottom = "#{bottom} #{x * -n}"
top.gsub!(/#{item[0]}(\^|\*\*)#{n}/, "")
end
end
if bottom
bottom.gsub!(BOTTOM_REGEX) { "#{Regexp.last_match(1)} " * Regexp.last_match(2).to_i }
# Separate leading decimal from denominator, if any
bottom_scalar, bottom = bottom.scan(NUMBER_UNIT_REGEX)[0]
end
@scalar = @scalar.to_f unless @scalar.nil? || @scalar.empty?
@scalar = 1 unless @scalar.is_a? Numeric
@scalar = @scalar.to_int if @scalar.to_int == @scalar
bottom_scalar = 1 if bottom_scalar.nil? || bottom_scalar.empty?
bottom_scalar = if bottom_scalar.to_i == bottom_scalar
bottom_scalar.to_i
else
bottom_scalar.to_f
end
@scalar /= bottom_scalar
@numerator ||= UNITY_ARRAY
@denominator ||= UNITY_ARRAY
@numerator = top.scan(self.class.unit_match_regex).delete_if(&:empty?).compact if top
@denominator = bottom.scan(self.class.unit_match_regex).delete_if(&:empty?).compact if bottom
# eliminate all known terms from this string. This is a quick check to see if the passed unit
# contains terms that are not defined.
used = "#{top} #{bottom}".to_s.gsub(self.class.unit_match_regex, "").gsub(%r{[\d*, "'_^/$]}, "")
raise(ArgumentError, "'#{passed_unit_string}' Unit not recognized") unless used.empty?
@numerator = @numerator.map do |item|
self.class.prefix_map[item[0]] ? [self.class.prefix_map[item[0]], self.class.unit_map[item[1]]] : [self.class.unit_map[item[1]]]
end.flatten.compact.delete_if(&:empty?)
@denominator = @denominator.map do |item|
self.class.prefix_map[item[0]] ? [self.class.prefix_map[item[0]], self.class.unit_map[item[1]]] : [self.class.unit_map[item[1]]]
end.flatten.compact.delete_if(&:empty?)
@numerator = UNITY_ARRAY if @numerator.empty?
@denominator = UNITY_ARRAY if @denominator.empty?
self
end
end
end