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 +2 -0
- data/Rakefile +1 -1
- data/lib/dimensional.rb +1 -1
- data/lib/dimensional/configuration.rb +90 -17
- data/lib/dimensional/configurator.rb +7 -34
- data/lib/dimensional/dimension.rb +24 -8
- data/lib/dimensional/metric.rb +115 -41
- data/lib/dimensional/system.rb +3 -2
- data/lib/dimensional/unit.rb +55 -17
- data/lib/dimensional/version.rb +1 -1
- data/test/configuration_test.rb +217 -0
- data/test/configurator_test.rb +25 -30
- data/test/demo.rb +240 -51
- data/test/dimension_test.rb +28 -3
- data/test/dimensional_test.rb +47 -2
- data/test/metric_test.rb +250 -78
- data/test/unit_test.rb +83 -19
- metadata +4 -5
- data/lib/dimensional/measure.rb +0 -122
- data/test/measure_test.rb +0 -226
data/CHANGELOG
CHANGED
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 = %
|
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
@@ -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
|
8
|
+
# * Dimensions
|
8
9
|
# * Systems, including a prioritized array of Systems to be consulted during parsing.
|
9
|
-
# * Metrics, including Unit formatting and parsing options
|
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) =>
|
24
|
-
#
|
25
|
-
# Measure#change_system(system)
|
25
|
+
# Measure.parse(str, detectors) => measure
|
26
|
+
# instance of measure with unit matched by detectors
|
26
27
|
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
35
|
-
|
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
|
-
|
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, :
|
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
|
-
@
|
46
|
-
|
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, :
|
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,
|
79
|
-
u = unit(name, :abbreviation => abbreviation, :
|
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, :
|
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
|
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 :
|
31
|
+
attr_reader :name, :symbol, :basis
|
33
32
|
|
34
|
-
def initialize(name, symbol = nil,
|
35
|
-
|
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
|
-
@
|
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
|
-
|
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
|
data/lib/dimensional/metric.rb
CHANGED
@@ -1,64 +1,138 @@
|
|
1
|
-
require 'dimensional/
|
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
|
6
|
-
class Metric
|
7
|
-
|
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
|
-
|
13
|
+
class << self
|
14
|
+
attr_accessor :dimension, :base, :default
|
10
15
|
|
11
|
-
|
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
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
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
|
-
|
26
|
-
|
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
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
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
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
52
|
-
|
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
|
-
|
57
|
-
|
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
|
61
|
-
|
134
|
+
def inspect
|
135
|
+
strfmeasure("<%p <%#U>>")
|
62
136
|
end
|
63
137
|
end
|
64
138
|
end
|