dcc 0.2.1 → 0.2.2

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.
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Dcc
6
+ module QuantityMath
7
+ # A complex quantity in Cartesian form, backed by a two-element `Matrix`:
8
+ # element 0 is the real part, element 1 the imaginary part, and both
9
+ # share one unit.
10
+ #
11
+ # The full 2x2 covariance is propagated through every operation, so
12
+ # chaining stays correct. A product's real and imaginary parts are
13
+ # correlated, and dropping that off-diagonal term understates the next
14
+ # result — for `(z1 * z2) * z3` by a third in the worked case.
15
+ #
16
+ # `+` and `-` assume the two operands are statistically INDEPENDENT, so
17
+ # `z - z` returns twice the covariance rather than zero.
18
+ class Complex
19
+ ONE = BigDecimal(1)
20
+ private_constant :ONE
21
+
22
+ # Significant digits kept for the division derivatives. They are the
23
+ # only non-terminating arithmetic in the propagation path: Ruby's
24
+ # `Complex#/` runs Smith's algorithm, whose internal `bim/bre` does not
25
+ # terminate, so an unbounded derivative carries ~130 digits of which
26
+ # only ~30 are meaningful. Those digits then multiply forward into
27
+ # every downstream covariance.
28
+ #
29
+ # Bounding them here rather than rounding the covariance is deliberate.
30
+ # Inside `QuantityMath.exact_covariance`, which sets
31
+ # `BigDecimal.limit(0)`, multiplication and addition are exact, so
32
+ # `J C J^T` over bounded derivatives stays exact — and therefore
33
+ # exactly symmetric and exactly positive semi-definite. That exactness
34
+ # comes from the zero limit, not from BigDecimal itself: under a
35
+ # non-zero `BigDecimal.limit` both operations round. Moving any of this
36
+ # arithmetic outside that block reintroduces the asymmetry the
37
+ # covariance validation rejects. Rounding the covariance instead would
38
+ # risk failing PSD validation on the exactly-singular matrices that a
39
+ # component with no uncertainty produces.
40
+ #
41
+ # It happens to equal `SQRT_PRECISION`, but the two are independent:
42
+ # that one sizes a square root of a variance, this one a derivative.
43
+ # Changing either should not drag the other along.
44
+ DERIVATIVE_PRECISION = 30
45
+
46
+ attr_reader :matrix
47
+
48
+ # @param matrix [Matrix] two elements sharing one unit
49
+ def initialize(matrix:)
50
+ ensure_complex_shape!(matrix)
51
+ @matrix = matrix
52
+ end
53
+
54
+ # @param real [Real] real component
55
+ # @param imag [Real] imaginary component, same unit as `real`
56
+ # @param covariance [Array<Array>, nil] 2x2. Defaults to the diagonal
57
+ # built from each component's uncertainty; a component with no
58
+ # uncertainty contributes zero variance, i.e. is treated as exact.
59
+ # @return [Complex]
60
+ def self.cartesian(real:, imag:, covariance: nil)
61
+ new(matrix: Matrix.new(values: [real.value, imag.value],
62
+ units: [real.unit, imag.unit],
63
+ covariance: covariance || diagonal(real, imag)))
64
+ end
65
+
66
+ def self.diagonal(real, imag)
67
+ QuantityMath.exact_covariance do
68
+ zero = BigDecimal(0)
69
+ [[variance(real), zero], [zero, variance(imag)]]
70
+ end
71
+ end
72
+
73
+ def self.variance(component)
74
+ component.uncertain? ? component.uncertainty**2 : BigDecimal(0)
75
+ end
76
+ private_class_method :diagonal, :variance
77
+
78
+ # @return [String, nil] siunitx expression shared by both components.
79
+ def unit
80
+ matrix.units.first
81
+ end
82
+
83
+ # @return [Real]
84
+ def real
85
+ matrix[0]
86
+ end
87
+
88
+ # @return [Real]
89
+ def imag
90
+ matrix[1]
91
+ end
92
+
93
+ # @return [Array<Array<BigDecimal>>] the 2x2 covariance.
94
+ def covariance
95
+ matrix.covariance
96
+ end
97
+
98
+ # Reads the variances directly rather than going through
99
+ # `Matrix#uncertainties`: for the non-negative diagonal that validation
100
+ # guarantees, `x.zero?` and `sqrt(x).zero?` agree, and this avoids two
101
+ # 30-digit square roots to answer a zero test.
102
+ #
103
+ # @return [Boolean] whether either component carries uncertainty.
104
+ def uncertain?
105
+ covariance.each_with_index.any? { |row, i| !row[i].zero? }
106
+ end
107
+
108
+ # @return [::Complex] the value as a Ruby Complex of BigDecimals.
109
+ def to_c
110
+ ::Complex.rectangular(*matrix.values)
111
+ end
112
+
113
+ # Assumes independence from `other`.
114
+ # @return [Complex]
115
+ def +(other)
116
+ Complex.new(matrix: matrix + other.matrix)
117
+ end
118
+
119
+ # Assumes independence from `other`.
120
+ # @return [Complex]
121
+ def -(other)
122
+ Complex.new(matrix: matrix - other.matrix)
123
+ end
124
+
125
+ # @return [Complex]
126
+ def *(other)
127
+ combine(other,
128
+ value: to_c * other.to_c,
129
+ unit: compose_unit(unit, other.unit),
130
+ d_self: other.to_c,
131
+ d_other: to_c)
132
+ end
133
+
134
+ # The quotient VALUE follows the caller's ambient BigDecimal context,
135
+ # like every other value here. Its DERIVATIVES feed the covariance, so
136
+ # `1/z2` and `-z1/z2^2` are computed at full precision instead.
137
+ #
138
+ # @raise [ZeroDivisionError] when `other` is exactly 0 + 0i.
139
+ # @return [Complex]
140
+ def /(other)
141
+ ensure_nonzero!(other)
142
+ divisor = other.to_c
143
+ d_self, d_other = division_derivatives(divisor)
144
+ combine(other,
145
+ value: to_c / divisor,
146
+ unit: compose_unit(unit, inverse_unit(other.unit)),
147
+ d_self: d_self, d_other: d_other)
148
+ end
149
+
150
+ def to_s
151
+ re, im = matrix.values
152
+ sign = im.negative? ? "-" : "+"
153
+ suffix = unit ? " #{unit}" : ""
154
+ "(#{measured(re, 0)}) #{sign} (#{measured(im.abs, 1)})i#{suffix}"
155
+ end
156
+
157
+ private
158
+
159
+ # Renders one component, dropping the "± u" when its variance is zero.
160
+ # `uncertain?` already treats an all-zero covariance as exact, so
161
+ # printing "± 0.0" would have this class contradicting itself.
162
+ def measured(value, index)
163
+ variance = covariance[index][index]
164
+ return value.to_s if variance.zero?
165
+
166
+ "#{value} ± #{variance.sqrt(SQRT_PRECISION)}"
167
+ end
168
+
169
+ # dw/dz1 = 1/z2 and dw/dz2 = -z1/z2^2, both at full precision because
170
+ # they feed the covariance.
171
+ def division_derivatives(divisor)
172
+ QuantityMath.exact_covariance do
173
+ [bounded(ONE / divisor), bounded(-(to_c / divisor) / divisor)]
174
+ end
175
+ end
176
+
177
+ # Trims a derivative to DERIVATIVE_PRECISION significant digits.
178
+ def bounded(derivative)
179
+ ::Complex.rectangular(
180
+ derivative.real.div(ONE, DERIVATIVE_PRECISION),
181
+ derivative.imaginary.div(ONE, DERIVATIVE_PRECISION),
182
+ )
183
+ end
184
+
185
+ # Stacks self and `other` into one four-element quantity whose
186
+ # covariance is block-diagonal (the operands are assumed independent of
187
+ # each other, though each keeps its own internal correlation), then
188
+ # propagates the 2x4 Jacobian.
189
+ def combine(other, value:, unit:, d_self:, d_other:)
190
+ Complex.new(matrix: stack(other).propagate(
191
+ values: [value.real, value.imaginary],
192
+ units: [unit, unit],
193
+ jacobian: jacobian_for(d_self, d_other),
194
+ ))
195
+ end
196
+
197
+ # `d_self` and `d_other` are the complex derivatives dw/dz1 and dw/dz2.
198
+ # Multiplying by a complex number p has real Jacobian
199
+ # [[Re p, -Im p], [Im p, Re p]]. The signs WITHIN each 2x2 rotation
200
+ # block are load-bearing — unlike a diagonal model, they do not cancel
201
+ # under J C J^T. Negating a whole block is invisible, because `stack`
202
+ # always builds a block-diagonal C and each block contributes
203
+ # `A C A^T`, which is invariant under `A -> -A`. That would stop
204
+ # holding if correlated operands were ever supported.
205
+ def jacobian_for(d_self, d_other)
206
+ [[d_self.real, -d_self.imaginary, d_other.real, -d_other.imaginary],
207
+ [d_self.imaginary, d_self.real, d_other.imaginary, d_other.real]]
208
+ end
209
+
210
+ def stack(other)
211
+ Matrix.new(values: matrix.values + other.matrix.values,
212
+ units: matrix.units + other.matrix.units,
213
+ covariance: block_diagonal(covariance, other.covariance))
214
+ end
215
+
216
+ def block_diagonal(first, second)
217
+ zeros = [BigDecimal(0), BigDecimal(0)]
218
+ first.map { |row| row + zeros } + second.map { |row| zeros + row }
219
+ end
220
+
221
+ def ensure_complex_shape!(matrix)
222
+ unless matrix.values.size == 2
223
+ raise ::ArgumentError,
224
+ "complex quantity needs 2 elements, got #{matrix.values.size}"
225
+ end
226
+ return if matrix.units.uniq.size == 1
227
+
228
+ raise ::ArgumentError, "unit mismatch: #{matrix.units.inspect}"
229
+ end
230
+
231
+ def ensure_nonzero!(other)
232
+ return unless other.to_c.zero?
233
+
234
+ raise ::ZeroDivisionError, "divided by a zero complex quantity"
235
+ end
236
+
237
+ def compose_unit(left, right)
238
+ return nil if left.nil? && right.nil?
239
+ return left if right.nil?
240
+ return right if left.nil?
241
+
242
+ "#{left}\\cdot#{right}"
243
+ end
244
+
245
+ def inverse_unit(unit)
246
+ return nil if unit.nil?
247
+
248
+ "#{unit}^-1"
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Dcc
6
+ module QuantityMath
7
+ # A multivariate quantity: a vector of values with per-element units and
8
+ # a full covariance matrix. Propagation follows the GUM Supplement 2 law
9
+ # `C_y = J C J^T`, so correlation between elements survives.
10
+ #
11
+ # `+` and `-` assume the two operands are statistically INDEPENDENT: the
12
+ # covariance matrices are summed with no cross term, so `x - x` returns
13
+ # `2C` rather than zero. Model correlated quantities as elements of one
14
+ # `Matrix` and combine them with `propagate`.
15
+ class Matrix
16
+ attr_reader :values, :units, :covariance
17
+
18
+ # @param values [Array<BigDecimal, Numeric, String>]
19
+ # @param units [Array<String, nil>] one siunitx expression per element
20
+ # @param covariance [Array<Array<BigDecimal, Numeric, String>>] n by n,
21
+ # symmetric and positive semi-definite
22
+ def initialize(values:, units:, covariance:)
23
+ @values = values.map { |v| decimal(v) }.freeze
24
+ @units = units.map { |u| u&.dup&.freeze }.freeze
25
+ @covariance = decimal_rows(covariance).map(&:freeze).freeze
26
+ validate!
27
+ end
28
+
29
+ # @return [Array<BigDecimal>] standard uncertainty of each element.
30
+ def uncertainties
31
+ covariance.each_with_index.map { |row, i| row[i].sqrt(SQRT_PRECISION) }
32
+ end
33
+
34
+ # @raise [IndexError] when `index` is outside the vector.
35
+ # @return [Real] element `index` as a univariate quantity.
36
+ def [](index)
37
+ ensure_index!(index)
38
+ Real.new(value: values[index], unit: units[index],
39
+ uncertainty: covariance[index][index].sqrt(SQRT_PRECISION))
40
+ end
41
+
42
+ # Assumes independence from `other`.
43
+ # @return [Matrix]
44
+ def +(other)
45
+ ensure_same_units!(other)
46
+ combine(other) { |a, b| a + b }
47
+ end
48
+
49
+ # Assumes independence from `other`.
50
+ # @return [Matrix]
51
+ def -(other)
52
+ ensure_same_units!(other)
53
+ combine(other) { |a, b| a - b }
54
+ end
55
+
56
+ # Law of propagation of uncertainty: `C_y = J C J^T`.
57
+ #
58
+ # Only the Jacobian's shape is validated. The output values, the
59
+ # Jacobian entries and the output units are the caller's
60
+ # responsibility: this does not derive units from the Jacobian and does
61
+ # not check that `values` is what `jacobian` actually computes.
62
+ #
63
+ # @param values [Array<BigDecimal, Numeric>] output values, length m
64
+ # @param units [Array<String, nil>] output units, length m
65
+ # @param jacobian [Array<Array<BigDecimal, Numeric>>] m by n
66
+ # @return [Matrix]
67
+ def propagate(values:, units:, jacobian:)
68
+ j = decimal_rows(jacobian)
69
+ ensure_jacobian_shape!(j, values.size)
70
+ propagated = QuantityMath.exact_covariance do
71
+ multiply(multiply(j, covariance), j.transpose)
72
+ end
73
+ Matrix.new(values: values, units: units, covariance: propagated)
74
+ end
75
+
76
+ def to_s
77
+ values.each_index.map { |i| self[i].to_s }.join(", ")
78
+ end
79
+
80
+ private
81
+
82
+ # Values combine under the caller's operator. The covariance matrices
83
+ # always add, off-diagonals included, for both `+` and `-`.
84
+ def combine(other, &)
85
+ Matrix.new(
86
+ values: values.zip(other.values).map(&),
87
+ units: units,
88
+ covariance: QuantityMath.exact_covariance do
89
+ add(covariance, other.covariance)
90
+ end,
91
+ )
92
+ end
93
+
94
+ # A matching unit vector implies a matching length, so this also guards
95
+ # the element-wise zips.
96
+ def ensure_same_units!(other)
97
+ return if units == other.units
98
+
99
+ raise ::ArgumentError,
100
+ "unit mismatch: #{units.inspect} vs #{other.units.inspect}"
101
+ end
102
+
103
+ # Negative indices would otherwise wrap silently, and an over-large one
104
+ # would reach `nil[index]` instead of saying what went wrong.
105
+ def ensure_index!(index)
106
+ return if index.between?(0, values.size - 1)
107
+
108
+ raise ::IndexError,
109
+ "index #{index} out of range (0...#{values.size})"
110
+ end
111
+
112
+ def ensure_jacobian_shape!(jacobian, rows)
113
+ n = values.size
114
+ return if jacobian.size == rows && jacobian.all? { |row| row.size == n }
115
+
116
+ raise ::ArgumentError,
117
+ "jacobian shape mismatch: expected #{rows}x#{n}, got " \
118
+ "#{jacobian.size}x#{jacobian.map(&:size).uniq.join(',')}"
119
+ end
120
+
121
+ def validate!
122
+ validate_dimensions!
123
+ covariance.each_with_index { |row, i| validate_covariance_row!(row, i) }
124
+ return if semi_definite?
125
+
126
+ raise ::ArgumentError, "covariance matrix is not positive semi-definite"
127
+ end
128
+
129
+ def validate_dimensions!
130
+ n = values.size
131
+ return if units.size == n && covariance.size == n &&
132
+ covariance.all? { |row| row.size == n }
133
+
134
+ raise ::ArgumentError,
135
+ "dimension mismatch: expected #{n} units and a #{n}x#{n} " \
136
+ "covariance matrix"
137
+ end
138
+
139
+ def validate_covariance_row!(row, row_index)
140
+ row.each_with_index do |entry, column|
141
+ validate_covariance_entry!(entry, row_index, column)
142
+ end
143
+ return unless row[row_index].negative?
144
+
145
+ raise ::ArgumentError,
146
+ "covariance[#{row_index}][#{row_index}] is a negative variance"
147
+ end
148
+
149
+ def validate_covariance_entry!(entry, row_index, column)
150
+ unless entry.finite?
151
+ raise ::ArgumentError,
152
+ "covariance[#{row_index}][#{column}] is not finite"
153
+ end
154
+ return if entry == covariance[column][row_index]
155
+
156
+ raise ::ArgumentError,
157
+ "covariance is not symmetric at [#{row_index}][#{column}]"
158
+ end
159
+
160
+ # LDL^T elimination in exact Rational arithmetic, so there is no
161
+ # tolerance to tune and a legitimately singular matrix is not
162
+ # rejected. Nothing computed here escapes the predicate.
163
+ def semi_definite?
164
+ work = covariance.map { |row| row.map(&:to_r) }
165
+ work.each_index.all? { |index| eliminate_pivot!(work, index) }
166
+ end
167
+
168
+ # Eliminates below the pivot at `index`, returning false when that
169
+ # pivot proves the matrix is not positive semi-definite. A zero pivot
170
+ # is only admissible when the rest of its column is zero too.
171
+ def eliminate_pivot!(work, index)
172
+ pivot = work[index][index]
173
+ below = work[(index + 1)..]
174
+ return false if pivot.negative?
175
+ return below.all? { |row| row[index].zero? } if pivot.zero?
176
+
177
+ below.each { |row| reduce_row!(row, work[index], index, pivot) }
178
+ true
179
+ end
180
+
181
+ def reduce_row!(row, pivot_row, index, pivot)
182
+ factor = row[index] / pivot
183
+ (index...row.size).each do |column|
184
+ row[column] -= factor * pivot_row[column]
185
+ end
186
+ end
187
+
188
+ def multiply(left, right)
189
+ columns = right.transpose
190
+ left.map do |row|
191
+ columns.map { |column| row.zip(column).sum { |x, y| x * y } }
192
+ end
193
+ end
194
+
195
+ def add(left, right)
196
+ left.zip(right).map { |lr, rr| lr.zip(rr).map { |x, y| x + y } }
197
+ end
198
+
199
+ def decimal_rows(rows)
200
+ rows.map { |row| row.map { |x| decimal(x) } }
201
+ end
202
+
203
+ # Normalises -0 to 0: it survives the `negative?` variance check but
204
+ # keeps its sign through `sqrt`, which would print an uncertainty of
205
+ # `-0.0`. A non-terminating Rational is rounded to SQRT_PRECISION
206
+ # significant digits.
207
+ def decimal(value)
208
+ decimal = case value
209
+ when ::BigDecimal then value
210
+ when ::Rational then BigDecimal(value, SQRT_PRECISION)
211
+ else BigDecimal(value.to_s)
212
+ end
213
+ decimal.zero? ? BigDecimal(0) : decimal
214
+ end
215
+ end
216
+ end
217
+ end
@@ -5,10 +5,38 @@ require "bigdecimal/math"
5
5
 
