Unit converter

Converts measures, mostly between metric and US style. "convert names" gives you the defined units. Working, but unfinished weekend fun project by Richard Suchenwirth on 1999-07-22. Examples:

 convert 10 km/h
 6.21 mph
 convert 6 ft 4 in ;# tries to group commensurable measures
 193.04 cm
 convert 6 ft 4 oz ;# ... else does them one by one
 182.88 cm 113.40 g
 convert 37.4 C
 99.32 F
 convert 193 cm ;# this, of course, is bad. Needs some work... 
 6.33 ft

See also Euro converter - Unit math.

See also the Units Conversion Library at http://units.sourceforge.net/ for a package that converts and reduces units by dimensional analysis. (i.e., it can convert milliwebers/femtocoulomb into ohms)


 proc convert {args} {
    set res {}
    array set tbl {
        in {2.54 cm} ft {30.48 cm} yd {0.9144 m} mi {1.6093 km}
        mph {1.6093 km/h}
        nmile {1.852 km} PS {0.7355 kW} sq.ft {.0929 m2} sq.mi {2.59 km2}
        EUR {1.95583 DM} C - F -
        gal {4.5436 l}  oz {28.3495 g} lb {453.59 g}  ton {1016.05 kg}
    }
    foreach i [array names tbl] {
        if {$tbl($i)=="-"}  continue
        foreach {fac unit2} $tbl($i) break ;# inverted measures
        if {![llength [array names tbl $unit2]]} {
                set tbl($unit2) [list [expr 1./$fac] $i]}
        }
    if {$args==""} {
        error "usage: convert {? value unit... }?, or: convert names.\n\
                Units:\n[convert names]"
    }
    if {$args=="names"} {return [lsort [array names tbl]]}
    if {[llength $args]==1} {set args [lindex $args 0]}
    foreach {n unit} $args {
        if {$unit=="F"} {
            set res [list [expr ($n-32)*5/9] C]
        } elseif {$unit=="C"} {
            set res [list [expr $n*9/5+32] F]
        } elseif [llength [array names tbl $unit]] {
            foreach {fac unit2} $tbl($unit) break
            lappend res [format %.2f [expr $n*$fac]] $unit2
        } else {
            lappend res $n $unit
        }
    }
    foreach {x y} $res {lappend xx $x; ladd yy $y}
    if [llength $yy]==1 {set res [list [expr [join $xx +]] $yy] }
    return $res
 }
 proc ladd {_list what} {
    upvar $_list list
    if {![info exists list] || [lsearch $list $what] == -1} {
        lappend list $what
    }
 } ;# RS, updated 2003-08-04

Here's a somewhat divergent implementation which I hope is more general and extensible. It doesn't convert multiple measurements as the original does. On the other hand, it's more forgiving of "3m" or "3meters" or "3 meter", or even "3metre" etc. as input. Feedback welcome.

Updated 23 Feb 2000 to handle percentage conversions, include more temperature and distance initializations. Converting points to metric units seems to have an inherent binary representation problem.

Updated 1 March 2000 to deal with significant digits better.

                        -- [mailto:[email protected]]

