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.
- data/History.txt +4 -0
- data/LEGAL +5 -0
- data/LICENSE +22 -0
- data/README.md +102 -0
- data/doc/units.txt +53 -0
- data/lib/active_support/README.txt +2 -0
- data/lib/active_support/core_ext/string.rb +7 -0
- data/lib/active_support/core_ext/string/inflections.rb +167 -0
- data/lib/active_support/inflections.rb +55 -0
- data/lib/active_support/inflector.rb +396 -0
- data/lib/uom.rb +7 -0
- data/lib/uom/composite_unit.rb +105 -0
- data/lib/uom/composite_unit_key_canonicalizer.rb +53 -0
- data/lib/uom/dimension.rb +39 -0
- data/lib/uom/dimensions.rb +12 -0
- data/lib/uom/error.rb +3 -0
- data/lib/uom/factor.rb +55 -0
- data/lib/uom/factors.rb +32 -0
- data/lib/uom/measurement.rb +127 -0
- data/lib/uom/unit.rb +213 -0
- data/lib/uom/unit_factory.rb +145 -0
- data/lib/uom/units.rb +115 -0
- metadata +101 -0
data/lib/uom.rb
ADDED
@@ -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
|
data/lib/uom/error.rb
ADDED
data/lib/uom/factor.rb
ADDED
@@ -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
|
data/lib/uom/factors.rb
ADDED
@@ -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
|
data/lib/uom/unit.rb
ADDED
@@ -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
|