dimensional 0.0.6 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -0,0 +1,2 @@
1
+ 0.1.0
2
+ 0.1.1 Simpler conversion system-to-system and redefinition of preference semantics.
data/Rakefile CHANGED
@@ -19,7 +19,7 @@ spec = Gem::Specification.new do |s|
19
19
  s.name = %q{dimensional}
20
20
  s.version = Dimensional::VERSION
21
21
  s.required_ruby_version = '>= 1.6.8'
22
- s.date = %q{2009-10-09}
22
+ s.date = Time.now.strftime("%Y-%m-%d")
23
23
  s.authors = ["Chris Hapgood"]
24
24
  s.email = %q{cch1@hapgoods.com}
25
25
  s.summary = %q{Dimensional provides handling for numbers with units.}
data/lib/dimensional.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  require 'dimensional/dimension'
2
2
  require 'dimensional/system'
3
3
  require 'dimensional/unit'
4
- require 'dimensional/measure'
4
+ require 'dimensional/metric'
5
5
  require 'dimensional/version'
6
6
 
7
7
  module Dimensional
@@ -2,47 +2,120 @@ require 'dimensional/dimension'
2
2
  require 'dimensional/system'
3
3
  require 'dimensional/unit'
4
4
  require 'dimensional/metric'
5
+ require 'forwardable'
5
6
 
6
7
  # Encapsulates the application-specific configuration of Dimensional elements, including
7
- # * Dimensions, including which are considered fundamental.
8
+ # * Dimensions
8
9
  # * Systems, including a prioritized array of Systems to be consulted during parsing.
9
- # * Metrics, including Unit formatting and parsing options per Metric
10
+ # * Metrics, including associated Unit formatting and parsing options
10
11
  # * Units
11
12
  # Configurations can be constructed with a Configurator. They can also be copied and extended.
12
13
  #
13
14
  # Metric == context
14
15
  #
15
16
  # Configuration#options(unit, context) => options_hash
17
+ # a hash of formatting and parsing options for the given unit in the given context
16
18
 
17
- # Configuration#format(unit, context) => format_string
19
+ # Configuration#format(unit, context = nil) => format_string
18
20
  # #strfmeasure-compatible format string for given unit and given context
19
21
 
20
- # Configuration#detectors(context) => detector_hash
21
- # #parse-compatible detector->unit pairs suitable for given context
22
+ # Configuration#detectors(context = nil) => detector_hash
23
+ # Measure#parse-compatible detector->unit pairs suitable for given context
22
24
 
23
- # Measure.parse(str, detectors) => instance of measure with unit matched by detectors
24
- # Measure#strfmeasure(format)
25
- # Measure#change_system(system)
25
+ # Measure.parse(str, detectors) => measure
26
+ # instance of measure with unit matched by detectors
26
27
 
27
- class Configuration
28
- class Systems < Array
29
- end
28
+ # Measure.new(value, unit, context) => measure
29
+ # instance of measure with given value and unit, and with the given context.
30
+
31
+ # Measure#strfmeasure(format) => str
32
+ # formatted string representing the given measure
33
+
34
+ # Measure#change_system(system) => measure
35
+ # a
30
36
 
37
+ # Create a Measure from a Numeric and Unit, optionally for a given Metric (load attribute from DB)
38
+ # Create a Measure from a String for a given Metric with a preferred System (parse form input)
39
+ # Convert a Measure from a given Unit to the most appropriate Unit in a given System
40
+ # Format a Measure as a String given a specific format String
41
+
42
+ class Configuration
43
+ # A (unordered and unique) set of dimensions with generous lookup semantics
44
+ # TODO: Prevent a composite dimension from being added unless its fundamentals are already included
31
45
  class Dimensions < Set
46
+ def [](str)
47
+ raise unless str
48
+ detect{|d| d.name == str.to_s || d.symbol == str.to_s}
49
+ end
32
50
  end
33
51
 
34
- class Metrics < Set
35
- end
52
+ # An ordered and unique collection of systems with generous lookup semantics and re-orderability. Order
53
+ # is not defined prior to invoking #priortize.
54
+ class Systems < DelegateClass(Set)
55
+ include Enumerable
56
+ attr_reader :priority
57
+
58
+ def initialize(*args)
59
+ @priority = []
60
+ super(args)
61
+ end
36
62
 
