# frozen_string_literal: true
require_relative 'temperature/additional_helpers'
require_relative 'temperature/assertions'
require_relative 'temperature/casting'
require_relative 'temperature/errors'
require_relative 'temperature/initialization'
require_relative 'temperature/memoization'
require_relative 'temperature/rounding'
module BasicTemperature
# 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:Temperature#set_degrees] and
# {set_scale}[rdoc-ref:Temperature#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:Temperature#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
#
# == Queries
#
# Temperature[0, :celsius].boil_water?
# # => false
#
# Temperature[0, :celsius].freeze_water?
# # => true
#
class Temperature
include Comparable
include AdditionalHelpers
include Assertions
include Casting
include Errors
include Initialization
include Memoization
include Rounding
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:Temperature::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)
Temperature.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)
Temperature.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:Temperature::InvalidScaleError]
# when scale can not be casted to any possible scale value
# (see {SCALES}[rdoc-ref:Temperature::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
Temperature.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
Temperature.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
Temperature.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
Temperature.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)
round_degrees(self.to_scale(other.scale).degrees) <=> round_degrees(other.degrees)
end
##
# Returns true when temperature boils water (is greater than or equal to 100 °C),
# false otherwise.
#
def boil_water?
self.to_celsius.degrees >= 100
end
##
# Returns true when temperature freezes water (is less than or equal to 0 °C),
# false otherwise.
#
def freeze_water?
self.to_celsius.degrees <= 0
end
# Is used by {+}[rdoc-ref:Temperature#+] and {-}[rdoc-ref:Temperature#-]
# for {Ruby coersion mechanism}[https://ruby-doc.org/core/Numeric.html#method-i-coerce].
def coerce(numeric) #:nodoc:
assert_numeric!(numeric)
[Temperature.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
end
# rubocop:enable Metrics/ClassLength
end