dcc 0.2.0 → 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.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +19 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +4 -0
  5. data/.rubocop_todo.yml +560 -10
  6. data/CHANGELOG.adoc +34 -1
  7. data/CONTRIBUTING.adoc +8 -1
  8. data/Gemfile +5 -7
  9. data/README.adoc +162 -7
  10. data/dcc.gemspec +9 -2
  11. data/docs/schema-versions.adoc +20 -7
  12. data/examples/build_minimal_dcc.rb +0 -0
  13. data/examples/convert_all_formats.rb +2 -1
  14. data/examples/parse_and_inspect.rb +0 -0
  15. data/examples/quantity_math_demo.rb +7 -7
  16. data/examples/validate_full.rb +1 -1
  17. data/lib/dcc/base/calibration_laboratory.rb +2 -1
  18. data/lib/dcc/base/condition.rb +5 -2
  19. data/lib/dcc/base/core_data.rb +8 -3
  20. data/lib/dcc/base/formula.rb +3 -3
  21. data/lib/dcc/base/identification.rb +6 -3
  22. data/lib/dcc/base/mathml.rb +33 -0
  23. data/lib/dcc/base/quantity.rb +2 -1
  24. data/lib/dcc/base/resp_person.rb +4 -2
  25. data/lib/dcc/base/text.rb +1 -1
  26. data/lib/dcc/base.rb +2 -1
  27. data/lib/dcc/builder/dsl.rb +1 -1
  28. data/lib/dcc/builder/session.rb +16 -14
  29. data/lib/dcc/builder.rb +3 -3
  30. data/lib/dcc/cli/cli.rb +152 -26
  31. data/lib/dcc/cli/formatters.rb +35 -7
  32. data/lib/dcc/cli.rb +1 -1
  33. data/lib/dcc/context_configuration.rb +2 -1
  34. data/lib/dcc/context_options.rb +4 -2
  35. data/lib/dcc/convert/csv.rb +27 -9
  36. data/lib/dcc/convert/html.rb +26 -110
  37. data/lib/dcc/convert/json.rb +10 -7
  38. data/lib/dcc/convert/liquid_filters.rb +58 -0
  39. data/lib/dcc/convert/result.rb +1 -1
  40. data/lib/dcc/convert/templates/report.liquid +250 -0
  41. data/lib/dcc/convert/yaml.rb +1 -1
  42. data/lib/dcc/convert.rb +1 -0
  43. data/lib/dcc/diff/change.rb +1 -1
  44. data/lib/dcc/diff/result.rb +8 -4
  45. data/lib/dcc/diff.rb +43 -11
  46. data/lib/dcc/error.rb +7 -0
  47. data/lib/dcc/extract/file.rb +3 -4
  48. data/lib/dcc/extract/formula/ast.rb +137 -0
  49. data/lib/dcc/extract/formula/evaluator.rb +357 -0
  50. data/lib/dcc/extract/formula/parser.rb +305 -0
  51. data/lib/dcc/extract/formula/quantity.rb +89 -0
  52. data/lib/dcc/extract/formula.rb +110 -0
  53. data/lib/dcc/extract/ring.rb +1 -1
  54. data/lib/dcc/extract.rb +1 -1
  55. data/lib/dcc/i18n/text_lookup.rb +8 -4
  56. data/lib/dcc/i18n.rb +1 -1
  57. data/lib/dcc/inspect/report.rb +51 -14
  58. data/lib/dcc/inspect.rb +1 -1
  59. data/lib/dcc/migrate/route.rb +44 -0
  60. data/lib/dcc/migrate/v2_to_v3.rb +445 -0
  61. data/lib/dcc/migrate.rb +130 -26
  62. data/lib/dcc/plugin/base.rb +64 -0
  63. data/lib/dcc/plugin/registry.rb +7 -2
  64. data/lib/dcc/plugin.rb +6 -2
  65. data/lib/dcc/quantity_format/formatter.rb +16 -10
  66. data/lib/dcc/quantity_format.rb +1 -1
  67. data/lib/dcc/quantity_math/complex.rb +252 -0
  68. data/lib/dcc/quantity_math/matrix.rb +217 -0
  69. data/lib/dcc/quantity_math/quantity.rb +1 -1
  70. data/lib/dcc/quantity_math/real.rb +6 -6
  71. data/lib/dcc/quantity_math.rb +31 -3
  72. data/lib/dcc/schema/version.rb +2 -2
  73. data/lib/dcc/server/app.html +282 -0
  74. data/lib/dcc/server/app.rb +171 -87
  75. data/lib/dcc/server/demo_cert.xml +244 -0
  76. data/lib/dcc/server/storage/entry.rb +1 -1
  77. data/lib/dcc/server/storage/memory.rb +3 -2
  78. data/lib/dcc/server/storage.rb +1 -1
  79. data/lib/dcc/server.rb +1 -1
  80. data/lib/dcc/si/base/complex.rb +1 -1
  81. data/lib/dcc/si/base/complex_list_xml_list.rb +1 -1
  82. data/lib/dcc/si/base/constant.rb +1 -1
  83. data/lib/dcc/si/base/coverage_interval.rb +36 -0
  84. data/lib/dcc/si/base/coverage_interval_mu.rb +1 -1
  85. data/lib/dcc/si/base/expanded_mu.rb +1 -1
  86. data/lib/dcc/si/base/expanded_unc.rb +34 -0
  87. data/lib/dcc/si/base/expanded_unc_xml_list.rb +3 -2
  88. data/lib/dcc/si/base/hybrid.rb +3 -2
  89. data/lib/dcc/si/base/measurement_uncertainty_univariate.rb +1 -1
  90. data/lib/dcc/si/base/real.rb +13 -3
  91. data/lib/dcc/si/base/real_list_xml_list.rb +5 -3
  92. data/lib/dcc/si/base/si_list.rb +3 -2
  93. data/lib/dcc/si/base/standard_mu.rb +1 -1
  94. data/lib/dcc/si/base.rb +5 -2
  95. data/lib/dcc/si/v1/coverage_interval.rb +8 -0
  96. data/lib/dcc/si/v1/expanded_unc.rb +1 -16
  97. data/lib/dcc/si/v1/measurement_uncertainty_univariate.rb +2 -1
  98. data/lib/dcc/si/v1.rb +5 -2
  99. data/lib/dcc/si/v2/coverage_interval.rb +8 -0
  100. data/lib/dcc/si/v2/coverage_interval_mu_xml_list.rb +4 -2
  101. data/lib/dcc/si/v2/expanded_mu_xml_list.rb +2 -1
  102. data/lib/dcc/si/v2/expanded_unc.rb +8 -0
  103. data/lib/dcc/si/v2/measurement_uncertainty_univariate.rb +2 -1
  104. data/lib/dcc/si/v2.rb +8 -3
  105. data/lib/dcc/signature/result.rb +2 -2
  106. data/lib/dcc/signature/signer.rb +31 -18
  107. data/lib/dcc/signature/verifier.rb +23 -21
  108. data/lib/dcc/signature.rb +1 -1
  109. data/lib/dcc/streaming/reader.rb +246 -0
  110. data/lib/dcc/streaming.rb +79 -0
  111. data/lib/dcc/transform/result.rb +1 -1
  112. data/lib/dcc/transform/xslt.rb +22 -12
  113. data/lib/dcc/transform.rb +5 -4
  114. data/lib/dcc/type/base64_binary.rb +5 -3
  115. data/lib/dcc/type/decimal_xml_list.rb +99 -13
  116. data/lib/dcc/type/iso_country_code.rb +3 -2
  117. data/lib/dcc/type/iso_language_code.rb +3 -2
  118. data/lib/dcc/type/schema_version.rb +3 -2
  119. data/lib/dcc/type/si_unit.rb +3 -2
  120. data/lib/dcc/type_guards.rb +3 -3
  121. data/lib/dcc/v2/calibration_laboratory.rb +2 -1
  122. data/lib/dcc/v2/condition.rb +22 -1
  123. data/lib/dcc/v2/configuration.rb +1 -1
  124. data/lib/dcc/v2/digital_calibration_certificate.rb +2 -1
  125. data/lib/dcc/v2/identification.rb +14 -1
  126. data/lib/dcc/v2/mathml.rb +8 -0
  127. data/lib/dcc/v2/measurement_meta_data_list.rb +2 -1
  128. data/lib/dcc/v2/quantity.rb +4 -2
  129. data/lib/dcc/v2.rb +4 -2
  130. data/lib/dcc/v3/calibration_laboratory.rb +2 -1
  131. data/lib/dcc/v3/configuration.rb +1 -1
  132. data/lib/dcc/v3/digital_calibration_certificate.rb +2 -1
  133. data/lib/dcc/v3/mathml.rb +8 -0
  134. data/lib/dcc/v3/measurement_meta_data_list.rb +2 -1
  135. data/lib/dcc/v3/quantity.rb +4 -2
  136. data/lib/dcc/v3.rb +12 -2
  137. data/lib/dcc/validate/business_rules.rb +23 -9
  138. data/lib/dcc/validate/issue.rb +2 -1
  139. data/lib/dcc/validate/result.rb +6 -4
  140. data/lib/dcc/validate/schematron/profile.rb +73 -18
  141. data/lib/dcc/validate/schematron/rule.rb +13 -3
  142. data/lib/dcc/validate/schematron/rules/administrative_data_completeness.rb +11 -4
  143. data/lib/dcc/validate/schematron/rules/base.rb +1 -2
  144. data/lib/dcc/validate/schematron/rules/date_range_check.rb +11 -5
  145. data/lib/dcc/validate/schematron/rules/id_refid_linking.rb +0 -3
  146. data/lib/dcc/validate/schematron/rules/influence_conditions_placement.rb +22 -12
  147. data/lib/dcc/validate/schematron/rules/iso_code_validation.rb +31 -14
  148. data/lib/dcc/validate/schematron/rules/language_code_dedup.rb +12 -5
  149. data/lib/dcc/validate/schematron/rules/non_si_declaration.rb +7 -4
  150. data/lib/dcc/validate/schematron/rules/release_format_check.rb +7 -4
  151. data/lib/dcc/validate/schematron/rules/schema_version_check.rb +13 -9
  152. data/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb +39 -10
  153. data/lib/dcc/validate/schematron/rules/unit_format_check.rb +2 -3
  154. data/lib/dcc/validate/schematron/rules/used_methods_placement.rb +15 -10
  155. data/lib/dcc/validate/schematron/rules/used_software_placement.rb +15 -10
  156. data/lib/dcc/validate/schematron/rules/xml_list_spacing.rb +4 -3
  157. data/lib/dcc/validate/schematron/rules.rb +28 -14
  158. data/lib/dcc/validate/schematron.rb +1 -1
  159. data/lib/dcc/validate/xsd.rb +9 -3
  160. data/lib/dcc/validate.rb +1 -1
  161. data/lib/dcc/version.rb +1 -1
  162. data/lib/dcc/versioned_parser.rb +1 -1
  163. data/lib/dcc.rb +68 -10
  164. data/sample_dcc.xml +140 -0
  165. data/web/index.html +12 -0
  166. data/web/package-lock.json +1599 -0
  167. data/web/package.json +22 -0
  168. data/web/src/App.vue +349 -0
  169. data/web/src/lib/builder.ts +171 -0
  170. data/web/src/lib/dcc.ts +338 -0
  171. data/web/src/lib/units.json +5581 -0
  172. data/web/src/main.ts +5 -0
  173. data/web/src/style.css +27 -0
  174. data/web/tsconfig.json +15 -0
  175. data/web/vite.config.ts +14 -0
  176. metadata +148 -9
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dcc
4
+ module Plugin
5
+ # Included by a plugin class so it can declare what it adds to Dcc.
6
+ #
7
+ # @example
8
+ # class MyPlugin
9
+ # include Dcc::Plugin::Base
10
+ # register_validator MyRule
11
+ # end
12
+ module Base
13
+ # @param base [Class] the including class.
14
+ def self.included(base)
15
+ base.extend(ClassMethods)
16
+ end
17
+
18
+ # `Profile#call` does `rule_class.new.check_on(dcc)`, so a validator
19
+ # has to be a class. An instance responds to `#check_on` and so looks
20
+ # right, but would fail deep inside validation with a message naming
21
+ # neither the plugin nor the author's line.
22
+ #
23
+ # @param entry [Object] the candidate rule.
24
+ # @raise [ArgumentError] unless entry is a class defining #check_on.
25
+ # @return [Class] the entry.
26
+ def self.rule_class!(entry)
27
+ return entry if rule_class?(entry)
28
+
29
+ raise ::ArgumentError,
30
+ "expected a rule class responding to #check_on, " \
31
+ "got #{describe(entry)}"
32
+ end
33
+
34
+ # @param entry [Object]
35
+ # @return [Boolean]
36
+ def self.rule_class?(entry)
37
+ entry.is_a?(::Class) && entry.method_defined?(:check_on)
38
+ end
39
+ private_class_method :rule_class?
40
+
41
+ # @param entry [Object]
42
+ # @return [String]
43
+ def self.describe(entry)
44
+ return entry.inspect if entry.is_a?(::Class)
45
+
46
+ "an instance of #{entry.class}"
47
+ end
48
+ private_class_method :describe
49
+
50
+ # Declaration helpers available on the including class.
51
+ module ClassMethods
52
+ # Add a Schematron rule to the active validation profile.
53
+ #
54
+ # @param rule_class [Class] a rule class defining `#check_on(dcc)`.
55
+ # @raise [ArgumentError] unless rule_class is such a class.
56
+ # @return [Class] the rule class.
57
+ def register_validator(rule_class)
58
+ checked = ::Dcc::Plugin::Base.rule_class!(rule_class)
59
+ ::Dcc::Plugin.register(:validators, checked)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -9,12 +9,17 @@ module Dcc
9
9
  @mutex = ::Mutex.new
