measurand.rb 0.1.4

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d342e21e71e18b32c4e5cb2ded4762cfdf527342fb3fd057112392cd49cc9b23
4
+ data.tar.gz: 7e3c0829b85f6172d5deb62473ab0979747d5643441ad15cf62c82ebcd0297c5
5
+ SHA512:
6
+ metadata.gz: 73075c9f00f95b600e973aea6a6f41d82956d4870ea3ab3297c475cbed012a54f06515a7a8d12297628ae7262cbd498a810e96591dcf3cbef45c667156df59e9
7
+ data.tar.gz: b33a77c18c347f24bc801b561c74c33b948649bcac8de01c0d3c3d087fe4c2c8edd0bf0c7f832ae41a33fe70cb5582d9107d1e9cb20296832bf9662fd23e78b1
data/CHANGELOG ADDED
@@ -0,0 +1,73 @@
1
+ # CHANGELOG
2
+
3
+ ## 20260826
4
+
5
+ 0.1.4: + measurand.rb.gemspec, so that the gem resolves under both names.
6
+
7
+ 1. + measurand.rb.gemspec
8
+ 2. ~ Gemfile: /gemspec/gemspec name: 'measurand'/, bundler refusing to parse a Gemfile whose bare gemspec directive finds more than one gemspec to choose from.
9
+ 3. ~ Measurand::VERSION: /0.1.3/0.1.4/
10
+
11
+ 0.1.3: + minitest and rake as development dependencies, the Gemfile having resolved nothing.
12
+
13
+ 1. ~ measurand.gemspec: + spec.development_dependencies, + Gem::Specification#development_dependencies= helper.
14
+ 2. ~ Measurand::VERSION: /0.1.2/0.1.3/
15
+
16
+
17
+ ## 20260817
18
+
19
+ 0.1.2: Measurand#/ divided two integer values as integers.
20
+
21
+ 1. ~ Measurand#/: through Numeric#quo rather than #/, so that two Integer values divide exactly. A ratio of two integer counts came back as nothing at all — 9579583 over 10900458 gave 0 rather than 0.8788 — which is what a speedup or a rate is made of, and integer counts are what a clock reads in. A Float value stays a Float, and the partials were already in Float and are unaffected. duration.rb met the same defect at its 0.5.0 and took the same fix.
22
+ 2. + test/Measurand/Measurand_test.rb: that two integer values divide exactly, and that two exact ones keep a Rational.
23
+ 3. + test/Measurand/Measurand_test.rb: that Measurand#** carries no such defect, an integer raised to a negative exponent giving a Rational rather than truncating, in the value and in the derivative alike. Integer#** promotes where Integer#/ truncates, which is Ruby's inconsistency and not this gem's, and nothing here would have said so.
24
+ 4. ~ Measurand::VERSION: /0.1.1/0.1.2/
25
+
26
+ 0.1.1: Populate the .gitignore, which had been committed empty.
27
+
28
+ 1. ~ .gitignore: 47 lines, covering gem builds, bundler, RDoc and YARD output, .DS_Store, editor files, the Ruby version managers, test artefacts and .claude. The file was created empty by the root commit and nothing had since filled it in.
29
+ 2. ~ Measurand::VERSION: /0.1.0/0.1.1/
30
+
31
+
32
+ ## 20260815
33
+
34
+ 0.1.0: + Measurand#place, Measurand#rounded_uncertainty.
35
+
36
+ 1. + Measurand#place: the power of ten of the least significant digit worth showing, negative for digits after the point, and nil where the measurand is exact — every digit being real and there being no last one. It is what a caller wants in order to render a quantity in a unit of its own choosing rather than take #to_s, and monotonic.rb was reaching into Measurand::Format to get at it.
37
+ 2. + Measurand#rounded_uncertainty: the uncertainty as #to_s shows it, rounded by the Particle Data Group convention, and zero where the measurand is exact.
38
+ 3. ~ Measurand#to_s and #to_parenthetic: built upon the two above rather than calling Measurand::Format themselves, so that what is published and what is rendered cannot drift apart.
39
+ 4. + test/Measurand/Format_test.rb: #place and #rounded_uncertainty, including the exact case and that #place agrees with the digits #to_s renders.
40
+ 5. ~ lib/Measurand/Format.rb --> lib/measurand.rb: #place, #rounded_uncertainty, #to_s, #to_parenthetic, #inspect and the private #fixed, which are Measurand's and not Format's. The file is named for the module and now holds only it, as Monotonic/Time.rb holds Monotonic::Time. Naming them by the file they sat in was already a mistake waiting to be made.
41
+ 6. + CHANGELOG, and - CHANGELOG.md. Both were present at 0.0.0 and only the .md shipped; the gem is what is immutable, so 0.0.0 has been rewritten to carry the .md alone and the change of file belongs here. CHANGELOG is what every other gem here is spelt.
42
+ 7. ~ measurand.gemspec: /CHANGELOG.md/CHANGELOG/, following the above.
43
+ 8. ~ README.md: + #place and #rounded_uncertainty to the Presentation section.
44
+ 9. ~ Measurand::VERSION: /0.0.0/0.1.0/
45
+
46
+
47
+ ## 20260715
48
+
49
+ 0.0.0: + Measurand: measured values with uncertainty and GUM-conformant error propagation.
50
+
51
+ 1. + Measurand: Measured values with uncertainty and GUM-conformant error propagation, forward-mode automatic differentiation over a {Source => partial} map; the uncertainty is derived from the partials, not stored, so shared sources correlate (x - x is exactly 0 ± 0).
52
+ 2. + Measurand construction methods: .new, .relative, .cast, .cast\_relative, .from\_samples (mean ± standard error of the mean), .derived.
53
+ 3. + Measurand arithmetic methods: +, -, \*, /, \*\*, -@, abs, apply — chain-rule propagation; independent uncertainties combine in quadrature; scalars are exact.
54
+ 4. + Measurand comparison methods: ==/eql?/hash (exact: value and uncertainty), <=> (by value, Comparable), consistent\_with?, overlaps?, coerce (scalar-on-the-left arithmetic).
55
+ 5. + Measurand reader methods: value, uncertainty, relative\_uncertainty, exact?
56
+ 6. + Measurand::Format: Uncertainty-driven, PDG-rounded to\_s (ascii: keyword emits +/- instead of ±), to\_parenthetic, inspect (full precision).
57
+ 7. + Measurand::Math: Measurand::Math.sqrt, exp, log, log10, sin, cos, tan — chain-rule propagation with no Math monkey-patching.
58
+ 8. + Measurand::Numeric: opt-in Numeric#pm / #± and Enumerable#pm (separate require, so nothing is monkey-patched unless asked).
59
+ 9. + Measurand::Parse: Measurand.parse for ±, +/-, parenthetic (10), relative %, and exact forms; raises ArgumentError rather than returning nil.
60
+ 10. + Measurand::Source: Opaque per-source identity carrying its own uncertainty; deliberately no ==/eql?/hash, so shared sources correlate and independently-constructed ones never collapse.
61
+ 11. + Measurand::VERSION: 0.0.0
62
+ 12. + measurand.gemspec
63
+ 13. + Gemfile
64
+ 14. + LICENSE
65
+ 15. + Rakefile
66
+ 16. + README.md
67
+ 17. + test/test_helper.rb
68
+ 18. + test/Measurand/Format_test.rb
69
+ 19. + test/Measurand/Measurand_test.rb
70
+ 20. + test/Measurand/Math_test.rb
71
+ 21. + test/Measurand/Numeric_test.rb
72
+ 22. + test/Measurand/Parse_test.rb
73
+ 23. + test/Measurand/Source_test.rb
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://www.rubygems.org'
2
+
3
+ gemspec name: 'measurand'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thoran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # measurand
2
+
3
+ Measured values with uncertainty, and GUM-conformant error propagation.
4
+
5
+ A `Measurand` is a value with an associated uncertainty. Arithmetic on
6
+ measurands propagates the uncertainty correctly, so a calculation chain can
7
+ tell the truth about how well its result is known.
8
+
9
+ Uncertainty is propagated by first-order (GUM) error propagation over an
10
+ automatic-differentiation graph. Because each measurand remembers which
11
+ independent sources it depends on, shared sources correlate correctly:
12
+ `x - x` is exactly `0 ± 0`, not `0 ± √2·σ`.
13
+
14
+ ## Installation
15
+
16
+ ```
17
+ gem install measurand
18
+ ```
19
+
20
+ Or add to your Gemfile:
21
+
22
+ ```ruby
23
+ gem 'measurand'
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```ruby
29
+ require 'measurand'
30
+ ```
31
+
32
+ ### Construction
33
+
34
+ ```ruby
35
+ Measurand.new(5.129213, 0.01) # value ± uncertainty
36
+ Measurand.new(5.129213) # exact; uncertainty 0
37
+ Measurand.relative(5.129213, 0.02) # 2% relative — how instruments are spec'd
38
+
39
+ Measurand.cast([5.129, 5.132, 5.128], 0.001) # shared absolute uncertainty
40
+ Measurand.cast_relative([5.129, 5.132], 0.0002) # shared relative uncertainty
41
+
42
+ Measurand.from_samples([5.129, 5.132, 5.128]) # mean ± standard error of the mean
43
+ ```
44
+
45
+ ### Parsing
46
+
47
+ ```ruby
48
+ Measurand.parse('5.129213 ± 0.01')
49
+ Measurand.parse('5.129213 +/- 0.01') # ASCII, since ± is awkward to type
50
+ Measurand.parse('5.129213(10)') # parenthetic — CODATA/NIST notation
51
+ Measurand.parse('5.129213 ± 1.9%') # relative
52
+ Measurand.parse('5.129213') # exact
53
+ ```
54
+
55
+ `parse` raises `ArgumentError` on anything it does not recognise, rather than
56
+ returning `nil`.
57
+
58
+ ### Arithmetic
59
+
60
+ ```ruby
61
+ a + b
62
+ a - b
63
+ a * b
64
+ a / b
65
+ a ** b
66
+ -a
67
+ a.abs
68
+
69
+ Measurand.new(3.2, 0.1) * 2 # => 6.4 ± 0.2 — scalars are exact
70
+ 2 * Measurand.new(3.2, 0.1) # => 6.4 ± 0.2 — either side
71
+ ```
72
+
73
+ Independent uncertainties combine in quadrature. Shared sources are handled by
74
+ the derivative graph, not assumed independent:
75
+
76
+ ```ruby
77
+ x = Measurand.new(5.0, 0.1)
78
+ x - x # => 0.0 ± 0.0
79
+ x * 2 - x # => 5.0 ± 0.1 (exactly x)
80
+ ```
81
+
82
+ This is the difference from the old `plusminus` gem, which added errors
83
+ linearly: `3.2±0.1 + 4.5±0.1` is `7.7 ± 0.1414`, not `7.7 ± 0.2`.
84
+
85
+ ### Comparison
86
+
87
+ ```ruby
88
+ a == b # exact: value AND uncertainty match
89
+ a.eql?(b)
90
+ a <=> b # by value; Comparable (so <, >, sort order by value)
91
+ a.consistent_with?(b) # same measurement within combined uncertainty?
92
+ a.overlaps?(b) # do the ± intervals intersect?
93
+ ```
94
+
95
+ Note that `==` is stricter than `<=>`: two measurands with equal values but
96
+ different uncertainties compare `0` under `<=>` yet are not `==`. The
97
+ scientific question — "are these the same within error?" — is
98
+ `consistent_with?`, which subtracts (so correlation is handled) and asks
99
+ whether zero lies within the combined uncertainty.
100
+
101
+ ### Presentation
102
+
103
+ Digits follow the uncertainty — showing more precision than the measurement
104
+ supports would be a lie. The uncertainty is rounded by the Particle Data Group
105
+ convention, and the value is rounded to match:
106
+
107
+ ```ruby
108
+ m = Measurand.new(5.129213, 0.01)
109
+ m.to_s # => "5.129 ± 0.010"
110
+ m.to_s(ascii: true) # => "5.129 +/- 0.010" — for ASCII-only sinks
111
+ m.to_parenthetic # => "5.129(10)"
112
+ m.inspect # => "Measurand(5.129213, 0.01)" — full precision
113
+ ```
114
+
115
+ `to_s` emits the `±` character by default; pass `ascii: true` when writing to a
116
+
117
+ Where a caller wants to render the quantity in some unit of its own rather than
118
+ take the string here, the two figures `to_s` rests upon are readable:
119
+
120
+ ```ruby
121
+ m.place # => -3 the power of ten of the last real digit
122
+ m.rounded_uncertainty # => 0.01 the uncertainty as `to_s` shows it
123
+
124
+ Measurand.new(5.129213).place # => nil exact: every digit is real
125
+ Measurand.new(5.129213).rounded_uncertainty # => 0
126
+ ```
127
+ stream that isn't UTF-8. `parse` reads both forms, so either round-trips.
128
+
129
+ ### Transcendentals
130
+
131
+ Kept in a separate module rather than monkeypatching `Math`:
132
+
133
+ ```ruby
134
+ Measurand::Math.sqrt(m)
135
+ Measurand::Math.exp(m)
136
+ Measurand::Math.log(m)
137
+ Measurand::Math.log10(m)
138
+ Measurand::Math.sin(m)
139
+ Measurand::Math.cos(m)
140
+ Measurand::Math.tan(m)
141
+ ```
142
+
143
+ ### Numeric sugar — opt-in
144
+
145
+ Required separately, so nothing is monkeypatched unless you ask:
146
+
147
+ ```ruby
148
+ require 'Measurand/Numeric'
149
+
150
+ 5.129213.pm(0.01) # => Measurand(5.129213, 0.01)
151
+ 5.129213.±(0.01) # alias; not infix — Ruby won't allow it
152
+ [5.129, 5.132].pm(0.001) # Enumerable#pm — array of measurands
153
+ ```
154
+
155
+ ## Design notes
156
+
157
+ - **Forward-mode automatic differentiation.** Each measurand carries a map of
158
+ partial derivatives with respect to the independent sources it depends on.
159
+ The uncertainty is derived from those partials and the sources' own
160
+ uncertainties. Shared sources cancel correctly.
161
+ - **`==` is exact, `consistent_with?` is the science.** Equality compares both
162
+ value and uncertainty; consistency is the interesting question and gets its
163
+ own name.
164
+ - **The value is not coerced to `Float` at construction.** A reading from a
165
+ 6-digit instrument keeps its type; coercion happens at the point of
166
+ arithmetic.
167
+ - **No `Math` monkeypatching.** `Measurand::Math` is a separate module.
168
+
169
+ ## Limitations
170
+
171
+ **Uncertainty is symmetric.** A measurand carries a single standard
172
+ uncertainty, so it is always `value ± σ` — never `value +a −b`. This is the
173
+ first-order (GUM) model, and it matches the reference tools
174
+ ([`uncertainties`](https://pypi.org/project/uncertainties/),
175
+ [`errors`](https://cran.r-project.org/package=errors)), which are symmetric by
176
+ design.
177
+
178
+ Two consequences worth knowing:
179
+
180
+ - **Quoted asymmetric errors are not represented.** Literature values written
181
+ `5.3 +0.4 −0.2` — from non-Gaussian likelihoods, low-count Poisson
182
+ statistics, or parameters near a physical boundary — cannot be stored as
183
+ such.
184
+ - **Nonlinear propagation is linearized, and therefore symmetrized.** Even from
185
+ symmetric inputs, a nonlinear function produces an asymmetric interval that
186
+ first-order propagation flattens back to a single σ. For example,
187
+ `Measurand::Math.exp(Measurand.new(0.0, 1.0))` reports `1.0 ± 1.0`, but the
188
+ true ±1σ interval is `[exp(−1), exp(+1)] = [0.368, 2.718]`, i.e. `+1.72 /
189
+ −0.63`. Near strong nonlinearity or large *relative* uncertainty, read the
190
+ single σ as a linear approximation, not an exact interval.
191
+
192
+ Asymmetric and second-order propagation are deferred: combining asymmetric
193
+ errors correctly is genuinely unsettled (see Barlow, *Asymmetric Errors*), and
194
+ doing it properly means Monte-Carlo sampling or second-order moments rather
195
+ than the quadrature used here.
196
+
197
+ ## Related
198
+
199
+ - Python [`uncertainties`](https://pypi.org/project/uncertainties/) — the
200
+ reference implementation of this approach.
201
+ - R [`errors`](https://cran.r-project.org/package=errors) — GUM-conformant,
202
+ and, correctly, leaves units to a separate package.
203
+
204
+ ## License
205
+
206
+ MIT
@@ -0,0 +1,43 @@
1
+ # Measurand/Format.rb
2
+ # Measurand::Format
3
+
4
+ # Uncertainty-driven display. The value is only shown to the precision the
5
+ # uncertainty supports; showing more digits than the measurement supports
6
+ # would be a lie. Rounding of the uncertainty follows the Particle Data Group
7
+ # convention on its three leading digits:
8
+ # 100..354 -> keep two significant figures
9
+ # 355..949 -> keep one significant figure
10
+ # 950..999 -> round up to 1000, keep two significant figures
11
+ # The value is then rounded to the same decimal place as the uncertainty.
12
+
13
+ class Measurand
14
+ module Format
15
+ module_function
16
+
17
+ # Returns [rounded_uncertainty, place], where place is the power of ten of
18
+ # the least significant shown digit (negative for digits after the point).
19
+ def round_uncertainty(uncertainty)
20
+ exponent = ::Math.log10(uncertainty).floor
21
+ leading = (uncertainty / 10.0 ** (exponent - 2)).round
22
+ if leading < 355
23
+ significant_figures = 2
24
+ elsif leading < 950
25
+ significant_figures = 1
26
+ else
27
+ significant_figures = 2
28
+ exponent += 1
29
+ end
30
+ place = exponent - significant_figures + 1
31
+ [round_to_place(uncertainty, place), place]
32
+ end
33
+
34
+ def round_to_place(number, place)
35
+ factor = 10.0 ** place
36
+ (number / factor).round * factor
37
+ end
38
+
39
+ def decimals(place)
40
+ place < 0 ? -place : 0
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,53 @@
1
+ # Measurand/Math.rb
2
+ # Measurand::Math
3
+
4
+ # Transcendental functions that propagate uncertainty by the chain rule. Kept
5
+ # in a separate module rather than monkeypatching the standard Math, so that
6
+ # requiring measurand changes nothing you did not ask it to.
7
+ #
8
+ # Each function evaluates f(value) and multiplies the partials by f'(value).
9
+
10
+ class Measurand
11
+ module Math
12
+ module_function
13
+
14
+ def sqrt(measurand)
15
+ measurand = wrap(measurand)
16
+ measurand.apply(::Math.sqrt(measurand.value), 1.0 / (2 * ::Math.sqrt(measurand.value)))
17
+ end
18
+
19
+ def exp(measurand)
20
+ measurand = wrap(measurand)
21
+ measurand.apply(::Math.exp(measurand.value), ::Math.exp(measurand.value))
22
+ end
23
+
24
+ def log(measurand)
25
+ measurand = wrap(measurand)
26
+ measurand.apply(::Math.log(measurand.value), 1.0 / measurand.value)
27
+ end
28
+
29
+ def log10(measurand)
30
+ measurand = wrap(measurand)
31
+ measurand.apply(::Math.log10(measurand.value), 1.0 / (measurand.value * ::Math.log(10)))
32
+ end
33
+
34
+ def sin(measurand)
35
+ measurand = wrap(measurand)
36
+ measurand.apply(::Math.sin(measurand.value), ::Math.cos(measurand.value))
37
+ end
38
+
39
+ def cos(measurand)
40
+ measurand = wrap(measurand)
41
+ measurand.apply(::Math.cos(measurand.value), -::Math.sin(measurand.value))
42
+ end
43
+
44
+ def tan(measurand)
45
+ measurand = wrap(measurand)
46
+ measurand.apply(::Math.tan(measurand.value), 1.0 / (::Math.cos(measurand.value) ** 2))
47
+ end
48
+
49
+ def wrap(measurand)
50
+ measurand.is_a?(Measurand) ? measurand : Measurand.new(measurand)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ # Measurand/Numeric.rb
2
+ # Measurand::Numeric
3
+
4
+ # Opt-in sugar. Required separately so that nobody gets a monkeypatch they did
5
+ # not ask for. pm is primary; ± is an alias, since Ruby's parser won't accept
6
+ # it infix (5.129213 ± 0.01 is a SyntaxError) but 5.129213.±(0.01) parses.
7
+
8
+ require_relative '../measurand'
9
+
10
+ class Measurand
11
+ module Numeric
12
+ def pm(uncertainty)
13
+ Measurand.new(self, uncertainty)
14
+ end
15
+ alias_method :"±", :pm
16
+ end
17
+
18
+ module Enumerable
19
+ def pm(uncertainty)
20
+ map{|value| Measurand.new(value, uncertainty)}
21
+ end
22
+ end
23
+ end
24
+
25
+ Numeric.include(Measurand::Numeric)
26
+ Enumerable.include(Measurand::Enumerable)
@@ -0,0 +1,38 @@
1
+ # Measurand/Parse.rb
2
+ # Measurand.parse
3
+
4
+ # Parses the notations a measurand is written in. Raises on anything it does
5
+ # not recognise, rather than returning nil: the whole job of parse is to fail
6
+ # informatively.
7
+ #
8
+ # "5.129213 ± 0.01" "5.129213 +/- 0.01" absolute
9
+ # "5.129213 ± 1.9%" relative
10
+ # "5.129213(10)" parenthetic (CODATA/NIST)
11
+ # "5.129213" exact
12
+
13
+ class Measurand
14
+ NUMBER = '[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?'
15
+ PLUS_MINUS = '(?:±|\+/-|\+-)'
16
+
17
+ def self.parse(string)
18
+ string = string.strip
19
+ case string
20
+ when /\A(#{NUMBER})\((\d+)\)\z/
21
+ value, digits = $1, $2
22
+ new(value.to_f, digits.to_i * 10.0 ** -decimal_places(value))
23
+ when /\A(#{NUMBER})\s*#{PLUS_MINUS}\s*(#{NUMBER})%\z/
24
+ relative($1.to_f, $2.to_f / 100)
25
+ when /\A(#{NUMBER})\s*#{PLUS_MINUS}\s*(#{NUMBER})\z/
26
+ new($1.to_f, $2.to_f)
27
+ when /\A#{NUMBER}\z/
28
+ new(string.to_f)
29
+ else
30
+ raise ArgumentError, "can't parse measurand: #{string.inspect}"
31
+ end
32
+ end
33
+
34
+ def self.decimal_places(number)
35
+ point = number.index('.')
36
+ point ? number.length - point - 1 : 0
37
+ end
38
+ end
@@ -0,0 +1,19 @@
1
+ # Measurand/Source.rb
2
+ # Measurand::Source
3
+
4
+ # An independent source of uncertainty. Its identity is load-bearing: two
5
+ # Sources are never equal unless they are the same object, so error
6
+ # propagation can tell whether the same measurement reappears in an
7
+ # expression. Deliberately does not define ==, eql? or hash.
8
+
9
+ class Measurand
10
+ class Source
11
+ attr_reader :uncertainty
12
+
13
+ private
14
+
15
+ def initialize(uncertainty)
16
+ @uncertainty = uncertainty.abs
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ class Measurand
2
+ VERSION = '0.1.4'
3
+ end
data/lib/measurand.rb ADDED
@@ -0,0 +1,238 @@
1
+ # measurand.rb
2
+ # Measurand
3
+
4
+ require_relative 'Measurand/Format'
5
+ require_relative 'Measurand/Math'
6
+ require_relative 'Measurand/Parse'
7
+ require_relative 'Measurand/Source'
8
+ require_relative 'Measurand/VERSION'
9
+
10
+ # A measured value with an associated uncertainty. Arithmetic propagates the
11
+ # uncertainty by first-order (GUM) error propagation, using forward-mode
12
+ # automatic differentiation so that shared sources correlate correctly:
13
+ # x - x is exactly 0 ± 0, not 0 ± sqrt(2)*sigma.
14
+ #
15
+ # Internally a Measurand carries a central @value and a map of @partials,
16
+ # {Source => dvalue/dsource}. The uncertainty is derived from the partials
17
+ # and the sources' own uncertainties, not stored.
18
+
19
+ class Measurand
20
+ include Comparable
21
+
22
+ class << self
23
+ # Bypasses the one-source initializer to build a Measurand directly from
24
+ # a value and an already-computed partials map. Used by every operator.
25
+ def derived(value, partials)
26
+ measurand = allocate
27
+ measurand.send(:build, value, partials)
28
+ measurand
29
+ end
30
+
31
+ # A value whose uncertainty is a fraction of the value itself, which is
32
+ # how instruments are usually specified: relative(5.0, 0.02) is 5.0 +/- 2%.
33
+ def relative(value, relative_uncertainty)
34
+ new(value, value.abs * relative_uncertainty)
35
+ end
36
+
37
+ def cast(values, uncertainty)
38
+ values.map{|value| new(value, uncertainty)}
39
+ end
40
+
41
+ def cast_relative(values, relative_uncertainty)
42
+ values.map{|value| relative(value, relative_uncertainty)}
43
+ end
44
+
45
+ # Mean +/- standard error of the mean, from repeated readings.
46
+ def from_samples(values)
47
+ raise ArgumentError, "need at least two samples" if values.size < 2
48
+ floats = values.map(&:to_f)
49
+ mean = floats.sum / floats.size
50
+ variance = floats.map{|value| (value - mean) ** 2}.sum / (floats.size - 1)
51
+ new(mean, ::Math.sqrt(variance / floats.size))
52
+ end
53
+ end
54
+
55
+ def value
56
+ @value
57
+ end
58
+
59
+ def uncertainty
60
+ @uncertainty ||= ::Math.sqrt(@partials.sum{|source, partial| (partial * source.uncertainty) ** 2})
61
+ end
62
+
63
+ def relative_uncertainty
64
+ return 0.0 if uncertainty.zero?
65
+ uncertainty / @value.abs
66
+ end
67
+
68
+ def exact?
69
+ uncertainty.zero?
70
+ end
71
+
72
+ def +(other)
73
+ other = wrap(other)
74
+ self.class.derived(@value + other.value, combine(1.0, other, 1.0))
75
+ end
76
+
77
+ def -(other)
78
+ other = wrap(other)
79
+ self.class.derived(@value - other.value, combine(1.0, other, -1.0))
80
+ end
81
+
82
+ def *(other)
83
+ other = wrap(other)
84
+ self.class.derived(@value * other.value, combine(other.value, other, @value))
85
+ end
86
+
87
+ # Through #quo rather than #/, so that two Integer values divide exactly:
88
+ # 1.quo(3) is (1/3) where 1 / 3 is 0, and a ratio of two integer counts would
89
+ # otherwise come back as nothing at all. A Float value stays a Float. The
90
+ # partials were already in Float and are unaffected.
91
+ def /(other)
92
+ other = wrap(other)
93
+ self.class.derived(@value.quo(other.value), combine(1.0 / other.value, other, -@value.to_f / (other.value ** 2)))
94
+ end
95
+
96
+ def **(other)
97
+ other = wrap(other)
98
+ dself = other.value * @value ** (other.value - 1)
99
+ if other.exact?
100
+ self.class.derived(@value ** other.value, scale(dself))
101
+ else
102
+ self.class.derived(@value ** other.value, combine(dself, other, @value ** other.value * ::Math.log(@value)))
103
+ end
104
+ end
105
+
106
+ def -@
107
+ self.class.derived(-@value, scale(-1.0))
108
+ end
109
+
110
+ def abs
111
+ self.class.derived(@value.abs, scale(@value < 0 ? -1.0 : 1.0))
112
+ end
113
+
114
+ # Applies a differentiable unary function: the caller supplies f(value) and
115
+ # f'(value). This is how Measurand::Math builds transcendentals.
116
+ def apply(value, derivative)
117
+ self.class.derived(value, scale(derivative))
118
+ end
119
+
120
+ def ==(other)
121
+ other.is_a?(Measurand) && @value == other.value && uncertainty == other.uncertainty
122
+ end
123
+
124
+ def eql?(other)
125
+ other.instance_of?(Measurand) && @value.eql?(other.value) && uncertainty.eql?(other.uncertainty)
126
+ end
127
+
128
+ def hash
129
+ [@value, uncertainty].hash
130
+ end
131
+
132
+ # By value only, so Comparable's < > sort etc. order by magnitude. Note this
133
+ # is intentionally looser than ==: two measurands with equal values but
134
+ # different uncertainties compare 0 here yet are not ==.
135
+ def <=>(other)
136
+ other = wrap(other) if other.is_a?(Numeric)
137
+ return nil unless other.is_a?(Measurand)
138
+ @value <=> other.value
139
+ end
140
+
141
+ # Are these the same measurement within error? Uses subtraction so shared
142
+ # sources correlate correctly, then asks whether zero lies within sigmas of
143
+ # the difference.
144
+ def consistent_with?(other, sigmas: 1)
145
+ difference = self - wrap(other)
146
+ difference.value.abs <= sigmas * difference.uncertainty
147
+ end
148
+
149
+ def overlaps?(other)
150
+ other = wrap(other)
151
+ low, high = @value - uncertainty, @value + uncertainty
152
+ other_low, other_high = other.value - other.uncertainty, other.value + other.uncertainty
153
+ low <= other_high && other_low <= high
154
+ end
155
+
156
+ # Ruby calls this when a bare number is on the left: 2 * measurand.
157
+ def coerce(other)
158
+ [wrap(other), self]
159
+ end
160
+
161
+ # The power of ten of the least significant digit worth showing, by the
162
+ # convention above: negative for digits after the point, so a place of -3
163
+ # means thousandths are the last real thing. nil where the measurand is exact,
164
+ # every digit being real and there being no last one.
165
+ #
166
+ # This is what a caller wants in order to render a quantity in some unit of
167
+ # its own choosing, rather than taking the string here.
168
+ def place
169
+ return nil if exact?
170
+ @place ||= Format.round_uncertainty(uncertainty).last
171
+ end
172
+
173
+ # The uncertainty as #to_s shows it, rounded to one or two significant figures
174
+ # by the same convention. Zero where the measurand is exact.
175
+ def rounded_uncertainty
176
+ return 0 if exact?
177
+ @rounded_uncertainty ||= Format.round_uncertainty(uncertainty).first
178
+ end
179
+
180
+ def to_s(ascii: false)
181
+ return @value.to_s if exact?
182
+ digits = Format.decimals(place)
183
+ "#{fixed(Format.round_to_place(@value, place), digits)} #{ascii ? '+/-' : '±'} #{fixed(rounded_uncertainty, digits)}"
184
+ end
185
+
186
+ def to_parenthetic
187
+ return @value.to_s if exact?
188
+ digits = Format.decimals(place)
189
+ parenthetic_digits = (rounded_uncertainty / 10.0 ** place).round
190
+ "#{fixed(Format.round_to_place(@value, place), digits)}(#{parenthetic_digits})"
191
+ end
192
+
193
+ def inspect
194
+ "Measurand(#{@value}, #{uncertainty})"
195
+ end
196
+
197
+
198
+ protected
199
+
200
+ def partials
201
+ @partials
202
+ end
203
+
204
+ private
205
+
206
+ def fixed(number, digits)
207
+ format("%.#{digits}f", number)
208
+ end
209
+
210
+ def initialize(value, uncertainty = 0)
211
+ @value = value
212
+ @partials = uncertainty.zero? ? {} : {Source.new(uncertainty) => 1.0}
213
+ end
214
+
215
+ def build(value, partials)
216
+ @value = value
217
+ @partials = partials
218
+ end
219
+
220
+ def wrap(other)
221
+ other.is_a?(Measurand) ? other : self.class.new(other)
222
+ end
223
+
224
+ # New partials for a two-operand function f, given df/dself and df/dother.
225
+ # Sources shared between the operands sum here, which is where correlation
226
+ # is actually handled.
227
+ def combine(dself, other, dother)
228
+ result = Hash.new(0.0)
229
+ @partials.each{|source, partial| result[source] += partial * dself}
230
+ other.partials.each{|source, partial| result[source] += partial * dother}
231
+ result
232
+ end
233
+
234
+ # New partials for a unary function, scaling every partial by df/dself.
235
+ def scale(dself)
236
+ @partials.each_with_object({}){|(source, partial), result| result[source] = partial * dself}
237
+ end
238
+ end
@@ -0,0 +1,41 @@
1
+ # measurand.rb.gemspec
2
+
3
+ require_relative './lib/Measurand/VERSION'
4
+
5
+ class Gem::Specification
6
+ def development_dependencies=(gems)
7
+ gems.each{|gem| add_development_dependency(*gem)}
8
+ end
9
+ end
10
+
11
+ Gem::Specification.new do |spec|
12
+ spec.name = 'measurand.rb'
13
+ spec.version = Measurand::VERSION
14
+
15
+ spec.summary = "Measured values with uncertainty and GUM-conformant error propagation."
16
+ spec.description = "Measured values with uncertainty, propagated by first-order (GUM) error propagation over an automatic-differentiation graph, so that shared sources correlate correctly. Quadrature done right, not linear addition. Parsing, PDG-rounded presentation, and transcendentals."
17
+
18
+ spec.author = 'thoran'
19
+ spec.email = 'code@thoran.com'
20
+ spec.homepage = 'http://github.com/thoran/measurand'
21
+ spec.license = 'MIT'
22
+
23
+ spec.required_ruby_version = '>= 2.5'
24
+
25
+ spec.files = [
26
+ Dir['lib/**/*.rb'],
27
+ Dir['test/**/*.rb'],
28
+ 'CHANGELOG',
29
+ 'Gemfile',
30
+ 'LICENSE',
31
+ 'measurand.rb.gemspec',
32
+ 'README.md',
33
+ ].flatten
34
+
35
+ spec.require_paths = ['lib']
36
+
37
+ spec.development_dependencies = %w{
38
+ minitest
39
+ rake
40
+ }
41
+ end
@@ -0,0 +1,81 @@
1
+ # test/Measurand/Format_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe "Measurand formatting" do
6
+ describe "to_s, PDG-rounded" do
7
+ it "keeps two significant figures when the leading digits are 100..354" do
8
+ _(Measurand.new(5.129213, 0.01).to_s).must_equal "5.129 ± 0.010"
9
+ _(Measurand.new(1.23456, 0.0123).to_s).must_equal "1.235 ± 0.012"
10
+ end
11
+
12
+ it "keeps one significant figure when the leading digits are 355..949" do
13
+ _(Measurand.new(1.23456, 0.0356).to_s).must_equal "1.23 ± 0.04"
14
+ end
15
+
16
+ it "rounds up to two significant figures when the leading digits are 950..999" do
17
+ _(Measurand.new(1.23456, 0.096).to_s).must_equal "1.23 ± 0.10"
18
+ end
19
+
20
+ it "handles uncertainty larger than one" do
21
+ _(Measurand.new(1234.4, 12.3).to_s).must_equal "1234 ± 12"
22
+ end
23
+
24
+ it "shows only the value when exact" do
25
+ _(Measurand.new(5.0).to_s).must_equal "5.0"
26
+ _(Measurand.new(5).to_s).must_equal "5"
27
+ end
28
+
29
+ it "emits ASCII +/- on request, and parse reads it back" do
30
+ m = Measurand.new(5.129213, 0.01)
31
+ _(m.to_s(ascii: true)).must_equal "5.129 +/- 0.010"
32
+ reparsed = Measurand.parse(m.to_s(ascii: true))
33
+ _(reparsed.value).must_be_close_to 5.129
34
+ _(reparsed.uncertainty).must_be_close_to 0.010
35
+ end
36
+ end
37
+
38
+ describe "to_parenthetic" do
39
+ it "expresses the uncertainty in units of the last shown digit" do
40
+ _(Measurand.new(5.129213, 0.01).to_parenthetic).must_equal "5.129(10)"
41
+ _(Measurand.new(1.23456, 0.0356).to_parenthetic).must_equal "1.23(4)"
42
+ end
43
+
44
+ it "shows only the value when exact" do
45
+ _(Measurand.new(5.0).to_parenthetic).must_equal "5.0"
46
+ end
47
+ end
48
+
49
+ describe "inspect" do
50
+ it "shows the full-precision value and uncertainty" do
51
+ _(Measurand.new(5.129213, 0.01).inspect).must_equal "Measurand(5.129213, 0.01)"
52
+ end
53
+ end
54
+ describe "#place" do
55
+ it "is the power of ten of the least significant digit worth showing" do
56
+ _(Measurand.new(1_274_625, 84).place).must_equal(1)
57
+ _(Measurand.new(5.129213, 0.01).place).must_equal(-3)
58
+ end
59
+
60
+ it "is nil where the measurand is exact, every digit being real" do
61
+ _(Measurand.new(5.129213).place).must_be_nil
62
+ end
63
+
64
+ it "agrees with what #to_s renders" do
65
+ subject = Measurand.new(5.129213, 0.01)
66
+ _(subject.to_s.split(" ").first.split(".").last.length).must_equal(-subject.place)
67
+ end
68
+ end
69
+
70
+ describe "#rounded_uncertainty" do
71
+ it "is the uncertainty as #to_s shows it" do
72
+ _(Measurand.new(1_274_625, 84).rounded_uncertainty).must_equal(80)
73
+ _(Measurand.new(5.129213, 0.01).rounded_uncertainty).must_equal(0.01)
74
+ end
75
+
76
+ it "is zero where the measurand is exact" do
77
+ _(Measurand.new(5.129213).rounded_uncertainty).must_equal(0)
78
+ end
79
+ end
80
+
81
+ end
@@ -0,0 +1,41 @@
1
+ # test/Measurand/Math_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe Measurand::Math do
6
+ it "propagates through sqrt: d/dx sqrt(x) = 1/(2 sqrt(x))" do
7
+ m = Measurand::Math.sqrt(Measurand.new(4.0, 0.1))
8
+ _(m.value).must_equal 2.0
9
+ _(m.uncertainty).must_be_close_to 0.025
10
+ end
11
+
12
+ it "propagates through exp: d/dx exp(x) = exp(x)" do
13
+ m = Measurand::Math.exp(Measurand.new(1.0, 0.1))
14
+ _(m.value).must_be_close_to ::Math::E
15
+ _(m.uncertainty).must_be_close_to ::Math::E * 0.1, 0.0001
16
+ end
17
+
18
+ it "propagates through log: d/dx ln(x) = 1/x" do
19
+ m = Measurand::Math.log(Measurand.new(2.0, 0.1))
20
+ _(m.value).must_be_close_to ::Math.log(2.0)
21
+ _(m.uncertainty).must_be_close_to 0.05
22
+ end
23
+
24
+ it "propagates through sin: d/dx sin(x) = cos(x)" do
25
+ m = Measurand::Math.sin(Measurand.new(0.0, 0.1))
26
+ _(m.value).must_equal 0.0
27
+ _(m.uncertainty).must_be_close_to 0.1
28
+ end
29
+
30
+ it "propagates through cos: d/dx cos(x) = -sin(x)" do
31
+ m = Measurand::Math.cos(Measurand.new(0.0, 0.1))
32
+ _(m.value).must_equal 1.0
33
+ _(m.uncertainty).must_equal 0.0
34
+ end
35
+
36
+ it "accepts a bare number and returns an exact result" do
37
+ m = Measurand::Math.sqrt(4.0)
38
+ _(m.value).must_equal 2.0
39
+ _(m.exact?).must_equal true
40
+ end
41
+ end
@@ -0,0 +1,234 @@
1
+ # test/Measurand/Measurand_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe Measurand do
6
+ describe "construction" do
7
+ it "holds a value and an uncertainty" do
8
+ m = Measurand.new(5.129213, 0.01)
9
+ _(m.value).must_equal 5.129213
10
+ _(m.uncertainty).must_equal 0.01
11
+ end
12
+
13
+ it "is exact with no uncertainty" do
14
+ m = Measurand.new(5.129213)
15
+ _(m.uncertainty).must_equal 0.0
16
+ _(m.exact?).must_equal true
17
+ end
18
+
19
+ it "does not coerce the value to Float" do
20
+ _(Measurand.new(5).value).must_be_kind_of Integer
21
+ _(Measurand.new(Rational(1, 3)).value).must_be_kind_of Rational
22
+ end
23
+
24
+ it "builds from a relative uncertainty" do
25
+ m = Measurand.relative(5.0, 0.02)
26
+ _(m.uncertainty).must_be_close_to 0.1
27
+ end
28
+
29
+ it "casts a collection with a shared uncertainty" do
30
+ ms = Measurand.cast([5.129, 5.132, 5.128], 0.001)
31
+ _(ms.map(&:uncertainty)).must_equal [0.001, 0.001, 0.001]
32
+ end
33
+
34
+ it "casts a collection with a shared relative uncertainty" do
35
+ ms = Measurand.cast_relative([10.0, 20.0], 0.01)
36
+ _(ms.map(&:uncertainty)).must_equal [0.1, 0.2]
37
+ end
38
+ end
39
+
40
+ describe "readers" do
41
+ it "reports relative uncertainty" do
42
+ _(Measurand.new(10.0, 0.5).relative_uncertainty).must_be_close_to 0.05
43
+ end
44
+
45
+ it "reports zero relative uncertainty when exact" do
46
+ _(Measurand.new(10.0).relative_uncertainty).must_equal 0.0
47
+ end
48
+ end
49
+
50
+ describe "from_samples" do
51
+ it "is the mean plus or minus the standard error of the mean" do
52
+ m = Measurand.from_samples([5.0, 5.0, 5.0, 5.0])
53
+ _(m.value).must_equal 5.0
54
+ _(m.uncertainty).must_equal 0.0
55
+ end
56
+
57
+ it "computes the standard error of the mean" do
58
+ # sample sd of [2,4,4,4,5,5,7,9] is 2.138; sem = 2.138/sqrt(8)
59
+ m = Measurand.from_samples([2, 4, 4, 4, 5, 5, 7, 9])
60
+ _(m.value).must_equal 5.0
61
+ _(m.uncertainty).must_be_close_to 0.7559, 0.001
62
+ end
63
+
64
+ it "needs at least two samples" do
65
+ _{Measurand.from_samples([5.0])}.must_raise ArgumentError
66
+ end
67
+ end
68
+
69
+ describe "arithmetic" do
70
+ it "adds in quadrature, not linearly (the plusminus regression)" do
71
+ sum = Measurand.new(3.2, 0.1) + Measurand.new(4.5, 0.1)
72
+ _(sum.value).must_equal 7.7
73
+ _(sum.uncertainty).must_be_close_to 0.14142, 0.00001
74
+ end
75
+
76
+ it "subtracts in quadrature" do
77
+ difference = Measurand.new(4.5, 0.1) - Measurand.new(3.2, 0.1)
78
+ _(difference.value).must_be_close_to 1.3
79
+ _(difference.uncertainty).must_be_close_to 0.14142, 0.00001
80
+ end
81
+
82
+ it "combines relative uncertainties in quadrature when multiplying" do
83
+ product = Measurand.new(10.0, 0.1) * Measurand.new(20.0, 0.4)
84
+ _(product.value).must_equal 200.0
85
+ _(product.relative_uncertainty).must_be_close_to 0.02236, 0.00001
86
+ end
87
+
88
+ it "combines relative uncertainties in quadrature when dividing" do
89
+ quotient = Measurand.new(10.0, 0.1) / Measurand.new(20.0, 0.4)
90
+ _(quotient.value).must_equal 0.5
91
+ _(quotient.relative_uncertainty).must_be_close_to 0.02236, 0.00001
92
+ end
93
+
94
+ it "propagates through a scalar power" do
95
+ squared = Measurand.new(3.0, 0.1) ** 2
96
+ _(squared.value).must_equal 9.0
97
+ _(squared.uncertainty).must_be_close_to 0.6, 0.00001
98
+ end
99
+
100
+ it "propagates through an uncertain power" do
101
+ result = Measurand.new(2.0, 0.1) ** Measurand.new(3.0, 0.1)
102
+ _(result.value).must_be_close_to 8.0
103
+ # df/da = 3*2^2 = 12, df/db = 2^3*ln2 = 5.545
104
+ # unc = sqrt((12*0.1)^2 + (5.545*0.1)^2) = sqrt(1.44 + 0.3075)
105
+ _(result.uncertainty).must_be_close_to 1.3222, 0.001
106
+ end
107
+
108
+ it "negates" do
109
+ m = -Measurand.new(3.0, 0.1)
110
+ _(m.value).must_equal(-3.0)
111
+ _(m.uncertainty).must_equal 0.1
112
+ end
113
+
114
+ it "takes an absolute value, preserving uncertainty" do
115
+ m = Measurand.new(-3.0, 0.1).abs
116
+ _(m.value).must_equal 3.0
117
+ _(m.uncertainty).must_equal 0.1
118
+ end
119
+ end
120
+
121
+ describe "scalars are exact" do
122
+ it "scales value and uncertainty together without adding uncertainty" do
123
+ m = Measurand.new(3.2, 0.1) * 2
124
+ _(m.value).must_equal 6.4
125
+ _(m.uncertainty).must_equal 0.2
126
+ end
127
+
128
+ it "works with the scalar on the left, through coerce" do
129
+ m = 2 * Measurand.new(3.2, 0.1)
130
+ _(m.value).must_equal 6.4
131
+ _(m.uncertainty).must_equal 0.2
132
+ end
133
+
134
+ it "adds an exact scalar without adding uncertainty" do
135
+ m = Measurand.new(3.2, 0.1) + 1
136
+ _(m.value).must_equal 4.2
137
+ _(m.uncertainty).must_equal 0.1
138
+ end
139
+ end
140
+
141
+ describe "correlation" do
142
+ it "cancels a variable against itself: x - x is exactly zero" do
143
+ x = Measurand.new(5.0, 0.1)
144
+ difference = x - x
145
+ _(difference.value).must_equal 0.0
146
+ _(difference.uncertainty).must_equal 0.0
147
+ end
148
+
149
+ it "does not cancel independently constructed measurands of equal value" do
150
+ difference = Measurand.new(5.0, 0.1) - Measurand.new(5.0, 0.1)
151
+ _(difference.uncertainty).must_be_close_to 0.14142, 0.00001
152
+ end
153
+
154
+ it "handles 2x - x as exactly x" do
155
+ x = Measurand.new(5.0, 0.1)
156
+ result = x * 2 - x
157
+ _(result.value).must_equal 5.0
158
+ _(result.uncertainty).must_be_close_to 0.1
159
+ end
160
+ end
161
+
162
+ describe "comparison" do
163
+ it "is equal when value and uncertainty both match" do
164
+ _(Measurand.new(5.0, 0.1)).must_equal Measurand.new(5.0, 0.1)
165
+ end
166
+
167
+ it "is not equal when the uncertainty differs" do
168
+ _(Measurand.new(5.0, 0.1)).wont_equal Measurand.new(5.0, 0.2)
169
+ end
170
+
171
+ it "orders by value alone, looser than equality" do
172
+ a = Measurand.new(5.0, 0.1)
173
+ b = Measurand.new(5.0, 0.2)
174
+ _(a <=> b).must_equal 0
175
+ _(a == b).must_equal false
176
+ end
177
+
178
+ it "is Comparable by value" do
179
+ _(Measurand.new(3.0, 0.5) < Measurand.new(4.0, 0.1)).must_equal true
180
+ end
181
+
182
+ it "eql? and hash agree" do
183
+ a = Measurand.new(5.0, 0.1)
184
+ b = Measurand.new(5.0, 0.1)
185
+ _(a.eql?(b)).must_equal true
186
+ _(a.hash).must_equal b.hash
187
+ end
188
+
189
+ it "asks whether two measurements are consistent within error" do
190
+ a = Measurand.new(5.0, 0.1)
191
+ _(a.consistent_with?(Measurand.new(5.05, 0.1))).must_equal true
192
+ _(a.consistent_with?(Measurand.new(5.5, 0.1))).must_equal false
193
+ end
194
+
195
+ it "is consistent with itself" do
196
+ x = Measurand.new(5.0, 0.1)
197
+ _(x.consistent_with?(x)).must_equal true
198
+ end
199
+
200
+ it "reports overlapping intervals" do
201
+ a = Measurand.new(5.0, 0.1)
202
+ _(a.overlaps?(Measurand.new(5.15, 0.1))).must_equal true
203
+ _(a.overlaps?(Measurand.new(5.3, 0.1))).must_equal false
204
+ end
205
+ end
206
+
207
+ describe "division of integer values" do
208
+ it "divides exactly rather than as integers" do
209
+ _((Measurand.new(9_579_583, 84) / Measurand.new(10_900_458, 84)).value.to_f) \
210
+ .must_be_close_to(0.878824, 0.000001)
211
+ end
212
+
213
+ it "keeps a Rational where both are exact" do
214
+ _((Measurand.new(1) / Measurand.new(3)).value).must_equal(Rational(1, 3))
215
+ end
216
+ end
217
+
218
+ # Integer#** promotes to Rational where Integer#/ truncates, so the defect
219
+ # fixed above has no counterpart here. Held under test all the same, since
220
+ # nothing in Measurand says so and the two operators read alike.
221
+ describe "exponentiation of integer values" do
222
+ it "stays exact under a negative exponent" do
223
+ _((Measurand.new(2, 0.1) ** -1).value).must_equal(Rational(1, 2))
224
+ _((Measurand.new(8, 0.1) ** -1).value).must_equal(Rational(1, 8))
225
+ end
226
+
227
+ it "propagates through a derivative which is exact for the same reason" do
228
+ _((Measurand.new(2, 0.1) ** -1).uncertainty).must_be_close_to(0.025, 1e-9)
229
+ _((Measurand.new(8, 0.1) ** -1).uncertainty).must_be_close_to(0.0015625, 1e-9)
230
+ _((Measurand.new(2, 0.1) ** 3).uncertainty).must_be_close_to(1.2, 1e-9)
231
+ end
232
+ end
233
+
234
+ end
@@ -0,0 +1,32 @@
1
+ # test/Measurand/Numeric_test.rb
2
+
3
+ require_relative '../test_helper'
4
+ require_relative '../../lib/Measurand/Numeric'
5
+
6
+ describe Measurand::Numeric do
7
+ it "builds a measurand from a number with pm" do
8
+ m = 5.129213.pm(0.01)
9
+ _(m).must_be_instance_of Measurand
10
+ _(m.value).must_equal 5.129213
11
+ _(m.uncertainty).must_equal 0.01
12
+ end
13
+
14
+ it "aliases pm as ±" do
15
+ m = 5.129213.±(0.01)
16
+ _(m).must_be_instance_of Measurand
17
+ _(m.uncertainty).must_equal 0.01
18
+ end
19
+
20
+ it "reproduces the plusminus regression correctly" do
21
+ sum = 3.2.pm(0.1) + 4.5.pm(0.1)
22
+ _(sum.value).must_equal 7.7
23
+ _(sum.uncertainty).must_be_close_to 0.14142, 0.00001
24
+ end
25
+
26
+ it "builds an array of measurands from an Enumerable with pm" do
27
+ ms = [5.129, 5.132, 5.128].pm(0.001)
28
+ _(ms).must_be_kind_of Array
29
+ _(ms.map(&:value)).must_equal [5.129, 5.132, 5.128]
30
+ _(ms.map(&:uncertainty)).must_equal [0.001, 0.001, 0.001]
31
+ end
32
+ end
@@ -0,0 +1,46 @@
1
+ # test/Measurand/Parse_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe "Measurand.parse" do
6
+ it "parses the unicode plus-minus notation" do
7
+ m = Measurand.parse('5.129213 ± 0.01')
8
+ _(m.value).must_equal 5.129213
9
+ _(m.uncertainty).must_equal 0.01
10
+ end
11
+
12
+ it "parses the ASCII plus-minus notation" do
13
+ m = Measurand.parse('5.129213 +/- 0.01')
14
+ _(m.value).must_equal 5.129213
15
+ _(m.uncertainty).must_equal 0.01
16
+ end
17
+
18
+ it "parses parenthetic CODATA/NIST notation" do
19
+ m = Measurand.parse('5.129213(10)')
20
+ _(m.value).must_equal 5.129213
21
+ _(m.uncertainty).must_be_close_to 0.00001
22
+ end
23
+
24
+ it "parses relative notation" do
25
+ m = Measurand.parse('5.0 ± 2%')
26
+ _(m.value).must_equal 5.0
27
+ _(m.uncertainty).must_be_close_to 0.1
28
+ end
29
+
30
+ it "parses an exact value" do
31
+ m = Measurand.parse('5.129213')
32
+ _(m.value).must_equal 5.129213
33
+ _(m.exact?).must_equal true
34
+ end
35
+
36
+ it "raises on unparseable input, rather than returning nil" do
37
+ _{Measurand.parse('not a measurand')}.must_raise ArgumentError
38
+ end
39
+
40
+ it "round-trips through to_parenthetic at display precision" do
41
+ m = Measurand.new(5.129213, 0.01)
42
+ reparsed = Measurand.parse(m.to_parenthetic)
43
+ _(reparsed.value).must_be_close_to 5.129
44
+ _(reparsed.uncertainty).must_be_close_to 0.010
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ # test/Measurand/Source_test.rb
2
+
3
+ require_relative '../test_helper'
4
+
5
+ describe Measurand::Source do
6
+ it "is equal only to itself, never to another source of the same size" do
7
+ a = Measurand::Source.new(0.1)
8
+ b = Measurand::Source.new(0.1)
9
+ _(a).must_equal a
10
+ _(a).wont_equal b
11
+ end
12
+
13
+ it "keeps distinct sources distinct as hash keys" do
14
+ a = Measurand::Source.new(0.1)
15
+ b = Measurand::Source.new(0.1)
16
+ hash = {a => 1.0, b => 1.0}
17
+ _(hash.size).must_equal 2
18
+ end
19
+
20
+ it "stores uncertainty as a magnitude" do
21
+ _(Measurand::Source.new(-0.1).uncertainty).must_equal 0.1
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # test/test_helper.rb
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest/spec'
5
+
6
+ require_relative '../lib/measurand'
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: measurand.rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.4
5
+ platform: ruby
6
+ authors:
7
+ - thoran
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Measured values with uncertainty, propagated by first-order (GUM) error
41
+ propagation over an automatic-differentiation graph, so that shared sources correlate
42
+ correctly. Quadrature done right, not linear addition. Parsing, PDG-rounded presentation,
43
+ and transcendentals.
44
+ email: code@thoran.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG
50
+ - Gemfile
51
+ - LICENSE
52
+ - README.md
53
+ - lib/Measurand/Format.rb
54
+ - lib/Measurand/Math.rb
55
+ - lib/Measurand/Numeric.rb
56
+ - lib/Measurand/Parse.rb
57
+ - lib/Measurand/Source.rb
58
+ - lib/Measurand/VERSION.rb
59
+ - lib/measurand.rb
60
+ - measurand.rb.gemspec
61
+ - test/Measurand/Format_test.rb
62
+ - test/Measurand/Math_test.rb
63
+ - test/Measurand/Measurand_test.rb
64
+ - test/Measurand/Numeric_test.rb
65
+ - test/Measurand/Parse_test.rb
66
+ - test/Measurand/Source_test.rb
67
+ - test/test_helper.rb
68
+ homepage: http://github.com/thoran/measurand
69
+ licenses:
70
+ - MIT
71
+ metadata: {}
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '2.5'
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 4.0.19
87
+ specification_version: 4
88
+ summary: Measured values with uncertainty and GUM-conformant error propagation.
89
+ test_files: []