Tuesday, July 29, 2008

Ruby Unit Converting Hash

I'm currently working on a project where I need to convert from things in one set of units to any other set of units ( eg centimeters to inches and so forth)

I had a bunch of small helper functions to convert from X to Y, but these kept growing every time we needed to handle something which hadn't been anticipated.

This kind of thing is also exponential, as if we have 4 'unit types' and we add a 5th one, we need to add 8 new methods to convert each other type to and from the new type

A few hours of refactoring later, I have this, which I think is kind of cool, and will enable me to delete dozens of small annoying meters_to_pts methods all over the place.

Disclaimer: This is definitely not good OO. A hash is not and never should be a unit converter. In the production code I will refactor this to build an actual Unit Converter class which stores a hash internally :-)


# Builds a unit converter object given the specified relationships
#
# converter = UnitConverter.create({
#  # to convert FROM a TO B, multiply by C
#  :pts    => {:inches => 72},
#  :inches => {:feet   => 12},
#  :cm     => {:inches => 2.54, 
#              :meters => 100},
#  :mm     => {:cm     => 10},
# })
#
# You can then do
#
# converter.convert(2, :feet, :inches) 
# => 24
#
# The interesting part is, it will follow any links which can be inferred
# and also generate inverse relationships, so you can also (with the exact same hash) do
#
# converter.convert(2, :meters, :pts) # relationship inferred from meters => cm => inches => pts
# => 5669.29133858268
#
class UnitConverter < Hash
  
  # Create a conversion hash, and populate with derivative and inverse conversions
  def self.create( hsh )
    returning new(hsh) do |h|
      # build and merge the matching inverse conversions
      h.recursive_merge! h.build_inverse_conversions
      
      # build and merge implied conversions until we've merged them all
      while (convs = h.build_implied_conversions) && convs.any?
        h.recursive_merge!( convs )
      end
    end
  end
  
  # just create a simple conversion hash, don't build any implied or inverse conversions
  def initialize( hsh )
    merge!( hsh )
  end
  
  # Helper method which does self.inject but flattens the nested hashes so it yields with |memo, from, to, rate|
  def inject_tuples(&block)
    h = Hash.new{ |h, key| h[key] = {} }
    
    self.inject(h) do |m, (from, x)|
      x.each do |to, rate|
        yield m, from, to, rate
      end
      m
    end
  end
  
  # Builds any implied conversions and returns them in a new hash
  # If no *new* conversions can be implied, will return an empty hash
  # For example
  # {:mm => {:cm => 10}, :cm => {:meters => 100}} implies {:mm => {:meters => 1000 }}
  # so that will be returned
  def build_implied_conversions
    inject_tuples do |m, from, to, rate|
      if link = self[to]
        link.each do |link_to, link_rate|
          # add the implied conversion to the 'to be added' list, unless it's already contained in +self+,
          # or it's converting the same thing (inches to inches) which makes no sense
          if (not self[from].include?(link_to)) and (from != link_to)
            m[from][link_to] = rate * link_rate 
          end
        end
      end
      m
    end
  end
  
  # build inverse conversions
  def build_inverse_conversions
    inject_tuples do |m, from, to, rate|
      m[to][from] = 1.0/rate
      m
    end
  end
  
  # do the actual conversion
  def convert( value, from, to )
    value * self[to][from]
  end
end

I'm not sure if deriving it from Hash is the right way to go, but it basically is just a big hash full of all the inferred conversions, so I'll leave it at that.


Update

Woops, this code requires 'returning' which is part of rails' ActiveSupport, and an extension to the Hash class called recursive_merge!, which I found on an internet blog comment somewhere (so it's only fitting that I share back with this unitconverter)

Code for recursive_merge


class Hash
  def recursive_merge(hsh)
    self.merge(hsh) do |key, oldval, newval|
      oldval.is_a?(Hash) ? 
        oldval.recursive_merge(newval) :
        newval
    end
  end
  
  def recursive_merge!(hsh)
    self.merge!(hsh) do |key, oldval, newval|
      oldval.is_a?(Hash) ? 
        oldval.recursive_merge!(newval) :
        newval
    end
  end
end

Code for returning

class Object
  def returning( x )
    yield x
    x
  end
end

1 comment:

hoyhoy said...

Interesting post. Also, thanks for your answer on stackoverflow.