AM (18 june 2004) Some remarks:

  • I would not use F and C for degrees Fahrenheit and centigrade - this leads to confusion with units like F (farad) and C (coulomb). Better: oF and oC.
  • The default list of units should be more extensive, of course :)
  • The problem of Yards versus yards as raised in the comments can not be solved merely by making the search case-insensitive: case does matter! A unit like Nm (newton-meter) would become equal to nm (nanometers) if you did.

 # units.tcl --
 #
 #     A units conversion package.
 #
 #
 # Copyright 2000 Pinebush Technologies Inc.
 #
 #
 #
 # Thanks to Jonathan Guyer ([email protected]) for help coping with
 # leading and trailing 0s (in parse) and significant digits (in
 # convert).
 #
 #-----------------------------------------------------------------------

 package require msgcat

 package provide units 1.0

 namespace eval units {
     namespace export \
         convert \
         parse \
         addAbbrev \
         addConvert \
         normalize

     # Get any localized unit strings
     msgcat::mcload [file join [file dirname [info script]] msgs]

     # "global" array(s)
     variable Abbrev
     variable Convert
     variable Defaults
 }

 #=======================================================================
 # Public procs
 #=======================================================================
 # units::parse --
 #
 #     Normalize a string that may express a measurement
 # 
 #     Actually, it's much more simple minded than that.  All it does
 #     it separate a leading number from the following text.
 #
 # Arguments:
 #     string - A string including a number and units like "3m", 
 #              "4 in", "5meters", etc.
 #
 # Results:
 #     Returns a list in the form {number units}
 #
 #
 proc units::parse { str } {
     # We could use a regexp but scan is smart about number formats,
     # let's leverage that.
     set count [scan $str "%e%s" number units]
     switch -- $count {
         1 {
             return $str
         }

         2 {
             # Re-parse to preserve trailing 0's
             regexp "(.*)$units" $str whole number

             # Get rid of leading and trailing whitespace
             set number [string trim $number]


             # Normalize units (meter -> m, " -> in, etc.)
             if {[string length $units]} {
                 set units [normalize $units]
             }

             # Return parsed measurement with normalized units
             return [list $number $units]
         }

         default {
             error "Invalid measurement string, '$str', \
                     should be in the form 'number units'"
         }
     }
     # NOT REACHED
 }
 # units::parse
 # 
 #-----------------------------------------------------------------------
 # units::convert --
 #
 #     Convert a normalized measurement string (as from parse) to
 #     another unit.
 # 
 # Arguments:
 #     mea - The input measurement to convert
 #     to  - The new units for mea
 #
 # Results:
 #     mea converted to different units.
 #
 proc units::convert { mea {to DEFAULT} } {
     variable Convert
     variable Defaults

     #----------------------------------------
     # Parse the input
     set mea [string trim $mea]
     set mea [parse $mea]
     set numberIn [lindex $mea 0]
     set from [lindex $mea 1]

     # WUZ - it might be nice if [convert 12 in] assumed that mea was
     # in inches, that is, a no-op.

     #----------------------------------------
     # Handle some special cases
     if {[string equal $to DEFAULT]} {
         set to $Defaults($from)
     }

     if {[string equal $from $to]} {
         return $mea
     }

     #----------------------------------------
     # Do the conversion

     # WUZ - it would be nice to have some smarts here about converting
     # through intermediate units.  For example, if we know in->cm, and
     # we know cm->m, we should be able to do in->m

     set conversion $Convert($from,$to)
     set factor [lindex $conversion 0]
     set offset [lindex $conversion 1]
     # Do the math in the highest precision possible.
     set holdPrecision $::tcl_precision
     set ::tcl_precision 17
     set numberOut [expr {$numberIn * $factor + $offset}]
     set ::tcl_precision $holdPrecision

     #----------------------------------------
     # Preserve significant digits
     # This code is based on some concepts at:
     #     http://mathworld.wolfram.com/SignificantDigits.html

     # A regular expression to parse numbers
     set RE {(?ix)       # Ignore case, extended syntax
             ([-+]?)     # Optional leading sign
             ([0-9]*)    # Integer part
             \.?         # Optional decimal point
             ([0-9]*)    # Fractional part
             (e?[0-9]*)  # Optional exponent
     } 

     # The significant digits in the input is all the digits before and
     # after the decimal point.
     regexp $RE $numberIn whole sign int frac exp
     set sig [string length "$int$frac"]

     # Parse the output
     regexp $RE $numberOut whole sign int frac exp

     if {$int == 0} {
         set int ""
     }

     # Remember the magnitude of numberOut
     set mag [string length $int]

     # Build an integer string from the parts.
     set str "$int$frac"

     # Pad with 0's in case numberOut doesn't have enoug digits.
     append str "00000000000000000"

     # Build a new float with all the significant digits before the decimal
     # so we can use round() to get the right number.
     set str [string range $str 0 [expr {$sig-1}]].[string range $str $sig end]

     # Round to the right number of significant digits.
     set str [expr {round($str)}]

     # Rebuild the output
     set numberOut $sign
     append numberOut [string range $str 0 [expr {$mag-1}]]
     if {[string length $str] != $mag} {
         append numberOut .
         append numberOut [string range $str $mag end]
     }
     append numberOut $exp

     #----------------------------------------
     # Return the converted measurement
     return [list $numberOut $to]
 }
 # units::convert
 # 
 #-----------------------------------------------------------------------
 # units::normalize --
 #
 #     Normalize a unit string to cannonical form.  For example, " or
 #     inches to in, meters or metre to m, etc.
 # 
 # Arguments:
 #     str - The unit string to process.
 #
 # Results:
 #     Returns the normalized string or raises an error if str is
 #     unrecongnized.
 #
 proc units::normalize { str } {
     variable Abbrev

     # WUZ - what about case? "Yards" vs. "yards"

     if { ! [info exists Abbrev($str)]} {
         error "Invalid unit string, '$str'."
     }

     return $Abbrev($str)
 }
 # units::normalize
 # 
 #-----------------------------------------------------------------------
 # units::addAbbrev --
 #
 #     Add an abbreviation (or normal string) for a long unit string as
 #     well as any translations for that long unit string.  For
 #     example, the abbreviation for "meter" is "m".  If
 #     UNIT_STRING_METER is in the message catalog with a translation
 #     of "metre", then the abbreviation for "metre" is also "m"
 # 
 # Arguments:
 #     long  - The long string such as "inches" or "point"
 #     short - The normal string such as "in" or "pt"
 #
 # Results:
 #     The abbreviation array is updated.
 #
 proc units::addAbbrev { long short } {
     variable Abbrev

     # Add the, presumably English, basic abbreviation.
     set Abbrev($long) $short

     # Add any translations we can find
     #
     # Build a key to look up this type of unit in the message catalog
     set key "UNIT_STRING_[string toupper $long]"

     # Try to look up the string
     set s [msgcat::mc $key]

     # If there's an entry in the catalog, store it with the
     # same abbreviation as the original English.
     if {! [string equal $key $s]} {
         set Abbrev($s) $short
     }
 }
 # units::addAbbrev
 # 
 #-----------------------------------------------------------------------
 # units::addConvert --
 #
 #     Add a conversion from one unit to another and the reverse.
 #     
 #     All conversions are considered linear (e.g., 2.54cm/in) and
 #     reversible (1/2.54 in/cm).  The offset is provided for
 #     temperature (F = (C-32) * 5/9, K = C - 273.15); maybe there are
 #     other uses.
 # 
 # Arguments:
 #     from   - The cannonical units to convert from (e.g., the result of
 #              a call to [normalize]
 #     to     - The cannonical units to convert to.
 #     factor - The slope of the from vs. to line
 #     offset - The X-intercept of the from vs. to line
 #
 # Results:
 #     The conversion (and it's inverse) are recorded.
 #     If from hasn't been seen before, to is recorded as it's default
 #     conversion.
 proc units::addConvert { from to factor {offset 0} } {
     variable Convert
     variable Defaults

     # We might have been called with a formula (e.g., 9/5), so make
     # sure it's a number for our calculations below.
     set factor [expr 1.0*$factor]
     set offset [expr 1.0*$offset]

     # Set up the conversion supplied by the caller
     set Convert($from,$to) [list $factor $offset]
     # And the reverse
     set Convert($to,$from) [list [expr 1/$factor] [expr {0.0-$offset/$factor}]]

     # The first conversion added becomes the default.
     if {! [info exists Defaults($from)]} {
         set Defaults($from) $to
         set Defaults($to) $from
     }
 }
 # units::addConvert
 # 
 #=======================================================================
 # Private procs only below this line
 #=======================================================================
 # units::Init --
 #
 #     Initialize the abbreviation array used to normalize unit strings.
 #     This is fairly complete for distance, has a bunch of temperatures.
 # 
 # Arguments:
 #     NONE
 #
 # Results:
 #     The array is set with English and locale-specific entries.
 #     (The locale-specific entries are a by-product of addAbbrev.)
 #
 proc units::Init { } {
     variable Abbrev

     # Convert between 53% and .53, etc.
     addAbbrev % %
     addConvert "" % 100

     #----------------------------------------
     # Temperatures
     addAbbrev F      F
     addAbbrev C      C
     addAbbrev K      K
     addAbbrev R      R

     addConvert C F 9/5 32
     addConvert K F 9/5 -459.67
     addConvert K R 9/5 0
     addConvert C R 9/5 491.67
     addConvert C K 1 273.15
     addConvert F R 1 459.67

     #----------------------------------------
     # Distance
     # Missing units: mils, microns, angstroms, decimeters, others?
     # Yep: kilometers, nanometers

     addAbbrev in     in
     addAbbrev inch   in
     addAbbrev inches in
     addAbbrev \"     in

     addAbbrev ft     ft
     addAbbrev foot   ft
     addAbbrev feet   ft
     addAbbrev '      ft

     addAbbrev yd     yd
     addAbbrev yard   yd
     addAbbrev yards  yd

     addAbbrev pt     pt
     addAbbrev point  pt
     addAbbrev points pt

     addAbbrev mm          mm
     addAbbrev millimeter  mm
     addAbbrev millimeters mm

     addAbbrev cm          cm
     addAbbrev centimeter  cm
     addAbbrev centimeters cm

     addAbbrev m      m
     addAbbrev meters m
     addAbbrev meter  m


     addConvert in cm 2.54
     addConvert in pt 72
     addConvert in ft 1/12
     addConvert in yd 1/36
     addConvert in mm 25.4
     addConvert in m  .0254

     addConvert ft pt 12*72
     addConvert ft yd 1/3
     addConvert ft mm 304.8
     addConvert ft cm 30.48
     addConvert ft m  .3048

     addConvert yd pt 36*72
     addConvert yd mm 914.4
     addConvert yd cm 91.44
     addConvert yd m  .9144

     addConvert mm pt 72/25.4
     addConvert mm cm 1/10
     addConvert mm m  1/1000

     addConvert cm pt 72/2.54
     addConvert cm m  1/100
 }
 # units::Init

 # Initialize internal data structures
 units::Init