37
- class Units < Set
63
+ # Prioritize the system according to the given array of lookup keys
64
+ def prioritize(keys)
65
+ new = keys.map{|k| self[k]}.uniq.compact
66
+ priority.replace(new)
67
+ end
68
+
69
+ # Iteration is in priority order. We directly access the delegate object so that enumerable
70
+ # methods needed here don't recurse (#each being called from a mixin apparently triggers this behavior)
71
+ def each(&block)
72
+ @_dc_obj.sort_by{|s| priority.index(s) || priority.size}.each(&block)
73
+ end
74
+
75
+ def [](str)
76
+ detect {|s| (s == str.to_s) || (s.abbreviation == str.to_s) }
77
+ end
38
78
  end
39
79
 
40
- attr_reader :dimensions, :systems, :units, :metrics
80
+ attr_reader :dimensions, :systems, :metrics
81
+
82
+ include Enumerable
83
+ extend Forwardable
41
84
 
42
85
  def initialize
43
86
  @dimensions = Dimensions.new
44
87
  @systems = Systems.new
45
- @metrics = Metrics.new
46
- @units = Units.new
88
+ @units = Set.new
89
+ end
90
+
91
+ def_delegators :@units, :each
92
+ def_delegators :to_set, :size, :length, :empty?
93
+
94
+ def add(u)
95
+ systems << u.system
96
+ # TODO: Add fundamental dimensions before adding composite dimension
97
+ dimensions << u.dimension
98
+ @units << u
99
+ end
100
+ alias << add
101
+
102
+ def [](dim, sys, str)
103
+ us = select{|u| u.dimension == dim && u.system == sys}
104
+ us.detect{|u| str == u.name || (u.abbreviation && str == u.abbreviation)}
105
+ end
106
+
107
+ # Returns a new configuration with only the units with the specified dimension
108
+ def dimension(d)
109
+ scope(@units.select{|u| u.dimension == d})
110
+ end
111
+
112
+ private
113
+ # Returns a new configuration scoped to the supplied units
114
+ def scope(us)
115
+ config = self.dup
116
+ config.instance_eval do
117
+ @units = us
118
+ end
119
+ config
47
120
  end
48
121
  end
@@ -8,7 +8,7 @@ module Dimensional
8
8
  # to a local variable if they have the same name -it can subtly goof things up.
9
9
  class Configurator
10
10
  # A simple container for holding a context for definition, parsing and formatting of dimensional data
11
- Context = Struct.new(:system, :dimension, :unit) do
11
+ Context = Struct.new(:system, :dimension, :unit) do
12
12
  def valid?
13
13
  true
14
14
  end
@@ -57,15 +57,13 @@ module Dimensional
57
57
 
58
58
  # Register a new base unit
59
59
  def base(name, abbreviation = nil, options = {}, &block)
60
- u = unit(name, {:abbreviation => abbreviation})
61
- dimension_metric.prefer(u, default_preferences(u).merge(options))
60
+ u = unit(name, {:abbreviation => abbreviation}.merge(options))
62
61
  change_context({:unit => u}, block)
63
62
  end
64
63
 
65
64
  # Register a new derived unit
66
65
  def derive(name, abbreviation, factor, options = {}, &block)
67
- u = unit(name, {:abbreviation => abbreviation, :reference_unit => context.unit, :reference_factor => factor})
68
- dimension_metric.prefer(u, default_preferences(u).merge(options))
66
+ u = unit(name, {:abbreviation => abbreviation, :reference_units => {context.unit => 1}, :reference_factor => factor}.merge(options))
69
67
  change_context({:unit => u}, block)
70
68
  end
71
69
 
@@ -75,49 +73,24 @@ module Dimensional
75
73
  end
76
74
 
77
75
  # Register a new unit in the current context that references an arbitrary unit
78
- def reference(name, abbreviation, u, f, options = {}, &block)
79
- u = unit(name, :abbreviation => abbreviation, :reference_unit => u, :reference_factor => f)
80
- dimension_metric.prefer(u, default_preferences(u).merge(options))
76
+ def reference(name, abbreviation, ru, f, options = {}, &block)
77
+ u = unit(name, {:abbreviation => abbreviation, :reference_units => {ru => 1}, :reference_factor => f}.merge(options))
81
78
  change_context({:unit => u}, block)