10
10
 
11
11
  class << self
12
+ # Idempotent per (category, entry): registering the same entry in
13
+ # the same category twice is a no-op, so a doubly-loaded plugin does
14
+ # not run its validators twice.
15
+ #
12
16
  # @param category [Symbol]
13
17
  # @param entry [Class, Object]
14
18
  # @return [Class, Object]
15
19
  def register(category, entry)
16
20
  @mutex.synchronize do
17
- @categories[category.to_sym] << entry
21
+ entries = @categories[category.to_sym]
22
+ entries << entry unless entries.include?(entry)
18
23
  entry
19
24
  end
20
25
  end
@@ -42,4 +47,4 @@ module Dcc
42
47
  end
43
48
  end
44
49
  end
45
- end
50
+ end
data/lib/dcc/plugin.rb CHANGED
@@ -5,13 +5,17 @@
5
5
  # without modifying the core codebase (Open/Closed Principle).
6
6
  #
7
7
  # @example Register a custom validator
8
- # Dcc::Plugin.register(:validators, MyCustomRule)
8
+ # class MyPlugin
9
+ # include Dcc::Plugin::Base
10
+ # register_validator MyCustomRule
11
+ # end
9
12
  #
10
13
  # @example List all registered validators
11
14
  # Dcc::Plugin.all(:validators)