fredderic: The simplest way to do unit-to-unit conversions, is to take your starting unit, and do every conversion on it you know about, then see if you've found the unit you want. If not, you try again, and again, so long as you haven't exhausted all your conversion options (ie. as long as at least one conversion is performed per pass). You end up being able to convert between any two units in your table, so long as there is a chain between them. (I'm currently trying to upgrade it to handle multiple units, but that's a definite work in progress)

 set convert_table {}
 # temperature
 lappend convert_table oC +273.15 oK
 lappend convert_table oF +459.67 oR
 lappend convert_table oK *1.8    oR
 # distance
 lappend convert_table cm *10     mm
 lappend convert_table m  *100    cm
 lappend convert_table km *1000   m
 lappend convert_table in *2.54   cm
 lappend convert_table ft *12     in
 lappend convert_table yd *3      ft
 lappend convert_table in *72     pt

 proc convert {src dest {keep_table ""} } {
    foreach {value unit} $src {break}
    if { $keep_table != {} } {
       upvar 1 $keep_table tbl
       catch {unset tbl}
    }
    set tbl($unit) $value
    set match 1
    while { $match } {
       set match 0
       foreach unit [array names tbl] {
          foreach indx [lsearch -all $::convert_table $unit] {
             switch [expr {$indx % 3}] {
             1 {continue}
             0 {
               set mult [lindex $::convert_table [incr indx]]
               set new_unit [lindex $::convert_table [incr indx]]
              }
             2 {
               set mult [lindex $::convert_table [incr indx -1]]
               set new_unit [lindex $::convert_table [incr indx -1]]
               set mult [string map {* / + - - + / *} $mult]
              }
             }
             if { [info exists tbl($new_unit)] } continue
             set value [expr $tbl($unit) $mult]
             if { $new_unit eq $dest } {return $value}
             set tbl($new_unit) $value
             set match 1
          }
       }
    }
    return
 }

