measurand 0.0.0
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 +7 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +195 -0
- data/lib/Measurand/Format.rb +68 -0
- data/lib/Measurand/Math.rb +53 -0
- data/lib/Measurand/Numeric.rb +26 -0
- data/lib/Measurand/Parse.rb +38 -0
- data/lib/Measurand/Source.rb +19 -0
- data/lib/Measurand/VERSION.rb +3 -0
- data/lib/measurand.rb +193 -0
- data/measurand.gemspec +27 -0
- data/test/Measurand/Format_test.rb +54 -0
- data/test/Measurand/Math_test.rb +41 -0
- data/test/Measurand/Measurand_test.rb +206 -0
- data/test/Measurand/Numeric_test.rb +32 -0
- data/test/Measurand/Parse_test.rb +46 -0
- data/test/Measurand/Source_test.rb +23 -0
- data/test/test_helper.rb +6 -0
- metadata +61 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 77e11fd475e1104e6ff1b26e9d0528c9e6918a0340832106b58eefe09b46da1e
|
|
4
|
+
data.tar.gz: ca6538ff0f68ec369df90ced58ab97aa8ed4083680a3c4f4f555f5af80b7ee63
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 48bf9b4e4110cede8150f8abd1521d39e9e39c701d3db7cd0e9697fef8e59aba0e2ac21dd60590b8b71be8d6845bc14212ecc896f1a3085c45cb85ca433dc19e
|
|
7
|
+
data.tar.gz: 99f5e0811d117c252c0d703b144f30f1283f72d47ffb65b2b07e27d603357f7ac6cc4aa4c4413e1754c54d9734a2f7e9cf7db3abf754c89eb74fae73dc233762
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
## [0.0.0] - 20260716
|
|
4
|
+
## + Measurand: measured values with uncertainty and GUM-conformant error propagation.
|
|
5
|
+
|
|
6
|
+
1. + lib/measurand.rb: Measurand — 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).
|
|
7
|
+
2. + Measurand construction: .new, .relative, .cast, .cast\_relative, .from\_samples (mean ± standard error of the mean), .derived
|
|
8
|
+
3. + Measurand arithmetic: +, -, \*, /, \*\*, -@, abs, apply — chain-rule propagation; independent uncertainties combine in quadrature; scalars are exact
|
|
9
|
+
4. + Measurand comparison: ==/eql?/hash (exact: value and uncertainty), <=> (by value, Comparable), consistent\_with?, overlaps?, coerce (scalar-on-the-left arithmetic)
|
|
10
|
+
5. + Measurand readers: value, uncertainty, relative\_uncertainty, exact?
|
|
11
|
+
6. + lib/Measurand/Source.rb: opaque per-source identity carrying its own uncertainty; deliberately no ==/eql?/hash, so shared sources correlate and independently-constructed ones never collapse
|
|
12
|
+
7. + lib/Measurand/Format.rb: uncertainty-driven, PDG-rounded to\_s (ascii: keyword emits +/- instead of ±), to\_parenthetic, inspect (full precision)
|
|
13
|
+
8. + lib/Measurand/Parse.rb: Measurand.parse for ±, +/-, parenthetic (10), relative %, and exact forms; raises ArgumentError rather than returning nil
|
|
14
|
+
9. + lib/Measurand/Math.rb: Measurand::Math.sqrt, exp, log, log10, sin, cos, tan — chain-rule propagation with no Math monkeypatching
|
|
15
|
+
10. + lib/Measurand/Numeric.rb: opt-in Numeric#pm / #± and Enumerable#pm (separate require, so nothing is monkeypatched unless asked)
|
|
16
|
+
11. + lib/Measurand/VERSION.rb
|
|
17
|
+
12. + measurand.gemspec
|
|
18
|
+
13. + Gemfile
|
|
19
|
+
14. + LICENSE
|
|
20
|
+
15. + README.md
|
|
21
|
+
16. + test/test\_helper.rb, test/test\_all.rb
|
|
22
|
+
17. + test/Measurand/Measurand\_test.rb
|
|
23
|
+
18. + test/Measurand/Source\_test.rb
|
|
24
|
+
19. + test/Measurand/Format\_test.rb
|
|
25
|
+
20. + test/Measurand/Parse\_test.rb
|
|
26
|
+
21. + test/Measurand/Math\_test.rb
|
|
27
|
+
22. + test/Measurand/Numeric\_test.rb
|
data/Gemfile
ADDED
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,195 @@
|
|
|
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
|
+
stream that isn't UTF-8. `parse` reads both forms, so either round-trips.
|
|
117
|
+
|
|
118
|
+
### Transcendentals
|
|
119
|
+
|
|
120
|
+
Kept in a separate module rather than monkeypatching `Math`:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
Measurand::Math.sqrt(m)
|
|
124
|
+
Measurand::Math.exp(m)
|
|
125
|
+
Measurand::Math.log(m)
|
|
126
|
+
Measurand::Math.log10(m)
|
|
127
|
+
Measurand::Math.sin(m)
|
|
128
|
+
Measurand::Math.cos(m)
|
|
129
|
+
Measurand::Math.tan(m)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Numeric sugar — opt-in
|
|
133
|
+
|
|
134
|
+
Required separately, so nothing is monkeypatched unless you ask:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
require 'Measurand/Numeric'
|
|
138
|
+
|
|
139
|
+
5.129213.pm(0.01) # => Measurand(5.129213, 0.01)
|
|
140
|
+
5.129213.±(0.01) # alias; not infix — Ruby won't allow it
|
|
141
|
+
[5.129, 5.132].pm(0.001) # Enumerable#pm — array of measurands
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Design notes
|
|
145
|
+
|
|
146
|
+
- **Forward-mode automatic differentiation.** Each measurand carries a map of
|
|
147
|
+
partial derivatives with respect to the independent sources it depends on.
|
|
148
|
+
The uncertainty is derived from those partials and the sources' own
|
|
149
|
+
uncertainties. Shared sources cancel correctly.
|
|
150
|
+
- **`==` is exact, `consistent_with?` is the science.** Equality compares both
|
|
151
|
+
value and uncertainty; consistency is the interesting question and gets its
|
|
152
|
+
own name.
|
|
153
|
+
- **The value is not coerced to `Float` at construction.** A reading from a
|
|
154
|
+
6-digit instrument keeps its type; coercion happens at the point of
|
|
155
|
+
arithmetic.
|
|
156
|
+
- **No `Math` monkeypatching.** `Measurand::Math` is a separate module.
|
|
157
|
+
|
|
158
|
+
## Limitations
|
|
159
|
+
|
|
160
|
+
**Uncertainty is symmetric.** A measurand carries a single standard
|
|
161
|
+
uncertainty, so it is always `value ± σ` — never `value +a −b`. This is the
|
|
162
|
+
first-order (GUM) model, and it matches the reference tools
|
|
163
|
+
([`uncertainties`](https://pypi.org/project/uncertainties/),
|
|
164
|
+
[`errors`](https://cran.r-project.org/package=errors)), which are symmetric by
|
|
165
|
+
design.
|
|
166
|
+
|
|
167
|
+
Two consequences worth knowing:
|
|
168
|
+
|
|
169
|
+
- **Quoted asymmetric errors are not represented.** Literature values written
|
|
170
|
+
`5.3 +0.4 −0.2` — from non-Gaussian likelihoods, low-count Poisson
|
|
171
|
+
statistics, or parameters near a physical boundary — cannot be stored as
|
|
172
|
+
such.
|
|
173
|
+
- **Nonlinear propagation is linearized, and therefore symmetrized.** Even from
|
|
174
|
+
symmetric inputs, a nonlinear function produces an asymmetric interval that
|
|
175
|
+
first-order propagation flattens back to a single σ. For example,
|
|
176
|
+
`Measurand::Math.exp(Measurand.new(0.0, 1.0))` reports `1.0 ± 1.0`, but the
|
|
177
|
+
true ±1σ interval is `[exp(−1), exp(+1)] = [0.368, 2.718]`, i.e. `+1.72 /
|
|
178
|
+
−0.63`. Near strong nonlinearity or large *relative* uncertainty, read the
|
|
179
|
+
single σ as a linear approximation, not an exact interval.
|
|
180
|
+
|
|
181
|
+
Asymmetric and second-order propagation are deferred: combining asymmetric
|
|
182
|
+
errors correctly is genuinely unsettled (see Barlow, *Asymmetric Errors*), and
|
|
183
|
+
doing it properly means Monte-Carlo sampling or second-order moments rather
|
|
184
|
+
than the quadrature used here.
|
|
185
|
+
|
|
186
|
+
## Related
|
|
187
|
+
|
|
188
|
+
- Python [`uncertainties`](https://pypi.org/project/uncertainties/) — the
|
|
189
|
+
reference implementation of this approach.
|
|
190
|
+
- R [`errors`](https://cran.r-project.org/package=errors) — GUM-conformant,
|
|
191
|
+
and, correctly, leaves units to a separate package.
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
|
|
44
|
+
def to_s(ascii: false)
|
|
45
|
+
return @value.to_s if exact?
|
|
46
|
+
rounded_uncertainty, place = Format.round_uncertainty(uncertainty)
|
|
47
|
+
digits = Format.decimals(place)
|
|
48
|
+
"#{fixed(Format.round_to_place(@value, place), digits)} #{ascii ? '+/-' : '±'} #{fixed(rounded_uncertainty, digits)}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_parenthetic
|
|
52
|
+
return @value.to_s if exact?
|
|
53
|
+
rounded_uncertainty, place = Format.round_uncertainty(uncertainty)
|
|
54
|
+
digits = Format.decimals(place)
|
|
55
|
+
parenthetic_digits = (rounded_uncertainty / 10.0 ** place).round
|
|
56
|
+
"#{fixed(Format.round_to_place(@value, place), digits)}(#{parenthetic_digits})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inspect
|
|
60
|
+
"Measurand(#{@value}, #{uncertainty})"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def fixed(number, digits)
|
|
66
|
+
format("%.#{digits}f", number)
|
|
67
|
+
end
|
|
68
|
+
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
|
data/lib/measurand.rb
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
def /(other)
|
|
88
|
+
other = wrap(other)
|
|
89
|
+
self.class.derived(@value / other.value, combine(1.0 / other.value, other, -@value.to_f / (other.value ** 2)))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def **(other)
|
|
93
|
+
other = wrap(other)
|
|
94
|
+
dself = other.value * @value ** (other.value - 1)
|
|
95
|
+
if other.exact?
|
|
96
|
+
self.class.derived(@value ** other.value, scale(dself))
|
|
97
|
+
else
|
|
98
|
+
self.class.derived(@value ** other.value, combine(dself, other, @value ** other.value * ::Math.log(@value)))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def -@
|
|
103
|
+
self.class.derived(-@value, scale(-1.0))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def abs
|
|
107
|
+
self.class.derived(@value.abs, scale(@value < 0 ? -1.0 : 1.0))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Applies a differentiable unary function: the caller supplies f(value) and
|
|
111
|
+
# f'(value). This is how Measurand::Math builds transcendentals.
|
|
112
|
+
def apply(value, derivative)
|
|
113
|
+
self.class.derived(value, scale(derivative))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ==(other)
|
|
117
|
+
other.is_a?(Measurand) && @value == other.value && uncertainty == other.uncertainty
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def eql?(other)
|
|
121
|
+
other.instance_of?(Measurand) && @value.eql?(other.value) && uncertainty.eql?(other.uncertainty)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def hash
|
|
125
|
+
[@value, uncertainty].hash
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# By value only, so Comparable's < > sort etc. order by magnitude. Note this
|
|
129
|
+
# is intentionally looser than ==: two measurands with equal values but
|
|
130
|
+
# different uncertainties compare 0 here yet are not ==.
|
|
131
|
+
def <=>(other)
|
|
132
|
+
other = wrap(other) if other.is_a?(Numeric)
|
|
133
|
+
return nil unless other.is_a?(Measurand)
|
|
134
|
+
@value <=> other.value
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Are these the same measurement within error? Uses subtraction so shared
|
|
138
|
+
# sources correlate correctly, then asks whether zero lies within sigmas of
|
|
139
|
+
# the difference.
|
|
140
|
+
def consistent_with?(other, sigmas: 1)
|
|
141
|
+
difference = self - wrap(other)
|
|
142
|
+
difference.value.abs <= sigmas * difference.uncertainty
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def overlaps?(other)
|
|
146
|
+
other = wrap(other)
|
|
147
|
+
low, high = @value - uncertainty, @value + uncertainty
|
|
148
|
+
other_low, other_high = other.value - other.uncertainty, other.value + other.uncertainty
|
|
149
|
+
low <= other_high && other_low <= high
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Ruby calls this when a bare number is on the left: 2 * measurand.
|
|
153
|
+
def coerce(other)
|
|
154
|
+
[wrap(other), self]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
protected
|
|
158
|
+
|
|
159
|
+
def partials
|
|
160
|
+
@partials
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def initialize(value, uncertainty = 0)
|
|
166
|
+
@value = value
|
|
167
|
+
@partials = uncertainty.zero? ? {} : {Source.new(uncertainty) => 1.0}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build(value, partials)
|
|
171
|
+
@value = value
|
|
172
|
+
@partials = partials
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def wrap(other)
|
|
176
|
+
other.is_a?(Measurand) ? other : self.class.new(other)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# New partials for a two-operand function f, given df/dself and df/dother.
|
|
180
|
+
# Sources shared between the operands sum here, which is where correlation
|
|
181
|
+
# is actually handled.
|
|
182
|
+
def combine(dself, other, dother)
|
|
183
|
+
result = Hash.new(0.0)
|
|
184
|
+
@partials.each{|source, partial| result[source] += partial * dself}
|
|
185
|
+
other.partials.each{|source, partial| result[source] += partial * dother}
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# New partials for a unary function, scaling every partial by df/dself.
|
|
190
|
+
def scale(dself)
|
|
191
|
+
@partials.each_with_object({}){|(source, partial), result| result[source] = partial * dself}
|
|
192
|
+
end
|
|
193
|
+
end
|
data/measurand.gemspec
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require_relative './lib/Measurand/VERSION'
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = 'measurand'
|
|
5
|
+
spec.version = Measurand::VERSION
|
|
6
|
+
|
|
7
|
+
spec.summary = "Measured values with uncertainty and GUM-conformant error propagation."
|
|
8
|
+
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."
|
|
9
|
+
|
|
10
|
+
spec.author = 'thoran'
|
|
11
|
+
spec.email = 'code@thoran.com'
|
|
12
|
+
spec.homepage = 'http://github.com/thoran/measurand'
|
|
13
|
+
spec.license = 'MIT'
|
|
14
|
+
|
|
15
|
+
spec.required_ruby_version = '>= 2.5'
|
|
16
|
+
|
|
17
|
+
spec.files = [
|
|
18
|
+
'measurand.gemspec',
|
|
19
|
+
'Gemfile',
|
|
20
|
+
Dir['lib/**/*.rb'],
|
|
21
|
+
'CHANGELOG.md',
|
|
22
|
+
'LICENSE',
|
|
23
|
+
'README.md',
|
|
24
|
+
Dir['test/**/*.rb']
|
|
25
|
+
].flatten
|
|
26
|
+
spec.require_paths = ['lib']
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
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,206 @@
|
|
|
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
|
+
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
|
data/test/test_helper.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: measurand
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.0
|
|
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
|
+
description: Measured values with uncertainty, propagated by first-order (GUM) error
|
|
13
|
+
propagation over an automatic-differentiation graph, so that shared sources correlate
|
|
14
|
+
correctly. Quadrature done right, not linear addition. Parsing, PDG-rounded presentation,
|
|
15
|
+
and transcendentals.
|
|
16
|
+
email: code@thoran.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- Gemfile
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- lib/Measurand/Format.rb
|
|
26
|
+
- lib/Measurand/Math.rb
|
|
27
|
+
- lib/Measurand/Numeric.rb
|
|
28
|
+
- lib/Measurand/Parse.rb
|
|
29
|
+
- lib/Measurand/Source.rb
|
|
30
|
+
- lib/Measurand/VERSION.rb
|
|
31
|
+
- lib/measurand.rb
|
|
32
|
+
- measurand.gemspec
|
|
33
|
+
- test/Measurand/Format_test.rb
|
|
34
|
+
- test/Measurand/Math_test.rb
|
|
35
|
+
- test/Measurand/Measurand_test.rb
|
|
36
|
+
- test/Measurand/Numeric_test.rb
|
|
37
|
+
- test/Measurand/Parse_test.rb
|
|
38
|
+
- test/Measurand/Source_test.rb
|
|
39
|
+
- test/test_helper.rb
|
|
40
|
+
homepage: http://github.com/thoran/measurand
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata: {}
|
|
44
|
+
rdoc_options: []
|
|
45
|
+
require_paths:
|
|
46
|
+
- lib
|
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '2.5'
|
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0'
|
|
57
|
+
requirements: []
|
|
58
|
+
rubygems_version: 4.0.16
|
|
59
|
+
specification_version: 4
|
|
60
|
+
summary: Measured values with uncertainty and GUM-conformant error propagation.
|
|
61
|
+
test_files: []
|