MC logo

Unit Conversion

^  Ruby Example Code

<<Adder Test units.rb Unit Conversion Driver>>
This is a lot of code to demonstrate something simple. The module is a convenient place to group defintions which work together. In this case, to perform unit conversions. The next file is a short driver which uses this module.
#
# This module provides unit conversion.  The main public name is
# the method ratio, which gives the ratio of two dimensioned expressions, 
# or throws an exception if they do not conform.  The exceptions are also
# public, as are the BaseUnit and RelatedUnit classes, which can be used
# to add conversion information to the module.
#
#   Units.ratio(from, to)
# Will tell you how many to you need to equal from.  From and to are strings
# of the form
#    [ n ] { [ / ] unitname[ ^r ] }+
# Where n is the number of whatever units, defaulting to 1.0.  The unitname
# is a unit or alias created with BaseUnit or RelatedUnit.  Unitnames may
# be separated by spaces or dashes.  Power on the unit defaults to 1.
module Units
public
  # Two new exceptions just for us to pitch.
  class UnitsException < Exception
    # Base class for exceptions invented for this module.
  end

  # A unit expression string couldn't be parsed.
  class UnitParseError < UnitsException
    def initialize(m = "Cannot parse measurement expression")
      super(m)
    end
  end

  # Conversion of units which are not conformable (feet to liters or like 
  # that).
  class UnitConformability < UnitsException
    def initialize(m = "Units not conformable")
      super(m)
    end
  end

  # Abstract base class for units.
  class Unit
    # A list of all the units we know.
    @@units = { }

    # Access to @@units.
    def Unit.exists(n)
      return @@units.has_key?(n)
    end
    def Unit.named(n)
      return @@units[n]
    end

    # If this were Java, I'd define an abstract function isbase() which tells
    # if this object is a BaseUnit or not.
    def initialize(name)
      @name = name
      @@units[name] = self
      @@units[name + 's'] = self
    end
    attr_reader :name
    def alias(*names)
      names.each { |n| @@units[n] = self }
    end
  end

  # This is the base unit class.  There is one base unit for each
  # dimension.  Any unit in the right dimension could be used.
  # The class is just a name and a dimension name.
  class BaseUnit < Unit
    def isbase() 
      return true
    end
    def initialize(name, dname)
      super(name)
      @dimension = dname
    end
    attr_reader :dimension

    # This orders the unit objects arbitrarily, but that is sufficient to
    # sort them and compare lists.
    def <=> (u)
      return object_id <=> u.object_id
    end
  end

private
  # Here are the base units for each dimension.
  BaseUnit.new("meter", "length").alias("m", "metre")
  BaseUnit.new("gram", "mass").alias("g")
  BaseUnit.new("second", "time").alias("s", "sec")

  # A measurement is a number and some numerator and denominator units.
  # The unit lists are kept in lowest terms of base units, though the object
  # may be initialized with any units.
  class Measurement
    def initialize(m, num, denom)
      @mult = m.to_f    # The multiplier.  to_f in case you send an integer.
      @num = num        # The numerator units
      @denom = denom    # The denominator units.
      normalize         # Convert to lowest terms of base units.
    end
    attr_reader :mult, :num, :denom

    # Return the ratio.  Throws conformability.
    def ratio(divby)
      raise UnitConformability.new \
        if @num != divby.num || @denom  != divby.denom
      return @mult / divby.mult
    end

  private
    # Convert to base units only, in lowest terms.
    def normalize
      # Convert the lists to just base units.
      newnum = []
      newdenom = []
      basify(@num, false, newnum, newdenom)
      basify(@denom, true, newdenom, newnum)

      # Now eliminate units which appear in both places.  This depends on
      # an arbitrary ordering of the base units which allows us to compare
      # them in a merge order.
      newnum.sort!
      newdenom.sort!
      @num = [ ]
      @denom = [ ]
      while newnum.length > 0 && newdenom.length > 0
        rel = newnum[0] <=> newdenom[0]
        if rel < 0
          # They are different and the num comes first.  Must be kept.
          @num.push(newnum.shift)
        elsif rel > 0
          # They are different and the denom comes first.  Must be kept.
          @denom.push(newdenom.shift)
        else
          # A match.  Eliminate.
          newnum.shift
          newdenom.shift
        end
      end
      @num.concat(newnum)
      @denom.concat(newdenom)
    end

    # Convert the source list to base, adding the components to ndest and
    # ddest, and multiplying or dividing the measure's number.  The
    # src is a unit list.  These may not be base units, but, if not, the
    # measures they contain will be.
    def basify(src, divide, ndest, ddest)
      for unit in src
        if unit.isbase
          ndest.push(unit)
        else
          @mult = if divide then 
                @mult / unit.related.mult 
          else 
                @mult * unit.related.mult 
          end
          ndest.concat(unit.related.num)
          ddest.concat(unit.related.denom)
        end
      end
    end
  end

  # This can be called as (qty, unitex) or just (unitex), where m is
  # taken as 1.0.  Units expression is name[^pwr] [ / name... ]
  # unit names are always alpha.  - is a separator like a space, but not
  # between ^ and pwr.
  def Units.qty(m, s=nil)
    m,s = 1.0,m if s == nil

    # See if there's a number at the front of the expression.
    if s.sub!(/^\s*(\-?(\d+(\.\d*)?|\d*\.\d+))\s*/, '') 
      m *= $1.to_f
    end

    # Collect the stuff needed for the units part.
    num = []
    denom = []

    # What's the next thing?
    puthere = num
    otherone = denom
    while true
      # Strip leading crud, which is spaces or dashes
      s.sub!(/^(\s|\-)*/, '')
      break if s == ''

      # Find the next "thing", which is a unit name or a /.
      s.sub!(%r=^([a-zA-Z]+|/)=, '') or 
        raise UnitParseError.new('Expected unit name or slash at "' + s + '"' )
      thing = $1
      if thing == '/'
        # Swap which list the units go into.
        puthere, otherone = otherone, puthere
      else
        # Unit name.
        Unit.exists(thing) or 
          raise UnitParseError.new('Unknown unit name "' + thing + '"')
        unit = Unit.named(thing)

        # See if there's a ^n
        ct = 1
        top = true
        if s.sub!(/^\s*\^\s*(\-?)(\d+)/, '')
          ct = $2.to_i
          top = false if $1 != ''
        end
        if top
          ct.times { puthere.push(unit) }
        else
          ct.times { otherone.push(unit) }
        end
      end
    end

    return Measurement.new(m, num, denom)
  end