6
6
  module Dcc
7
7
  # `Dcc::QuantityMath` provides BigDecimal-based arithmetic on D-SI
8
- # quantities with proper uncertainty propagation. RSS (root-sum-square)
9
- # for addition/subtraction, fractional for multiplication/division.
8
+ # quantities with proper uncertainty propagation.
9
+ #
10
+ # `Real` is scalar: RSS (root-sum-square) for addition and subtraction,
11
+ # fractional for multiplication and division. `Matrix` is multivariate and
12
+ # carries a full covariance matrix, propagating by `C_y = J C J^T`.
13
+ # `Complex` is a Cartesian complex quantity built on a two-element
14
+ # `Matrix`, so the correlation between its real and imaginary parts
15
+ # survives every operation.
10
16
  module QuantityMath
17
+ # Significant digits requested when taking the square root of a
18
+ # BigDecimal variance. Well past double precision; metrology
19
+ # uncertainties never carry this many digits. Ordinary arithmetic
20
+ # follows the caller's ambient BigDecimal context.
21
+ SQRT_PRECISION = 30
22
+
11
23
  autoload :Quantity, "dcc/quantity_math/quantity"
12
24
  autoload :Real, "dcc/quantity_math/real"
25
+ autoload :Matrix, "dcc/quantity_math/matrix"
26
+ autoload :Complex, "dcc/quantity_math/complex"
27
+
28
+ # Runs `block` with an unlimited BigDecimal precision, restoring the
29
+ # caller's `BigDecimal.limit` on the way out (including when the block
30
+ # raises). Everything feeding a covariance matrix goes through here: a
31
+ # rounded result is no longer exactly symmetric, and `Matrix`'s
32
+ # validation would reject our own output.
33
+ #
34
+ # @return [Object] whatever `block` returns
35
+ def self.exact_covariance
36
+ BigDecimal.save_limit do
37
+ BigDecimal.limit(0)
38
+ yield
39
+ end
40
+ end
13
41
  end
14
42
  end