lib/auom/unit.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

module AUOM
  # A scalar with units
  class Unit
    include Equalizer.new(:scalar, :numerators, :denominators)
    include Algebra
    include Inspection
    include Relational
    include Equalization

    # Return scalar
    #
    # @example
    #
    #   include AUOM
    #   m = Unit.new(1, :meter)
    #   m.scalar # => Rational(1, 1)
    #
    # @return [Rational]
    #
    # @api public
    #
    attr_reader :scalar

    # Return numerators
    #
    # @example
    #
    #   include AUOM
    #   m = Unit.new(1, :meter)
    #   m.numerators # => [:meter]
    #
    # @return [Rational]
    #
    # @api public
    #
    attr_reader :numerators

    # Return denominators
    #
    # @example
    #
    #   include AUOM
    #   m = Unit.new(1, :meter)
    #   m.denominators # => []
    #
    # @return [Rational]
    #
    # @api public
    #
    attr_reader :denominators

    # Return unit descriptor
    #
    # @return [Array]
    #
    # @api public
    #
    # @example
    #
    #   u = Unit.new(1, [:meter, :meter], :euro)
    #   u.unit # => [[:meter, :meter], [:euro]]
    #
    attr_reader :unit

    # These constants can easily be changed
    # by an application specific subclass that overrides
    # AUOM::Unit.units with an own hash!
    UNITS = {
      item:      [1, :item],
      liter:     [1, :liter],
      pack:      [1, :pack],
      can:       [1, :can],
      kilogramm: [1, :kilogramm],
      euro:      [1, :euro],
      meter:     [1, :meter],
      kilometer: [1000, :meter]
    }.freeze

    # Return built-in units symbols
    #
    # @return [Hash]
    #
    # @api private
    #
    def self.units
      UNITS
    end

    # Check for unitless unit
    #
    # @return [true]
    #   return true if unit is unitless
    #
    # @return [false]
    #   return false if unit is NOT unitless
    #
    # @example
    #
    #   Unit.new(1).unitless?         # => true
    #   Unit.new(1, :meter).unitless ? # => false
    #
    # @api public
    #
    def unitless?
      numerators.empty? && denominators.empty?
    end

    # Test if units are the same
    #
    # @param [Unit] other
    #
    # @return [true]
    #   if units are the same
    #
    # @return [false]
    #   otherwise
    #
    # @example
    #
    #   a = Unit.new(1)
    #   b = Unit.new(1, :euro)
    #   c = Unit.new(2, :euro)
    #
    #   a.same_unit?(b) # => false
    #   b.same_unit?(c) # => true
    #
    # @api public
    #
    def same_unit?(other)
      other.unit.eql?(unit)
    end

    # Instantiate a new unit
    #
    # @param [Rational] scalar
    # @param [Enumerable] numerators
    # @param [Enumerable] denominators
    #
    # @return [Unit]
    #
    # @example
    #
    #   # A unitless unit
    #   u = Unit.new(1)
    #   u.unitless? # => true
    #   u.scalar    # => Rational(1, 1)
    #
    #   # A unitless unit from string
    #   u = Unit.new('1.5')
    #   u.unitless? # => true
    #   u.scalar    # => Rational(3, 2)
    #
    #   # A simple unit
    #   u = Unit.new(1, :meter)
    #   u.unitless?  # => false
    #   u.numerators # => [:meter]
    #   u.scalar     # => Rational(1, 1)
    #
    #   # A complex unit
    #   u = Unit.new(Rational(1, 3), :euro, :meter)
    #   u.fractions? # => true
    #   u.scalar     # => Rational(1, 3)
    #   u.inspect    # => <AUOM::Unit @scalar=~0.3333 euro/meter>
    #   u.unit       # => [[:euro], [:meter]]
    #
    # @api public
    #
    # TODO: Move defaults coercions etc to .build method
    #
    def self.new(scalar, numerators = nil, denominators = nil)
      scalar = rational(scalar)

      scalar, numerators   = resolve([*numerators], scalar, :*)
      scalar, denominators = resolve([*denominators], scalar, :/)

      super(scalar, *[numerators, denominators].map(&:sort)).freeze
    end

    # Assert units are the same
    #
    # @param [Unit] other
    #
    # @return [self]
    #
    # @api private
    #
    def assert_same_unit(other)
      fail ArgumentError, 'Incompatible units' unless same_unit?(other)

      self
    end

    # Return converted operand or raise error
    #
    # @param [Object] operand
    #
    # @return [Unit]
    #
    # @raise [ArgumentError]
    #   raises argument error in case operand cannot be converted
    #
    # @api private
    #
    def self.convert(operand)
      converted = try_convert(operand)
      unless converted
        fail ArgumentError, "Cannot convert #{operand.inspect} to #{self}"
      end

      converted
    end

    # Return converted operand or nil
    #
    # @param [Object] operand
    #
    # @return [Unit]
    #   return unit in case operand can be converted
    #
    # @return [nil]
    #   return nil in case operand can NOT be converted
    #
    # @api private
    #
    def self.try_convert(operand)
      case operand
      when self
        operand
      when Integer, Rational
        new(operand)
      end
    end

  private

    # Initialize unit
    #
    # @param [Rational] scalar
    # @param [Enumerable] numerators
    # @param [Enumerable] denominators
    #
    # @api private
    #
    def initialize(scalar, numerators, denominators)
      @scalar = scalar

      [numerators, denominators].permutation do |left, right|
        left.delete_if { |item| right.delete_at(right.index(item) || right.length) }
      end

      @numerators   = numerators.freeze
      @denominators = denominators.freeze

      @unit = [numerators, denominators].freeze
    end

    # Return rational converted from value
    #
    # @param [Object] value
    #
    # @return [Rational]
    #
    # @raise [ArgumentError]
    #   raises argument error when cannot be converted to a rational
    #
    # @api private
    #
    def self.rational(value)
      case value
      when Rational
        value
      when Integer
        Rational(value)
      else
        fail ArgumentError, "#{value.inspect} cannot be converted to rational"
      end
    end

    private_class_method :rational

    # Resolve unit component
    #
    # @param [Enumerable] components
    # @param [Symbol] operation
    #
    # @return [Array]
    #
    # @api private
    #
    def self.resolve(components, scalar, operation)
      resolved = components.map do |component|
        scale, component = lookup(component)
        scalar = scalar.public_send(operation, scale)
        component
      end
      [scalar, resolved]
    end

    private_class_method :resolve

    # Return unit information
    #
    # @param [Symbol] value
    #   the unit to search for
    #
    # @return [Array]
    #
    # @api private
    #
    def self.lookup(value)
      units.fetch(value) do
        fail ArgumentError, "Unknown unit #{value.inspect}"
      end
    end

    private_class_method :lookup
  end # Unit
end # AUOM