public
  # Return the ratio.
  def Units.ratio(top, bot)
    return Units.qty(top).ratio(Units.qty(bot))
  end

  # The units that are not basic
  class RelatedUnit < Unit
    def isbase() 
      return false
    end
    def initialize(name, measure)
      super(name)
      measure = Units.qty(measure) if measure.kind_of?(String)
      @related = measure
    end
    attr_reader :related
  end

private
  # Here are all the rest of the units we know about.
  RelatedUnit.new("kilometer", "1000 meter").alias("km")
  RelatedUnit.new("centimeter", "0.01 meter").alias("cm")
  RelatedUnit.new("milimeter", "0.01 meter").alias("mm")
  RelatedUnit.new("inch", "2.54 cm").alias("in")
  RelatedUnit.new("foot", "12 in").alias("ft", "feet")
  RelatedUnit.new("mile", "5280 ft").alias("mi")
  RelatedUnit.new("yard", "3 ft").alias("yd")
  RelatedUnit.new("furlong", "660 ft")

  RelatedUnit.new("milliliter", "cm^3").alias("ml", "cc")
  RelatedUnit.new("liter", "1000 ml").alias("l")
  RelatedUnit.new("gallon", "3.785412 liter").alias("gal")
  RelatedUnit.new("quart", "0.25 gal").alias("qt")
  RelatedUnit.new("pint", "0.5 quart").alias("pt")
  RelatedUnit.new("cup", "0.25 quart")

  RelatedUnit.new("acre", "43560 ft^2")
  RelatedUnit.new("hectare", "10000 m^2")

  RelatedUnit.new("minute", "60 sec").alias("min")
  RelatedUnit.new("hour", "60 min").alias("hr")
  RelatedUnit.new("day", "24 hr")
  RelatedUnit.new("week", "7 day").alias("wk")
  RelatedUnit.new("fortnight", "14 day")
  RelatedUnit.new("year", "365.25 day").alias("yr")

  RelatedUnit.new("kilogram", "1000 gram").alias("kg")
  RelatedUnit.new("slug", "14.593903 kg")

  RelatedUnit.new("newton", "kg-m/s^2").alias("N")
  RelatedUnit.new("pound", "4.448222 N").alias("lb")

  RelatedUnit.new("joule", "N-m").alias("J")
  RelatedUnit.new("calorie", "0.238846 J").alias("cal")
  RelatedUnit.new("kcal", "1000 cal")
  RelatedUnit.new("BTU", "1055.055853 J")

  RelatedUnit.new("watt", "J/s")
  RelatedUnit.new("kilowatt", "1000 watt").alias("kw")
  RelatedUnit.new("horsepower", "746 watt")

  RelatedUnit.new("knot", "1.68781 ft/sec")
end
<<Adder Test Unit Conversion Driver>>