dimensional 0.0.6 → 0.1.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/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