This procedure assumes you've already parsed the source unit string. Abreviations can fairly easily be added by 1-to-1 conversions between a units abreviation and its full name(s); it will slow down the conversion slightly as all the names for a unit will get added to the transition table, but I doubt it would matter. A better idea would probably be to do pre-conversions on the source and destination units.

The trick with keep_table is handy when you want to give a list of possible conversions after the requested conversion fails; basically it just unsets the variable if it exists, and uses it as the transient conversion table instead of creating one locally. If you're doing that, then I'd recommend inventing a convention for imaginary units, like preceeding them with an ! so you can filter them out easily. I was doing that in temperature conversions before I added oK and oR. The best part of this procedure is that when adding extra units to the table, feel free to flip a conversion around if it makes the numbers nicer. :)


AMG: Here's a simple Tk program that converts between various units.

#!/bin/sh
# The next line restarts with tclsh.\
exec tclsh "$0" ${1+"$@"}

package require Tcl 8.5
package require Tk

set units {
    Length {
        inch                0.0254
        foot                0.3048
        "statute mile"   1609.347219
        "nautical mile"  1852
        "Planck length"     1.616199e-35
        ångström            1e-10
        millimeter          1e-3
        meter               1
        kilometer           1e3
    } Area {
        "square inch"       6.4516e-4
        "square foot"       9.290304e-2
        "US acre"        4046.873
        "square meter"      1
    } Volume {
        "US teaspoon"       4.928921595e-6
        "US tablespoon"    14.7867647825e-6
        "US fl ounce"      29.5735295625e-6
        "US cup"          236.5882365e-6
        "US pint"         473.176473e-6
        "US quart"        946.352946e-6
        "US gallon"         3.785411784e-3
        milliliter          1e-6
        liter               1e-3
        "cubic meter"       1
    } Mass {
        ounce               0.028349523125
        slug                0.068521765561961046335582743012613
        pound               0.45359237
        milligram           1e-6
        gram                1e-3
        kilogram            1
    } Force {
        ounce               0.2780138509537812
        pound               4.4482216152605
        newton              1
    } Speed {
        "speed of light"    299792458
        knot                0.514444
        mile/hour           0.44704
        kilometer/hour      2.777778e-1
        meter/second        1
    } "Plane Angle" {
        degree              0.0027777777777777777777777777777778
        arcminute           4.6296296296296296296296296296296e-5
        arcsecond           7.716049382716049382716049382716e-7
        radian              0.15915494309189533576888376337251
        grad                0.0025
        revolution          1
    } Pressure {
        "pound/sq in"       6894.75729
        "pound/sq ft"       47.880259
        "inch of mercury"   3386.389
        atmosphere          101325
        bar                 1e5
        kilopascal          1e3
        millibar            1e2
        pascal              1
    } Time {
        century             3155695200
        decade              315569520
        year                31556952
        week                604800
        day                 86400
        hour                3600
        minute              60
        second              1
        millisecond         1e-3
        "atomic time unit"  2.418884254e-17
        "Planck time"       1.351211868e-43
    }
}

