Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/measured/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,18 @@ def method_missing(method, *args)
end

require "measured/unit_error"
require "measured/cache/cache_error"
require "measured/cycle_detected"
require "measured/unit_already_added"
require "measured/missing_conversion_path"
require "measured/arithmetic"
require "measured/parser"
require "measured/unit_conversion"
require "measured/unit"
require "measured/unit_system"
require "measured/unit_system_builder"
require "measured/conversion_table_builder"
require "measured/dynamic_conversion_table_builder"
require "measured/cache/null"
require "measured/cache/json_writer"
require "measured/cache/json"
Expand Down
4 changes: 4 additions & 0 deletions lib/measured/cache/cache_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true
module Measured
class CacheError < StandardError ; end
end
4 changes: 4 additions & 0 deletions lib/measured/conversion_table_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def initialize(units, cache: nil)
@units = units
cache ||= { class: Measured::Cache::Null }
@cache = cache[:class].new(*cache[:args])
after_initialize
end

def to_h
Expand All @@ -23,6 +24,9 @@ def cached?

private

# Used for DynamicConversionTableBuilder
def after_initialize; end

def generate_table
validate_no_cycles

Expand Down
75 changes: 75 additions & 0 deletions lib/measured/dynamic_conversion_table_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true
module Measured
class DynamicConversionTableBuilder < ConversionTableBuilder
SELF_CONVERSION = ->(x) { x * Rational(1, 1) }

private

def after_initialize
@units.map!(&:to_dynamic)
validate_cache
end

def validate_cache
return if @cache.is_a?(Measured::Cache::Null)

raise CacheError, "Dynamic unit systems cannot be cached"
end

def generate_table
validate_no_cycles

units.map(&:name).each_with_object({}) do |to_unit, table|
to_table = {to_unit => SELF_CONVERSION}

table.each do |from_unit, from_table|
conversion, inverse_conversion = find_conversion(to: from_unit, from: to_unit)
to_table[from_unit] = conversion
from_table[to_unit] = inverse_conversion
end

table[to_unit] = to_table
end
end

def find_direct_conversion(to:, from:)
units.each do |unit|
if unit.name == from && unit.conversion_unit == to
return [unit.conversion_amount, unit.inverse_conversion_amount]
end

if unit.name == to && unit.conversion_unit == from
return [unit.inverse_conversion_amount, unit.conversion_amount]
end
end

nil
end

def find_tree_traversal_conversion(to:, from:)
traverse(from: from, to: to, units_remaining: units.map(&:name), amount: ->(x) { x }, inverse_amount: ->(x) { x })
end

def traverse(from:, to:, units_remaining:, amount:, inverse_amount:)
units_remaining = units_remaining - [from]

units_remaining.each do |name|
conversion, inverse_conversion = find_direct_conversion_cached(from: from, to: name)

if conversion
new_amount = ->(x) { conversion.call amount.call(x) }
new_inverse_amount = ->(x) { inverse_amount.call inverse_conversion.call(x) }

if name == to
return [new_amount, new_inverse_amount]
else
result = traverse(from: name, to: to, units_remaining: units_remaining, amount: new_amount, inverse_amount: new_inverse_amount)
return result if result
end
end
end

nil
end
end
end
56 changes: 38 additions & 18 deletions lib/measured/unit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,33 @@
class Measured::Unit
include Comparable

attr_reader :name, :names, :aliases, :conversion_amount, :conversion_unit, :unit_system, :inverse_conversion_amount
attr_reader :name, :names, :aliases, :unit_system, :unit_conversion

def initialize(name, aliases: [], value: nil, unit_system: nil)
@name = name.to_s.freeze
@aliases = aliases.map(&:to_s).map(&:freeze).freeze
@names = ([@name] + @aliases).sort!.freeze
@conversion_amount, @conversion_unit = parse_value(value) if value
@inverse_conversion_amount = (1 / conversion_amount if conversion_amount)
@conversion_string = ("#{conversion_amount} #{conversion_unit}" if conversion_amount || conversion_unit)
@unit_conversion = Measured::UnitConversion.parse(value)
@unit_system = unit_system
end

def with(name: nil, unit_system: nil, aliases: nil, value: nil)
value ||= @unit_conversion.to_s
if dynamic?
value = @unit_conversion.value
end

self.class.new(
name || self.name,
aliases: aliases || self.aliases,
value: value || @conversion_string,
value: value,
unit_system: unit_system || self.unit_system
)
end

