uom 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ require 'uom/measurement'
2
+
3
+ # UOM implements Units of Measurement based on the International System of Units.
4
+ # See [http://github.com/caruby/uom] for details.
5
+ module UOM
6
+ VERSION = "1.2.1"
7
+ end
@@ -0,0 +1,105 @@
1
+ require 'singleton'
2
+ require 'generator'
3
+ require 'extensional'
4
+ require 'active_support/inflector'
5
+ require 'uom/unit'
6
+ require 'uom/composite_unit_key_canonicalizer'
7
+
8
+ module UOM
9
+ # A CompositeUnit represents a measurement unit across more than one dimension, e.g. +:gram_per_liter+.
10
+ class CompositeUnit < Unit
11
+ SUPPORTED_OPERATORS = [:/, :*]
12
+
13
+ @canonicalizer = CompositeUnitKeyCanonicalizer.new
14
+
15
+ make_extensional(Hash.new { |hash, spec| match(hash, *spec) }) do |hash, unit|
16
+ key = @canonicalizer.canonicalize(unit.axes.first, unit.axes.last, unit.operator)
17
+ hash[key] = unit
18
+ end
19
+
20
+ attr_reader :operator, :axes
21
+
22
+ # Creates a CompositeUnit from the given units u1 and u2 and operator symbol +:/+ or +:*+.
23
+ # Each unit can be either a Unit or another CompositeUnit.
24
+ # The remaining parameters are described in Unit.
25
+ #
26
+ # The CompositeUnit label is inferred from the operator constituent units, e.g.:
27
+ # CompositeUnit.new(Unit.for(:gram), Unit.for(:liter), :/) #=> grams_per_liter
28
+ def initialize(u1, u2, operator, *params)
29
+ @axes = [u1, u2]
30
+ @operator = operator
31
+ raise MeasurementError.new("Unit composition operator unsupported - expected #{SUPPORTED_OPERATORS.join(' or ')}, found #{operator}") unless SUPPORTED_OPERATORS.include?(operator)
32
+ # make the composite dimension parameter
33
+ dims = @axes.map { |axis| axis.dimension }
34
+ # Add the first axis and the dimension to the parameters. The first axis is designated
35
+ # the axis in Unit; the second axis is a qualifier.
36
+ params << CompositeDimension.for([dims, @operator])
37
+ # call the Unit initializer with the parameters except for the axes
38
+ super(*params)
39
+ # add to the extent
40
+ CompositeUnit << self
41
+ end
42
+
43
+ # Returns the the given quantity converted from this Unit into the given unit.
44
+ def as(quantity, unit)
45
+ # if unit wraps a composite unit axis, then convert unit => axis => self and invert
46
+ return 1.0 / unit.as(1.0 / quantity, self) unless CompositeUnit === unit
47
+ raise MeasurementError.new("No conversion from #{self} to #{unit}") unless unit.axes.size == axes.size and unit.operator == operator
48
+ # convert the the first axis quantity to the first unit axis
49
+ first = axes[0].as(quantity, unit.axes[0])
50
+ # convert the remaining units
51
+ vector = SyncEnumerator.new(axes[1..-1], unit.axes[1..-1]).map { |from, to| from.as(1, to).to_f }
52
+ # apply the operator
53
+ vector.inject(first) { |q, item| q.send(@operator, item) }
54
+ end
55
+
56
+ def to_s(quantity=nil)
57
+ @operator == :* ? super : [axes.first.to_s(quantity)].concat(axes[1..-1]).join('_per_')
58
+ end
59
+
60
+ def inspect
61
+ "#{self.class.name}@#{self.object_id}[#{([label] + abbreviations).join(', ')}: #{composition_to_s}]"
62
+ end
63
+
64
+ # Returns a string representation of this CompositeUnit's composition, e.g.
65
+ # ((UOM::METER * UOM::SECOND) / UOM::GRAM).composition_to_s #=> (meter * second) / gram
66
+ def composition_to_s
67
+ axes.map { |axis| CompositeUnit === axis ? "(#{axis.composition_to_s})" : axis.label.to_s }.join(" #{operator} ")
68
+ end
69
+
70
+ protected
71
+
72
+ def create_label
73
+ case @operator
74
+ when :/ then create_division_label
75
+ when :* then create_product_label
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # Returns the CompositeUnit which matches the key [u1, u2, operator] in hash. If there is no such key
82
+ # in hash, then match on the canonicalized form of [u1, u2, operator]. Creates a new CompositeUnit from
83
+ # the canonical form if there is no match.
84
+ def self.match(hash, u1, u2, operator)
85
+ # canonicalize the arguments into the form [cu1, cu2, op], where cu2 is a non-composite unit and
86
+ # cu1 is either a composite or a non-composite unit and op is an unit product or quotient operator.
87
+ # the locator block recursively matches a sub-specification generated by the canonicalizer.
88
+ spec = @canonicalizer.canonicalize(u1, u2, operator) { |spec| hash[spec] }
89
+ # if there is a match on the canonicalized spec [cu1, cu2, op], then return the match.
90
+ # otherwise, make a new CompositeUnit from cu1, cu2 and op.
91
+ hash.has_key?(spec) ? hash[spec] : new(*spec)
92
+ end
93
+
94
+ def create_division_label
95
+ [axes.first.label.to_s.pluralize].concat(axes[1..-1].map { |unit| unit.label.to_s }).join('_per_').to_sym
96
+ end
97
+
98
+ def create_product_label
99
+ # unit * unit has label square_unit
100
+ label = axes.first == axes.last ? "square_#{axes.first.label}" : "#{axes.first.label}_#{axes.last.label}"
101
+ # convert square_unit_unit to cubic_unit
102
+ label.gsub(/square_([[:alnum:]]+)_\1/, 'cubic_\1').to_sym
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,53 @@
1
+ require 'uom/composite_unit'
2
+
3
+ module UOM
4
+ # A CompositeUnitKeyCanonicalizer creates a standard key for the CompositeUnit extent.
5
+ class CompositeUnitKeyCanonicalizer
6
+ def match(hash, u1, u2, operator)
7
+ spec = canonicalize(u1, u2, operator)
8
+ hash[spec] ||= new(*spec)
9
+ end
10
+
11
+ # Returns the canonical form of the given units u1 and u2 and operator +:*+ or +:/+ as an array consisting
12
+ # of a CompositeUnit, non-composite Unit and operator, e.g.:
13
+ # canonicalize(METER, SECOND * SECOND, :*) #=> [METER * SECOND, SECOND, :*]
14
+ # canonicalize(METER, SECOND / SECOND, :*) #=> [METER * SECOND, SECOND, :/]
15
+ # canonicalize(METER, SECOND * SECOND, :/) #=> [METER / SECOND, SECOND, :/]
16
+ # canonicalize(METER, SECOND / SECOND, :/) #=> [METER / SECOND, SECOND, :*]
17
+ #
18
+ # The locator block given to this method matches a CompositeUnit with arguments u11, u12 and op1.
19
+ #
20
+ # See also canonicalize_operators.
21
+ def canonicalize(u1, u2, operator, &locator) # :yields: u11, u12, op1
22
+ # nothing to do if u2 is already a non-composite unit
23
+ return u1, u2, operator unless CompositeUnit === u2
24
+ # canonicalize u2
25
+ cu21, cu22, cop2 = canonicalize(u2.axes.first, u2.axes.last, u2.operator, &locator)
26
+ # redistribute u1 with the canonicalized u2 axes cu21 and cu22 using the redistributed operators rop1 and rop2
27
+ rop1, rop2 = redistribute_operators(operator, cop2)
28
+ # yield canonicalize(u1, cu21, rop1, &locator)
29
+ # the maximally reduced canonical form
30
+ return u1.send(rop1, cu21), cu22, rop2
31
+ end
32
+
33
+ # Returns the operators to apply to a canonical form according to the rules:
34
+ # x * (y * z) = (x * y) * z
35
+ # x * (y / z) = (x * y) / z
36
+ # x / (y * z) = (x / y) / z
37
+ # x / (y / z) = (x / y) * z
38
+ # i.e.:
39
+ # redistribute_operators(:*, :*) #=> [:*, :*]
40
+ # redistribute_operators(:*, :/) #=> [:*, :/]
41
+ # redistribute_operators(:/, :*) #=> [:/, :/]
42
+ # redistribute_operators(:/, :/) #=> [:/, :*]
43
+ def redistribute_operators(operator, other)
44
+ # not as obscure as it looks: if operator is * then there is no change to the operators since * is associative, i.e.:
45
+ # x * (y * z) = (x * y) * z
46
+ # x * (y / z) = (x * y) / z
47
+ # otherwise, operator is /. in that case, apply the rules:
48
+ # x / (y * z) = (x / y) / z
49
+ # x / (y / z) = (x / y) * z
50
+ operator == :* ? [operator, other] : (other == :* ? [:/, :/] : [:/, :*])
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,39 @@
1
+ require 'extensional'
2
+
3
+ module UOM
4
+ # Dimension enumerates the standard Unit dimensions. These consist of the seven physical International System of Units
5
+ # SI base unit dimensions and an INFORMATION dimension for representing computer storage.
6
+ class Dimension
7
+ attr_accessor :label
8
+
9
+ def initialize(label)
10
+ @label = label
11
+ end
12
+
13
+ def to_s
14
+ @label
15
+ end
16
+ end
17
+
18
+ # A CompositeDimension combines dimensions with an operator.
19
+ class CompositeDimension < Dimension
20
+ make_extensional(Hash.new { |hash, spec| new(*spec) }) { |hash, dim| hash[dim.dimensions + [dim.operator]] = dim }
21
+
22
+ attr_reader :dimensions, :operator
23
+
24
+ # Creates a CompositeDimension from the given dimensions and operator symbol
25
+ def initialize(dimensions, operator)
26
+ @dimensions = dimensions
27
+ @operator = operator
28
+ super(create_label)
29
+ # add to the extent
30
+ CompositeDimension << self
31
+ end
32
+
33
+ private
34
+
35
+ def create_label
36
+ @dimensions.join("_#{@operator}_")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ require 'uom/dimension'
2
+
3
+ module UOM
4
+ LENGTH = Dimension.new(:length)
5
+ MASS = Dimension.new(:mass)
6
+ TEMPERATURE = Dimension.new(:temperature)
7
+ TIME = Dimension.new(:time)
8
+ ENERGY = Dimension.new(:energy)
9
+ INTENSITY = Dimension.new(:intensity)
10
+ CURRENT = Dimension.new(:current)
11
+ INFORMATION = Dimension.new(:information)
12
+ end
@@ -0,0 +1,3 @@
1
+ module UOM
2
+ class MeasurementError < StandardError; end
3
+ end
@@ -0,0 +1,55 @@
1
+ require 'extensional'
2
+ require 'uom/error'
3
+
4
+ module UOM
5
+ # A Factor designates a Unit mangnitude along a dimensional axis.
6
+ class Factor
7
+ make_extensional do |hash, factor|
8
+ hash[factor.label] = factor if factor.label
9
+ hash[factor.abbreviation] = factor if factor.abbreviation
10
+ end
11
+
12
+ attr_reader :label, :abbreviation, :converter
13
+
14
+ # Creates a Factor with the given label, abbreviations and conversion multiplier or block.
15
+ # The multiplier is the amount of this factor in the base factor.
16
+ # For example, KILO and MILLI are defined as:
17
+ # KILO = Factor.new(:kilo, :k, UNIT, 1000)
18
+ # MILLI = Factor.new(:milli, :m, UNIT, .001)
19
+ # The +KILO+ definition is the same as:
20
+ # Factor.new(:kilo, :k, UNIT) { |unit| unit * 1000 }
21
+ # This definition denotes that one kilo of a unit equals 1000 of the units.
22
+ def initialize(label, abbreviation, base, multiplier=nil, &converter) # :yields: factor
23
+ @label = label
24
+ @abbreviation = abbreviation
25
+ @base = base
26
+ @converter = converter
27
+ @converter ||= lambda { |n| n * multiplier } if multiplier
28
+ # add this Factor to the extent
29
+ Factor << self
30
+ end
31
+
32
+ # Returns the multiplier which converts this Factor into the given factor.
33
+ def as(factor)
34
+ if factor == self or (@converter.nil? and factor.converter.nil?) then
35
+ 1.0
36
+ elsif @converter.nil? then
37
+ 1.0 / factor.as(self)
38
+ elsif factor == @base then
39
+ @converter.call(1)
40
+ else
41
+ self.as(@base) * @base.as(factor)
42
+ end
43
+ end
44
+
45
+ def to_s
46
+ label.to_s
47
+ end
48
+
49
+ def inspect
50
+ content = "#{label}"
51
+ content += ", #{abbreviation}" if abbreviation
52
+ "#{self.class.name}@#{self.object_id}[#{content}]"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,32 @@
1
+ require 'uom/factor'
2
+
3
+ module UOM
4
+ # the metric scaling factors
5
+ UNIT = Factor.new(nil, nil, nil) # the anonymous unit factor
6
+ DECI = Factor.new(:deci, :d, UNIT, 0.1)
7
+ CENTI = Factor.new(:centi, :c, UNIT, 0.01)
8
+ MILLI = Factor.new(:milli, :m, UNIT, 0.001)
9
+ MICRO = Factor.new(:micro, :u, MILLI, 0.001)
10
+ NANO = Factor.new(:nano, :n, MICRO, 0.001)
11
+ PICO = Factor.new(:pico, :p, NANO, 0.001)
12
+ FEMTO = Factor.new(:femto, :f, PICO, 0.001)
13
+ ATTO = Factor.new(:atto, :a, FEMTO, 0.001)
14
+ ZEPTO = Factor.new(:zepto, :z, ATTO, 0.001)
15
+ YOCTO = Factor.new(:yocto, :y, ZEPTO, 0.001)
16
+ DECA = Factor.new(:deca, :da, UNIT, 10)
17
+ HECTO = Factor.new(:hecto, :h, UNIT, 100)
18
+ KILO = Factor.new(:kilo, :k, UNIT, 1000)
19
+ MEGA = Factor.new(:mega, :M, KILO, 1000)
20
+ GIGA = Factor.new(:giga, :G, MEGA, 1000)
21
+ TERA = Factor.new(:tera, :T, GIGA, 1000)
22
+ PETA = Factor.new(:peta, :P, TERA, 1000)
23
+ EXA = Factor.new(:exa, :E, PETA, 1000)
24
+ ZETTA = Factor.new(:zetta, :Z, EXA, 1000)
25
+ YOTTA = Factor.new(:yotta, :Y, ZETTA, 1000)
26
+
27
+ # All metric factors.
28
+ METRIC_FACTORS = [YOTTA, ZETTA, EXA, TERA, GIGA, MEGA, KILO, HECTO, DECA, DECI, CENTI, MILLI, MICRO, NANO, PICO, FEMTO, ATTO, ZEPTO, YOCTO]
29
+
30
+ # Factors commonly used in electronics
31
+ ELECTRONIC_FACTORS = [MILLI, MICRO, NANO, PICO, TERA, GIGA, MEGA, KILO]
32
+ end
@@ -0,0 +1,127 @@
1
+ require 'forwardable'
2
+ require 'uom/error'
3
+ require 'uom/units'
4
+
5
+ module UOM
6
+ # Measurement qualifies a quantity with a unit.
7
+ class Measurement
8
+ extend Forwardable
9
+
10
+ attr_reader :quantity, :unit
11
+
12
+ # Creates a new Measurement with the given quantity and unit label, e.g.:
13
+ # Measurement.new(:mg, 4) #=> 4 mg
14
+ def initialize(unit, quantity)
15
+ unit = UOM::Unit.for(unit) if Symbol === unit
16
+ @quantity = quantity
17
+ @unit = unit
18
+ @unit_alias = unit unless unit == @unit.label
19
+ end
20
+
21
+ # numeric operators without an argument delegate to the quantity
22
+ unary_operators = Numeric.instance_methods(false).map { |method| method.to_sym }.select { |method| Numeric.instance_method(method).arity.zero? } + [:to_i, :to_f]
23
+ def_delegators(:@quantity, *unary_operators)
24
+
25
+ # numeric operators with an argument apply to the quantity and account for a Measurement argument
26
+ binary_operators = Numeric.instance_methods(false).map { |method| method.to_sym }.select { |method| Numeric.instance_method(method).arity == 1 } + [:+, :-, :*, :/, :==, :<=>, :eql?]
27
+ binary_operators.each { |method| define_method(method) { |other| apply_to_quantity(method, other) } }
28
+ [:+, :-, :==, :<=>, :div, :divmod, :quo, :eql?].each { |method| define_method(method) { |other| apply_to_quantity(method, other) } }
29
+
30
+ def ==(other)
31
+ return quantity == other unless Measurement === other
32
+ unit == other.unit ? quantity == other.quantity : other.as(unit) == self
33
+ end
34
+
35
+ # Returns the product of this measurement and the other measurement or Numeric.
36
+ # If other is a Measurement, then the returned Measurement Unit is the product of this
37
+ # Measurement's unit and the other Measurement's unit.
38
+ def *(other)
39
+ compose(:*, other)
40
+ end
41
+
42
+ # Returns the quotient of this measurement and the other measurement or Numeric.
43
+ # If other is a Measurement, then the returned Measurement Unit is the quotient of this
44
+ # Measurement's unit and the other Measurement's unit.
45
+ def /(other)
46
+ compose(:/, other)
47
+ end
48
+
49
+ # Returns a new Measurement which expresses this measurement as the given unit.
50
+ def as(unit)
51
+ unit = UOM::Unit.for(unit.to_sym) if String === unit or Symbol === unit
52
+ return self if @unit == unit
53
+ Measurement.new(unit, @unit.as(quantity, unit))
54
+ end
55
+
56
+ def to_s
57
+ unit = @unit_alias || unit.to_s(quantity)
58
+ unit_s = unit.to_s
59
+ suffix = quantity == 1 ? unit_s : unit_s.pluralize
60
+ "#{quantity} #{suffix}"
61
+ end
62
+
63
+ private
64
+
65
+ # Returns a new Measurement whose unit is this Measurement's unit and quantity is the
66
+ # result of applying the given method to this Measurement's quantity and the other quantity.
67
+ #
68
+ # If other is a Measurement, then the operation argument is the other Measurement quantity, e.g.:
69
+ # Measurement.new(:g, 3).apply(Measurement.new(:mg, 2000), :div) #=> 1 gram
70
+ def apply_to_quantity(method, other)
71
+ other = other.as(unit).quantity if Measurement === other
72
+ new_quantity = block_given? ? yield(to_f, other) : to_f.send(method, other)
73
+ Measurement.new(unit, new_quantity)
74
+ end
75
+
76
+ # Returns the application of method to this measurement and the other measurement or Numeric.
77
+ # If other is a Measurement, then the returned Measurement Unit is the composition of this
78
+ # Measurement's unit and the other Measurement's unit.
79
+ def compose(method, other)
80
+ return apply_to_quantity(method, other) unless Measurement === other
81
+ other = other.as(unit) if other.unit.axis == unit.axis
82
+ new_quantity = quantity.zero? ? 0.0 : quantity.to_f.send(method, other.quantity)
83
+ Measurement.new(unit.send(method, other.unit), new_quantity)
84
+ end
85
+ end
86
+ end
87
+
88
+ class String
89
+ # Returns the Measurement parsed from this string, e.g.:
90
+ # "1 gm".to_measurement.unit #=> grams
91
+ # If no unit is discernable from this string, then the default unit is used.
92
+ #
93
+ # Raises MeasurementError if there is no unit in either this string or the argument.
94
+ def to_measurement(default_unit=nil)
95
+ stripped = strip.delete(',')
96
+ quantity_s = stripped[/[.\d]*/]
97
+ quantity = quantity_s =~ /\./ ? quantity_s.to_f : quantity_s.to_i
98
+ unit_s = stripped[quantity_s.length..-1] if quantity_s.length < length
99
+ unit_s ||= default_unit.to_s if default_unit
100
+ raise UOM::MeasurementError.new("Unit could not be determined from #{self}") if unit_s.nil?
101
+ unit_s = unit_s.sub('/', '_per_')
102
+ unit = UOM::Unit.for(unit_s.strip.to_sym)
103
+ UOM::Measurement.new(unit, quantity)
104
+ end
105
+
106
+ # Returns this String as a unitized quantity.
107
+ # If this is a numerical String, then it is returned as a Numeric.
108
+ # Commas and a non-numeric prefix are removed if present.
109
+ # Returns nil if this is not a measurement string.
110
+ # If unit is given, then this method converts the measurement to the given unit.
111
+ def to_measurement_quantity(unit=nil)
112
+ # remove commas
113
+ return to_measurement_quantity(delete(',')) if self[',']
114
+ # extract the quantity portion
115
+ quantity_s = self[/\d*\.?\d*/]
116
+ return if quantity_s.nil? or quantity_s == '.'
117
+ quantity = quantity_s['.'] ? quantity_s.to_f : quantity_s.to_i
118
+ # extract the unit portion
119
+ unit_s = self[/([[:alpha:]]+)(\s)?$/, 1]
120
+ return quantity if unit_s.nil?
121
+ # make the measurement
122
+ msmt = "#{quantity} #{unit_s.downcase}".to_measurement
123
+ # return the measurement quantity
124
+ msmt = msmt.as(unit) if unit
125
+ msmt.quantity
126
+ end
127
+ end
@@ -0,0 +1,213 @@
1
+ require 'set'
2
+ require 'active_support/inflector'
3
+ require 'extensional'
4
+ require 'uom/factors'
5
+ require 'uom/unit_factory'
6
+
7
+ module UOM
8
+ # A Unit demarcates a standard magnitude on a Measurement Dimension.
9
+ # A _base_ unit is an unscaled dimensional Unit, e.g. METER.
10
+ # A _derived_ unit is composed of other units. The derived unit includes
11
+ # a required dimensional _axis_ unit and an optional _scalar_ Factor,
12
+ # e.g. the +millimeter+ unit is derived from the +meter+ axis and the
13
+ # +milli+ scaling factor.
14
+ #
15
+ # The axis is orthogonal to the scalar. There is a distinct unit for each
16
+ # axis and scalar. The base unit specifies the permissible scalar factors.
17
+ class Unit
18
+ @factory = UnitFactory.new
19
+
20
+ # make the extension label=>Unit hash. this hash creates a new Unit on demand
21
+ make_extensional(Hash.new { |hash, label| @factory.create(label) }) { |hash, unit| add_to_extent(hash, unit) }
22
+
23
+ attr_reader :label, :scalar, :axis, :abbreviations, :dimension, :permissible_factors
24
+
25
+ # Creates the Unit with the given label and parameters. The params include the following:
26
+ # * a unit label followed by zero, one or more Symbol unit abbreviations
27
+ # * one or more Dimension objects for a basic unit
28
+ # * an optional axis Unit for a derived unit
29
+ # * an optional scaling Factor for a derived unit
30
+ # * an optional normalization multiplier which converts this unit to the axis
31
+ # For example, a second is defined as:
32
+ # SECOND = Unit.new(:second, :sec, Dimension::TIME, MILLI, MICRO, NANO, PICO, FEMTO)
33
+ # and a millisecond is defined as:
34
+ # Unit.new(MILLI, UOM::SECOND)
35
+ # In most cases, a derived unit does not need to define a label or abbreviation
36
+ # since these are inferred from the axis and factor.
37
+ def initialize(*params, &converter)
38
+ # this long initializer ensures that every unit is correct by construction
39
+ # the first symbol is the label
40
+ labels = params.select { |param| Symbol === param }
41
+ @label = labels.first
42
+ # a Numeric parameter indicates a conversion multiplier instead of a converter block
43
+ multiplier = params.detect { |param| Numeric === param }
44
+ if multiplier then
45
+ # there can't be both a converter and a multiplier
46
+ if converter then
47
+ raise MeasurementError.new("Derived unit #{label} specifies both a conversion multiplier constant and a converter block")
48
+ end
49
+ # make the converter block from the multiplier
50
+ converter = lambda { |n| n * multiplier }
51
+ end
52
+ # the optional Factor parameters are the permissible scaling factors
53
+ factors = params.select { |param| Factor === param }.to_set
54
+ # a convertable unit must have a unique factor
55
+ if converter and factors.size > 1 then
56
+ raise MeasurementError.new("Derived unit #{label} can have at most one scalar: #{axes.join(', ')}")
57
+ @permissible_factors = []
58
+ else
59
+ @permissible_factors = factors
60
+ end
61
+ # the optional single Unit parameter is the axis for a derived unit
62
+ axes = params.select { |param| Unit === param }
63
+ raise MeasurementError.new("Unit #{label} can have at most one axis: #{axes.join(', ')}") if axes.size > 1
64
+ @axis = axes.first
65
+ # validate that a convertable unit has an axis; the converter argument is an axis quantity
66
+ raise MeasurementError.new("Derived unit #{label} has a converter but does not have an axis unit") if @default_converter and @axis.nil?
67
+ # the axis of an underived base unit is the unit itself
68
+ @axis ||= self
69
+ # validate that there is not a converter on self
70
+ raise MeasurementError.new("Unit #{label} specifies a converter but not a conversion unit") unless converter.nil? if @axis == self
71
+ # the default converter for a derived unit is identity
72
+ converter ||= lambda { |n| n } unless @axis == self
73
+ # the scalar is the first specified factor, or UNIT if there are multiple permissible factors
74
+ @scalar = @permissible_factors.size == 1 ? @permissible_factors.to_a.first : UNIT
75
+ # validate the scalar
76
+ if @axis == self then
77
+ #a derived unit cannot have a scalar factor
78
+ raise MeasurementError.new("Base unit #{label} cannot have a scalar value - #{@scalar}") unless @scalar == UNIT
79
+ elsif @scalar != UNIT and not @axis.permissible_factors.include?(@scalar) then
80
+ # a derived unit scalar factor must be in the axis permissible factors
81
+ raise MeasurementError.new("Derived unit #{label} scalar #{scalar} not a #{@axis} permissible factor #{@axis.permissible_factors.to_a.join(', ')}")
82
+ end
83
+ # if a scalar is defined, then adjust the converter
84
+ scaled_converter = @scalar == UNIT ? converter : lambda { |n| @scalar.as(@axis.scalar) * converter.call(n) }
85
+ # add the axis converter to the converters hash
86
+ @converters = {}
87
+ @converters[@axis] = scaled_converter if converter
88
+ # define the multiplier converter inverse
89
+ @axis.add_converter(self) { |n| 1.0 / scaled_converter.call(1.0 / n) } unless @scalar.nil? and multiplier.nil?
90
+ # make the label from the scalar and axis
91
+ @label ||= create_label
92
+ # validate label existence
93
+ raise MeasurementError.new("Unit does not have a label") if self.label.nil?
94
+ # validate label uniqueness
95
+ if Unit.extent.association.has_key?(@label) then
96
+ raise MeasurementError.new("Unit label #{@label} conflicts with existing unit #{Unit.extent.association[@label].inspect}")
97
+ end
98
+ # get the dimension
99
+ dimensions = params.select { |param| Dimension === param }
100
+ if dimensions.empty? then
101
+ # a base unit must have a dimension
102
+ raise MeasurementError.new("Base unit #{label} is missing a dimension") if @axis == self
103
+ # a derived unit dimension is the axis dimension
104
+ @dimension = axis.dimension
105
+ elsif dimensions.size > 1 then
106
+ # there can be at most one dimension
107
+ raise MeasurementError.new("Unit #{label} can have at most one dimension")
108
+ else
109
+ # the sole specified dimension
110
+ @dimension = dimensions.first
111
+ end
112
+ # the remaining symbols are abbreviations
113
+ @abbreviations = labels.size < 2 ? [] : labels[1..-1]
114
+ # validate abbreviation uniqueness
115
+ conflict = @abbreviations.detect { |abbrev| Unit.extent.association.has_key?(abbrev) }
116
+ raise MeasurementError.new("Unit label #{@label} conflicts with an existing unit") if conflict
117
+ # add this Unit to the extent
118
+ Unit << self
119
+ end
120
+
121
+ # Returns the Unit which is the basis for a derived unit.
122
+ # If this unit's axis is the axis itself, then that is the basis.
123
+ # Otherwise, the basis is this unit's axis basis.
124
+ def basis
125
+ basic? ? self : axis.basis
126
+ end
127
+
128
+ # Returns whether this unit's axis is the unit itself.
129
+ def basic?
130
+ self == axis
131
+ end
132
+
133
+ def add_abbreviation(abbrev)
134
+ @abbreviations << abbrev.to_sym
135
+ Unit.extent.association[abbrev] = self
136
+ end
137
+
138
+ # Defines a conversion from this unit to the other unit.
139
+ def add_converter(other, &converter)
140
+ @converters[other] = converter
141
+ end
142
+
143
+ # Returns a division CompositeUnit consisting of this unit and the other unit, e.g.:
144
+ # (Unit.for(:gram) / Unit.for(:liter)).label #=> gram_per_liter
145
+ def /(other)
146
+ CompositeUnit.for(self, other, :/)
147
+ end
148
+
149
+ # Returns a product CompositeUnit consisting of this unit and the other unit, e.g.:
150
+ # (Unit.for(:pound) * Unit.for(:inch)).label #=> foot_pound
151
+ def *(other)
152
+ CompositeUnit.for(self, other, :*)
153
+ end
154
+
155
+ # Returns the given quantity converted from this Unit into the given unit.
156
+ def as(quantity, unit)
157
+ begin
158
+ convert(quantity, unit)
159
+ rescue MeasurementError => e
160
+ raise MeasurementError.new("No conversion path from #{self} to #{unit} - #{e}")
161
+ end
162
+ end
163
+
164
+ def to_s(quantity=nil)
165
+ (quantity.nil? or quantity == 1) ? label.to_s : label.to_s.pluralize
166
+ end
167
+
168
+ def inspect
169
+ "#{self.class.name}@#{self.object_id}[#{([label] + abbreviations).join(', ')}]"
170
+ end
171
+
172
+ protected
173
+
174
+ def create_label
175
+ "#{scalar.label}#{axis.label}".to_sym
176
+ end
177
+
178
+ private
179
+
180
+ # Returns the given quantity converted from this Unit into the given unit.
181
+ def convert(quantity, unit)
182
+ return quantity if unit == self
183
+ # if there is a converter to the target unit, then call it
184
+ converter = @converters[unit]
185
+ return converter.call(quantity) if converter
186
+ # validate the target unit dimension
187
+ raise MeasurementError.new("Cannot convert #{unit} dimension #{unit.dimension} to #{self} dimension #{@dimension}") unless @dimension == unit.dimension
188
+ # convert via an axis pivot intermediary
189
+ pivot = conversion_pivot(unit)
190
+ pivot.as(self.as(quantity, pivot), unit)
191
+ end
192
+
193
+ def conversion_pivot(unit)
194
+ # this unit's axis is the preferred pivot unless there is no separate axis
195
+ return @axis unless self == @axis
196
+ # out of luck if there is no axis intermediary
197
+ raise MeasurementError.new("No converter from #{self} to #{unit}") if unit.axis == unit
198
+ # convert via the other unit's axis intermediary
199
+ unit.axis
200
+ end
201
+
202
+ # Utility method called by the Unit class after initialization to add this unit to the extent.
203
+ def self.add_to_extent(hash, unit)
204
+ hash[unit.label] = unit
205
+ hash[unit.label.to_s.pluralize.to_sym] = unit
206
+ unit.abbreviations.each do |abbrev|
207
+ hash[abbrev] = unit
208
+ hash[abbrev.to_s.pluralize.to_sym] = unit
209
+ end
210
+ unit
211
+ end
212
+ end
213
+ end