# frozen_string_literal: true require 'basic_temperature/version' # rubocop:disable Metrics/ClassLength ## # Temperature is a simple {Value Object}[https://martinfowler.com/bliki/ValueObject.html] for basic # temperature operations like conversions from Celsius to Fahrenhait or Kelvin # etc. # # Supported scales: Celsius, Fahrenheit, Kelvin and Rankine. # # == Creating Temperatures # # A new temperature can be created in multiple ways: # # - Using keyword arguments: # # Temperature.new(degrees: 0, scale: :celsius) # # - Using positional arguments: # # Temperature.new(0, :celsius) # # - Even more concise way using Temperature.[] (an alias of Temperature.new): # # Temperature[0, :celsius] # # # == Creating Temperatures from already existing temperature objects # # Sometimes it is useful to create a new temperature from already existing one. # # For such cases, there are {set_degrees}[rdoc-ref:BasicTemperature#set_degrees and # {set_scale}[rdoc-ref:BasicTemperature#set_scale]. # # Since temperatures are {Value Objects}[https://martinfowler.com/bliki/ValueObject.html], both methods # returns new instances. # # Examples: # # temperature = Temperature[0, :celsius] # # => 0 °C # # new_temperature = temperature.set_degrees(15) # # => 15 °C # # temperature = Temperature[0, :celsius] # # => 0 °C # # new_temperature = temperature.set_scale(:kelvin) # # => 0 K # # == Conversions # # Temperatures can be converted to diffirent scales. # # Currently, the following scales are supported: Celsius, Fahrenheit, Kelvin and # Rankine. # # Temperature[20, :celsius].to_celsius # # => 20 °C # # Temperature[20, :celsius].to_fahrenheit # # => 68 °F # # Temperature[20, :celsius].to_kelvin # # => 293.15 K # # Temperature[20, :celsius].to_rankine # # => 527.67 °R # # If it is necessary to convert scale dynamically, {to_scale}[rdoc-ref:BasicTemperature#to_scale] method is # available. # # Temperature[20, :celsius].to_scale(scale) # # All conversion formulas are taken from # {RapidTables}[https://www.rapidtables.com/convert/temperature/index.html]. # # Conversion precision: 2 accurate digits after the decimal dot. # # == Comparison # # Temperature implements idiomatic {<=> spaceship operator}[https://ruby-doc.org/core/Comparable.html] and # mixes in {Comparable}[https://ruby-doc.org/core/Comparable.html] module. # # As a result, all methods from Comparable are available, e.g: # # Temperature[20, :celsius] < Temperature[25, :celsius] # # => true # # Temperature[20, :celsius] <= Temperature[25, :celsius] # # => true # # Temperature[20, :celsius] == Temperature[25, :celsius] # # => false # # Temperature[20, :celsius] > Temperature[25, :celsius] # # => false # # Temperature[20, :celsius] >= Temperature[25, :celsius] # # => false # # Temperature[20, :celsius].between?(Temperature[15, :celsius], Temperature[25, :celsius]) # # => true # # # Starting from Ruby 2.4.6 # Temperature[20, :celsius].clamp(Temperature[20, :celsius], Temperature[25, :celsius]) # # => 20 °C # # Please note, if other temperature has a different scale, temperature is automatically converted # to that scale before comparison. # # Temperature[20, :celsius] == Temperature[293.15, :kelvin] # # => true # # IMPORTANT !!! # # degrees are rounded to the nearest value with a precision of 2 decimal digits before comparison. # # This means the following temperatures are considered as equal: # # Temperature[20.020, :celsius] == Temperature[20.024, :celsius] # # => true # # Temperature[20.025, :celsius] == Temperature[20.029, :celsius] # # => true # # while these ones are treated as NOT equal: # # Temperature[20.024, :celsius] == Temperature[20.029, :celsius] # # => false # # == Math # # ==== Addition/Subtraction. # # Temperature[20, :celsius] + Temperature[10, :celsius] # # => 30 °C # # Temperature[20, :celsius] - Temperature[10, :celsius] # # => 10 °C # # If second temperature has a different scale, first temperature is automatically converted to that scale # before degrees addition/subtraction. # # Temperature[283.15, :kelvin] + Temperature[10, :celsius] # # => 10 °C # # Returned temperature will have the same scale as the second temperature. # # It is possible to add/subtract numerics. # # Temperature[20, :celsius] + 10 # # => 30 °C # # Temperature[20, :celsius] - 10 # # => 10 °C # # In such cases, returned temperature will have the same scale as the first temperature. # # Also {Ruby coersion mechanism}[https://ruby-doc.org/core/Numeric.html#method-i-coerce] is supported. # # 10 + Temperature[20, :celsius] # # => 30 °C # # 10 - Temperature[20, :celsius] # # => -10 °C # # ==== Negation # # -Temperature[20, :celsius] # # => -20 °C # class BasicTemperature include Comparable # Raised when Temperature.new is called with mixed positional and keyword arguments or without # arguments at all. class InitializationArgumentsError < StandardError; end # Raised when degrees is not a Numeric. class InvalidDegreesError < StandardError; end # Raised when scale can not be casted to any possible scale value. # See {SCALES}[rdoc-ref:BasicTemperature::SCALES]. class InvalidScaleError < StandardError; end # Raised when other is not a Numeric or Temperature in math operations. class InvalidNumericOrTemperatureError < StandardError; end CELSIUS = 'celsius' FAHRENHEIT = 'fahrenheit' KELVIN = 'kelvin' RANKINE = 'rankine' # A list of all currently supported scale values. SCALES = [CELSIUS, FAHRENHEIT, KELVIN, RANKINE].freeze # Degrees of the temperature. attr_reader :degrees # Scale of the temperature. Look at {SCALES}[rdoc-ref:BasicTemperature::SCALES] for possible values. attr_reader :scale ## # Creates a new instance of Temperature. Alias for new. # # :call-seq: # [](degrees:, scale:) # [](degrees, scale) # def self.[](*args, **kwargs) new(*args, **kwargs) end ## # Creates a new instance of Temperature. Is aliased as []. # # :call-seq: # new(degrees:, scale:) # new(degrees, scale) # def initialize(*positional_arguments, **keyword_arguments) assert_either_positional_arguments_or_keyword_arguments!(positional_arguments, keyword_arguments) if keyword_arguments.any? initialize_via_keywords_arguments(keyword_arguments) else # positional_arguments.any? initialize_via_positional_arguments(positional_arguments) end end # rubocop:disable Naming/AccessorMethodName # Returns a new Temperature with updated degrees. # # temperature = Temperature[0, :celsius] # # => 0 °C # # new_temperature = temperature.set_degrees(15) # # => 15 °C # def set_degrees(degrees) BasicTemperature.new(degrees, scale) end # rubocop:enable Naming/AccessorMethodName # rubocop:disable Naming/AccessorMethodName # Returns a new Temperature with updated scale. # # temperature = Temperature[0, :celsius] # # => 0 °C # # new_temperature = temperature.set_scale(:kelvin) # # => 0 K # def set_scale(scale) BasicTemperature.new(degrees, scale) end # rubocop:enable Naming/AccessorMethodName ## # Converts temperature to specific scale. # If temperature is already in desired scale, returns current temperature object. # # Raises {InvalidScaleError}[rdoc-ref:BasicTemperature::InvalidScaleError] # when scale can not be casted to any possible scale value # (see {SCALES}[rdoc-ref:BasicTemperature::SCALES]). # # Temperature[60, :fahrenheit].to_scale(:celsius) # # => 15.56 °C # def to_scale(scale) casted_scale = cast_scale(scale) assert_valid_scale!(casted_scale) case casted_scale when CELSIUS to_celsius when FAHRENHEIT to_fahrenheit when KELVIN to_kelvin when RANKINE to_rankine end end ## # Converts temperature to Celsius scale. If temperature is already in Celsius, returns current # temperature object. # # Memoizes subsequent calls. # # Conversion formulas are taken from {RapidTables}[https://www.rapidtables.com/]: # 1. {Celsius to Fahrenheit}[https://www.rapidtables.com/convert/temperature/celsius-to-fahrenheit.html]. # 2. {Celsius to Kelvin}[https://www.rapidtables.com/convert/temperature/celsius-to-kelvin.html]. # 3. {Celsius to Rankine}[https://www.rapidtables.com/convert/temperature/celsius-to-rankine.html]. # # Temperature[0, :fahrenheit].to_celsius # # => -17.78 °C # def to_celsius memoized(:to_celsius) || memoize(:to_celsius, -> { return self if self.scale == CELSIUS degrees = case self.scale when FAHRENHEIT (self.degrees - 32) * (5 / 9r) when KELVIN self.degrees - 273.15 when RANKINE (self.degrees - 491.67) * (5 / 9r) end BasicTemperature.new(degrees, CELSIUS) }) end ## # Converts temperature to Fahrenheit scale. If temperature is already in Fahrenheit, returns current # temperature object. # # Memoizes subsequent calls. # # Conversion formulas are taken from {RapidTables}[https://www.rapidtables.com/]: # 1. {Fahrenheit to Celsius}[https://www.rapidtables.com/convert/temperature/fahrenheit-to-celsius.html]. # 2. {Fahrenheit to Kelvin}[https://www.rapidtables.com/convert/temperature/fahrenheit-to-kelvin.html]. # 3. {Fahrenheit to Rankine}[https://www.rapidtables.com/convert/temperature/fahrenheit-to-rankine.html]. # # Temperature[0, :celsius].to_fahrenheit # # => 32 °F # def to_fahrenheit memoized(:to_fahrenheit) || memoize(:to_fahrenheit, -> { return self if self.scale == FAHRENHEIT degrees = case self.scale when CELSIUS self.degrees * (9 / 5r) + 32 when KELVIN self.degrees * (9 / 5r) - 459.67 when RANKINE self.degrees - 459.67 end BasicTemperature.new(degrees, FAHRENHEIT) }) end ## # Converts temperature to Kelvin scale. If temperature is already in Kelvin, returns current # temperature object. # # Memoizes subsequent calls. # # Conversion formulas are taken from {RapidTables}[https://www.rapidtables.com/]: # 1. {Kelvin to Celsius}[https://www.rapidtables.com/convert/temperature/kelvin-to-celsius.html]. # 2. {Kelvin to Fahrenheit}[https://www.rapidtables.com/convert/temperature/kelvin-to-fahrenheit.html]. # 3. {Kelvin to Rankine}[https://www.rapidtables.com/convert/temperature/kelvin-to-rankine.html]. # # Temperature[0, :kelvin].to_rankine # # => 0 °R # def to_kelvin memoized(:to_kelvin) || memoize(:to_kelvin, -> { return self if self.scale == KELVIN degrees = case self.scale when CELSIUS self.degrees + 273.15 when FAHRENHEIT (self.degrees + 459.67) * (5 / 9r) when RANKINE self.degrees * (5 / 9r) end BasicTemperature.new(degrees, KELVIN) }) end ## # Converts temperature to Rankine scale. If temperature is already in Rankine, returns current # temperature object. # # Memoizes subsequent calls. # # Conversion formulas are taken from {RapidTables}[https://www.rapidtables.com/]: # 1. {Rankine to Celsius}[https://www.rapidtables.com/convert/temperature/rankine-to-celsius.html]. # 2. {Rankine to Fahrenheit}[https://www.rapidtables.com/convert/temperature/rankine-to-fahrenheit.html]. # 3. {Rankine to Kelvin}[https://www.rapidtables.com/convert/temperature/rankine-to-kelvin.html]. # # Temperature[0, :rankine].to_kelvin # # => 0 K # def to_rankine memoized(:to_rankine) || memoize(:to_rankine, -> { return self if self.scale == RANKINE degrees = case self.scale when CELSIUS (self.degrees + 273.15) * (9 / 5r) when FAHRENHEIT self.degrees + 459.67 when KELVIN self.degrees * (9 / 5r) end BasicTemperature.new(degrees, RANKINE) }) end ## # Compares temperture with other temperature. # # Returns 0 if they are considered as equal. # # Two temperatures are considered as equal when they have the same amount of degrees. # # Returns -1 if temperature is lower than other temperature. # # Returns 1 if temperature is higher than other temperature. # # If other temperature has a different scale, temperature is automatically converted to that scale # before degrees comparison. # # Temperature[20, :celsius] <=> Temperature[20, :celsius] # # => 0 # # Temperature[20, :celsius] <=> Temperature[293.15, :kelvin] # # => 0 # # IMPORTANT!!! # # This method rounds degrees to the nearest value with a precision of 2 decimal digits. # # This means the following: # # Temperature[20.020, :celsius] <=> Temperature[20.024, :celsius] # # => 0 # # Temperature[20.025, :celsius] <=> Temperature[20.029, :celsius] # # => 0 # # Temperature[20.024, :celsius] <=> Temperature[20.029, :celsius] # # => -1 # def <=>(other) return unless assert_temperature(other) compare_degrees(self.to_scale(other.scale).degrees, other.degrees) end ## # Performs addition. Returns a new Temperature. # # Temperature[20, :celsius] + Temperature[10, :celsius] # # => 30 °C # # If the second temperature has a different scale, the first temperature is automatically converted to that # scale before degrees addition. # # Temperature[283.15, :kelvin] + Temperature[20, :celsius] # # => 30 °C # # Returned temperature will have the same scale as the second temperature. # # It is possible to add numerics. # # Temperature[20, :celsius] + 10 # # => 30 °C # # In such cases, returned temperature will have the same scale as the first temperature. # # Also {Ruby coersion mechanism}[https://ruby-doc.org/core/Numeric.html#method-i-coerce] is supported. # # 10 + Temperature[20, :celsius] # # => 30 °C # # :call-seq: # +(temperature) # +(numeric) # def +(other) assert_numeric_or_temperature!(other) degrees, scale = case other when Numeric [self.degrees + other, self.scale] when BasicTemperature [self.to_scale(other.scale).degrees + other.degrees, other.scale] end BasicTemperature.new(degrees, scale) end ## # Performs subtraction. Returns a new Temperature. # # Temperature[20, :celsius] - Temperature[10, :celsius] # # => 10 °C # # If the second temperature has a different scale, the first temperature is automatically converted to that # scale before degrees subtraction. # # Temperature[283.15, :kelvin] + Temperature[10, :celsius] # # => 10 °C # # Returned temperature will have the same scale as the second temperature. # # It is possible to subtract numerics. # # Temperature[20, :celsius] - 10 # # => 10 °C # # In such cases, returned temperature will have the same scale as the first temperature. # # Also {Ruby coersion mechanism}[https://ruby-doc.org/core/Numeric.html#method-i-coerce] is supported. # # 10 - Temperature[20, :celsius] # # => -10 °C # # :call-seq: # -(temperature) # -(numeric) # def -(other) self + -other end ## # Returns a new Temperature with negated degrees. # # -Temperature[20, :celsius] # # => -20 °C # def -@ BasicTemperature.new(-self.degrees, self.scale) end # Is used by {+}[rdoc-ref:BasicTemperature#+] and {-}[rdoc-ref:BasicTemperature#-] # for {Ruby coersion mechanism}[https://ruby-doc.org/core/Numeric.html#method-i-coerce]. def coerce(numeric) #:nodoc: assert_numeric!(numeric) [BasicTemperature.new(numeric, self.scale), self] end # Returns a string containing a human-readable representation of temperature. def inspect #:nodoc: rounded_degrees = round_degrees(degrees) printable_degrees = degrees_without_decimal?(rounded_degrees) ? rounded_degrees.to_i : rounded_degrees scale_symbol = case self.scale when CELSIUS '°C' when FAHRENHEIT '°F' when KELVIN 'K' when RANKINE '°R' end "#{printable_degrees} #{scale_symbol}" end private # Initialization def initialize_via_positional_arguments(positional_arguments) degrees, scale = positional_arguments initialize_arguments(degrees, scale) end def initialize_via_keywords_arguments(keyword_arguments) degrees, scale = keyword_arguments.values_at(:degrees, :scale) initialize_arguments(degrees, scale) end def initialize_arguments(degrees, scale) casted_degrees = cast_degrees(degrees) casted_scale = cast_scale(scale) assert_valid_degrees!(casted_degrees) assert_valid_scale!(casted_scale) @degrees = casted_degrees @scale = casted_scale end # Casting def cast_degrees(degrees) Float(degrees) rescue nil end def cast_scale(scale) scale.to_s end # Assertions def assert_either_positional_arguments_or_keyword_arguments!(positional_arguments, keyword_arguments) raise_initialization_arguments_error if positional_arguments.any? && keyword_arguments.any? raise_initialization_arguments_error if positional_arguments.none? && keyword_arguments.none? end def assert_valid_degrees!(degrees) raise_invalid_degrees_error unless degrees.is_a?(Numeric) end def assert_valid_scale!(scale) raise_invalid_scale_error unless SCALES.include?(scale) end def assert_numeric_or_temperature!(numeric_or_temperature) return if numeric_or_temperature.is_a?(Numeric) || numeric_or_temperature.instance_of?(BasicTemperature) raise_invalid_numeric_or_temperature_error(numeric_or_temperature) end def assert_numeric!(numeric) raise_invalid_numeric unless numeric.is_a?(Numeric) end def assert_temperature(temperature) temperature.instance_of?(BasicTemperature) end # Raising errors def raise_initialization_arguments_error message = 'Positional and keyword arguments are mixed or ' \ 'neither positional nor keyword arguments are passed.' raise InitializationArgumentsError, message end def raise_invalid_degrees_error raise InvalidDegreesError, 'degree is NOT a numeric value.' end def raise_invalid_scale_error message = 'scale has invalid value, ' \ "valid values are #{SCALES.map { |scale| "'#{scale}'" }.join(', ')}." raise InvalidScaleError, message end def raise_invalid_numeric_or_temperature_error(numeric_or_temperature) raise InvalidNumericOrTemperatureError, "`#{numeric_or_temperature}` is neither Numeric nor Temperature." end # Rounding def round_degrees(degrees) degrees.round(2) end def compare_degrees(first_degrees, second_degrees) round_degrees(first_degrees) <=> round_degrees(second_degrees) end def degrees_with_decimal?(degrees) degrees % 1 != 0 end def degrees_without_decimal?(degrees) !degrees_with_decimal?(degrees) end # Memoization def memoized(key) name = convert_to_variable_name(key) instance_variable_get(name) if instance_variable_defined?(name) end def memoize(key, proc) name = convert_to_variable_name(key) value = proc.call instance_variable_set(name, value) end def convert_to_variable_name(key) "@#{key}" end end # rubocop:enable Metrics/ClassLength