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 +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
|