def to_s(with_conversion_string: true)
if with_conversion_string && @conversion_string
"#{name} (#{@conversion_string})".freeze
if with_conversion_string && @unit_conversion.to_s
"#{name} (#{@unit_conversion})".freeze
else
name
end
Expand All @@ -34,7 +37,7 @@ def to_s(with_conversion_string: true)
def inspect
pieces = [name]
pieces << "(#{aliases.join(", ")})" if aliases.any?
pieces << @conversion_string if @conversion_string
pieces << @unit_conversion if @unit_conversion.to_s
"#<#{self.class.name}: #{pieces.join(" ")}>".freeze
end

Expand All @@ -44,25 +47,42 @@ def <=>(other)
if names_comparison != 0
names_comparison
else
conversion_amount <=> other.conversion_amount
compared_value(conversion_amount) <=> compared_value(other.conversion_amount)
end
else
name <=> other
end
end

def conversion_unit
@unit_conversion.unit
end

def conversion_amount
@unit_conversion.amount
end

def inverse_conversion_amount
@unit_conversion.inverse_amount
end

def dynamic?
@unit_conversion.dynamic?
end

def to_dynamic
@unit_conversion = @unit_conversion.to_dynamic
self
end

private

def parse_value(tokens)
case tokens
when String
tokens = Measured::Parser.parse_string(tokens)
when Array
raise Measured::UnitError, "Cannot parse [number, unit] formatted tokens from #{tokens}." unless tokens.size == 2
def compared_value(conversion_amount)
case conversion_amount
when Proc
conversion_amount.call(1)
else
raise Measured::UnitError, "Unit must be defined as string or array, but received #{tokens}"
conversion_amount
end

[tokens[0].to_r, tokens[1].freeze]
end
end
111 changes: 111 additions & 0 deletions lib/measured/unit_conversion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

module Measured
class UnitConversion
def self.parse(tokens, description: nil)
if tokens.nil?
return StaticUnitConversion.new(amount: nil, unit: nil)
end

case tokens
when String
tokens = Measured::Parser.parse_string(tokens)
when Array
raise Measured::UnitError, "Cannot parse [number, unit] formatted tokens from #{tokens}." unless tokens.size == 2
else
raise Measured::UnitError, "Unit must be defined as string or array, but received #{tokens}"
end

case tokens[0]
when Hash
DynamicUnitConversion.new(
amount: tokens[0][:conversion],
inverse_amount: tokens[0][:reverse_conversion],
description: tokens[0][:description],
unit: tokens[1].freeze
)
else
StaticUnitConversion.new(
amount: tokens[0].to_r,
unit: tokens[1].freeze
)
end
end
end

class DynamicUnitConversion
attr_reader :amount, :inverse_amount, :unit

def initialize(amount:, inverse_amount:, unit:, description: nil)
@amount = amount
@inverse_amount = inverse_amount
@unit = unit
@description = description
end

def value
[
{
conversion: amount,
reverse_conversion: inverse_amount,
description: @description
},
unit
]
end

def dynamic?
true
end

def static?
false
end

def to_s
@description
end

def to_dynamic
self
end
end

class StaticUnitConversion
attr_reader :amount, :unit

def initialize(amount:, unit:)
@amount = amount
@unit = unit
end

def dynamic?
false
end

def static?
true
end

def to_s
return unless amount && unit

"#{amount} #{unit}"
end

def inverse_amount
return unless amount

(1 / amount)
end

def to_dynamic
DynamicUnitConversion.new(
amount: ->(x) { x * amount },
inverse_amount: ->(x) { x * inverse_amount },
unit: unit,
description: to_s
)
end
end
end
16 changes: 13 additions & 3 deletions lib/measured/unit_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ def initialize(units, cache: nil)
@unit_name_to_unit = @units.each_with_object({}) do |unit, hash|
unit.names.each { |name| hash[name.to_s] = unit }
end
@conversion_table_builder = Measured::ConversionTableBuilder.new(@units, cache: cache)

table_builder = Measured::ConversionTableBuilder
if @units.any?(&:dynamic?)
table_builder = Measured::DynamicConversionTableBuilder
end

@conversion_table_builder = table_builder.new(@units, cache: cache)
@conversion_table = @conversion_table_builder.to_h.freeze
end

Expand All @@ -42,8 +48,12 @@ def convert(value, from:, to:)
conversion = conversion_table.fetch(from.name, {})[to.name]

raise Measured::UnitError, "Cannot find conversion entry from #{from} to #{to}" unless conversion

value.to_r * conversion
case conversion
when Proc
conversion.call(value).to_r
else
value.to_r * conversion
end
end

def update_cache
Expand Down
Loading