Skip to content
Closed
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,6 +38,7 @@ 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"
Expand All @@ -46,7 +47,9 @@ def method_missing(method, *args)
require "measured/unit"
require "measured/unit_system"
require "measured/unit_system_builder"
require "measured/conversion_table_builder_base"
require "measured/conversion_table_builder"
require "measured/functional_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
30 changes: 1 addition & 29 deletions lib/measured/conversion_table_builder.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Measured::ConversionTableBuilder
attr_reader :units
include Measured::ConversionTableBuilderBase

def initialize(units, cache: nil)
@units = units
Expand Down Expand Up @@ -39,23 +39,6 @@ def generate_table
end
end

def validate_no_cycles
graph = units.select { |unit| unit.conversion_unit.present? }.group_by { |unit| unit.name }
validate_acyclic_graph(graph, from: graph.keys[0])
end

# This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search
def validate_acyclic_graph(graph, from:, visited: [])
graph[from]&.each do |edge|
adjacent_node = edge.conversion_unit
if visited.include?(adjacent_node)
raise Measured::CycleDetected.new(edge)
else
validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
end
end
end

def find_conversion(to:, from:)
conversion = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from)

Expand All @@ -64,17 +47,6 @@ def find_conversion(to:, from:)
conversion
end

def find_direct_conversion_cached(to:, from:)
@direct_conversion_cache ||= {}
@direct_conversion_cache[to] ||= {}

if @direct_conversion_cache[to].key?(from)
@direct_conversion_cache[to][from]
else
@direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
end
end

def find_direct_conversion(to:, from:)
units.each do |unit|
return unit.conversion_amount if unit.name == from && unit.conversion_unit == to
Expand Down
34 changes: 34 additions & 0 deletions lib/measured/conversion_table_builder_base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Measured::ConversionTableBuilderBase
attr_reader :units

private

def validate_no_cycles
graph = units.select { |unit| unit.conversion_unit.present? }.group_by(&:name)
validate_acyclic_graph(graph, from: graph.keys[0])
end

# This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search
def validate_acyclic_graph(graph, from:, visited: [])
graph[from]&.each do |edge|
adjacent_node = edge.conversion_unit
if visited.include?(adjacent_node)
raise Measured::CycleDetected.new(edge)
else
validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
end
end
end

def find_direct_conversion_cached(to:, from:)
@direct_conversion_cache ||= {}
@direct_conversion_cache[to] ||= {}

if @direct_conversion_cache[to].key?(from)
@direct_conversion_cache[to][from]
else
@direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
end
end
end
106 changes: 106 additions & 0 deletions lib/measured/functional_conversion_table_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# frozen_string_literal: true
class Measured::FunctionalConversionTableBuilder
include Measured::ConversionTableBuilderBase

IDENTITY = ->(x) { x }

def initialize(units, cache: nil)
@units = units
cache ||= { class: Measured::Cache::Null }
cache_instance = cache[:class].new(*cache[:args])
unless cache_instance.is_a?(Measured::Cache::Null)
raise Measured::CacheError, "Functional unit systems cannot be cached"
end
end

def to_h
@table ||= generate_table
end

def update_cache
raise Measured::CacheError, "Functional unit systems cannot be cached"
end

def cached?
false
end

private

def generate_table
validate_no_cycles

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

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

table[to_unit] = to_table
end
end

def find_conversion(to:, from:)
result = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from)
raise Measured::MissingConversionPath.new(from, to) unless result
result
end

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

if unit.name == to && unit.conversion_unit == from
backward = wrap_conversion(unit.conversion_amount)
forward = wrap_conversion(unit.inverse_conversion_amount)
return [forward, backward]
end
end

nil
end

def wrap_conversion(amount)
return amount if amount.is_a?(Proc)

->(x) { x * amount }
end

def find_tree_traversal_conversion(to:, from:)
identity = ->(x) { x }
traverse(from: from, to: to, units_remaining: units.map(&:name), forward: identity, backward: identity)
end

def traverse(from:, to:, units_remaining:, forward:, backward:)
units_remaining = units_remaining - [from]

units_remaining.each do |name|
pair = find_direct_conversion_cached(from: from, to: name)
next unless pair

step_forward, step_backward = pair
new_forward = compose(step_forward, forward)
new_backward = compose(backward, step_backward)

if name == to
return [new_forward, new_backward]
else
result = traverse(from: name, to: to, units_remaining: units_remaining, forward: new_forward, backward: new_backward)
return result if result
end
end

nil
end

