diff --git a/Gemfile b/Gemfile index ba3aa8c..30b6c6a 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ group :optional do gem "pry" gem "redcarpet", platform: :mri # redcarpet doesn't support jruby gem "reek" + gem "ruby-lsp" gem "ruby-maven", platform: :jruby gem "ruby-prof", platform: :mri gem "simplecov-html" diff --git a/Gemfile.lock b/Gemfile.lock index 45cdfae..dcd9b19 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -188,6 +188,10 @@ GEM rubocop-rspec (3.8.0) lint_roller (~> 1.1) rubocop (~> 1.81) + ruby-lsp (0.26.4) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) ruby-maven (3.9.3) ruby-maven-libs (~> 3.9.9) ruby-maven-libs (3.9.9) @@ -261,6 +265,7 @@ DEPENDENCIES rubocop rubocop-rake rubocop-rspec + ruby-lsp ruby-maven ruby-prof ruby-units! diff --git a/README.md b/README.md index 64b74e7..9682e74 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ # Ruby Units -[![Maintainability](https://api.codeclimate.com/v1/badges/4e858d14a07dd453f748/maintainability.svg)](https://codeclimate.com/github/olbrich/ruby-units/maintainability) -[![CodeClimate Status](https://api.codeclimate.com/v1/badges/4e858d14a07dd453f748/test_coverage.svg)](https://codeclimate.com/github/olbrich/ruby-units/test_coverage) -[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Folbrich%2Fruby-units.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Folbrich%2Fruby-units?ref=badge_shield) - Kevin C. Olbrich, Ph.D. Project page: @@ -146,21 +142,89 @@ Unit.new("100 kg").to_s(:lbs) # returns 220 lbs, 7 oz Unit.new("100 kg").to_s(:stone) # returns 15 stone, 10 lb ``` +You can also use Ruby's standard string formatting operator (`%`) with units: + +```ruby +'%0.2f' % '1 mm'.to_unit # "1.00 mm" +'%0.2f in' % '1 mm'.to_unit # "0.04 in" - format and convert +'%.2e' % '1000 m'.to_unit # "1.00e+03 m" - scientific notation +``` + +Strings can be converted to units using the `convert_to` method: + +```ruby +'10 mm'.convert_to('cm') # converts directly to centimeters +'100 km/h'.convert_to('m/s') # converts compound units +``` + ### Time Helpers -`Time`, `Date`, and `DateTime` objects can have time units added or subtracted. +Ruby-units extends the `Time`, `Date`, and `DateTime` classes to support unit-based arithmetic, +allowing you to add or subtract durations from time objects naturally. + +#### Adding and Subtracting Durations ```ruby -Time.now + Unit.new("10 min") +Time.now + Unit.new("10 min") #=> 10 minutes from now +Time.now - Unit.new("2 hours") #=> 2 hours ago + +Date.today + Unit.new("1 week") #=> 7 days from today +Date.today - Unit.new("30 days") #=> 30 days ago ``` -Several helpers have also been defined. Note: If you include the 'Chronic' gem, -you can specify times in natural language. +**Important:** When adding or subtracting large time units (years, decades, centuries), +the duration is first converted to days and rounded to maintain calendar accuracy. +This means `1 year` is treated as approximately 365 days rather than an exact number of seconds. ```ruby -Unit.new('min').since(DateTime.parse('9/18/06 3:00pm')) +Time.now + Unit.new("1 year") #=> Approximately 365 days from now +Time.now - Unit.new("1 decade") #=> Approximately 3650 days ago +``` + +For more precise durations, use smaller units (hours, minutes, seconds): + +```ruby +Time.now + Unit.new("24 hours") #=> Exactly 24 hours from now +``` + +#### Converting Time and Date to Units + +You can convert `Time` objects to units representing the duration since the Unix epoch: + +```ruby +Time.now.to_unit #=> Duration in seconds since epoch +Time.now.to_unit('hours') #=> Duration in hours since epoch +Time.now.to_unit('days') #=> Duration in days since epoch +``` + +You can convert `Date` objects to units representing days since the Julian calendar start: + +```ruby +Date.today.to_unit #=> Duration in days since Julian calendar start +Date.today.to_unit('week') #=> Duration in weeks since Julian calendar start +Date.today.to_unit('year') #=> Duration in years since Julian calendar start +``` + +#### Creating Time from Units + +Use `Time.at` to create a Time object from a duration unit: + +```ruby +Time.at(Unit.new("1000 seconds")) #=> Time 1000 seconds after epoch +Time.at(Unit.new("1 hour"), 500, :ms) #=> Time 1 hour + 500 milliseconds after epoch ``` +#### Convenience Methods + +The `Time.in` method provides a shorthand for calculating future times: + +```ruby +Time.in('5 min') #=> 5 minutes from now +Time.in('2 hours') #=> 2 hours from now +``` + +#### Duration Formats + Durations may be entered as 'HH:MM:SS, usec' and will be returned in 'hours'. ```ruby @@ -172,6 +236,21 @@ Unit.new('0:30:30') #=> 0.5 h + 30 sec If only one ":" is present, it is interpreted as the separator between hours and minutes. +#### Compatibility with Chronic + +Several helpers are available for working with natural language time parsing. +Note: If you include the 'Chronic' gem, you can specify times in natural language. + +```ruby +Unit.new('min').since(DateTime.parse('9/18/06 3:00pm')) +``` + +#### Range Errors and DateTime Fallback + +If time arithmetic would result in a date outside the valid range for the `Time` class +(typically 1970-2038 on 32-bit systems), ruby-units automatically falls back to using +`DateTime` objects to handle the calculation. + ### Ranges ```ruby @@ -180,10 +259,68 @@ minutes. works so long as the starting point has an integer scalar -### Math functions +### Math Functions + +Ruby-units extends the `Math` module to support Unit objects seamlessly. All trigonometric +and mathematical functions work with units, handling conversions automatically. + +#### Supported Functions + +**Trigonometric Functions** (angles converted to radians automatically): +- `sin`, `cos`, `tan` - Standard trigonometric functions +- `sinh`, `cosh`, `tanh` - Hyperbolic trigonometric functions + +**Inverse Trigonometric Functions** (return angles in radians as Unit objects): +- `asin`, `acos`, `atan` - Inverse trigonometric functions +- `atan2` - Two-argument arctangent for full quadrant determination + +**Root Functions** (preserve dimensional analysis): +- `sqrt` - Square root (e.g., √(4 m²) = 2 m) +- `cbrt` - Cube root (e.g., ³√(27 m³) = 3 m) + +**Other Functions**: +- `hypot` - Euclidean distance calculation with units +- `log`, `log10` - Logarithmic functions (extract scalar from units) -All Trig math functions (sin, cos, sinh, hypot...) can take a unit as their -parameter. It will be converted to radians and then used if possible. +#### Examples + +```ruby +# Trigonometric functions with angle units +Math.sin(Unit.new("90 deg")) #=> 1.0 +Math.cos(Unit.new("180 deg")) #=> -1.0 +Math.tan(Unit.new("45 deg")) #=> 1.0 + +# Works with different angle units +Math.sin(Unit.new("1.571 rad")) #=> 1.0 (approximately π/2) +Math.cos(Unit.new("3.14159 rad")) #=> -1.0 (approximately π) + +# Inverse functions return Unit objects in radians +Math.asin(0.5) #=> Unit.new("0.524 rad") (30°) +Math.atan(1) #=> Unit.new("0.785 rad") (45°) +Math.acos(0) #=> Unit.new("1.571 rad") (90°) + +# Root functions preserve dimensional analysis +Math.sqrt(Unit.new("4 m^2")) #=> Unit.new("2 m") +Math.cbrt(Unit.new("27 m^3")) #=> Unit.new("3 m") +Math.sqrt(Unit.new("9 kg*m/s^2")) #=> Unit.new("3 kg^(1/2)*m^(1/2)/s") + +# Hypot for distance calculations (Pythagorean theorem) +Math.hypot(Unit.new("3 m"), Unit.new("4 m")) #=> Unit.new("5 m") +Math.hypot(Unit.new("30 cm"), Unit.new("40 cm")) #=> Unit.new("50 cm") + +# atan2 for converting Cartesian to polar coordinates +Math.atan2(Unit.new("1 m"), Unit.new("1 m")) #=> Unit.new("0.785 rad") (45°) +Math.atan2(Unit.new("1 m"), Unit.new("0 m")) #=> Unit.new("1.571 rad") (90°) + +# Logarithmic functions (units must be compatible for input) +Math.log10(Unit.new("100")) #=> 2.0 +Math.log(Unit.new("2.718")) #=> 1.0 (natural log, approximately) +Math.log(Unit.new("8"), 2) #=> 3.0 (log base 2) +``` + +**Note:** Trigonometric functions expect angular units or dimensionless numbers. If you pass +a Unit with dimensions (like meters), it will be converted to radians, which may produce +unexpected results. ### Temperatures @@ -238,13 +375,37 @@ It is possible to define new units or redefine existing ones. #### Define New Unit -The easiest approach is to define a unit in terms of other units. +The easiest approach is to define a unit in terms of other units using the block form. ```ruby Unit.define("foobar") do |foobar| foobar.definition = Unit.new("1 foo") * Unit.new("1 bar") # anything that results in a Unit object - foobar.aliases = %w{foobar fb} # array of synonyms for the unit - foobar.display_name = "Foobar" # How unit is displayed when output + foobar.aliases = %w{foobar fb} # array of synonyms for the unit + foobar.display_name = "Foobar" # How unit is displayed when output +end +``` + +You can also create a unit definition directly and pass it to `Unit.define`: + +```ruby +unit_definition = Unit::Definition.new("foobar") do |foobar| + foobar.definition = Unit.new("1 baz") + foobar.aliases = %w{foobar fb} + foobar.display_name = "Foobar" +end +Unit.define(unit_definition) +``` + +For more control, you can set the unit attributes explicitly: + +```ruby +Unit.define("electron-volt") do |ev| + ev.aliases = %w{eV electron-volt electron_volt} + ev.scalar = 1.602e-19 + ev.kind = :energy + ev.numerator = %w{ } + ev.denominator = %w{ } + ev.display_name = "electron-volt" end ``` @@ -306,14 +467,44 @@ Configuration options can be set like: ```ruby RubyUnits.configure do |config| config.format = :rational - config.separator = false + config.separator = :none + config.default_precision = 0.001 end ``` -| Option | Description | Valid Values | Default | -| --------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------- | ----------- | -| format | Only used for output formatting. `:rational` is formatted like `3 m/s^2`. `:exponential` is formatted like `3 m*s^-2`. | `:rational, :exponential` | `:rational` | -| separator | Use a space separator for output. `true` is formatted like `3 m/s`, `false` is like `3m/s`. | `true, false` | `true` | +#### Configuration Options + +| Option | Description | Valid Values | Default | +|---------------------|------------------------------------------------------------------------------------------------------------------------|-----------------------------|-------------| +| `format` | Only used for output formatting. `:rational` is formatted like `3 m/s^2`. `:exponential` is formatted like `3 m*s^-2`. | `:rational`, `:exponential` | `:rational` | +| `separator` | Use a space separator for output. `:space` is formatted like `3 m/s`, `:none` is like `3m/s`. | `:space`, `:none` | `:space` | +| `default_precision` | The precision used when rationalizing fractional values in unit output. | Any positive number | `0.0001` | + +#### Examples + +```ruby +# Change output format to exponential notation +RubyUnits.configure do |config| + config.format = :exponential +end +# => "1 m*s^-2" + +# Remove spaces between numbers and units +RubyUnits.configure do |config| + config.separator = :none +end +# => "1m/s" + +# Adjust precision for rational number conversion +RubyUnits.configure do |config| + config.default_precision = 0.001 +end + +# Reset to defaults +RubyUnits.reset +``` + +**Note:** Boolean values (`true`/`false`) for `separator` are deprecated but still supported for backward compatibility. Use `:space` instead of `true` and `:none` instead of `false`. ### NOTES diff --git a/lib/ruby_units/configuration.rb b/lib/ruby_units/configuration.rb index 3d4206f..0c32e06 100644 --- a/lib/ruby_units/configuration.rb +++ b/lib/ruby_units/configuration.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true +# RubyUnits provides a comprehensive unit conversion and manipulation library for Ruby. +# It allows for the creation, conversion, and mathematical operations on physical quantities +# with associated units of measurement. module RubyUnits class << self - attr_writer :configuration - end - - def self.configuration - @configuration ||= Configuration.new + # Get or initialize the configuration + # @return [Configuration] the configuration instance + def configuration + @configuration ||= Configuration.new + end end # Reset the configuration to the default values @@ -14,25 +17,62 @@ def self.reset @configuration = Configuration.new end - # allow for optional configuration of RubyUnits + # Allow for optional configuration of RubyUnits # # Usage: # # RubyUnits.configure do |config| - # config.separator = false + # config.separator = :none # end def self.configure yield configuration end - # holds actual configuration values for RubyUnits + # Configuration class for RubyUnits + # + # This class manages global configuration settings that control how units are + # formatted and represented throughout the RubyUnits library. It provides a + # centralized way to customize output behavior without modifying individual + # Unit instances. + # + # == Configuration Options + # + # [separator] + # Controls the spacing between numeric values and unit strings in output. + # - `:space` (default): Adds a single space (e.g., "5 m") + # - `:none`: No space is added (e.g., "5m") + # + # [format] + # Determines the notation style for unit representation. + # - `:rational` (default): Uses numerator/denominator notation (e.g., "3 m/s^2") + # - `:exponential`: Uses exponential notation (e.g., "3 m*s^-2") + # + # [default_precision] + # Sets the precision level when converting fractional unit values to rationals. + # Default is 0.0001. Must be a positive number. + # + # == Usage + # + # # Access global configuration + # config = RubyUnits.configuration + # + # # Configure via block + # RubyUnits.configure do |config| + # config.separator = :none + # config.format = :exponential + # config.default_precision = 0.0001 + # end + # + # # Reset to defaults + # RubyUnits.reset + # class Configuration # Used to separate the scalar from the unit when generating output. A value - # of `true` will insert a single space, and `false` will prevent adding a + # of `:space` will insert a single space, and `:none` will prevent adding a # space to the string representation of a unit. # # @!attribute [rw] separator - # @return [Boolean] whether to include a space between the scalar and the unit + # @return [String, nil] the separator string (" " for :space, nil for :none) attr_reader :separator # The style of format to use by default when generating output. When set to `:exponential`, all units will be @@ -42,29 +82,60 @@ class Configuration # @return [Symbol] the format to use when generating output (:rational or :exponential) (default: :rational) attr_reader :format + # The default precision to use when rationalizing fractional values in unit output. + # + # @!attribute [rw] default_precision + # @return [Numeric] the precision to use when converting to a rational (default: 0.0001) + attr_reader :default_precision + # Initialize configuration with keyword arguments # - # @param separator [Boolean] whether to include a space between the scalar and the unit (default: true) - # @param format [Symbol] the format to use when generating output (default: :rational) - # @param _options [Hash] additional keyword arguments (ignored, for forward compatibility) + # @param separator [Symbol, Boolean] the separator to use (:space or :none, true/false for backward compatibility) (default: :space) + # @param format [Symbol] the format to use when generating output (:rational or :exponential) (default: :rational) + # @param default_precision [Numeric] the precision to use when converting to a rational (default: 0.0001) # @return [Configuration] a new configuration instance - def initialize(separator: true, format: :rational, **_options) + def initialize(separator: :space, format: :rational, default_precision: 0.0001) self.separator = separator self.format = format + self.default_precision = default_precision end - # Use a space for the separator to use when generating output. + # Set the separator to use when generating output. # - # @param value [Boolean] whether to include a space between the scalar and the unit + # @param value [Symbol, Boolean] the separator to use (:space or :none, true/false for backward compatibility) # @return [void] def separator=(value) - raise ArgumentError, "configuration 'separator' may only be true or false" unless [true, false].include?(value) + normalized_value = normalize_separator_value(value) + validate_separator_value(normalized_value) + @separator = normalized_value == :space ? " " : nil + end + + private + + # Normalize deprecated boolean separator values to symbols + # @param value [Symbol, Boolean] the separator value + # @return [Symbol] the normalized separator value + def normalize_separator_value(value) + return value unless [true, false].include?(value) + + warn "DEPRECATION WARNING: Using boolean values for separator is deprecated. Use :space instead of true and :none instead of false." + value ? :space : :none + end + + # Validate the separator value + # @param value [Symbol] the separator value to validate + # @return [void] + # @raise [ArgumentError] if the value is not valid + def validate_separator_value(value) + return if %i[space none].include?(value) - @separator = value ? " " : nil + raise ArgumentError, "configuration 'separator' may only be :space or :none" end + public + # Set the format to use when generating output. - # The `:rational` style will generate units string like `3 m/s^2` and the `:exponential` style will generate units + # The `:rational` style will generate unit strings like `3 m/s^2` and the `:exponential` style will generate units # like `3 m*s^-2`. # # @param value [Symbol] the format to use when generating output (:rational or :exponential) @@ -74,5 +145,15 @@ def format=(value) @format = value end + + # Set the default precision to use when rationalizing fractional values. + # + # @param value [Numeric] the precision to use when converting to a rational + # @return [void] + def default_precision=(value) + raise ArgumentError, "configuration 'default_precision' must be a positive number" unless value.is_a?(Numeric) && value.positive? + + @default_precision = value + end end end diff --git a/lib/ruby_units/date.rb b/lib/ruby_units/date.rb index 2707ee2..59d5725 100644 --- a/lib/ruby_units/date.rb +++ b/lib/ruby_units/date.rb @@ -5,51 +5,84 @@ module RubyUnits # Extra methods for [::Date] to allow it to be used as a [RubyUnits::Unit] module Date - # Allow date objects to do offsets by a time unit + # Add a duration to a date. For large time units (years, decades, centuries), + # the duration is first converted to days and rounded to maintain calendar accuracy. # - # @example Date.today + Unit.new("1 week") => gives today+1 week - # @param [RubyUnits::Unit, Object] other - # @return [RubyUnits::Unit] + # @example Add weeks to a date + # Date.today + Unit.new("1 week") #=> Date 7 days in future + # + # @example Add months (converted to days) + # Date.new(2024, 1, 15) + Unit.new("1 month") #=> Approximately 30 days later + # + # @example Add years (converted to days and rounded) + # Date.today + Unit.new("1 year") #=> Approximately 365 days in future + # + # @param [RubyUnits::Unit, Object] other Duration to add or standard Ruby object for default behavior + # @return [Date] A new Date object with the duration added def +(other) - case other - when RubyUnits::Unit - other = other.convert_to("d").round if %w[y decade century].include? other.units - super(other.convert_to("day").scalar) - else - super - end + return super unless other.is_a?(RubyUnits::Unit) + + duration_in_days = convert_to_days(other) + super(duration_in_days) end - # Allow date objects to do offsets by a time unit + # Subtract a duration from a date. For large time units (years, decades, centuries), + # the duration is first converted to days and rounded to maintain calendar accuracy. + # + # @example Subtract weeks from a date + # Date.today - Unit.new("1 week") #=> Date 7 days in past + # + # @example Subtract months (converted to days) + # Date.new(2024, 3, 15) - Unit.new("1 month") #=> Approximately 30 days earlier + # + # @example Subtract years (converted to days and rounded) + # Date.today - Unit.new("1 year") #=> Approximately 365 days in past # - # @example Date.today - Unit.new("1 week") => gives today-1 week - # @param [RubyUnits::Unit, Object] other - # @return [RubyUnits::Unit] + # @param [RubyUnits::Unit, Object] other Duration to subtract or standard Ruby object for default behavior + # @return [Date, Numeric] A new Date object with duration subtracted, or days between dates if both are Dates def -(other) - case other - when RubyUnits::Unit - other = other.convert_to("d").round if %w[y decade century].include? other.units - super(other.convert_to("day").scalar) - else - super - end + return super unless other.is_a?(RubyUnits::Unit) + + duration_in_days = convert_to_days(other) + super(duration_in_days) end - # Construct a unit from a Date. This returns the number of days since the - # start of the Julian calendar as a Unit. + # Convert a Date object to a Unit representing the number of days since the + # start of the Julian calendar (Julian Day Number). + # + # @example Convert date to unit in days + # Date.today.to_unit #=> Unit representing days since Julian calendar start # - # @example Date.today.to_unit => Unit - # @return [RubyUnits::Unit] - # @param other [RubyUnits::Unit, String] convert to same units as passed + # @example Convert date to unit and change units + # Date.today.to_unit('week') #=> Unit in weeks since Julian calendar start + # Date.today.to_unit('year') #=> Unit in years since Julian calendar start + # + # @param other [RubyUnits::Unit, String, nil] Optional target unit for conversion + # @return [RubyUnits::Unit] A Unit object representing the date as a duration def to_unit(other = nil) other ? RubyUnits::Unit.new(self).convert_to(other) : RubyUnits::Unit.new(self) end - # @deprecated The dump parameter is deprecated and will be removed in a future version. - # @param dump [Boolean] if true, use default inspect; if false, use to_s (deprecated behavior) # @return [String] - def inspect(dump = false) - dump ? super : to_s + def inspect + to_s + end + + private + + # Convert a unit to days, rounding large time units (years, decades, centuries) to days first. + # This handles calendar complexities where years don't have a fixed number of days. + # + # @param unit [RubyUnits::Unit] The duration unit to convert + # @return [Numeric] The duration in days + # :reek:UtilityFunction - Private helper method, state independence is acceptable + def convert_to_days(unit) + normalized_unit = if %w[y decade century].include?(unit.units) + unit.convert_to("d").round + else + unit.convert_to("day") + end + normalized_unit.scalar end end end diff --git a/lib/ruby_units/definition.rb b/lib/ruby_units/definition.rb index 8d9ad01..0095591 100644 --- a/lib/ruby_units/definition.rb +++ b/lib/ruby_units/definition.rb @@ -2,10 +2,10 @@ class RubyUnits::Unit < Numeric # Handle the definition of units + # :reek:TooManyInstanceVariables - These instance variables represent the core attributes of a unit definition + # :reek:Attribute - Setters are needed for the block-based initialization DSL + # :reek:DataClump - name and definition are the constructor parameters passed through helper methods class Definition - # @return [Array] - attr_writer :aliases - # @return [Symbol] attr_accessor :kind @@ -23,77 +23,185 @@ class Definition # back. # # @return [String] + # @example + # definition.display_name = "meter" attr_accessor :display_name - # @example Raw definition from a hash - # Unit::Definition.new("rack-unit",[%w{U rack-U}, (6405920109971793/144115188075855872), :length, %w{} ]) + # Create a new unit definition # - # @example Block form + # @param name [String] the name of the unit (angle brackets will be stripped) + # @param definition [Array] optional array containing definition components: + # - definition[0] [Array] array of aliases for the unit + # - definition[1] [Numeric] scalar conversion factor + # - definition[2] [Symbol] the kind of unit (e.g., :length, :mass, :time) + # - definition[3] [Array] numerator units (in base unit form) + # - definition[4] [Array] denominator units (in base unit form) + # @yield [self] optional block to configure the definition using DSL + # + # @example Array-based definition (legacy form) + # Unit::Definition.new("rack-unit", [%w{U rack-U}, Rational(6405920109971793, 144115188075855872), :length, %w{}]) + # + # @example Block-based definition (recommended) # Unit::Definition.new("rack-unit") do |unit| # unit.aliases = %w{U rack-U} # unit.definition = RubyUnits::Unit.new("7/4 inches") # end # + # @example Block-based definition with explicit attributes + # Unit::Definition.new("electron-volt") do |unit| + # unit.aliases = %w{eV electron-volt} + # unit.scalar = 1.602e-19 + # unit.kind = :energy + # unit.numerator = %w{ } + # unit.denominator = %w{ } + # end + # def initialize(name, definition = []) + @name = nil + @aliases = nil + @scalar = nil + @kind = nil + @numerator = nil + @denominator = nil + @display_name = nil yield self if block_given? - self.name ||= name.gsub(/[<>]/, "") - @aliases ||= definition[0] || [name] - @scalar ||= definition[1] - @kind ||= definition[2] - @numerator ||= definition[3] || RubyUnits::Unit::UNITY_ARRAY - @denominator ||= definition[4] || RubyUnits::Unit::UNITY_ARRAY - @display_name ||= @aliases.first + set_defaults(name, definition) end - # name of the unit + # Get the unit's name with angle brackets + # # nil if name is not set, adds '<' and '>' around the name - # @return [String, nil] + # @return [String, nil] the unit name wrapped in angle brackets, or nil if name not set + # @example + # definition.name #=> "" # @todo refactor Unit and Unit::Definition so we don't need to wrap units with angle brackets def name - "<#{@name}>" if defined?(@name) && @name + "<#{@name}>" if @name end - # set the name, strip off '<' and '>' - # @param name_value [String] - # @return [String] - def name=(name_value) - @name = name_value.gsub(/[<>]/, "") - end - - # alias array must contain the name of the unit and entries must be unique - # @return [Array] + # Get the array of aliases for this unit + # + # The alias array must contain the name of the unit and entries must be unique. + # This method combines all aliases, the name, and display_name into a unique array. + # @return [Array] unique array of all aliases, name, and display_name + # @example + # definition.aliases #=> ["m", "meter", "meters", "metre", "metres"] def aliases [[@aliases], @name, @display_name].flatten.compact.uniq end - # define a unit in terms of another unit - # @param [Unit] unit - # @return [Unit::Definition] + # Set the aliases for this unit + # @!attribute [w] aliases + # @param value [Array] array of string aliases + # @return [Array] + # @example + # definition.aliases = %w{foo bar baz} + attr_writer :aliases + + # Set the unit's name (angle brackets will be stripped) + # @param name_value [String] the name to assign (angle brackets will be removed) + # @return [String] the name without angle brackets + # @example + # definition.name = "" # stores as "meter" + def name=(name_value) + @name = name_value.gsub(/[<>]/, "") + end + + # Define a unit in terms of another unit + # + # This is a convenience method that allows you to specify a unit definition + # using another Unit object. It will extract the base unit properties from + # the provided unit and assign them to this definition. + # + # @param unit [RubyUnits::Unit] a unit object to use as the definition source + # @return [RubyUnits::Unit::Definition] self + # @example + # definition.definition = RubyUnits::Unit.new("7/4 inches") + # # This will set scalar, kind, numerator, and denominator based on the base unit representation def definition=(unit) - base = unit.to_base - @scalar = base.scalar - @kind = base.kind - @numerator = base.numerator + base = unit.to_base + @scalar = base.scalar + @kind = base.kind + @numerator = base.numerator @denominator = base.denominator end - # is this definition for a prefix? - # @return [Boolean] + # Check if this definition represents a prefix + # @return [Boolean] true if this is a prefix definition + # @example + # kilo_definition.prefix? #=> true + # meter_definition.prefix? #=> false def prefix? = kind == :prefix - # Is this definition the unity definition? - # @return [Boolean] + # Check if this definition is the unity definition (dimensionless with scalar = 1) + # @return [Boolean] true if this is the unity (dimensionless) prefix definition + # @example + # unity_definition.unity? #=> true + # kilo_definition.unity? #=> false def unity? = prefix? && scalar == 1 - # is this a base unit? - # units are base units if the scalar is one, and the unit is defined in terms of itself. - # @return [Boolean] + # Check if this is a base unit definition + # + # Units are base units if: + # - The scalar is exactly 1 + # - The denominator is the unity array (dimensionless) + # - The numerator has exactly one element + # - The numerator references itself (e.g., meter is defined in terms of ) + # + # @return [Boolean] true if this is a base unit definition + # @example + # meter_definition.base? #=> true (defined as 1 ) + # foot_definition.base? #=> false (defined in terms of meters) def base? - (denominator == RubyUnits::Unit::UNITY_ARRAY) && - (numerator != RubyUnits::Unit::UNITY_ARRAY) && - (numerator.size == 1) && - (scalar == 1) && - (numerator.first == self.name) + return false unless scalar == 1 + return false unless single_numerator? + return false unless unity_denominator? + + numerator.first == name + end + + private + + # Set default values for attributes from definition array + # @param name [String] the unit name + # @param definition [Array] array of definition values + # @return [void] + def set_defaults(name, definition) + assign_name(name) + set_attributes_from_definition(name, definition) + end + + # Set the unit name if not already set + # @param name [String] the unit name + # @return [void] + def assign_name(name) + self.name ||= name.gsub(/[<>]/, "") + end + + # Set attributes from the definition array + # @param name [String] the unit name (used as fallback for aliases) + # @param definition [Array] array of definition values + # @return [void] + # :reek:TooManyStatements - This method sets multiple related attributes from the definition + def set_attributes_from_definition(name, definition) + @aliases = @aliases || definition[0] || [name] + @scalar ||= definition[1] + @kind ||= definition[2] + @numerator = @numerator || definition[3] || RubyUnits::Unit::UNITY_ARRAY + @denominator = @denominator || definition[4] || RubyUnits::Unit::UNITY_ARRAY + @display_name ||= @aliases.first # rubocop:disable Naming/MemoizedInstanceVariableName + end + + # Check if denominator is the unity array + # @return [Boolean] + def unity_denominator? + denominator == RubyUnits::Unit::UNITY_ARRAY + end + + # Check if numerator is not unity and has exactly one element + # @return [Boolean] + def single_numerator? + numerator != RubyUnits::Unit::UNITY_ARRAY && numerator.size == 1 end end end diff --git a/lib/ruby_units/math.rb b/lib/ruby_units/math.rb index 6801c90..6bf8187 100644 --- a/lib/ruby_units/math.rb +++ b/lib/ruby_units/math.rb @@ -1,8 +1,36 @@ # frozen_string_literal: true module RubyUnits - # Math will convert unit objects to radians and then attempt to use the value for - # trigonometric functions. + # Extends Ruby's Math module to support RubyUnits::Unit objects. + # + # This module provides unit-aware versions of standard mathematical functions. + # When a Unit object is passed to these functions, it handles unit conversions + # automatically: + # + # - Trigonometric functions (sin, cos, tan, etc.) convert angle units to radians + # - Inverse trigonometric functions return results as Unit objects in radians + # - Root functions (sqrt, cbrt) preserve dimensional analysis + # - Logarithmic functions extract scalar values from units + # - hypot and atan2 handle units properly for vector calculations + # + # @example Using trigonometric functions with units + # Math.sin(Unit.new("90 deg")) #=> 1.0 + # Math.cos(Unit.new("180 deg")) #=> -1.0 + # Math.tan(Unit.new("45 deg")) #=> 1.0 + # + # @example Using inverse trigonometric functions + # Math.asin(0.5) #=> Unit.new("0.524 rad") + # Math.atan(1) #=> Unit.new("0.785 rad") + # + # @example Using root functions with units + # Math.sqrt(Unit.new("4 m^2")) #=> Unit.new("2 m") + # Math.cbrt(Unit.new("27 m^3")) #=> Unit.new("3 m") + # + # @example Using hypot for distance calculations + # Math.hypot(Unit.new("3 m"), Unit.new("4 m")) #=> Unit.new("5 m") + # + # @note This module is prepended to Ruby's Math singleton class, so all functions + # work seamlessly with both regular numbers and Unit objects. module Math # Take the square root of a unit or number # @@ -28,14 +56,26 @@ def cbrt(number) end end - # @param angle [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the sine of an angle. + # + # If the angle is a Unit object, it will be converted to radians before calculation. + # + # @param angle [Numeric, RubyUnits::Unit] angle in radians or any angular unit + # @return [Numeric] the sine of the angle (dimensionless) + # @example + # Math.sin(Unit.new("90 deg")) #=> 1.0 + # Math.sin(Math::PI / 2) #=> 1.0 def sin(angle) angle.is_a?(RubyUnits::Unit) ? super(angle.convert_to("radian").scalar) : super end - # @param number [Numeric, RubyUnits::Unit] - # @return [Numeric, RubyUnits::Unit] + # Calculate the arcsine (inverse sine) of a number. + # + # @param number [Numeric, RubyUnits::Unit] a dimensionless value between -1 and 1 + # @return [Numeric, RubyUnits::Unit] angle in radians (as Unit if input was Unit) + # @example + # Math.asin(0.5) #=> Unit.new("0.524 rad") + # Math.asin(1) #=> Unit.new("1.571 rad") def asin(number) if number.is_a?(RubyUnits::Unit) [super, "radian"].to_unit @@ -44,14 +84,26 @@ def asin(number) end end - # @param angle [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the cosine of an angle. + # + # If the angle is a Unit object, it will be converted to radians before calculation. + # + # @param angle [Numeric, RubyUnits::Unit] angle in radians or any angular unit + # @return [Numeric] the cosine of the angle (dimensionless) + # @example + # Math.cos(Unit.new("180 deg")) #=> -1.0 + # Math.cos(Math::PI) #=> -1.0 def cos(angle) angle.is_a?(RubyUnits::Unit) ? super(angle.convert_to("radian").scalar) : super end - # @param number [Numeric, RubyUnits::Unit] - # @return [Numeric, RubyUnits::Unit] + # Calculate the arccosine (inverse cosine) of a number. + # + # @param number [Numeric, RubyUnits::Unit] a dimensionless value between -1 and 1 + # @return [Numeric, RubyUnits::Unit] angle in radians (as Unit if input was Unit) + # @example + # Math.acos(0) #=> Unit.new("1.571 rad") + # Math.acos(-1) #=> Unit.new("3.142 rad") def acos(number) if number.is_a?(RubyUnits::Unit) [super, "radian"].to_unit @@ -60,33 +112,61 @@ def acos(number) end end - # @param number [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the hyperbolic sine of a number. + # + # If the input is a Unit object, it will be converted to radians before calculation. + # + # @param number [Numeric, RubyUnits::Unit] angle in radians or any angular unit + # @return [Numeric] the hyperbolic sine (dimensionless) def sinh(number) number.is_a?(RubyUnits::Unit) ? super(number.convert_to("radian").scalar) : super end - # @param number [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the hyperbolic cosine of a number. + # + # If the input is a Unit object, it will be converted to radians before calculation. + # + # @param number [Numeric, RubyUnits::Unit] angle in radians or any angular unit + # @return [Numeric] the hyperbolic cosine (dimensionless) def cosh(number) number.is_a?(RubyUnits::Unit) ? super(number.convert_to("radian").scalar) : super end - # @param angle [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the tangent of an angle. + # + # If the angle is a Unit object, it will be converted to radians before calculation. + # + # @param angle [Numeric, RubyUnits::Unit] angle in radians or any angular unit + # @return [Numeric] the tangent of the angle (dimensionless) + # @example + # Math.tan(Unit.new("45 deg")) #=> 1.0 + # Math.tan(Math::PI / 4) #=> 1.0 def tan(angle) angle.is_a?(RubyUnits::Unit) ? super(angle.convert_to("radian").scalar) : super end - # @param number [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the hyperbolic tangent of a number. + # + # If the input is a Unit object, it will be converted to radians before calculation. + # + # @param number [Numeric, RubyUnits::Unit] angle in radians or any angular unit + # @return [Numeric] the hyperbolic tangent (dimensionless) def tanh(number) number.is_a?(RubyUnits::Unit) ? super(number.convert_to("radian").scalar) : super end - # @param x [Numeric, RubyUnits::Unit] - # @param y [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the hypotenuse (Euclidean distance) of a right triangle. + # + # Returns sqrt(x² + y²). If both parameters are Unit objects, the result + # will be a Unit object with the same dimensions. + # + # @param x [Numeric, RubyUnits::Unit] first side of the triangle + # @param y [Numeric, RubyUnits::Unit] second side of the triangle + # @return [Numeric, RubyUnits::Unit] the hypotenuse length + # @example + # Math.hypot(Unit.new("3 m"), Unit.new("4 m")) #=> Unit.new("5 m") + # Math.hypot(3, 4) #=> 5.0 + # :reek:UncommunicativeParameterName def hypot(x, y) if x.is_a?(RubyUnits::Unit) && y.is_a?(RubyUnits::Unit) ((x**2) + (y**2))**Rational(1, 2) @@ -95,9 +175,14 @@ def hypot(x, y) end end - # @param number [Numeric, RubyUnits::Unit] + # Calculate the arctangent (inverse tangent) of a number. + # + # @param number [Numeric, RubyUnits::Unit] a dimensionless value # @return [Numeric] if argument is a number - # @return [RubyUnits::Unit] if argument is a unit + # @return [RubyUnits::Unit] angle in radians if argument is a unit + # @example + # Math.atan(1) #=> Unit.new("0.785 rad") + # Math.atan(0) #=> Unit.new("0 rad") def atan(number) if number.is_a?(RubyUnits::Unit) [super, "radian"].to_unit @@ -106,23 +191,47 @@ def atan(number) end end - # @param x [Numeric, RubyUnits::Unit] - # @param y [Numeric, RubyUnits::Unit] - # @return [Numeric] if all parameters are numbers - # @return [RubyUnits::Unit] if parameters are units - # @raise [ArgumentError] if parameters are not numbers or compatible units + # Calculate the arctangent of y/x using the signs of both to determine the quadrant. + # + # This is useful for converting Cartesian coordinates to polar coordinates. + # If both parameters are Unit objects, they must have compatible dimensions. + # + # @param x [Numeric, RubyUnits::Unit] x-coordinate + # @param y [Numeric, RubyUnits::Unit] y-coordinate + # @return [Numeric] angle in radians if parameters are numbers + # @return [RubyUnits::Unit] angle in radians if parameters are units + # @raise [ArgumentError] if parameters are incompatible units + # @example + # Math.atan2(Unit.new("1 m"), Unit.new("1 m")) #=> Unit.new("0.785 rad") + # Math.atan2(1, 1) #=> 0.7853981633974483 + # :reek:UncommunicativeParameterName + # :reek:UncommunicativeMethodName + # :reek:TooManyStatements def atan2(x, y) - raise ArgumentError, "Incompatible RubyUnits::Units" if x.is_a?(RubyUnits::Unit) && y.is_a?(RubyUnits::Unit) && !x.compatible?(y) + x_is_unit = x.is_a?(RubyUnits::Unit) + y_is_unit = y.is_a?(RubyUnits::Unit) + both_units = x_is_unit && y_is_unit + units_compatible = both_units && x.compatible?(y) + + raise ArgumentError, "Incompatible RubyUnits::Units" if both_units && !units_compatible - if x.is_a?(RubyUnits::Unit) && y.is_a?(RubyUnits::Unit) && x.compatible?(y) + if units_compatible [super(x.base_scalar, y.base_scalar), "radian"].to_unit else super end end - # @param number [Numeric, RubyUnits::Unit] - # @return [Numeric] + # Calculate the base-10 logarithm of a number. + # + # If the input is a Unit object, the scalar value is extracted before calculation. + # + # @param number [Numeric, RubyUnits::Unit] a positive number + # @return [Numeric] the base-10 logarithm (dimensionless) + # @example + # Math.log10(Unit.new("100")) #=> 2.0 + # Math.log10(1000) #=> 3.0 + # :reek:UncommunicativeMethodName def log10(number) if number.is_a?(RubyUnits::Unit) super(number.to_f) @@ -131,9 +240,18 @@ def log10(number) end end - # @param number [Numeric, RubyUnits::Unit] - # @param base [Numeric] - # @return [Numeric] + # Calculate the logarithm of a number with a specified base. + # + # If the input is a Unit object, the scalar value is extracted before calculation. + # Defaults to natural logarithm (base e) if no base is specified. + # + # @param number [Numeric, RubyUnits::Unit] a positive number + # @param base [Numeric] the logarithm base (defaults to e) + # @return [Numeric] the logarithm (dimensionless) + # @example + # Math.log(Unit.new("2.718")) #=> ~1.0 (natural log) + # Math.log(Unit.new("8"), 2) #=> 3.0 (log base 2) + # Math.log(Math::E) #=> 1.0 def log(number, base = ::Math::E) if number.is_a?(RubyUnits::Unit) super(number.to_f, base) diff --git a/lib/ruby_units/string.rb b/lib/ruby_units/string.rb index 231e191..2209752 100644 --- a/lib/ruby_units/string.rb +++ b/lib/ruby_units/string.rb @@ -5,30 +5,66 @@ module RubyUnits # Extra methods for converting [String] objects to [RubyUnits::Unit] objects # and using string formatting with Units. + # + # These extensions allow strings to be easily converted to Unit objects and + # provide Ruby's standard formatting operators for working with units. module String - # Make a string into a unit + # Converts a string into a Unit object # - # @param other [RubyUnits::Unit, String] unit to convert to - # @return [RubyUnits::Unit] + # Parses the string as a unit specification and optionally converts it to + # another unit. The string should follow the standard unit format (e.g., "1 mm", + # "5 kg/s", "10 degC"). + # + # @example Create a simple unit + # "1 mm".to_unit #=> #"], ...> + # @example Create and convert a unit + # "1 mm".to_unit('cm') #=> #"], ...> + # @example Parse a complex unit + # "5 kg*m/s^2".to_unit #=> # + # + # @param other [RubyUnits::Unit, String, nil] optional unit to convert to after parsing + # @return [RubyUnits::Unit] the parsed (and optionally converted) unit def to_unit(other = nil) other ? RubyUnits::Unit.new(self).convert_to(other) : RubyUnits::Unit.new(self) end # Format unit output using formatting codes - # @example '%0.2f' % '1 mm'.to_unit => '1.00 mm' # - # @param other [RubyUnits::Unit, Object] - # @return [String] + # Allows Ruby's string formatting syntax to work with units. When the argument + # is a Unit, it will format the unit using this string as a format template. + # Otherwise, it falls back to the standard String#% behavior. + # + # @example Format a unit with precision + # '%0.2f' % '1 mm'.to_unit #=> '1.00 mm' + # @example Format and convert a unit + # '%0.2f in' % '1 mm'.to_unit #=> '0.04 in' + # @example Standard string formatting (non-unit) + # 'Hello %s' % ['World'] #=> 'Hello World' + # + # @param other [RubyUnits::Unit, Object] the unit to format, or other objects for standard formatting + # @return [String] the formatted string def %(*other) - if other.first.is_a?(RubyUnits::Unit) - other.first.to_s(self) + first_arg = other.first + if first_arg.is_a?(RubyUnits::Unit) + first_arg.to_s(self) else super end end - # @param (see RubyUnits::Unit#convert_to) - # @return (see RubyUnits::Unit#convert_to) + # Converts the string to a Unit and then converts it to another unit + # + # This is a convenience method that combines parsing and conversion into a single + # operation. It's equivalent to calling `to_unit.convert_to(other)`. + # + # @example Convert millimeters to centimeters + # "10 mm".convert_to('cm') #=> #"], ...> + # @example Convert between compound units + # "100 km/h".convert_to('m/s') #=> # + # + # @param other [RubyUnits::Unit, String] the target unit to convert to + # @return [RubyUnits::Unit] the converted unit + # @raise [ArgumentError] if the units are incompatible for conversion def convert_to(other) to_unit.convert_to(other) end diff --git a/lib/ruby_units/time.rb b/lib/ruby_units/time.rb index 8cb6d81..ef225a9 100644 --- a/lib/ruby_units/time.rb +++ b/lib/ruby_units/time.rb @@ -8,77 +8,145 @@ module RubyUnits # is in years, decades, or centuries. This leads to less precise values, but ones that match the # calendar better. module Time - # Class methods for [Time] objects + # Class methods for [::Time] objects module ClassMethods # Convert a duration to a [::Time] object by considering the duration to be - # the number of seconds since the epoch + # the number of seconds since the epoch. # - # @param [Array] args - # @return [::Time] - def at(*args, **kwargs) - case args.first - when RubyUnits::Unit - options = args.last.is_a?(Hash) ? args.pop : kwargs - secondary_unit = args[2] || "microsecond" - case args[1] - when Numeric - super((args.first + RubyUnits::Unit.new(args[1], secondary_unit.to_s)).convert_to("second").scalar, **options) - else - super(args.first.convert_to("second").scalar, **options) - end - else - super - end + # @example Create time from a Unit duration + # Time.at(Unit.new("5 min")) #=> Time object 300 seconds after epoch + # + # @example Create time from Unit with offset + # Time.at(Unit.new("1 h"), 500, :millisecond) #=> Time 1 hour + 500 ms after epoch + # + # @param [Array] args Arguments passed to Time.at + # @return [::Time] A Time object at that many seconds since epoch + def at(*args, **) + first_arg = args.first + return super unless first_arg.is_a?(RubyUnits::Unit) + + time_in_seconds = calculate_time_in_seconds(first_arg, args[1], args[2]) + remaining_args = build_remaining_args(args) + + super(time_in_seconds, *remaining_args, **) end - # @example - # Time.in '5 min' - # @param duration [#to_unit] - # @return [::Time] + # Calculate a future time by adding a duration to the current time. + # This is a convenience method equivalent to Time.now + duration. + # + # @example Get time 5 minutes from now + # Time.in('5 min') #=> Time object 5 minutes in the future + # + # @example Using various duration formats + # Time.in('2 hours') #=> 2 hours from now + # Time.in(Unit.new('30 sec')) #=> 30 seconds from now + # + # @param duration [String, RubyUnits::Unit, #to_unit] A duration that can be converted to a Unit + # @return [::Time] A Time object representing the current time plus the duration + # :reek:UtilityFunction - This is a class method convenience wrapper, state independence is by design def in(duration) ::Time.now + duration.to_unit end + + private + + # Calculate the time in seconds from a Unit and optional offset + # @param base_unit [RubyUnits::Unit] The base time unit + # @param offset_value [Numeric, nil] Optional offset value + # @param offset_unit [String, Symbol, nil] Unit for the offset (default: "microsecond") + # @return [Numeric] Time in seconds + # :reek:UtilityFunction - Private helper method, state independence is acceptable + # :reek:ControlParameter - Default parameter handling is appropriate here + def calculate_time_in_seconds(base_unit, offset_value, offset_unit) + return base_unit.convert_to("second").scalar unless offset_value.is_a?(Numeric) + + unit_str = offset_unit&.to_s || "microsecond" + (base_unit + RubyUnits::Unit.new(offset_value, unit_str)).convert_to("second").scalar + end + + # Build remaining arguments to pass to super, skipping the first 3 processed args + # @param args [Array] Original arguments array + # @return [Array] Remaining arguments after the first three + # :reek:UtilityFunction - Private helper method, state independence is acceptable + def build_remaining_args(args) + args[3..] || [] + end end - # Convert a [::Time] object to a [RubyUnits::Unit] object. The time is - # considered to be a duration with the number of seconds since the epoch. + # Convert a [::Time] object to a [RubyUnits::Unit] object representing + # the duration in seconds since the Unix epoch (January 1, 1970 00:00:00 UTC). + # + # @example Convert time to unit + # Time.now.to_unit #=> Unit representing seconds since epoch + # + # @example Convert time to specific unit + # Time.now.to_unit('hour') #=> Unit in hours since epoch # - # @param other [String, RubyUnits::Unit] - # @return [RubyUnits::Unit] + # @param other [String, RubyUnits::Unit, nil] Optional target unit for conversion + # @return [RubyUnits::Unit] A Unit object representing the seconds since epoch def to_unit(other = nil) - other ? RubyUnits::Unit.new(self).convert_to(other) : RubyUnits::Unit.new(self) + unit = RubyUnits::Unit.new(self) + other ? unit.convert_to(other) : unit end - # @param other [::Time, RubyUnits::Unit] - # @return [RubyUnits::Unit, ::Time] + # Add a duration to a time. For large units (years, decades, centuries), + # the duration is first converted to days and rounded to handle calendar complexities. + # If the result would be out of range for Time, falls back to DateTime. + # + # @example Add hours to time + # Time.now + Unit.new('2 hours') #=> Time 2 hours in future + # + # @example Add years (rounded to days) + # Time.now + Unit.new('1 year') #=> Time ~365 days in future + # + # @param other [::Time, RubyUnits::Unit, Numeric] Value to add + # @return [::Time, DateTime] The resulting time, or DateTime if out of Time range def +(other) - case other - when RubyUnits::Unit - other = other.convert_to("d").round.convert_to("s") if %w[y decade century].include? other.units - begin - super(other.convert_to("s").scalar) - rescue RangeError - to_datetime + other - end - else - super - end + return super unless other.is_a?(RubyUnits::Unit) + + duration_in_seconds = convert_to_seconds(other) + super(duration_in_seconds) + rescue RangeError + to_datetime + other end - # @param other [::Time, RubyUnits::Unit] - # @return [RubyUnits::Unit, ::Time] + # Subtract a duration from a time. For large units (years, decades, centuries), + # the duration is first converted to days and rounded to handle calendar complexities. + # If the result would be out of range for Time, falls back to DateTime. + # + # @example Subtract hours from time + # Time.now - Unit.new('2 hours') #=> Time 2 hours in past + # + # @example Subtract years (rounded to days) + # Time.now - Unit.new('1 year') #=> Time ~365 days in past + # + # @param other [::Time, RubyUnits::Unit, Numeric] Value to subtract + # @return [::Time, DateTime, Numeric] The resulting time (DateTime if out of range), + # or numeric difference in seconds if subtracting another Time def -(other) - case other - when RubyUnits::Unit - other = other.convert_to("d").round.convert_to("s") if %w[y decade century].include? other.units - begin - super(other.convert_to("s").scalar) - rescue RangeError - public_send(:to_datetime) - other - end - else - super - end + return super unless other.is_a?(RubyUnits::Unit) + + duration_in_seconds = convert_to_seconds(other) + super(duration_in_seconds) + rescue RangeError + public_send(:to_datetime) - other + end + + private + + # Convert a unit to seconds, rounding large time units (years, decades, centuries) to days first. + # This handles calendar complexities where years don't have a fixed number of seconds. + # + # @param unit [RubyUnits::Unit] The duration unit to convert + # @return [Numeric] The duration in seconds + # :reek:UtilityFunction - Private helper method, state independence is acceptable + def convert_to_seconds(unit) + normalized_unit = if %w[y decade century].include?(unit.units) + unit.convert_to("d").round.convert_to("s") + else + unit.convert_to("s") + end + normalized_unit.scalar end end end diff --git a/lib/ruby_units/unit.rb b/lib/ruby_units/unit.rb index 264ca8a..04e07bc 100644 --- a/lib/ruby_units/unit.rb +++ b/lib/ruby_units/unit.rb @@ -101,6 +101,25 @@ class << self FAHRENHEIT = [""].freeze RANKINE = [""].freeze CELSIUS = [""].freeze + + # Temperature conversion constants + CELSIUS_OFFSET_TO_KELVIN = 273.15 # offset to convert Celsius to Kelvin + FAHRENHEIT_OFFSET_TO_RANKINE = 459.67 # offset to convert Fahrenheit to Rankine + RATIO_5_9 = Rational(5, 9).freeze # 5/9 ratio for temperature conversions + RATIO_9_5 = Rational(9, 5).freeze # 9/5 ratio for temperature conversions + + # Valid fractional exponents for root operations (1/1 through 1/9) + VALID_ROOT_EXPONENTS = (1..9).map { Rational(1, _1) }.freeze + + # Centesimal constants for prefix calculations + CENTESIMAL_VALUE = Rational(1, 100).freeze # 1/100 + DECILE_VALUE = Rational(1, 10).freeze # 1/10 + + # Imperial/US customary unit conversion constants + INCHES_IN_FOOT = 12 + OUNCES_IN_POUND = 16 + POUNDS_IN_STONE = 14 + @temp_regex = nil @special_format_regex = nil SIGNATURE_VECTOR = %i[ @@ -168,6 +187,13 @@ class << self # Class Methods + # Use this method to refer to the current class inside instance methods which will facilitate inheritance. + # + # @return [Class] + def unit_class + @unit_class ||= self.class + end + # Callback triggered when a subclass is created. This properly sets up the internal variables, and copies # definitions from the parent class. # @@ -297,46 +323,44 @@ def self.parse(input) second.nil? ? new(first) : new(first).convert_to(second) end - # @param q [Numeric] quantity - # @param n [Array] numerator - # @param d [Array] denominator + # @param scalar [Numeric] quantity + # @param numerator_units [Array] numerator + # @param denominator_units [Array] denominator # @return [Hash] - def self.eliminate_terms(q, n, d) - num = n.dup - den = d.dup - num.delete(UNITY) - den.delete(UNITY) + def self.eliminate_terms(scalar, numerator_units, denominator_units) + working_numerator = numerator_units.dup + working_denominator = denominator_units.dup + working_numerator.delete(UNITY) + working_denominator.delete(UNITY) combined = ::Hash.new(0) - [[num, 1], [den, -1]].each do |array, increment| + [[working_numerator, 1], [working_denominator, -1]].each do |array, increment| array.chunk_while { |elt_before, _| definition(elt_before).prefix? } .to_a .each { combined[_1] += increment } end - num = [] - den = [] + result_numerator = [] + result_denominator = [] combined.each do |key, value| if value.positive? - value.times { num << key } + value.times { result_numerator << key } elsif value.negative? - value.abs.times { den << key } + value.abs.times { result_denominator << key } end end - num = UNITY_ARRAY if num.empty? - den = UNITY_ARRAY if den.empty? - scalar = q - numerator = num.flatten - denominator = den.flatten - { scalar:, numerator:, denominator: } + result_numerator = UNITY_ARRAY if result_numerator.empty? + result_denominator = UNITY_ARRAY if result_denominator.empty? + + { scalar:, numerator: result_numerator.flatten, denominator: result_denominator.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)) + unit_class.new(unit_class.eliminate_terms(@scalar, @numerator, @denominator)) end # return an array of base units @@ -363,9 +387,9 @@ def self.parse_into_numbers_and_units(string) # 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) + whole_part = Regexp.last_match(2).to_i + fractional_part = Rational(Regexp.last_match(3).to_i, Regexp.last_match(4).to_i) + sign * (whole_part + fractional_part) else num.to_f end, @@ -399,10 +423,7 @@ def self.prefix_regex 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&.aliases - end.flatten.compact + aliases = temp_units.filter_map { |unit| definition(unit)&.aliases }.flatten regex_str = aliases.empty? ? "(?!x)x" : aliases.join("|") Regexp.new "(?:#{regex_str})" end @@ -432,20 +453,36 @@ def self.use_definition(definition) @unit_match_regex = nil # invalidate the unit match regex @temp_regex = nil # invalidate the temp regex @special_format_regex = nil # invalidate the special format regex + definition_name = definition.name + definition_aliases = definition.aliases + definition_scalar = definition.scalar if definition.prefix? - prefix_values[definition.name] = definition.scalar - definition.aliases.each { prefix_map[_1] = definition.name } + 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_value = unit_values[definition_name] = {} + definition_numerator = definition.numerator + definition_denominator = definition.denominator + unit_value[:scalar] = definition_scalar + unit_value[:numerator] = definition_numerator if definition_numerator + unit_value[:denominator] = definition_denominator if definition_denominator + definition_aliases.each { unit_map[_1] = definition_name } @unit_regex = nil # invalidate the unit regex end end + # Format a fraction part with optional rationalization + # @param frac [Float] the fractional part + # @param precision [Float] the precision for rationalization + # @return [String] the formatted fraction string + def self.format_fraction(frac, precision: RubyUnits.configuration.default_precision) + return "" if frac.zero? + + rationalized = frac.rationalize(precision) + "-#{rationalized}" + end + include Comparable # @return [Numeric] @@ -520,7 +557,7 @@ def initialize(*options) # return the kind of the unit (:mass, :length, etc...) # @return [Symbol] def kind - self.class.kinds[signature] + unit_class.kinds[signature] end # Convert the unit to a Unit, possibly performing a conversion. @@ -543,7 +580,7 @@ def base? @base = (@numerator + @denominator) .compact .uniq - .map { self.class.definition(_1) } + .map { unit_class.definition(_1) } .all? { _1.unity? || _1.base? } @base end @@ -557,8 +594,8 @@ def base? def to_base return self if base? - if self.class.unit_map[units] =~ /\A<(?:temp|deg)[CRF]>\Z/ - @signature = self.class.kinds.key(:temperature) + if unit_class.unit_map[units] =~ /\A<(?:temp|deg)[CRF]>\Z/ + @signature = unit_class.kinds.key(:temperature) base = if temperature? convert_to("tempK") elsif degree? @@ -567,44 +604,57 @@ def to_base return base end - cached_unit = self.class.base_unit_cache.get(units) + cached_unit = unit_class.base_unit_cache.get(units) return cached_unit * scalar unless cached_unit.nil? num = [] den = [] - q = Rational(1) + conversion_factor = Rational(1) + prefix_vals = unit_class.prefix_values + unit_vals = unit_class.unit_values @numerator.compact.each do |num_unit| - if self.class.prefix_values[num_unit] - q *= self.class.prefix_values[num_unit] + prefix_value = prefix_vals[num_unit] + if prefix_value + conversion_factor *= prefix_value 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] + unit_value = unit_vals[num_unit] + if unit_value + unit_scalar = unit_value[:scalar] + unit_numerator = unit_value[:numerator] + unit_denominator = unit_value[:denominator] + conversion_factor *= unit_scalar + num << unit_numerator if unit_numerator + den << unit_denominator if unit_denominator + end end end @denominator.compact.each do |num_unit| - if self.class.prefix_values[num_unit] - q /= self.class.prefix_values[num_unit] + prefix_value = prefix_vals[num_unit] + if prefix_value + conversion_factor /= prefix_value 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] + unit_value = unit_vals[num_unit] + if unit_value + unit_scalar = unit_value[:scalar] + unit_numerator = unit_value[:numerator] + unit_denominator = unit_value[:denominator] + conversion_factor /= unit_scalar + den << unit_numerator if unit_numerator + num << unit_denominator if unit_denominator + end 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 = unit_class.new(unit_class.eliminate_terms(conversion_factor, num, den)) + unit_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") @@ -621,67 +671,31 @@ def to_base # @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) + def to_s(target_units = nil, precision: RubyUnits.configuration.default_precision, 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 = "#{'-' if negative?}#{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 = "#{'-' if negative?}#{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 = "#{'-' if negative?}#{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 + out = case target_units + when :ft + to_feet_inches(precision: precision) + when :lbs + to_pounds_ounces(precision: precision) + when :stone + to_stone_pounds(precision: precision) + when String + convert_string_target(target_units, format) + else + format_scalar(units(format: format)) + 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 The dump parameter is deprecated. Use the default inspect behavior for debugging. - # @param dump [Symbol, nil] pass :dump to see internal structure (deprecated) + # Pretty prints the unit as a string. + # To see the internal structure, use the standard Ruby inspect via Kernel#p or similar # @return [String] - def inspect(dump = nil) - return super() if dump - + def inspect to_s end @@ -689,7 +703,7 @@ def inspect(dump = nil) # @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) + degree? && units.match?(unit_class.temp_regex) end alias is_temperature? temperature? @@ -708,7 +722,7 @@ def degree? def temperature_scale return nil unless temperature? - "deg#{self.class.unit_map[units][/temp([CFRK])/, 1]}" + "deg#{unit_class.unit_map[units][/temp([CFRK])/, 1]}" end # returns true if no associated units @@ -736,8 +750,8 @@ def <=>(other) base_scalar <=> other.base_scalar else - x, y = coerce(other) - y <=> x + coerced_unit, coerced_other = coerce(other) + coerced_other <=> coerced_unit end end @@ -758,8 +772,8 @@ def ==(other) base_scalar == other.base_scalar else begin - x, y = coerce(other) - x == y + coerced_unit, coerced_other = coerce(other) + coerced_unit == coerced_other rescue ArgumentError # return false when object cannot be coerced false end @@ -779,8 +793,8 @@ def ==(other) def =~(other) return signature == other.signature if other.is_a?(Unit) - x, y = coerce(other) - x =~ y + coerced_unit, coerced_other = coerce(other) + coerced_unit =~ coerced_other rescue ArgumentError # return false when `other` cannot be converted to a [Unit] false end @@ -800,8 +814,8 @@ def ===(other) (scalar == other.scalar) && (units == other.units) else begin - x, y = coerce(other) - x.same_as?(y) + coerced_unit, coerced_other = coerce(other) + coerced_unit.same_as?(coerced_other) rescue ArgumentError false end @@ -828,11 +842,11 @@ def +(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) + unit_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) + unit_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) + unit_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}')" @@ -840,8 +854,8 @@ def +(other) 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 + coerced_unit, coerced_other = coerce(other) + coerced_other + coerced_unit end end @@ -855,20 +869,22 @@ def -(other) case other when Unit if zero? + other_copy = other.dup if other.zero? - other.dup * -1 # preserve Units class + other_copy * -1 # preserve Units class else - -other.dup + -other_copy end elsif self =~ other + scalar_difference = base_scalar - other.base_scalar 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) + unit_class.new(scalar: scalar_difference, numerator: KELVIN, denominator: UNITY_ARRAY, signature: @signature).convert_to(temperature_scale) elsif temperature? - self.class.new(scalar: (base_scalar - other.base_scalar), numerator: [""], denominator: UNITY_ARRAY, signature: @signature).convert_to(self) + unit_class.new(scalar: scalar_difference, numerator: [""], 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) + unit_class.new(scalar: scalar_difference, numerator: base.numerator, denominator: base.denominator, signature: @signature).convert_to(self) end else raise ArgumentError, "Incompatible Units ('#{self}' not compatible with '#{other}')" @@ -876,8 +892,8 @@ def -(other) 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 + coerced_unit, coerced_other = coerce(other) + coerced_other - coerced_unit end end @@ -890,14 +906,14 @@ def *(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 = unit_class.eliminate_terms(@scalar * other.scalar, @numerator + other.numerator, @denominator + other.denominator) opts[:signature] = @signature + other.signature - self.class.new(opts) + unit_class.new(opts) when Numeric - self.class.new(scalar: @scalar * other, numerator: @numerator, denominator: @denominator, signature: @signature) + unit_class.new(scalar: @scalar * other, numerator: @numerator, denominator: @denominator, signature: @signature) else - x, y = coerce(other) - x * y + coerced_unit, coerced_other = coerce(other) + coerced_unit * coerced_other end end @@ -913,20 +929,18 @@ def /(other) 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) + sc = unit_class.simplify_rational(Rational(@scalar, other.scalar)) + opts = unit_class.eliminate_terms(sc, @numerator + other.denominator, @denominator + other.numerator) opts[:signature] = @signature - other.signature - self.class.new(opts) + unit_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) + sc = unit_class.simplify_rational(Rational(@scalar, other)) + unit_class.new(scalar: sc, numerator: @numerator, denominator: @denominator, signature: @signature) else - x, y = coerce(other) - y / x + coerced_unit, coerced_other = coerce(other) + coerced_other / coerced_unit end end @@ -938,7 +952,7 @@ def /(other) 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) + unit_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 @@ -960,7 +974,7 @@ def divmod(other) 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) + unit_class.new(base_scalar % other.to_unit.base_scalar, to_base.units).convert_to(self) end alias modulo % @@ -1003,10 +1017,10 @@ def **(other) when Integer power(other) when Float - return self**other.to_i if other == other.to_i + other_as_int = other.to_i + return self**other_as_int if other == other_as_int - valid = (1..9).map { Rational(1, _1) } - raise ArgumentError, "Not a n-th root (1..9), use 1/n" unless valid.include? other.abs + raise ArgumentError, "Not a n-th root (1..9), use 1/n" unless VALID_ROOT_EXPONENTS.include? other.abs root(Rational(1, other).to_int) when Complex @@ -1019,62 +1033,67 @@ def **(other) # Raise a unit to a power. # Returns the unit raised to the n-th power. # - # @param n [Integer] the exponent (must be an integer) + # @param exponent [Integer] the exponent (must be an integer) # @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] when exponent is not an integer + def power(exponent) 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 + raise ArgumentError, "Exponent must an Integer" unless exponent.is_a?(Integer) + return inverse if exponent == -1 + return 1 if exponent.zero? + return self if exponent == 1 - (1..-(n - 1).to_i).inject(self) { |acc, _elem| acc / self } + iterations = (exponent - 1).to_i.abs + return (1..iterations).inject(self) { |acc, _elem| acc * self } if exponent >= 0 + + (1..iterations).inject(self) { |acc, _elem| acc / self } end # Calculates the n-th root of a unit # Returns the nth root of a unit. - # If n < 0, returns 1/unit^(1/n) + # If exponent < 0, returns 1/unit^(1/exponent) # - # @param n [Integer] the root degree (must be an integer, cannot be 0) + # @param exponent [Integer] the root degree (must be an integer, cannot be 0) # @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] when exponent is not an integer + # @raise [ArgumentError] when exponent is 0 + def root(exponent) 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? + raise ArgumentError, "Exponent must an Integer" unless exponent.is_a?(Integer) + raise ArgumentError, "0th root undefined" if exponent.zero? + return self if exponent == 1 + return root(exponent.abs).inverse if exponent.negative? - vec = unit_signature_vector - vec = vec.map { _1 % n } - raise ArgumentError, "Illegal root" unless vec.max.zero? + signature_vector = unit_signature_vector + signature_vector = signature_vector.map { _1 % exponent } + raise ArgumentError, "Illegal root" unless signature_vector.max.zero? - num = @numerator.dup - den = @denominator.dup + result_numerator = @numerator.dup + result_denominator = @denominator.dup + items_to_remove_per_unit = exponent - 1 @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)) } + count = result_numerator.count(item) + count_over_exponent = count / exponent + removals = (count_over_exponent * items_to_remove_per_unit).to_int + removals.times { result_numerator.delete_at(result_numerator.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)) } + count = result_denominator.count(item) + count_over_exponent = count / exponent + removals = (count_over_exponent * items_to_remove_per_unit).to_int + removals.times { result_denominator.delete_at(result_denominator.index(item)) } end - self.class.new(scalar: @scalar**Rational(1, n), numerator: num, denominator: den) + unit_class.new(scalar: @scalar**Rational(1, exponent), numerator: result_numerator, denominator: result_denominator) end # returns inverse of Unit (1/unit) # @return [Unit] def inverse - self.class.new("1") / self + unit_class.new("1") / self end # convert to a specified unit string or to the same units as another Unit @@ -1105,7 +1124,7 @@ def convert_to(other) 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) + if (other.is_a?(Unit) && other.temperature?) || (other.is_a?(String) && other =~ unit_class.temp_regex) raise ArgumentError, "Receiver is not a temperature unit" unless degree? start_unit = units @@ -1121,35 +1140,39 @@ def convert_to(other) return self if target_unit == start_unit # @type [Numeric] - @base_scalar ||= case self.class.unit_map[start_unit] + unit_map = unit_class.unit_map + scalar_rational = @scalar.to_r + @base_scalar ||= case unit_map[start_unit] when "" - @scalar + 273.15 + @scalar + CELSIUS_OFFSET_TO_KELVIN when "" @scalar when "" - (@scalar + 459.67).to_r * Rational(5, 9) + (@scalar + FAHRENHEIT_OFFSET_TO_RANKINE).to_r * RATIO_5_9 when "" - @scalar.to_r * Rational(5, 9) + scalar_rational * RATIO_5_9 end # @type [Numeric] - q = case self.class.unit_map[target_unit] - when "" - @base_scalar - 273.15 - when "" - @base_scalar - when "" - (@base_scalar.to_r * Rational(9, 5)) - 459.67r - when "" - @base_scalar.to_r * Rational(9, 5) - end - self.class.new("#{q} #{target_unit}") + base_scalar_rational = @base_scalar.to_r + base_times_ratio_nine_fifths = base_scalar_rational * RATIO_9_5 + result_scalar = case unit_map[target_unit] + when "" + @base_scalar - CELSIUS_OFFSET_TO_KELVIN + when "" + @base_scalar + when "" + base_times_ratio_nine_fifths - FAHRENHEIT_OFFSET_TO_RANKINE + when "" + base_times_ratio_nine_fifths + end + unit_class.new("#{result_scalar} #{target_unit}") else # @type [Unit] target = case other when Unit other when String - self.class.new(other) + unit_class.new(other) else raise ArgumentError, "Unknown target units" end @@ -1157,21 +1180,27 @@ def convert_to(other) 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 + prefix_vals = unit_class.prefix_values + unit_vals = unit_class.unit_values + to_scalar = ->(unit_array) { unit_array.map { prefix_vals[_1] || _1 }.map { _1.is_a?(Numeric) ? _1 : unit_vals[_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 + target_num = target.numerator + target_den = target.denominator + source_numerator_values = to_scalar.call(@numerator) + source_denominator_values = to_scalar.call(@denominator) + target_numerator_values = to_scalar.call(target_num) + target_denominator_values = to_scalar.call(target_den) # @type [Rational, Numeric] - conversion_scalar = @scalar.is_a?(Integer) ? @scalar.to_r : @scalar - q = conversion_scalar * (numerator1 + denominator2).reduce(1, :*) / (numerator2 + denominator1).reduce(1, :*) + scalar_is_integer = @scalar.is_a?(Integer) + conversion_scalar = scalar_is_integer ? @scalar.to_r : @scalar + converted_value = conversion_scalar * (source_numerator_values + target_denominator_values).reduce(1, :*) / (target_numerator_values + source_denominator_values).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) + if scalar_is_integer + converted_as_int = converted_value.to_i + converted_value = converted_as_int if converted_as_int == converted_value + end + unit_class.new(scalar: converted_value, numerator: target_num, denominator: target_den, signature: target.signature) end end @@ -1236,36 +1265,34 @@ def units(with_prefix: true, format: nil) num = @numerator.clone.compact den = @denominator.clone.compact - unless num == UNITY_ARRAY - definitions = num.map { self.class.definition(_1) } + process_unit_array = lambda do |unit_array| + definitions = unit_array.map { unit_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 } + definitions.chunk_while { |definition, _| definition.prefix? }.to_a.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 } + output_numerator = process_unit_array.call(num) unless num == UNITY_ARRAY + output_denominator = process_unit_array.call(den) unless den == UNITY_ARRAY + + format_output = lambda do |output_array, exponential_negative = false| + output_array.uniq.map do |element| + count = output_array.count(element) + element_str = element.to_s.strip + if exponential_negative + element_str + (count.positive? ? "^#{-count}" : "") + else + element_str + (count > 1 ? "^#{count}" : "") + end + end end - on = output_numerator - .uniq - .map { [_1, output_numerator.count(_1)] } - .map { |element, power| (element.to_s.strip + (power > 1 ? "^#{power}" : "")) } + on = format_output.call(output_numerator) if format == :exponential - od = output_denominator - .uniq - .map { [_1, output_denominator.count(_1)] } - .map { |element, power| (element.to_s.strip + (power.positive? ? "^#{-power}" : "")) } + od = format_output.call(output_denominator, true) (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}" : "")) } + od = format_output.call(output_denominator) "#{on.join('*')}#{"/#{od.join('*')}" unless od.empty?}".strip end end @@ -1281,27 +1308,30 @@ def -@ # absolute value of a unit # @return [Numeric,Unit] def abs - return @scalar.abs if unitless? + abs_scalar = @scalar.abs + return abs_scalar if unitless? - self.class.new(scalar: @scalar.abs, numerator: @numerator, denominator: @denominator) + with_new_scalar(abs_scalar) end # ceil of a unit # Forwards all arguments to the scalar's ceil method # @return [Numeric,Unit] def ceil(...) - return @scalar.ceil(...) if unitless? + ceiled_scalar = @scalar.ceil(...) + return ceiled_scalar if unitless? - self.class.new(scalar: @scalar.ceil(...), numerator: @numerator, denominator: @denominator) + with_new_scalar(ceiled_scalar) end # Floor of a unit # Forwards all arguments to the scalar's floor method # @return [Numeric,Unit] def floor(...) - return @scalar.floor(...) if unitless? + floored_scalar = @scalar.floor(...) + return floored_scalar if unitless? - self.class.new(scalar: @scalar.floor(...), numerator: @numerator, denominator: @denominator) + with_new_scalar(floored_scalar) end # Round the unit according to the rules of the scalar's class. Call this @@ -1316,18 +1346,20 @@ def floor(...) # # @return [Numeric,Unit] def round(...) - return @scalar.round(...) if unitless? + rounded_scalar = @scalar.round(...) + return rounded_scalar if unitless? - self.class.new(scalar: @scalar.round(...), numerator: @numerator, denominator: @denominator) + with_new_scalar(rounded_scalar) end # Truncate the unit according to the scalar's truncate method # Forwards all arguments to the scalar's truncate method # @return [Numeric, Unit] def truncate(...) - return @scalar.truncate(...) if unitless? + truncated_scalar = @scalar.truncate(...) + return truncated_scalar if unitless? - self.class.new(scalar: @scalar.truncate(...), numerator: @numerator, denominator: @denominator) + with_new_scalar(truncated_scalar) end # Returns next unit in a range. Increments the scalar by 1. @@ -1338,9 +1370,9 @@ def truncate(...) # @return [Unit] # @raise [ArgumentError] when scalar is not equal to an integer def succ - raise ArgumentError, "Non Integer Scalar" unless @scalar == @scalar.to_i + raise ArgumentError, "Non Integer Scalar" unless scalar_is_integer? - self.class.new(scalar: @scalar.to_i.succ, numerator: @numerator, denominator: @denominator) + with_new_scalar(@scalar.to_i.succ) end alias next succ @@ -1353,9 +1385,9 @@ def succ # @return [Unit] # @raise [ArgumentError] when scalar is not equal to an integer def pred - raise ArgumentError, "Non Integer Scalar" unless @scalar == @scalar.to_i + raise ArgumentError, "Non Integer Scalar" unless scalar_is_integer? - self.class.new(scalar: @scalar.to_i.pred, numerator: @numerator, denominator: @denominator) + with_new_scalar(@scalar.to_i.pred) end # Tries to make a Time object from current unit. Assumes the current unit hold the duration in seconds from the epoch. @@ -1423,9 +1455,9 @@ def before(time_point = ::Time.now) def since(time_point) case time_point when Time - self.class.new(::Time.now - time_point, "second").convert_to(self) + unit_class.new(::Time.now - time_point, "second").convert_to(self) when DateTime, Date - self.class.new(::DateTime.now - time_point, "day").convert_to(self) + unit_class.new(::DateTime.now - time_point, "day").convert_to(self) else raise ArgumentError, "Must specify a Time, Date, or DateTime" end @@ -1437,9 +1469,9 @@ def since(time_point) def until(time_point) case time_point when Time - self.class.new(time_point - ::Time.now, "second").convert_to(self) + unit_class.new(time_point - ::Time.now, "second").convert_to(self) when DateTime, Date - self.class.new(time_point - ::DateTime.now, "day").convert_to(self) + unit_class.new(time_point - ::DateTime.now, "day").convert_to(self) else raise ArgumentError, "Must specify a Time, Date, or DateTime" end @@ -1474,7 +1506,7 @@ def from(time_point) def coerce(other) return [other.to_unit, self] if other.respond_to?(:to_unit) - [self.class.new(other), self] + [unit_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 @@ -1493,14 +1525,16 @@ def best_prefix return to_base if scalar.zero? return self if units.include?("kg") + prefix_vals = unit_class.prefix_values + centesimal_range = CENTESIMAL_VALUE..DECILE_VALUE 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) + prefix_vals.key(2**((::Math.log(base_scalar, 2) / 10.0).floor * 10)) + elsif centesimal_range.cover?(base_scalar) + prefix_vals.key(CENTESIMAL_VALUE) else - self.class.prefix_values.key(10**((::Math.log10(base_scalar) / 3.0).floor * 3)) + prefix_vals.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))) + to(unit_class.new(unit_class.prefix_map.key(best_prefix) + units(with_prefix: false))) end # override hash method so objects with same values are considered equal @@ -1541,21 +1575,142 @@ def unit_signature_vector 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 + @numerator.map { unit_class.definition(_1) }.each do |definition| + kind = definition.kind + index = SIGNATURE_VECTOR.index(kind) + if index + current_value = vector[index] + vector[index] = current_value + 1 + end end - @denominator.map { self.class.definition(_1) }.each do |definition| - index = SIGNATURE_VECTOR.index(definition.kind) - vector[index] -= 1 if index + @denominator.map { unit_class.definition(_1) }.each do |definition| + kind = definition.kind + index = SIGNATURE_VECTOR.index(kind) + if index + current_value = vector[index] + vector[index] = current_value - 1 + end end raise ArgumentError, "Power out of range (-20 < net power of a unit < 20)" if vector.any? { _1.abs >= 20 } vector end + # Helper to simplify a rational by returning numerator if denominator is 1 + # @param [Rational] rational + # @return [Integer, Rational] + def self.simplify_rational(rational) + rational.denominator == 1 ? rational.numerator : rational + end + private + # String formatting helper methods for to_s + + # Format compound units (like feet/inches, lbs/oz, stone/lbs) + # @param whole [Numeric] the whole part + # @param part [Numeric] the fractional part + # @param whole_unit [String] the unit for the whole part + # @param part_unit [String] the unit for the fractional part + # @param precision [Float] precision for rationalization + # @return [String] formatted compound unit string + def format_compound_unit(whole, part, whole_unit, part_unit, precision: RubyUnits.configuration.default_precision) + separator = RubyUnits.configuration.separator + improper, frac = part.divmod(1) + frac_str = unit_class.format_fraction(frac, precision: precision) + sign = negative? ? "-" : "" + "#{sign}#{whole}#{separator}#{whole_unit} #{improper}#{frac_str}#{separator}#{part_unit}" + end + + # Convert to string representation for feet/inches format + # @param precision [Float] precision for rationalization + # @return [String] formatted string + def to_feet_inches(precision: RubyUnits.configuration.default_precision) + feet, inches = convert_to("in").scalar.abs.divmod(INCHES_IN_FOOT) + improper, frac = inches.divmod(1) + frac_str = unit_class.format_fraction(frac, precision: precision) + sign = negative? ? "-" : "" + "#{sign}#{feet}'#{improper}#{frac_str}\"" + end + + # Convert to string representation for pounds/ounces format + # @param precision [Float] precision for rationalization + # @return [String] formatted string + def to_pounds_ounces(precision: RubyUnits.configuration.default_precision) + pounds, ounces = convert_to("oz").scalar.abs.divmod(OUNCES_IN_POUND) + format_compound_unit(pounds, ounces, "lbs", "oz", precision: precision) + end + + # Convert to string representation for stone/pounds format + # @param precision [Float] precision for rationalization + # @return [String] formatted string + def to_stone_pounds(precision: RubyUnits.configuration.default_precision) + stone, pounds = convert_to("lbs").scalar.abs.divmod(POUNDS_IN_STONE) + format_compound_unit(stone, pounds, "stone", "lbs", precision: precision) + end + + # Handle string target_units conversion + # @param target_units [String] the target units string + # @param format [Symbol] the format to use + # @return [String] formatted string + def convert_string_target(target_units, format) + case target_units.strip + when /\A\s*\Z/ # whitespace only + "" + when /(%[-+.\w#]+)\s*(.+)*/ # format string like '%0.2f in' + convert_with_format_string(Regexp.last_match(1), Regexp.last_match(2), target_units, format) + when /(\S+)/ # unit only 'mm' or '1/mm' + convert_to(Regexp.last_match(1)).to_s(format: format) + else + raise "unhandled case" + end + end + + # Convert with a format string + # @param format_str [String] the format string + # @param target_unit [String, nil] the target unit + # @param original_target [String] the original target_units string (for strftime fallback) + # @param format [Symbol] the format to use + # @return [String] formatted string + def convert_with_format_string(format_str, target_unit, original_target, format) + if target_unit # unit specified, need to convert + convert_to(target_unit).to_s(format_str, format: format) + else + separator = RubyUnits.configuration.separator + "#{format_str % @scalar}#{separator}#{target_unit || units(format: format)}".strip + end + rescue StandardError # parse it like a strftime format string + (DateTime.new(0) + self).strftime(original_target) + end + + # Format the scalar value + # @param unit_str [String] the unit string + # @return [String] formatted string + def format_scalar(unit_str) + separator = RubyUnits.configuration.separator + case @scalar + when Complex + "#{@scalar}#{separator}#{unit_str}" + when Rational + "#{scalar_is_integer? ? @scalar.to_i : @scalar}#{separator}#{unit_str}" + else + "#{'%g' % @scalar}#{separator}#{unit_str}" + end.strip + end + + # Helper to check if scalar is effectively an integer + # @return [Boolean] + def scalar_is_integer? + @scalar == @scalar.to_i + end + + # Helper to create a new unit with modified scalar but same units + # @param [Numeric] new_scalar + # @return [Unit] + def with_new_scalar(new_scalar) + unit_class.new(scalar: new_scalar, numerator: @numerator, denominator: @denominator) + end + # used by #dup to duplicate a Unit # @param [Unit] other # @private @@ -1629,7 +1784,7 @@ def parse_string_arg(str) # @param [String] unit_string # @return [void] def parse_two_args(scalar, unit_string) - cached = self.class.cached.get(unit_string) + cached = unit_class.cached.get(unit_string) if cached copy(cached * scalar) else @@ -1645,7 +1800,7 @@ def parse_two_args(scalar, unit_string) def parse_three_args(scalar, numerator, denominator) unit_str = "#{Array(numerator).join}/#{Array(denominator).join}" - cached = self.class.cached.get(unit_str) + cached = unit_class.cached.get(unit_str) if cached copy(cached * scalar) else @@ -1762,10 +1917,10 @@ def cache_unit_if_needed(options) # @param [String] option_string # @return [void] def cache_parsed_string_unit(option_string) - _opt_scalar, opt_units = self.class.parse_into_numbers_and_units(option_string) + _opt_scalar, opt_units = unit_class.parse_into_numbers_and_units(option_string) return unless opt_units && !opt_units.empty? - self.class.cached.set(opt_units, scalar == 1 ? self : opt_units.to_unit) + unit_class.cached.set(opt_units, scalar == 1 ? self : opt_units.to_unit) end # Cache a unary unit if appropriate @@ -1774,7 +1929,7 @@ def cache_parsed_string_unit(option_string) def cache_unary_unit(unary_unit) return if unary_unit == "" - self.class.cached.set(unary_unit, scalar == 1 ? self : unary_unit.to_unit) + unit_class.cached.set(unary_unit, scalar == 1 ? self : unary_unit.to_unit) end # Freeze all instance variables @@ -1821,14 +1976,19 @@ def parse(passed_unit_string = "0") 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]) + real_str = match[:real] + imaginary_str = match[:imaginary] + real = Float(real_str) if real_str + imaginary = Float(imaginary_str) unit_s = match[:unit] - real = real.to_i if real.to_i == real - imaginary = imaginary.to_i if imaginary.to_i == imaginary + real_as_int = real.to_i if real + real = real_as_int if real_as_int == real + imaginary_as_int = imaginary.to_i + imaginary = imaginary_as_int if imaginary_as_int == 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 + complex_real = complex.real + complex = complex.to_i if complex.imaginary.zero? && complex_real == complex_real.to_i + result = unit_class.new(unit_s || 1) * complex copy(result) return end @@ -1837,25 +1997,31 @@ def parse(passed_unit_string = "0") 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_string = match[:proper] + raise ArgumentError, "Improper fractions must have a whole number part" if !proper_string.nil? && !proper_string.match?(/^#{INTEGER_REGEX}$/) - proper = match[:proper].to_i + proper = proper_string.to_i unit_s = match[:unit] + fraction = Rational(numerator, denominator) rational = if proper.negative? - (proper - Rational(numerator, denominator)) + (proper - fraction) else - (proper + Rational(numerator, denominator)) + (proper + fraction) end - rational = rational.to_int if rational.to_int == rational - result = self.class.new(unit_s || 1) * rational + rational_as_int = rational.to_int + rational = rational_as_int if rational_as_int == rational + result = unit_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 + unit_str = match[:unit] + unit = unit_class.cached.get(unit_str) + scalar_str = match[:scalar] + mult = scalar_str == "" ? 1.0 : scalar_str.to_f + mult_as_int = mult.to_int + mult = mult_as_int if mult_as_int == mult if unit copy(unit) @@ -1864,10 +2030,11 @@ def parse(passed_unit_string = "0") return self end - while unit_string.gsub!(/<(#{self.class.prefix_regex})><(#{self.class.unit_regex})>/, '<\1\2>') + while unit_string.gsub!(/<(#{unit_class.prefix_regex})><(#{unit_class.unit_regex})>/, '<\1\2>') # replace with end - while unit_string.gsub!(/<#{self.class.unit_match_regex}><#{self.class.unit_match_regex}>/, '<\1\2>*<\3\4>') + unit_match_regex = unit_class.unit_match_regex + while unit_string.gsub!(/<#{unit_match_regex}><#{unit_match_regex}>/, '<\1\2>*<\3\4>') # collapse into *... end # ... and then strip the remaining brackets for x*y*z @@ -1880,10 +2047,10 @@ def parse(passed_unit_string = "0") 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") + result = unit_class.new("#{hours || 0} hours") + + unit_class.new("#{minutes || 0} minutes") + + unit_class.new("#{seconds || 0} seconds") + + unit_class.new("#{milliseconds || 0} milliseconds") copy(result) return end @@ -1894,9 +2061,9 @@ def parse(passed_unit_string = "0") feet = Integer(match[:feet]) inches = match[:inches] result = if feet.negative? - self.class.new("#{feet} ft") - self.class.new("#{inches} inches") + unit_class.new("#{feet} ft") - unit_class.new("#{inches} inches") else - self.class.new("#{feet} ft") + self.class.new("#{inches} inches") + unit_class.new("#{feet} ft") + unit_class.new("#{inches} inches") end copy(result) return @@ -1907,9 +2074,9 @@ def parse(passed_unit_string = "0") pounds = Integer(match[:pounds]) oz = match[:oz] result = if pounds.negative? - self.class.new("#{pounds} lbs") - self.class.new("#{oz} oz") + unit_class.new("#{pounds} lbs") - unit_class.new("#{oz} oz") else - self.class.new("#{pounds} lbs") + self.class.new("#{oz} oz") + unit_class.new("#{pounds} lbs") + unit_class.new("#{oz} oz") end copy(result) return @@ -1920,9 +2087,9 @@ def parse(passed_unit_string = "0") stone = Integer(match[:stone]) pounds = match[:pounds] result = if stone.negative? - self.class.new("#{stone} stone") - self.class.new("#{pounds} lbs") + unit_class.new("#{stone} stone") - unit_class.new("#{pounds} lbs") else - self.class.new("#{stone} stone") + self.class.new("#{pounds} lbs") + unit_class.new("#{stone} stone") + unit_class.new("#{pounds} lbs") end copy(result) return @@ -1934,13 +2101,14 @@ def parse(passed_unit_string = "0") @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}/, "") + unit_part = item[0] + exponent = item[1].to_i + unit_with_space = "#{unit_part} " + if exponent >= 0 + top.gsub!(/#{unit_part}(\^|\*\*)#{exponent}/) { unit_with_space * exponent } + elsif exponent.negative? + bottom = "#{bottom} #{unit_with_space * -exponent}" + top.gsub!(/#{unit_part}(\^|\*\*)#{exponent}/, "") end end if bottom @@ -1951,11 +2119,13 @@ def parse(passed_unit_string = "0") @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 + scalar_as_int = @scalar.to_int + @scalar = scalar_as_int if scalar_as_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 + bottom_scalar_as_int = bottom_scalar.to_i + bottom_scalar = if bottom_scalar_as_int == bottom_scalar + bottom_scalar_as_int else bottom_scalar.to_f end @@ -1964,21 +2134,27 @@ def parse(passed_unit_string = "0") @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 + @numerator = top.scan(unit_match_regex).delete_if(&:empty?).compact if top + @denominator = bottom.scan(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}".gsub(self.class.unit_match_regex, "").gsub(%r{[\d*, "'_^/$]}, "") + used = "#{top} #{bottom}".gsub(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?) + prefix_map = unit_class.prefix_map + unit_map = unit_class.unit_map + transform_units = lambda do |item| + prefix = item[0] + unit = item[1] + prefix_value = prefix_map[prefix] + unit_value = unit_map[unit] + prefix_value ? [prefix_value, unit_value] : [unit_value] + end + + @numerator = @numerator.map(&transform_units).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?) + @denominator = @denominator.map(&transform_units).flatten.compact.delete_if(&:empty?) @numerator = UNITY_ARRAY if @numerator.empty? @denominator = UNITY_ARRAY if @denominator.empty? diff --git a/spec/ruby_units/configuration_spec.rb b/spec/ruby_units/configuration_spec.rb index a90970f..13ec834 100644 --- a/spec/ruby_units/configuration_spec.rb +++ b/spec/ruby_units/configuration_spec.rb @@ -5,7 +5,7 @@ describe RubyUnits::Configuration do describe "#initialize" do it "accepts keyword arguments for separator and format" do - config = described_class.new(separator: false, format: :exponential) + config = described_class.new(separator: :none, format: :exponential) expect(config.separator).to be_nil expect(config.format).to eq :exponential end @@ -16,30 +16,36 @@ expect(config.format).to eq :rational end - it "accepts additional keyword arguments for forward compatibility" do - expect { described_class.new(separator: true, unknown_option: "value") }.not_to raise_error - end - it "validates separator value" do expect { described_class.new(separator: "invalid") }.to raise_error(ArgumentError) end + it "accepts deprecated boolean separator values with warning" do + expect { described_class.new(separator: true) }.to output(/DEPRECATION WARNING.*boolean/).to_stderr + expect { described_class.new(separator: false) }.to output(/DEPRECATION WARNING.*boolean/).to_stderr + end + + it "converts deprecated boolean values correctly" do + expect(described_class.new(separator: true).separator).to eq " " + expect(described_class.new(separator: false).separator).to be_nil + end + it "validates format value" do expect { described_class.new(format: :invalid) }.to raise_error(ArgumentError) end end describe ".separator" do - context "when set to true" do + context "when set to :space" do it "has a space between the scalar and the unit" do expect(RubyUnits::Unit.new("1 m").to_s).to eq "1 m" end end - context "when set to false" do + context "when set to :none" do around do |example| RubyUnits.configure do |config| - config.separator = false + config.separator = :none end example.run RubyUnits.reset @@ -54,6 +60,28 @@ expect(RubyUnits::Unit.new("123.55 lbs").to_s("%0.2f")).to eq "123.55lbs" end end + + context "when set to false (deprecated)" do + around do |example| + RubyUnits.configure do |config| + config.separator = false + end + example.run + RubyUnits.reset + end + + it "issues a deprecation warning" do + expect do + RubyUnits.configure do |config| + config.separator = false + end + end.to output(/DEPRECATION WARNING.*boolean/).to_stderr + end + + it "works like :none for backward compatibility" do + expect(RubyUnits::Unit.new("1 m").to_s).to eq "1m" + end + end end describe ".format" do