12
15
  module Dcc
13
16
  module Plugin
14
17
  autoload :Registry, "dcc/plugin/registry"
18
+ autoload :Base, "dcc/plugin/base"
15
19
 
16
20
  class << self
17
21
  # @param category [Symbol] e.g. :validators, :converters, :cli_commands.
@@ -39,4 +43,4 @@ module Dcc
39
43
  end
40
44
  end
41
45
  end
42
- end
46
+ end
@@ -45,25 +45,24 @@ module Dcc
45
45
  private
46
46
 
47
47
  def format_value
48
- # Two decimals by default; tightened to uncertainty precision when known.
49
- return @value.round(2).to_s("F") if uncertainty.nil?
48
+ # Two decimals by default, tightened to the uncertainty's precision
49
+ # when it has any. Neither a nil nor a NaN uncertainty has one.
50
+ return @value.round(2).to_s("F") if uncertainty.nil? || uncertainty.nan?
50
51
 
51
- # Decimal places = 1 - floor(log10(uncertainty))
52
- decimal_places = [0, 1 - Integer(Math.log10(uncertainty.to_f).floor)].max
53
52
  @value.round(decimal_places).to_s("F")
54
53
  end
55
54
 
56
55
  def format_uncertainty
57
56
  return "" if uncertainty.nil?
