The 2003 Perl Advent Calendar
[about] | [archives] | [contact] | [home]

On the 9th day of Advent my True Language brought to me..
Data::Dimensions

Does anyone remember when the

  • Mars Orbiter Crashed
  • because one group of scientists were using metric and another were using imperial measurements. Wasn't that funny?

    Well, of course it's funny. It gives us a warm feeling inside to know that even the most intelligent people around - literately rocket scientists - make the same mistakes we do. You bet however it wasn't funny for the scientists who lost $125 million dollars worth of hardware, and set back the space programme.

    However, when we - as programmers - have to do the same kind of work we won't be laughing. Or will we? I will. I've got Data::Dimensions handy and it does all my unit conversion and unit checking for me, so I don't have these problems.

    I live in the UK. We use the metric system for most things (which is why it gets my goat when people refer to imperial measurement as English measurements.) So I can tell you the size of a room in meters, I weigh things in kilograms, I measure what I eat in kilocalories and my Sprite comes in litres. It's the way my brain works.

    Of course, that's not strictly true. I measure how tall and how heavy people are in feet and stone, long distances in miles and speed in miles per hour, and (of course) beer in (uk) pints.

    This isn't much of a problem until I need to convert one to the other. For example, when I'm trying to work out how far away the pub is. For this I need to use Perl.

      #!/usr/bin/perl
      # turn on perl's safety feature
      use strict;
      use warnings;
      # conversion utility
      use Math::Units qw(convert);
      # it's about 200m to the bus stop from my house
      my $distance = 200;
      # it's one and a half miles into town
      $distance += convert(1.5,"miles","m");
      # it's another twenty paces to the pub
      $distance += convert(20,"yd","m");
      # now how many miles is that?
      $distance = convert($distance,"m","miles");
      printf "It's %0.2f miles to the pub\n",$distance;

    This prints out

      bash-2.05b$ perl ~/to_the_pub
      It's 1.64 miles to the pub

    Excellent. I keep all the figures in SI units by converting immedialty to meters, and only convert to miles (the format I use for long distances) at the very last minute. So we now know how far away the pub is. Maybe I'll just pop to my local instead.

    Though this code works, The problem is that I'm just storing a single value in my variables, and doing it this way it's easy to make a mistake. How about if I don't like the pub when I get there and I want to go another thirty meters onto the next pub. Let's add that onto the bottom of the script.

      # it's another thirty meters to the next pub (I like London)
      $distance += 30;
      printf "It's %0.2f miles to the 2nd pub\n",$distance;

    Whoops! What did I do wrong here. Oh yes, I'd converted distance to miles already. It's certainly not 31.64 miles to the pub. I'd die of thirst first.

    Data::Dimensions

    What we need to do is store the metadata - what units we're using - in with the figures themselves so that the units themselves can take care on any conversion that needs doing. This is where Data::Dimensions comes in. It exports a function units that allows us to create unit objects by passing two arguments. The first argument is a hash of types this unit uses, and the second argument is the value for that unit. For example:

      # distance, 10 feet
      my $distance = units({ feet => 1 }, 10)
      
      # speed, 30 miles per hour.
      my $speed = units({ mile => 1, hour => -1 }, 30);

    Things are combined by using the set keyword and normal mathematical functions. Things that are in different units (like meters and feet) are automatically converted.

     # increase the distance
     set $distance = $distance + units({ meter => 1 }, 10);
     # work out how fast we're accelerating
     my $time = units({ second => 10 }, 10);
     my $accl = units({ meter => 1, second => -1});
     set $accl = $speed / $time;

    The units have to match on one side of the equation to the other - for example, you can't add speed and time together as it makes no sense.

     # meters per second per second != meters per second + seconds
     set $accl = $speed + time;

    And you can't mix up your units either.

     # meters per second per second != meters
     set $accl = $speed * $time;

    In both these situations Data::Dimensions will throw an exception at you. This is a good thing (tm). It tends to point out in testing something that you can easily overlook. Mistakes need to be big and loud so you can spot them, not silent and hard to find.

    So without further ado, we convert our above script to use Data::Dimensions.

      #!/usr/bin/perl
      # turn on perl's safety feature
      use strict;
      use warnings;
      use Data::Dimensions qw(&units extended);
      # declare that we're measuring distances to our destination
      # in miles
      my $distance = units({ mile => 1 }, 0);
      # two hundred meters to the bus stop
      set $distance = $distance + units({ meter => 1 }, 200);
      # it's one and a half miles into town
      set $distance = $distance + units({ mile => 1 }, 1.5);
      # it's another twenty paces to the pub
      set $distance = $distance + units({ yd => 1 }, 20);
      printf "It's %0.2f miles to the pub\n", $distance;
      # it's another thirty meters to the next pub (I like London)
      $distance = $distance + units({ meter => 1 }, 30);
      printf "It's %0.2f miles to the 2nd pub\n", $distance;

    This prints out:

      bash-2.05b$ perl ~/to_pub_dd
      It's 1.64 miles to the pub
      It's 1.65 miles to the 2nd pub

    Note how in this version we don't have to worry about converting our distance into miles. It's always in miles no matter what operation we perform on it, as that's what it's defined as at the top of the program. And because of this, we can't make the same mistake we did previously.

    Defining Your Own Units

    Firstly, you don't have do anything to simply define your own units. It's quite possible for you to make up new units on the fly, and as long as you don't ever need to convert this unit into another format Data::Dimensions will do the right thing.

      use Data::Dimensions qw(&units extended);
      # new units
      my $camels = units( { camels => 1 }, 20  );
      my $money  = units( { pounds => 1 }, 400 );
      # how much?
      my $price = units({ camels => 1, pounds => -1 });
      set $price = $camels / $money;
      # throws an error, can't mix snakes with camels
      set $camels = $camels + units({ snake => 1 }, 3);

    All the units that Data::Dimensions understands are located in the

  • Data::Dimensions::Map
  • module. Defining your own units is just a matter of altering the %units hash in this module.

      #!/usr/bin/perl
      # turn on perl's safety feature
      use strict;
      use warnings;
      use Data::Dimensions qw(&units extended);
      # define S.A. units (standard armadillo)
      # based on 'Dasypus novemcinctus' nine banded armadillo
      # 1 S.A. length is 573mm, maximum length of the species
      $Data::Dimensions::Map::units{armadillo_length}
        = [ 0.573, { m => 1 }];
      # 1 S.A. mass is 10kg, maximum mass of the species
      $Data::Dimensions::Map::units{armadillo_mass}
        = [ 10, { kg => 1 }];
      # 1 S.A. time is 9 years, estimated wild lifespan
      $Data::Dimensions::Map::units{armadillo_time}
        = [ 9*365*24*60*60, { s => 1 }];
      # what's 30 miles per hour in armadillo_length per armadillo_time?
      my $speed = units({ armadillo_length => 1, 
    	              armadillo_time   => -1 });
      set $speed = units({ mile => 1, hour => -1 }, 30);
      print "30mph is $speed armadillo_length per armadillo_time\n";

    The units you use to define your new units must be those in the %SI_base hash in the Data::Dimensions::Map class.

  • Data::Dimensions::Map
  • Math::Units