82
79
  end
83
80
 
84
81
  # Register a new unit in the current context that is composed of multiple units
85
82
  def combine(name, abbreviation, components, options = {}, &block)
86
- u = unit(name, :abbreviation => abbreviation, :reference_factor => 1, :reference_unit => components)
87
- dimension_metric.prefer(u, default_preferences(u).merge(options))
83
+ u = unit(name, {:abbreviation => abbreviation, :reference_factor => 1, :reference_units => components}.merge(options))
88
84
  change_context({:unit => u}, block)
89
85
  end
90
86
 
91
- # Register preferred options for the current unit in the named metric
92
- def prefer(name, options = {})
93
- m = find_or_register_metric(name, dimension_metric)
94
- m.prefer(context.unit, options)
95
- end
96
-
97
87
  def to_s
98
88
  context.to_s
99
89
  end
100
-
90
+
101
91
  private
102
92
  def unit(name, options = {})
103
93
  Unit.register(name, context.system, context.dimension, options)
104
94
  end
105
-
106
- def dimension_metric
107
- find_or_register_metric(self.class.dimension_default_metric_name(context.dimension), nil)
108
- end
109
-
110
- # Basic preferences for formatting and parsing the given unit
111
- def default_preferences(u)
112
- o = {}
113
- o[:format] = u.dimension.nil? ? "%s %U" : "%s%U"
114
- o[:detector] = /\A#{[u.name, u.abbreviation].compact.join('|')}\Z/
115
- o
116
- end
117
-
118
- # Register the metric defined by the given key and the current context if it does not already exist.
119
- def find_or_register_metric(name, parent = nil)
120
- Metric[name] || Metric.register(name, context.dimension, parent)
121
- end
122
95
  end
123
96
  end
@@ -8,8 +8,7 @@ module Dimensional
8
8
 
9
9
  def self.register(*args)
10
10
  d = new(*args)
11
- raise "Dimension #{d.name} already exists" if @registry[d.name]
12
- raise "Dimension #{d.name}'s symbol already exists" if @symbol_registry[d.symbol]
11
+ raise "Dimension #{d}'s symbol already exists" if @symbol_registry[d.symbol]
13
12
  @registry[d.name.to_sym] = d
14
13
  @symbol_registry[d.symbol.to_sym] = d
15
14
  const_set(d.symbol.to_s, d) rescue nil # Not all symbols strings are valid constant names
@@ -29,20 +28,37 @@ module Dimensional
29
28
  @symbol_registry.clear
30
29
  end
31
30
 
32
- attr_reader :exponents, :name, :symbol
31
+ attr_reader :name, :symbol, :basis
33
32
 
34
- def initialize(name, symbol = nil, exponents = {})
35
- exponents.each_pair do |k,v|
33
+ def initialize(name, symbol = nil, basis = {})
34
+ basis.each_pair do |k,v|
36
35
  raise "Invalid fundamental dimension #{k}" unless k.fundamental?
37
- raise "Invalid exponent #{v}" unless v.kind_of?(Integer) # Can't this really be any Rational?
36
+ raise "Invalid exponent for basis member #{v}" unless v.kind_of?(Integer) # Can't this really be any Rational?
38
37
  end
39
- @exponents = Hash.new(0).merge(exponents)
38
+ @basis = Hash.new(0).merge(basis)
40
39
  @name = name.to_s
41
40
  @symbol = symbol.nil? ? name.to_s.slice(0, 1).upcase : symbol.to_s
42
41
  end
43
42
 
44
43
  def fundamental?
45
- exponents.empty?
44
+ basis.empty?
45
+ end
46
+ alias base? fundamental?
47
+
48
+ # Equality is determined by equality of value-ish attributes. Specifically, equal basis for non-fundamental units
49
+ # and identicality for fundamental units. The nil dimension is inherently un-equal to any non-nil dimension.
50
+ def ==(other)
51
+ other.kind_of?(self.class) && ((fundamental? && other.fundamental?) ? eql?(other) : other.basis == basis)
52
+ end
53
+
54
+ # Hashing collisions are desired when we have same identity-defining attributes.
55
+ def eql?(other)
56
+ other.kind_of?(self.class) && other.name.eql?(self.name)
57
+ end
58
+
59
+ # This is pretty lame, but the expected usage means we shouldn't get penalized
60
+ def hash
61
+ [self.class, name].hash
46
62
  end