menu .menubar
menu .category
.menubar add cascade -label Category -menu .category
. configure -menu .menubar
ttk::frame .units
pack .units -fill both -expand 1
wm resizable . 0 0
wm title . Convert

foreach {category _} $units {
    .category add command -label $category -command [list category $category]
}

proc convert {unit factor value} {
    global units category values
    foreach {unit2 factor2} [dict get $units $category] {
        if {$unit ne $unit2} {
            set value2 ""
            catch {
                set value2 [format %g\
                    [expr {double($value) * $factor / $factor2}]]
            }
            set values([list $category $unit2]) $value2
        }
    }
    return 1
}

proc category {newcategory} {
    global units category
    set category $newcategory
    destroy {*}[winfo children .units]
    foreach {unit factor} [dict get $units $category] {
        set name [string tolower [string map {. _} $unit]]
        ttk::entry .units.$name-entry -validate key\
            -textvariable values([list $category $unit])\
            -validatecommand [list convert $unit $factor %P]
        ttk::label .units.$name-label -text $unit -anchor w
        grid .units.$name-entry .units.$name-label -sticky nsew
    }
    grid columnconfigure .units 0 -weight 1
}

category [lindex $units 0]

# vim: set sts=4 sw=4 tw=80 et ft=tcl:

TODO: add more units, add support for units with a different zero point (temperature), add support for logarithmic units?


IDG Where do those absurdly precise conversion factors for pounds and ounces come from? Wikipedia states:

In 1958 the United States and countries of the Commonwealth of Nations agreed to define the international avoirdupois pound to be exactly 0.45359237 kilograms. Consequently, since 1958, the international avoirdupois ounce is exactly 0.45359237⁄16 kg (28.349523125 g) by definition.

Is this wrong?

AMG: I derived the absurdly precise conversion factors based on gravitational acceleration being 9.8 m/s². But I went ahead and put in your numbers instead, since if there's a standard I'd prefer to follow it. Similarly, I also simplified the foot and statute mile conversions to match what's given in Wikipedia. I had started with the conversion I had memorized (3.28084 feet per meter), which is almost the reciprocal of the defined conversion from feet to meters (0.3048). I guess from now on I will have to remember 0.3048! Ditto 1852 meters per nautical mile, the exact value, whereas I had memorized 6076.12 feet per nautical mile, an approximation.


Screenshots

Unit converter screenshot

gold added pix, for AMG's unit conversion script above

AMG: The screenshot would be more interesting if it showed numbers. Updated. As you can see, one nautical mile is (exactly) 1852 meters or (very nearly) 6072.12 feet; also, nautical miles are 15.078% longer than statute miles. Hmm, I wonder if it would be helpful to indicate which numbers are exact and which are approximations.


See also units conversion library (Nelson), unit math