57
+ return "NaN" if uncertainty.nan?
58
58
 
59
- decimal_places = [0, 1 - Integer(Math.log10(uncertainty.to_f).floor)].max
60
59
  uncertainty.round(decimal_places).to_s("F")
61
60
  end
62
61
 
63
62
  def align_value_and_uncertainty
64
63
  return [@value.to_s("F"), uncertainty.to_s("F")] if uncertainty.nil?
64
+ return [format_value, "NaN"] if uncertainty.nan?
65
65
 
66
- decimal_places = [0, 1 - Integer(Math.log10(uncertainty.to_f).floor)].max
67
66
  v_str = @value.round(decimal_places).to_s("F")
68
67
  u_str = uncertainty.round(decimal_places).to_s("F")
69
68
  # Short form: digits of uncertainty after the last common digit
@@ -71,6 +70,13 @@ module Dcc
71
70
  [v_str, last_digits]
72
71
  end
73
72
 
73
+ # Decimal places = 1 - floor(log10(uncertainty)). D-SI writes `NaN` for a
74
+ # not-measured entry (SI_Format.xsd:1195) and `Math.log10` cannot answer
75
+ # for it, so every caller excludes nil and NaN first.
76
+ def decimal_places
77
+ [0, 1 - Integer(Math.log10(uncertainty.to_f).floor)].max
78
+ end
79
+
74
80
  def unit_suffix
75
81
  return "" if unit.nil? || unit.empty?
76
82
 
@@ -81,10 +87,10 @@ module Dcc
81
87
  unit
82
88
  .to_s
83
89
  .gsub(/\\([a-z]+)/, '\\1') # \kelvin → kelvin
84
- .gsub(/\\per/, '/')
85
- .gsub(/\\cubic/, '^3')
86
- .gsub(/\\square/, '^2')
90
+ .gsub("\\per", "/")
91
+ .gsub("\\cubic", "^3")
92
+ .gsub("\\square", "^2")
87
93
  end
88
94
  end
89
95
  end
90
- end
96
+ end
@@ -7,4 +7,4 @@ module Dcc
7
7
  module QuantityFormat
8
8
  autoload :Formatter, "dcc/quantity_format/formatter"
9
9
  end
10
- end
10
+ end
@@ -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
@@ -43,4 +43,4 @@ module Dcc
43
43
  end
44
44
  end
45
45
  end
46
- end
46
+ end