dcc 0.2.1 → 0.2.3
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 +4 -4
- data/dcc.gemspec +3 -2
- data/lib/dcc/cli/cli.rb +122 -14
- data/lib/dcc/extract/formula/ast.rb +137 -0
- data/lib/dcc/extract/formula/evaluator.rb +357 -0
- data/lib/dcc/extract/formula/parser.rb +305 -0
- data/lib/dcc/extract/formula/quantity.rb +89 -0
- data/lib/dcc/extract/formula.rb +110 -0
- data/lib/dcc/quantity_math/complex.rb +252 -0
- data/lib/dcc/quantity_math/matrix.rb +217 -0
- data/lib/dcc/quantity_math.rb +30 -2
- data/lib/dcc/streaming/reader.rb +246 -0
- data/lib/dcc/streaming.rb +79 -0
- data/lib/dcc/version.rb +1 -1
- data/lib/dcc.rb +13 -3
- metadata +29 -6
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Dcc
|
|
6
|
+
module Extract
|
|
7
|
+
module Formula
|
|
8
|
+
# Turns an `Mml::V3::Math` model into a `Formula::Ast`.
|
|
9
|
+
#
|
|
10
|
+
# mml splits an element's children into one collection per child type
|
|
11
|
+
# (`apply_value`, `ci_value`, …), which loses order across types.
|
|
12
|
+
# `element_order` records the original child sequence, so we walk that
|
|
13
|
+
# and pop from the matching collection with a per-name cursor.
|
|
14
|
+
module Parser
|
|
15
|
+
OPERATORS = {
|
|
16
|
+
"plus" => :+,
|
|
17
|
+
"minus" => :-,
|
|
18
|
+
"times" => :*,
|
|
19
|
+
"divide" => :/,
|
|
20
|
+
"power" => :**,
|
|
21
|
+
"root" => :root,
|
|
22
|
+
"exp" => :exp,
|
|
23
|
+
"ln" => :ln,
|
|
24
|
+
"log" => :log,
|
|
25
|
+
"sin" => :sin,
|
|
26
|
+
"cos" => :cos,
|
|
27
|
+
"tan" => :tan,
|
|
28
|
+
"abs" => :abs,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
CONSTANTS = { "pi" => :pi, "exponentiale" => :e }.freeze
|
|
32
|
+
|
|
33
|
+
# `<cn>` encodings whose value is a single decimal literal. Every
|
|
34
|
+
# other type spreads the number across `<sep/>`-separated parts.
|
|
35
|
+
PLAIN_CN_TYPES = %w[real integer double].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @param math [Mml::V3::Math]
|
|
39
|
+
# @param bindings [Hash{String => Quantity}]
|
|
40
|
+
# @return [Dcc::Extract::Formula::Ast]
|
|
41
|
+
def call(math, bindings: {})
|
|
42
|
+
name, model = only(children(math))
|
|
43
|
+
return parse_declaration(model, bindings) if name == "declare"
|
|
44
|
+
|
|
45
|
+
Ast.new(name: nil, bound_variables: [],
|
|
46
|
+
body: node(name, model, []), bindings: bindings)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_declaration(declaration, bindings)
|
|
52
|
+
nodes = children(declaration)
|
|
53
|
+
check_declaration(nodes)
|
|
54
|
+
lambda_children = children(fetch(nodes, "lambda"))
|
|
55
|
+
bound = bound_variable_names(lambda_children)
|
|
56
|
+
body = expression(without_bvars(lambda_children), bound)
|
|
57
|
+
Ast.new(name: text_of(nodes, "ci"), bound_variables: bound,
|
|
58
|
+
body: body, bindings: bindings)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# `fetch` and `text_of` take the first match, so a second
|
|
62
|
+
# <lambda> or a second name <ci> would vanish the same way a
|
|
63
|
+
# second top-level expression used to.
|
|
64
|
+
def check_declaration(nodes)
|
|
65
|
+
names = nodes.map(&:first)
|
|
66
|
+
return if names == %w[ci lambda]
|
|
67
|
+
|
|
68
|
+
raise ::Dcc::ExtractionError,
|
|
69
|
+
"<declare> must hold one <ci> then one <lambda>, " \
|
|
70
|
+
"found #{names.empty? ? 'nothing' : names.join(', ')}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def without_bvars(nodes)
|
|
74
|
+
nodes.reject { |pair| pair.first == "bvar" }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def bound_variable_names(nodes)
|
|
78
|
+
nodes.select { |pair| pair.first == "bvar" }
|
|
79
|
+
.map { |pair| bound_name(children(pair.last)) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A <bvar> names one variable. MathML also allows a <degree>
|
|
83
|
+
# qualifier there, but that spells a derivative and this
|
|
84
|
+
# evaluator has none to offer, so it is refused rather than
|
|
85
|
+
# ignored — and the message names what was actually there.
|
|
86
|
+
def bound_name(contents)
|
|
87
|
+
names = contents.map(&:first)
|
|
88
|
+
return text(contents.first.last) if names == %w[ci]
|
|
89
|
+
|
|
90
|
+
raise ::Dcc::ExtractionError,
|
|
91
|
+
"<bvar> must contain exactly one <ci>, " \
|
|
92
|
+
"found #{names.empty? ? 'nothing' : names.join(', ')}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param nodes [Array<(String, Object)>] ordered
|
|
96
|
+
# [element name, model] pairs.
|
|
97
|
+
# @return [Object] the single expression node.
|
|
98
|
+
def expression(nodes, bound = [])
|
|
99
|
+
name, model = only(nodes)
|
|
100
|
+
node(name, model, bound)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `<math>` and a `<lambda>` body each hold exactly one
|
|
104
|
+
# expression. Taking `.first` of several dropped the rest
|
|
105
|
+
# without a word — the same silent wrong answer this file
|
|
106
|
+
# refuses everywhere else. (`<bvar>` has its own check, in
|
|
107
|
+
# `bound_name`, because a qualifier there is not an
|
|
108
|
+
# expression.)
|
|
109
|
+
def only(nodes)
|
|
110
|
+
case nodes.size
|
|
111
|
+
when 1 then nodes.first
|
|
112
|
+
when 0
|
|
113
|
+
raise ::Dcc::ExtractionError,
|
|
114
|
+
"dcc:formula contains no formula expression"
|
|
115
|
+
else
|
|
116
|
+
raise ::Dcc::ExtractionError,
|
|
117
|
+
"dcc:formula contains #{nodes.size} expressions " \
|
|
118
|
+
"where exactly one is expected"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def node(name, model, bound)
|
|
123
|
+
case name
|
|
124
|
+
when "apply" then apply(model, bound)
|
|
125
|
+
when "cn" then number_node(model)
|
|
126
|
+
when "ci" then variable(text(model), bound)
|
|
127
|
+
else constant_node(name)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A structured `<cn>` splits its number across text segments
|
|
132
|
+
# separated by `<sep/>`, and `text` concatenates them, so a
|
|
133
|
+
# rational `1<sep/>2` would silently read as 12 rather than one
|
|
134
|
+
# half. A wrong answer is worse than a refusal, so encodings we
|
|
135
|
+
# do not implement are rejected outright.
|
|
136
|
+
#
|
|
137
|
+
# No explicit coercion here — Ast::Number's constructor runs the
|
|
138
|
+
# text through Quantity.decimal.
|
|
139
|
+
def number_node(model)
|
|
140
|
+
check_plain_number(model)
|
|
141
|
+
literal = text(model)
|
|
142
|
+
number = Ast::Number.new(value: literal)
|
|
143
|
+
check_integral(model.type, number.value, literal)
|
|
144
|
+
number
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# `<cn type="integer">1.5</cn>` declares one thing and writes
|
|
148
|
+
# another. Reading it as 1.5 is the same contradiction
|
|
149
|
+
# `base="8"` is refused for.
|
|
150
|
+
#
|
|
151
|
+
# Returns the node explicitly rather than ending on the check:
|
|
152
|
+
# a check's return value in an operand slot would put nil into
|
|
153
|
+
# the tree and leak a raw NoMethodError out of `abs`.
|
|
154
|
+
# Quotes the source lexeme, not `value.to_s("F")`: expanding
|
|
155
|
+
# the decimal turns `1e-100000` — nine bytes of document —
|
|
156
|
+
# into a hundred-kilobyte exception message.
|
|
157
|
+
def check_integral(type, value, literal)
|
|
158
|
+
return unless type == "integer"
|
|
159
|
+
return if value.frac.zero?
|
|
160
|
+
|
|
161
|
+
raise ::Dcc::ExtractionError,
|
|
162
|
+
%(<cn type="integer"> holds a non-integer value: #{literal})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def check_plain_number(model)
|
|
166
|
+
check_base(model)
|
|
167
|
+
unless model.sep_value.empty?
|
|
168
|
+
raise ::Dcc::ExtractionError,
|
|
169
|
+
"Structured <cn> with <sep/> is not supported"
|
|
170
|
+
end
|
|
171
|
+
return if model.type.nil? || PLAIN_CN_TYPES.include?(model.type)
|
|
172
|
+
|
|
173
|
+
raise ::Dcc::ExtractionError,
|
|
174
|
+
"Unsupported <cn> type: #{model.type}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# `<cn base="8">17</cn>` is octal — fifteen, not seventeen.
|
|
178
|
+
# Reading any other base as decimal is the same silent wrong
|
|
179
|
+
# number the `<sep/>` check refuses, so only base 10 is accepted.
|
|
180
|
+
def check_base(model)
|
|
181
|
+
return if model.base.nil? || model.base == "10"
|
|
182
|
+
|
|
183
|
+
raise ::Dcc::ExtractionError,
|
|
184
|
+
"Unsupported <cn> base: #{model.base}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def constant_node(name)
|
|
188
|
+
constant = CONSTANTS.fetch(name) do
|
|
189
|
+
raise ::Dcc::ExtractionError,
|
|
190
|
+
"Unsupported MathML element: #{name}"
|
|
191
|
+
end
|
|
192
|
+
Ast::Constant.new(name: constant)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def apply(model, bound)
|
|
196
|
+
(operator_name,), *operands = children(model)
|
|
197
|
+
operator = OPERATORS.fetch(operator_name) do
|
|
198
|
+
raise ::Dcc::ExtractionError,
|
|
199
|
+
"Unsupported MathML operator: #{operator_name}"
|
|
200
|
+
end
|
|
201
|
+
Ast::Apply.new(
|
|
202
|
+
operator: operator,
|
|
203
|
+
operands: operand_nodes(operator, operands, bound),
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def operand_nodes(operator, operands, bound)
|
|
208
|
+
return root_operands(operands, bound) if operator == :root
|
|
209
|
+
|
|
210
|
+
operands.map { |name, child| node(name, child, bound) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# MathML puts an optional `<degree>` wrapper before the radicand.
|
|
214
|
+
# Unwrap it and store operands as [value, degree]. Cardinality is
|
|
215
|
+
# enforced here: silently taking `.first` of several radicands or
|
|
216
|
+
# several degrees would corrupt the evaluator's operand ordering.
|
|
217
|
+
def root_operands(operands, bound)
|
|
218
|
+
degrees, values =
|
|
219
|
+
operands.partition { |pair| pair.first == "degree" }
|
|
220
|
+
check_root_arity(values, degrees)
|
|
221
|
+
|
|
222
|
+
name, child = values.first
|
|
223
|
+
nodes = [node(name, child, bound)]
|
|
224
|
+
return nodes if degrees.empty?
|
|
225
|
+
|
|
226
|
+
nodes + [degree_node(degrees.first.last, bound)]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def check_root_arity(values, degrees)
|
|
230
|
+
unless values.size == 1
|
|
231
|
+
raise ::Dcc::ExtractionError,
|
|
232
|
+
"<root> must have exactly one radicand, " \
|
|
233
|
+
"found #{values.size}"
|
|
234
|
+
end
|
|
235
|
+
return unless degrees.size > 1
|
|
236
|
+
|
|
237
|
+
raise ::Dcc::ExtractionError,
|
|
238
|
+
"<root> must have at most one <degree>, " \
|
|
239
|
+
"found #{degrees.size}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def degree_node(model, bound)
|
|
243
|
+
contents = children(model)
|
|
244
|
+
unless contents.size == 1
|
|
245
|
+
raise ::Dcc::ExtractionError,
|
|
246
|
+
"<degree> must contain exactly one expression, " \
|
|
247
|
+
"found #{contents.size}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
name, child = contents.first
|
|
251
|
+
node(name, child, bound)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def variable(name, bound)
|
|
255
|
+
return Ast::BoundVariable.new(name: name) if bound.include?(name)
|
|
256
|
+
|
|
257
|
+
Ast::Variable.new(name: name)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Rebuild the original child sequence from `element_order`.
|
|
261
|
+
# @return [Array<(String, Object)>]
|
|
262
|
+
def children(model)
|
|
263
|
+
cursors = ::Hash.new(0)
|
|
264
|
+
elements = model.element_order.select do |e|
|
|
265
|
+
e.node_type == :element
|
|
266
|
+
end
|
|
267
|
+
elements.map do |element|
|
|
268
|
+
name = element.name
|
|
269
|
+
child = collection(model, name)[cursors[name]]
|
|
270
|
+
cursors[name] += 1
|
|
271
|
+
[name, child]
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# `element_order` can name any MathML element; only the ones mml
|
|
276
|
+
# models have a `*_value` reader. This is a boundary check against
|
|
277
|
+
# the gem, not defensive padding.
|
|
278
|
+
def collection(model, name)
|
|
279
|
+
reader = :"#{name.tr('-', '_')}_value"
|
|
280
|
+
unless model.respond_to?(reader)
|
|
281
|
+
raise ::Dcc::ExtractionError,
|
|
282
|
+
"Unsupported MathML element: #{name}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
model.public_send(reader)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def fetch(nodes, name)
|
|
289
|
+
nodes.assoc(name)&.last ||
|
|
290
|
+
raise(::Dcc::ExtractionError,
|
|
291
|
+
"Expected a <#{name}> element in dcc:formula")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def text_of(nodes, name)
|
|
295
|
+
text(fetch(nodes, name))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def text(model)
|
|
299
|
+
Array(model.value).join.strip
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Dcc
|
|
6
|
+
module Extract
|
|
7
|
+
module Formula
|
|
8
|
+
# A named numeric binding taken from a `dcc:quantity`. `value` is a
|
|
9
|
+
# single `BigDecimal` for `si:real` and an array for the `si:*XMLList`
|
|
10
|
+
# forms. `uncertainty` is part of the shape callers see, but nothing
|
|
11
|
+
# populates it yet — extraction always passes nil.
|
|
12
|
+
#
|
|
13
|
+
# `Quantity.decimal` is the one place untrusted input becomes a
|
|
14
|
+
# BigDecimal. The constructor runs every value through it, so a
|
|
15
|
+
# Quantity cannot exist holding a non-finite or non-numeric value.
|
|
16
|
+
# That invariant is what lets `Evaluator#quantify` trust its contents.
|
|
17
|
+
Quantity = ::Data.define(:value, :unit, :uncertainty) do
|
|
18
|
+
# The single coercion boundary for the whole formula pipeline: the
|
|
19
|
+
# parser's `<cn>` text, the extractor's `si:value` (declared merely
|
|
20
|
+
# `:string`, so it can hold anything), and the evaluator's overrides
|
|
21
|
+
# all land here.
|
|
22
|
+
#
|
|
23
|
+
# @param input [Object] anything that might be a number.
|
|
24
|
+
# @return [BigDecimal] finite, always.
|
|
25
|
+
# @raise [Dcc::ExtractionError] if it is not a finite number.
|
|
26
|
+
def self.decimal(input)
|
|
27
|
+
number = input.is_a?(::BigDecimal) ? input : from_text(input)
|
|
28
|
+
unless number.finite?
|
|
29
|
+
raise ::Dcc::ExtractionError,
|
|
30
|
+
"Formula value is not a finite number: #{input.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
number
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
raise ::Dcc::ExtractionError,
|
|
36
|
+
"Formula value is not a number: #{input.inspect}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `BigDecimal("1_000")` quietly reads 1000, but no document writes a
|
|
40
|
+
# number that way. Malformed text, not a value ten times its face.
|
|
41
|
+
def self.from_text(input)
|
|
42
|
+
text = input.to_s
|
|
43
|
+
raise ::ArgumentError if text.include?("_")
|
|
44
|
+
|
|
45
|
+
BigDecimal(text)
|
|
46
|
+
end
|
|
47
|
+
private_class_method :from_text
|
|
48
|
+
|
|
49
|
+
# Lists are narrowed with `Array.try_convert`, not `is_a?(Array)`:
|
|
50
|
+
# the D-SI branch hands back a `Dcc::Type::DecimalXmlList::Values`,
|
|
51
|
+
# which is array-like but is not an Array. `try_convert` also
|
|
52
|
+
# rejects an object whose `to_ary` breaks the protocol, which a
|
|
53
|
+
# bare `respond_to?(:to_ary)` would pass through to a NoMethodError.
|
|
54
|
+
#
|
|
55
|
+
# `Data` freezes the struct but not what it holds, so the coerced
|
|
56
|
+
# list is frozen too.
|
|
57
|
+
def initialize(value:, unit: nil, uncertainty: nil)
|
|
58
|
+
super(value: Quantity.coerce(value), unit: unit,
|
|
59
|
+
uncertainty: uncertainty)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.coerce(value)
|
|
63
|
+
return decimal(value) unless value.respond_to?(:to_ary)
|
|
64
|
+
|
|
65
|
+
list = ::Array.try_convert(value)
|
|
66
|
+
raise ::Dcc::ExtractionError, broken_list(value) if list.nil?
|
|
67
|
+
|
|
68
|
+
list.map { |item| decimal(item) }.freeze
|
|
69
|
+
rescue ::TypeError
|
|
70
|
+
raise ::Dcc::ExtractionError, broken_list(value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.broken_list(value)
|
|
74
|
+
"Formula value is not a usable list: #{value.class}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Array<BigDecimal>]
|
|
78
|
+
def values
|
|
79
|
+
list? ? value : [value]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
def list?
|
|
84
|
+
value.is_a?(::Array)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Dcc
|
|
6
|
+
module Extract
|
|
7
|
+
# `Dcc::Extract::Formula` pulls content-MathML formulae out of a parsed
|
|
8
|
+
# DCC. Each `dcc:formula` that carries a `dcc:mathml` becomes one `Ast`,
|
|
9
|
+
# bound to the `dcc:quantity` values sitting in the same `dcc:data`
|
|
10
|
+
# block — that is where PTB puts a formula's parameters and inputs.
|
|
11
|
+
module Formula
|
|
12
|
+
autoload :Ast, "dcc/extract/formula/ast"
|
|
13
|
+
autoload :Evaluator, "dcc/extract/formula/evaluator"
|
|
14
|
+
autoload :Parser, "dcc/extract/formula/parser"
|
|
15
|
+
autoload :Quantity, "dcc/extract/formula/quantity"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# @param dcc [Lutaml::Model::Serializable] a parsed DCC.
|
|
19
|
+
# @return [Array<Dcc::Extract::Formula::Ast>] in document order.
|
|
20
|
+
def call(dcc)
|
|
21
|
+
data_blocks(dcc).flat_map { |data| formulae_in(data) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def formulae_in(data)
|
|
27
|
+
bindings = bindings_in(data)
|
|
28
|
+
data.formula.filter_map do |formula|
|
|
29
|
+
math = formula.mathml&.math
|
|
30
|
+
Parser.call(math, bindings: bindings) if math
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parameters live in `dcc:data/dcc:list/dcc:quantity`, but the XSD
|
|
35
|
+
# also allows a bare `dcc:quantity` directly under `dcc:data`.
|
|
36
|
+
def bindings_in(data)
|
|
37
|
+
quantities_in(data).each_with_object({}) do |quantity, bindings|
|
|
38
|
+
next unless quantity.id
|
|
39
|
+
|
|
40
|
+
value = quantity_value(quantity)
|
|
41
|
+
bindings[quantity.id] = value if value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `dcc:listType` is recursive, and both it and `dcc:dataType`
|
|
46
|
+
# expose `quantity` and `list`. Walking only the first level of
|
|
47
|
+
# lists loses every binding nested below it.
|
|
48
|
+
def quantities_in(container)
|
|
49
|
+
container.quantity +
|
|
50
|
+
container.list.flat_map { |list| quantities_in(list) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `si:value` is declared `attribute :value, :string` in
|
|
54
|
+
# `lib/dcc/si/base/real.rb` — completely unvalidated, so a real
|
|
55
|
+
# document can carry "banana", "Infinity", or nothing at all. Hand
|
|
56
|
+
# it to Quantity untouched and let the one coercion boundary decide;
|
|
57
|
+
# calling `BigDecimal` here would leak a raw ArgumentError out of a
|
|
58
|
+
# public extraction call.
|
|
59
|
+
#
|
|
60
|
+
# Both `real` and `real_list_xml_list` are collection attributes on
|
|
61
|
+
# `Dcc::V3::Quantity`, so both need `.first`.
|
|
62
|
+
def quantity_value(quantity)
|
|
63
|
+
real = quantity.real.first
|
|
64
|
+
return scalar(real) if real
|
|
65
|
+
|
|
66
|
+
list = quantity.real_list_xml_list.first
|
|
67
|
+
list_values(list) if list
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scalar(real)
|
|
71
|
+
Quantity.new(value: real.value, unit: real.unit, uncertainty: nil)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def list_values(list)
|
|
75
|
+
Quantity.new(
|
|
76
|
+
value: list.value_xml_list,
|
|
77
|
+
unit: list.unit_xml_list,
|
|
78
|
+
uncertainty: nil,
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Same shape as `Dcc::Extract::File.each`: walk the model graph and
|
|
83
|
+
# collect every `dcc:data` node in document order.
|
|
84
|
+
def data_blocks(dcc)
|
|
85
|
+
blocks = []
|
|
86
|
+
walk(dcc, ::Set.new, blocks)
|
|
87
|
+
blocks
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `Set#add?` returns nil when already present, so it doubles as the
|
|
91
|
+
# cycle guard for the self-referential list types.
|
|
92
|
+
def walk(node, visited, blocks)
|
|
93
|
+
return unless node.is_a?(::Lutaml::Model::Serializable)
|
|
94
|
+
return unless visited.add?(node.object_id)
|
|
95
|
+
|
|
96
|
+
blocks << node if data?(node)
|
|
97
|
+
node.class.attributes.each_key do |attr_name|
|
|
98
|
+
Array(node.public_send(attr_name)).each do |child|
|
|
99
|
+
walk(child, visited, blocks)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def data?(node)
|
|
105
|
+
node.class.name.end_with?("::Data")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|