47
63
 
48
64
  def to_s
@@ -1,64 +1,138 @@
1
- require 'dimensional/dimension'
1
+ require 'dimensional/unit'
2
+ require 'delegate'
2
3
 
3
4
  module Dimensional
4
5
  # A specific physical entity that can be measured.
5
- # TODO: Add a hierarchy that allows metrics to be grouped by domain, like shipping, carpentry or sports
6
- class Metric
7
- include Enumerable
6
+ # TODO: Add a hierarchy that allows metrics to be built into a taxonomy by domain, like shipping, carpentry or sports
7
+ class Metric < DelegateClass(Numeric)
8
+ # A Measure string is composed of a number followed by a unit separated by optional whitespace.
9
+ # A unit (optional) is composed of a non-digit character followed by zero or more word characters and terminated by some stuff.
10
+ # Scientific notation is not currently supported.
11
+ NUMERIC_REGEXP = /((?=\d|\.\d)\d*(?:\.\d*)?)\s*(\D\w*?)?(?=\b|\d|\W|$)/
8
12
 
9
- @registry = Hash.new
13
+ class << self
14
+ attr_accessor :dimension, :base, :default
10
15
 
11
- attr_reader :name, :dimension, :parent
16
+ # The units applicable to this metric in priority order (highest priority first)
17
+ def units
18
+ @units ||= Unit.select{|u| u.dimension == dimension}.sort_by{|u| configuration[u][:preference]}.reverse
19
+ end
12
20
 
13
- def self.register(*args)
14
- m = new(*args)
15
- raise "Metric #{m} has already been registered." if @registry[m.name]
16
- @registry[m.name && m.name.to_sym] = m
17
- m
18
- end
21
+ # Find the unit matching the given string, preferring units in the given system
22
+ def find_unit(str, system = nil)
23
+ system = System[system] unless system.kind_of?(System)
24
+ us = self.units.select{|u| configuration[u][:detector].match(str.to_s)}
25
+ us.detect{|u| u.system == system} || us.first
26
+ end
27
+
28
+ def configuration
29
+ @configuration ||= Hash.new do |h,u|
30
+ h[u] = {:detector => u.detector, :format => u.format, :preference => u.preference}
31
+ end
32
+ end
33
+
34
+ def configure(unit, options = {})
35
+ @dimension ||= unit.dimension
36
+ @base ||= unit
37
+ @default ||= unit
38
+ @units = nil
39
+ raise "Unit #{unit} is not compatible with dimension #{dimension || '<nil>'}." unless unit.dimension == dimension
40
+ configuration[unit] = {:detector => unit.detector, :format => unit.format, :preference => unit.preference * 1.01}.merge(options)
41
+ end
19
42
 
20
- # Lookup the metric by key. The default metric for a dimension is keyed by the dimension's symbol
21
- def self.[](sym)
22
- @registry[sym && sym.to_sym]
43
+ # Parse a string into a Metric instance. Providing a unit system (or associated symbol) will prefer the units from that system.
44
+ # Unrecognized strings return nil.
45
+ def parse(str, system = nil)
46
+ system = System[system] unless system.kind_of?(System)
47
+ elements = str.to_s.scan(NUMERIC_REGEXP).map do |(v, us)|
48
+ unit = us.nil? ? default : find_unit(us, system)
49
+ raise ArgumentError, "Unit cannot be determined (#{us})" unless unit
50
+ system = unit.system # Set the system to restrict subsequent filtering
51
+ value = Integer(v) rescue Float(v)
52
+ new(value, unit)
53
+ end
54
+ # Coalesce the elements into a single Measure instance in "expression base" units.
55
+ # The expression base is the first provided unit in an expression like "1 mile 200 feet"
56
+ elements.inject do |t, e|
57
+ raise ArgumentError, "Inconsistent units in compound metric" unless t.unit.system == e.unit.system
58
+ converted_value = e.convert(t.unit)
59
+ new(t + converted_value, t.unit)
60
+ end
61
+ end
62
+
63
+ # Sort units by "best" fit for the desired order of magnitude. Preference values offset OOM differences.
64
+ def best_fit(target_oom)
65
+ units.sort_by do |u|
66
+ oom_delta = (Math.log10(u.factor) - target_oom).abs
67
+ configuration[u][:preference] - oom_delta
68
+ end
69
+ end
23
70
  end