def compose(outer, inner)
->(x) { outer.call(inner.call(x)) }
end
end
3 changes: 3 additions & 0 deletions lib/measured/temperature.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true
require "measured/base"
require "measured/units/temperature"
56 changes: 49 additions & 7 deletions lib/measured/unit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@
class Measured::Unit
include Comparable

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

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)
@inverse_conversion_amount ||= compute_inverse(@conversion_amount)
@conversion_string = build_conversion_string
@unit_system = unit_system
end

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

def functional?
@conversion_amount.is_a?(Proc)
end

def to_s(with_conversion_string: true)
if with_conversion_string && @conversion_string
"#{name} (#{@conversion_string})".freeze
Expand All @@ -44,7 +48,7 @@ def <=>(other)
if names_comparison != 0
names_comparison
else
conversion_amount <=> other.conversion_amount
comparable_amount(conversion_amount) <=> comparable_amount(other.conversion_amount)
end
else
name <=> other
Expand All @@ -53,16 +57,54 @@ def <=>(other)

private

def comparable_amount(amount)
amount.is_a?(Proc) ? amount.call(Rational(1)) : amount
end

def compute_inverse(amount)
return nil unless amount

1 / amount
end

def build_conversion_string
return nil unless @conversion_amount || @conversion_unit

if @conversion_amount.is_a?(Proc)
@description
else
"#{@conversion_amount} #{@conversion_unit}"
end
end

def parse_value(tokens)
case tokens
when String
tokens = Measured::Parser.parse_string(tokens)
parsed = Measured::Parser.parse_string(tokens)
@raw_value = tokens
[parsed[0].to_r, parsed[1].freeze]
when Array
raise Measured::UnitError, "Cannot parse [number, unit] formatted tokens from #{tokens}." unless tokens.size == 2

if tokens[0].is_a?(Hash)
parse_functional_value(tokens)
else
@raw_value = tokens
[tokens[0].to_r, tokens[1].to_s.freeze]
end
else
raise Measured::UnitError, "Unit must be defined as string or array, but received #{tokens}"
end
end

def parse_functional_value(tokens)
opts = tokens[0]
forward = opts.fetch(:forward)
backward = opts.fetch(:backward)
@description = opts[:description]
@raw_value = tokens
@inverse_conversion_amount = backward

[tokens[0].to_r, tokens[1].freeze]
[forward, tokens[1].to_s.freeze]
end
end
23 changes: 20 additions & 3 deletions lib/measured/unit_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,23 @@ def initialize(units, cache: nil)
next unit unless unit.conversion_unit
conversion_unit = @units.find { |u| u.names.include?(unit.conversion_unit) }
next unit unless conversion_unit
unit.with(value: [unit.conversion_amount, conversion_unit.name])
if unit.functional?
unit.with(value: [
{ forward: unit.conversion_amount, backward: unit.inverse_conversion_amount, description: unit.description },
conversion_unit.name
])
else
unit.with(value: [unit.conversion_amount, conversion_unit.name])
end
end
@unit_names = @units.map(&:name).sort.freeze
@unit_names_with_aliases = @units.flat_map(&:names).sort.freeze
@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)

builder_class = @units.any?(&:functional?) ? Measured::FunctionalConversionTableBuilder : Measured::ConversionTableBuilder
@conversion_table_builder = builder_class.new(@units, cache: cache)
@conversion_table = @conversion_table_builder.to_h.freeze
end

Expand Down Expand Up @@ -43,7 +52,15 @@ def convert(value, from:, to:)

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

value.to_r * conversion
if conversion.is_a?(Proc)
conversion.call(value.to_r)
else
value.to_r * conversion
end
end

def functional?
@units.any?(&:functional?)
end

def update_cache
Expand Down
15 changes: 14 additions & 1 deletion lib/measured/unit_system_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ def initialize
@cache = nil
end

def unit(unit_name, aliases: [], value: nil)
def unit(unit_name, aliases: [], value: nil, convert_to: nil, forward: nil, backward: nil, description: nil)
value = build_functional_value(convert_to: convert_to, forward: forward, backward: backward, description: description) || value
@units << build_unit(unit_name, aliases: aliases, value: value)
nil
end
Expand Down Expand Up @@ -64,6 +65,18 @@ def build_unit(name, aliases: [], value: nil)
unit
end

def build_functional_value(convert_to:, forward:, backward:, description:)
return nil unless convert_to

unless forward && backward
raise Measured::UnitError, "forward: and backward: are required when convert_to: is specified"
end

opts = { forward: forward, backward: backward }
opts[:description] = description if description
[opts, convert_to]
end

def check_for_duplicate_unit_names!(unit)
names = @units.flat_map(&:names)
if names.any? { |name| unit.names.include?(name) }
Expand Down
Loading