24
71
 
25
- def self.reset!
26
- @registry.clear
72
+ attr_reader :unit
73
+ def initialize(value, unit = self.class.default)
74
+ raise ArgumentError, "No default unit set" unless unit
75
+ @unit = unit
76
+ super(value)
27
77
  end
28
78
 
29
- def initialize(name, dimension, parent = nil)
30
- @name = name && name.to_s
31
- @dimension = dimension
32
- @units = {}
33
- @parent = parent
79
+ # Convert this dimensional value to a different unit
80
+ def convert(new_unit)
81
+ new_value = self * unit.convert(new_unit)
82
+ self.class.new(new_value, new_unit)
34
83
  end
35
-
36
- def prefer(unit, options = {})
37
- raise "Unit #{unit} is not compatible with dimension #{dimension || '<nil>'}." unless unit.dimension == dimension
38
- @units[unit] = options
84
+
85
+ # Convert this metric to the "most appropriate" unit in the given system. A similar order-of-magnitude for the result is preferred.
86
+ def change_system(system)
87
+ system = System[system] unless system.kind_of?(System)
88
+ target_oom = Math.log10(self.unit.factor)
89
+ bu = self.class.best_fit(target_oom).select{|u| u.system == system}.last
90
+ convert(bu)
39
91
  end
40
92
 
41
- def units
42
- baseline = parent ? parent.units : @units.keys
43
- baseline.sort_by{|u| [1.0 - preference(u), u.name, u.system]}
93
+ # Convert this metric to the "most appropriate" unit in the current system. A resulting order of magnitude close to zero is preferred.
94
+ def preferred
95
+ target_oom = Math.log10(self) + Math.log10(self.unit.factor)
96
+ bu = self.class.best_fit(target_oom).select{|u| u.system == unit.system}.last
97
+ convert(bu)
44
98
  end
45
-
46
- def preferences(u)
47
- baseline = parent ? parent.preferences(u) : {}
48
- baseline.merge(@units[u] || {})
99
+
100
+ # Return a new metric expressed in the base unit
101
+ def base
102
+ raise "No base unit defined" unless self.class.base
103
+ convert(self.class.base)
49
104
  end
50
-
51
- # How "preferred" is the given unit for this metric?
52
- def preference(u)
53
- @units.has_key?(u) ? 1 : 0
105
+
106
+ def to_s
107
+ strfmeasure(self.class.configuration[unit][:format])
54
108
  end
55
109
 
56
- def each
57
- units.each
110
+ # Like Date, Time and DateTime, Metric represents both a value and a context. Like those built-in classes,
111
+ # Metric needs this output method to control the context. The format string is identical to that used by
112
+ # Kernel.sprintf with the addition of support for the U specifier:
113
+ # %U replace with unit. This specifier supports the '#' flag to use the unit's name instead of abbreviation
114
+ # In addition, this specifier supports the same width and precision modfiers as the '%s' specifier.
115
+ # For example: %#10.10U
116
+ # All other specifiers are applied to the numeric value of the measure.
117
+ # TODO: Support modulo subordinate units with format hash -> {1 => "'", 12 => :inch} or {1 => "%d#", 16 => "%doz."}
118
+ def strfmeasure(format)
119
+ v = if (precision = self.class.configuration[unit][:precision])
120
+ # TODO: This precision could more usefully be converted to "signifigant digits"
121
+ pfactor = 10**(-precision)
122
+ ((self * pfactor).round / pfactor.to_f)
123
+ else
124
+ __getobj__ # We need the native value to prevent infinite recursion if the user specifies the %s specifier.
125
+ end
126
+ format = format.gsub(/%(#)?([\d.\-\*]*)U/) do |s|
127
+ us = ($1) ? unit.name : (unit.abbreviation || unit.name)
128
+ Kernel.sprintf("%#{$2}s", us)
129
+ end
130
+ count = format.scan(/(?:\A|[^%])(%[^% ]*[A-Za-z])/).size
131
+ Kernel.sprintf(format, *Array.new(count, v))
58
132
  end
59
133
 
60
- def to_s
61
- name || super
134
+ def inspect
135
+ strfmeasure("<%p <%#U>>")
62
136
  end
63
137
  end
64
138
  end