asciichem 0.28.2 → 0.29.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 +4 -4
- data/.github/workflows/ci.yml +9 -5
- data/.github/workflows/release.yml +1 -1
- data/CHANGELOG.md +21 -0
- data/benchmarks/README.md +25 -0
- data/benchmarks/parsanol_recheck.rb +61 -29
- data/lib/asciichem/engine/parsanol_engine.rb +118 -0
- data/lib/asciichem/engine/parslet_engine.rb +34 -0
- data/lib/asciichem/engine.rb +59 -0
- data/lib/asciichem/grammar.rb +2 -457
- data/lib/asciichem/grammar_rules.rb +476 -0
- data/lib/asciichem/parser.rb +8 -10
- data/lib/asciichem/transform.rb +1 -851
- data/lib/asciichem/transform_rules.rb +855 -0
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem.rb +3 -0
- metadata +6 -1
data/lib/asciichem/transform.rb
CHANGED
|
@@ -11,856 +11,6 @@ module AsciiChem
|
|
|
11
11
|
# isotope prefix from suffix charge) is encoded in the grammar, not
|
|
12
12
|
# here.
|
|
13
13
|
class Transform < Parslet::Transform
|
|
14
|
-
|
|
15
|
-
# string captured by the grammar into typed model fields. The
|
|
16
|
-
# strip_value helper normalises parslet's quirky representation
|
|
17
|
-
# of "zero matches" (an empty array) into nil so the rest of the
|
|
18
|
-
# builder logic can treat nil as "absent".
|
|
19
|
-
module BuilderHelpers
|
|
20
|
-
def strip_value(value)
|
|
21
|
-
return nil if value.nil?
|
|
22
|
-
return nil if value.is_a?(Array) && value.empty?
|
|
23
|
-
|
|
24
|
-
s = value.to_s.strip
|
|
25
|
-
s.empty? ? nil : s
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# -- top level --------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
rule(formula: subtree(:subtree)) do
|
|
32
|
-
nodes = FormulaNormaliser.new(subtree).to_a
|
|
33
|
-
Model::Formula.new(nodes: nodes)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# -- text -------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
rule(text_run: simple(:text)) do
|
|
39
|
-
Model::Text.new(content: TextNormaliser.strip_quotes(text.to_s))
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
rule(group_text_run: simple(:text)) do
|
|
43
|
-
Model::Text.new(content: TextNormaliser.strip_quotes(text.to_s))
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# -- embedded math ---------------------------------------------------
|
|
47
|
-
|
|
48
|
-
rule(math_source: simple(:source)) do
|
|
49
|
-
formula = Plurimath::Asciimath.new(source.to_s).to_formula
|
|
50
|
-
Model::EmbeddedMath.new(formula: formula, source: source.to_s)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# -- atoms -----------------------------------------------------------
|
|
54
|
-
#
|
|
55
|
-
# One rule covers every atom shape. The grammar wraps atoms in
|
|
56
|
-
# `.as(:atom)`, so the transform sees `{ atom: { element:...,
|
|
57
|
-
# isotope:..., subscript:..., superscript:..., lone_pairs:...,
|
|
58
|
-
# radical_electrons:... } }` with any of the optional keys absent.
|
|
59
|
-
# AtomBuilder.from_parse_tree normalises the variations.
|
|
60
|
-
rule(atom: subtree(:attrs)) do
|
|
61
|
-
AtomBuilder.from_parse_tree(attrs).build
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# -- bonds ------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
rule(quadruple: simple(:_q)) { Model::Bond.new(kind: :quadruple) }
|
|
67
|
-
rule(triple: simple(:_t)) { Model::Bond.new(kind: :triple) }
|
|
68
|
-
rule(wedge: simple(:_w)) { Model::Bond.new(kind: :wedge) }
|
|
69
|
-
rule(hash: simple(:_h)) { Model::Bond.new(kind: :hash) }
|
|
70
|
-
rule(dative: simple(:_d)) { Model::Bond.new(kind: :dative) }
|
|
71
|
-
rule(wavy: simple(:_wv)) { Model::Bond.new(kind: :wavy) }
|
|
72
|
-
rule(double: simple(:_d2)) { Model::Bond.new(kind: :double) }
|
|
73
|
-
rule(single: simple(:_s)) { Model::Bond.new(kind: :single) }
|
|
74
|
-
|
|
75
|
-
# -- molecules -------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
rule(stereo: simple(:letter),
|
|
78
|
-
coefficient: subtree(:coef),
|
|
79
|
-
units: subtree(:units)) do
|
|
80
|
-
Model::Molecule.new(
|
|
81
|
-
coefficient: CoefficientNormaliser.new(coef).to_s,
|
|
82
|
-
nodes: Array(units),
|
|
83
|
-
stereo: StereoNormaliser.normalise(letter)
|
|
84
|
-
)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
rule(stereo: simple(:letter), units: subtree(:units)) do
|
|
88
|
-
Model::Molecule.new(
|
|
89
|
-
nodes: Array(units),
|
|
90
|
-
stereo: StereoNormaliser.normalise(letter)
|
|
91
|
-
)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
rule(coefficient: subtree(:coef), units: subtree(:units)) do
|
|
95
|
-
Model::Molecule.new(
|
|
96
|
-
coefficient: CoefficientNormaliser.new(coef).to_s,
|
|
97
|
-
nodes: Array(units)
|
|
98
|
-
)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
rule(units: subtree(:units)) do
|
|
102
|
-
Model::Molecule.new(nodes: Array(units))
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# -- annotated molecules -------------------------------------------
|
|
106
|
-
#
|
|
107
|
-
# An annotated molecule wraps a built Model::Molecule with
|
|
108
|
-
# `@key("value")` metadata annotations. The grammar produces
|
|
109
|
-
# `{ mol: <Molecule>, annotations: [{ann_type:, ann_value:}, ...] }`.
|
|
110
|
-
# The transform receives the already-built Molecule (parslet
|
|
111
|
-
# transforms bottom-up) and applies the annotations.
|
|
112
|
-
|
|
113
|
-
rule(mol: simple(:mol), annotations: subtree(:anns)) do
|
|
114
|
-
AnnotationApplicator.new(mol, Array(anns)).apply
|
|
115
|
-
mol
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# -- groups ----------------------------------------------------------
|
|
119
|
-
|
|
120
|
-
rule(open_bracket: simple(:open),
|
|
121
|
-
group_nodes: subtree(:nodes),
|
|
122
|
-
close_bracket: simple(:_close),
|
|
123
|
-
multiplicity: subtree(:mult)) do
|
|
124
|
-
Model::Group.new(
|
|
125
|
-
nodes: Array(nodes),
|
|
126
|
-
multiplicity: MultiplicityNormaliser.new(mult).to_s,
|
|
127
|
-
bracket: Group.bracket_kind(open.to_s)
|
|
128
|
-
)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# -- reactions -------------------------------------------------------
|
|
132
|
-
|
|
133
|
-
rule(reactants: subtree(:reactants),
|
|
134
|
-
arrow: subtree(:arrow),
|
|
135
|
-
products: subtree(:products)) do
|
|
136
|
-
ReactionBuilder.new(reactants, arrow, products).build
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# -- reaction cascades ------------------------------------------------
|
|
140
|
-
#
|
|
141
|
-
# The grammar wraps a chain in `{ cascade: { first: <Reaction>,
|
|
142
|
-
# arrow:..., products:... (repeated) } }`. Fold into a single
|
|
143
|
-
# ReactionCascade with steps[0] = first and each subsequent step
|
|
144
|
-
# built from the previous step's products as reactants.
|
|
145
|
-
|
|
146
|
-
rule(cascade: subtree(:data)) do
|
|
147
|
-
CascadeBuilder.new(data).build
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# -- electron configuration ------------------------------------------
|
|
151
|
-
|
|
152
|
-
# Inner rule: each `{orbital:..., occupancy:...}` hash becomes a
|
|
153
|
-
# `[orbital, occupancy]` string pair. The outer rule then collects
|
|
154
|
-
# these into an ElectronConfiguration.
|
|
155
|
-
rule(orbital: simple(:orb), occupancy: simple(:occ)) do
|
|
156
|
-
[orb.to_s, occ.to_s]
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
rule(electron_config: subtree(:raw_pairs)) do
|
|
160
|
-
pairs = Array(raw_pairs).map { |pair| Array(pair) }
|
|
161
|
-
Model::ElectronConfiguration.new(orbitals: pairs)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# -- crystals -------------------------------------------------------
|
|
165
|
-
#
|
|
166
|
-
# Grammar captures crystal_name, crystal_params, and crystal_body
|
|
167
|
-
# as optional strings. CrystalBuilder parses them into the model.
|
|
168
|
-
|
|
169
|
-
rule(crystal_node: subtree(:data)) do
|
|
170
|
-
hash = data.is_a?(Hash) ? data : {}
|
|
171
|
-
CrystalBuilder.new(
|
|
172
|
-
hash[:crystal_name],
|
|
173
|
-
hash[:crystal_params],
|
|
174
|
-
hash[:crystal_body]
|
|
175
|
-
).build
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# -- spectra --------------------------------------------------------
|
|
179
|
-
|
|
180
|
-
rule(spectrum_node: subtree(:data)) do
|
|
181
|
-
hash = data.is_a?(Hash) ? data : {}
|
|
182
|
-
SpectrumBuilder.new(
|
|
183
|
-
hash[:spectrum_type],
|
|
184
|
-
hash[:spectrum_params],
|
|
185
|
-
hash[:spectrum_body]
|
|
186
|
-
).build
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
# -- computational chemistry ----------------------------------------
|
|
190
|
-
|
|
191
|
-
rule(calc_node: subtree(:data)) do
|
|
192
|
-
hash = data.is_a?(Hash) ? data : {}
|
|
193
|
-
CalculationBuilder.new(hash[:calc_params], hash[:calc_body]).build
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
# -- Z-Matrix -------------------------------------------------------
|
|
197
|
-
|
|
198
|
-
rule(zmatrix_node: subtree(:data)) do
|
|
199
|
-
hash = data.is_a?(Hash) ? data : {}
|
|
200
|
-
ZMatrixBuilder.new(hash[:zmatrix_body]).build
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# -- reaction mechanisms --------------------------------------------
|
|
204
|
-
|
|
205
|
-
rule(mechanism_node: subtree(:data)) do
|
|
206
|
-
hash = data.is_a?(Hash) ? data : {}
|
|
207
|
-
MechanismBuilder.new(hash[:mechanism_body]).build
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
# -- internal helpers ------------------------------------------------
|
|
211
|
-
|
|
212
|
-
# Builds a Crystal from parsed grammar captures. The grammar
|
|
213
|
-
# captures the name, params, and body as raw strings; this class
|
|
214
|
-
# parses them into the model fields.
|
|
215
|
-
class CrystalBuilder
|
|
216
|
-
include BuilderHelpers
|
|
217
|
-
|
|
218
|
-
def initialize(name, params_str, body_str)
|
|
219
|
-
@name = strip_value(name)
|
|
220
|
-
@params_str = strip_value(params_str)
|
|
221
|
-
@body_str = strip_value(body_str)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def build
|
|
225
|
-
params = parse_params(@params_str)
|
|
226
|
-
atoms = parse_atoms(@body_str)
|
|
227
|
-
Model::Crystal.new(
|
|
228
|
-
name: @name,
|
|
229
|
-
a: params['a'],
|
|
230
|
-
b: params['b'],
|
|
231
|
-
c: params['c'],
|
|
232
|
-
alpha: params['alpha'],
|
|
233
|
-
beta: params['beta'],
|
|
234
|
-
gamma: params['gamma'],
|
|
235
|
-
spacegroup: params['sg'],
|
|
236
|
-
atoms: atoms
|
|
237
|
-
)
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
private
|
|
241
|
-
|
|
242
|
-
def parse_params(str)
|
|
243
|
-
return {} unless str
|
|
244
|
-
|
|
245
|
-
str.split(',').each_with_object({}) do |pair, memo|
|
|
246
|
-
key, val = pair.strip.split('=', 2)
|
|
247
|
-
memo[key] = val&.strip if key
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def parse_atoms(str)
|
|
252
|
-
return [] unless str
|
|
253
|
-
|
|
254
|
-
formula = AsciiChem.parse(str)
|
|
255
|
-
formula.nodes.flat_map do |node|
|
|
256
|
-
next [] unless node.is_a?(Model::Molecule)
|
|
257
|
-
|
|
258
|
-
node.nodes.select { |n| n.is_a?(Model::Atom) }
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Builds a Spectrum from parsed grammar captures. Parses peak
|
|
264
|
-
# lines from the body string.
|
|
265
|
-
class SpectrumBuilder
|
|
266
|
-
include BuilderHelpers
|
|
267
|
-
|
|
268
|
-
def initialize(type_str, params_str, body_str)
|
|
269
|
-
@type = strip_value(type_str)
|
|
270
|
-
@params_str = strip_value(params_str)
|
|
271
|
-
@body_str = strip_value(body_str)
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def build
|
|
275
|
-
Model::Spectrum.new(
|
|
276
|
-
type: @type,
|
|
277
|
-
params: parse_params(@params_str),
|
|
278
|
-
peaks: parse_peaks(@body_str)
|
|
279
|
-
)
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
private
|
|
283
|
-
|
|
284
|
-
def parse_params(str)
|
|
285
|
-
return {} unless str
|
|
286
|
-
|
|
287
|
-
str.split(',').each_with_object({}) do |pair, memo|
|
|
288
|
-
key, val = pair.strip.split('=', 2)
|
|
289
|
-
memo[key] = val&.strip if key
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def parse_peaks(str)
|
|
294
|
-
return [] unless str
|
|
295
|
-
|
|
296
|
-
validate_peaks(str)
|
|
297
|
-
str.split("\n").filter_map { |line| parse_peak(line.strip) }
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
def validate_peaks(str)
|
|
301
|
-
str.split("\n").each_with_index do |line, idx|
|
|
302
|
-
stripped = line.strip
|
|
303
|
-
next if stripped.empty?
|
|
304
|
-
next if stripped.include?(':')
|
|
305
|
-
|
|
306
|
-
raise AsciiChem::ParseError,
|
|
307
|
-
"spectrum peak on line #{idx + 1} is missing ':' separator: #{stripped.inspect}"
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
def parse_peak(line)
|
|
312
|
-
return nil if line.empty?
|
|
313
|
-
|
|
314
|
-
assignment = nil
|
|
315
|
-
match = line.match(/"([^"]*)"/)
|
|
316
|
-
if match
|
|
317
|
-
assignment = match[1]
|
|
318
|
-
line = line.sub(/"[^"]*"/, '').strip
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
pos, rest = line.split(':', 2)
|
|
322
|
-
tokens = rest&.strip&.split(/\s+/) || []
|
|
323
|
-
|
|
324
|
-
Model::Spectrum::Peak.new(
|
|
325
|
-
position: pos&.strip,
|
|
326
|
-
intensity: tokens[0],
|
|
327
|
-
multiplicity: tokens[1],
|
|
328
|
-
assignment: assignment
|
|
329
|
-
)
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
# Builds a Calculation from grammar captures.
|
|
334
|
-
# Params: "method/basis" string. Body: key-value lines.
|
|
335
|
-
class CalculationBuilder
|
|
336
|
-
include BuilderHelpers
|
|
337
|
-
def initialize(params_str, body_str)
|
|
338
|
-
@params_str = strip_value(params_str)
|
|
339
|
-
@body_str = strip_value(body_str)
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
def build
|
|
343
|
-
method, basis = parse_method_basis(@params_str)
|
|
344
|
-
Model::Calculation.new(
|
|
345
|
-
method: method,
|
|
346
|
-
basis: basis,
|
|
347
|
-
properties: parse_properties(@body_str)
|
|
348
|
-
)
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
private
|
|
352
|
-
|
|
353
|
-
def parse_method_basis(str)
|
|
354
|
-
return [nil, nil] unless str
|
|
355
|
-
|
|
356
|
-
parts = str.split('/', 2)
|
|
357
|
-
[parts[0]&.strip, parts[1]&.strip]
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
def parse_properties(str)
|
|
361
|
-
return [] unless str
|
|
362
|
-
|
|
363
|
-
validate_properties(str)
|
|
364
|
-
str.split("\n").filter_map do |line|
|
|
365
|
-
line = line.strip
|
|
366
|
-
next nil if line.empty?
|
|
367
|
-
|
|
368
|
-
key, rest = line.split(':', 2)
|
|
369
|
-
next nil unless key
|
|
370
|
-
|
|
371
|
-
tokens = rest&.strip&.split(/\s+/) || []
|
|
372
|
-
Model::Calculation::Property.new(
|
|
373
|
-
title: key.strip,
|
|
374
|
-
value: tokens[0],
|
|
375
|
-
units: tokens[1]
|
|
376
|
-
)
|
|
377
|
-
end
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
def validate_properties(str)
|
|
381
|
-
str.split("\n").each_with_index do |line, idx|
|
|
382
|
-
stripped = line.strip
|
|
383
|
-
next if stripped.empty?
|
|
384
|
-
next if stripped.include?(':')
|
|
385
|
-
|
|
386
|
-
raise AsciiChem::ParseError,
|
|
387
|
-
"calculation property on line #{idx + 1} is missing ':' separator: #{stripped.inspect}"
|
|
388
|
-
end
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Builds a ZMatrix from grammar captures.
|
|
393
|
-
# Each body line: atom [ref1 distance] [ref2 angle] [ref3 dihedral]
|
|
394
|
-
class ZMatrixBuilder
|
|
395
|
-
include BuilderHelpers
|
|
396
|
-
def initialize(body_str)
|
|
397
|
-
@body_str = strip_value(body_str)
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
def build
|
|
401
|
-
Model::ZMatrix.new(rows: parse_rows(@body_str))
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
private
|
|
405
|
-
|
|
406
|
-
def parse_rows(str)
|
|
407
|
-
return [] unless str
|
|
408
|
-
|
|
409
|
-
str.split("\n").filter_map { |line| parse_row(line.strip) }
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
def parse_row(line)
|
|
413
|
-
return nil if line.empty?
|
|
414
|
-
|
|
415
|
-
tokens = line.split(/\s+/)
|
|
416
|
-
Model::ZMatrix::ZRow.new(
|
|
417
|
-
atom: tokens[0],
|
|
418
|
-
ref1: tokens[1],
|
|
419
|
-
distance: tokens[2],
|
|
420
|
-
ref2: tokens[3],
|
|
421
|
-
angle: tokens[4],
|
|
422
|
-
ref3: tokens[5],
|
|
423
|
-
dihedral: tokens[6]
|
|
424
|
-
)
|
|
425
|
-
end
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
# Builds a Mechanism from grammar captures.
|
|
429
|
-
# Each body line: key: value (step1: reaction, spectator: ion)
|
|
430
|
-
class MechanismBuilder
|
|
431
|
-
include BuilderHelpers
|
|
432
|
-
def initialize(body_str)
|
|
433
|
-
@body_str = strip_value(body_str)
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
def build
|
|
437
|
-
steps = []
|
|
438
|
-
spectators = []
|
|
439
|
-
parse_entries(@body_str).each do |key, value|
|
|
440
|
-
if key == 'spectator'
|
|
441
|
-
spectators.concat(value.split(/\s+/).map(&:strip))
|
|
442
|
-
else
|
|
443
|
-
steps << Model::Mechanism::Step.new(label: key, reaction: value)
|
|
444
|
-
end
|
|
445
|
-
end
|
|
446
|
-
Model::Mechanism.new(steps: steps, spectators: spectators)
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
private
|
|
450
|
-
|
|
451
|
-
def parse_entries(str)
|
|
452
|
-
return [] unless str
|
|
453
|
-
|
|
454
|
-
validate_entries(str)
|
|
455
|
-
str.split("\n").filter_map do |line|
|
|
456
|
-
line = line.strip
|
|
457
|
-
next nil if line.empty?
|
|
458
|
-
|
|
459
|
-
key, val = line.split(':', 2)
|
|
460
|
-
next nil unless key && val
|
|
461
|
-
|
|
462
|
-
[key.strip, val.strip]
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
def validate_entries(str)
|
|
467
|
-
str.split("\n").each_with_index do |line, idx|
|
|
468
|
-
stripped = line.strip
|
|
469
|
-
next if stripped.empty?
|
|
470
|
-
next if stripped.include?(':')
|
|
471
|
-
|
|
472
|
-
raise AsciiChem::ParseError,
|
|
473
|
-
"mechanism entry on line #{idx + 1} is missing ':' separator: #{stripped.inspect}"
|
|
474
|
-
end
|
|
475
|
-
end
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# Strips the surrounding `"..."` quotes from a quoted text match.
|
|
479
|
-
# Used by both `text_run` and `group_text_run` rules so the
|
|
480
|
-
# model never carries the delimiters — the formatter re-adds them
|
|
481
|
-
# on output.
|
|
482
|
-
module TextNormaliser
|
|
483
|
-
def self.strip_quotes(s)
|
|
484
|
-
s.start_with?('"') && s.end_with?('"') ? s[1..-2] : s
|
|
485
|
-
end
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
# Applies `@key("value")` molecule annotations to a built
|
|
489
|
-
# Model::Molecule. The annotation type determines which field is
|
|
490
|
-
# set: name → names[], inchi/smiles/cas → identifiers[], etc.
|
|
491
|
-
class AnnotationApplicator
|
|
492
|
-
IDENTIFIER_TYPES = %w[inchi smiles cas iupac cid chebi].freeze
|
|
493
|
-
|
|
494
|
-
def initialize(molecule, annotations)
|
|
495
|
-
@molecule = molecule
|
|
496
|
-
@annotations = annotations
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
def apply
|
|
500
|
-
@annotations.each { |ann| apply_one(ann) }
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
private
|
|
504
|
-
|
|
505
|
-
def apply_one(ann)
|
|
506
|
-
if ann[:meta_key]
|
|
507
|
-
@molecule.metadata << Model::Molecule::Meta.new(
|
|
508
|
-
name: ann[:meta_key].to_s,
|
|
509
|
-
content: ann[:meta_value].to_s
|
|
510
|
-
)
|
|
511
|
-
return
|
|
512
|
-
end
|
|
513
|
-
type = ann[:ann_type].to_s
|
|
514
|
-
value = ann[:ann_value].to_s
|
|
515
|
-
case type
|
|
516
|
-
when 'name'
|
|
517
|
-
@molecule.names << Model::Name.new(content: value)
|
|
518
|
-
when 'title'
|
|
519
|
-
@molecule.title = value
|
|
520
|
-
when 'formula'
|
|
521
|
-
@molecule.formulas << Model::Molecule::Formula.new(concise: value)
|
|
522
|
-
when 'label'
|
|
523
|
-
@molecule.labels << Model::Molecule::Label.new(value: value)
|
|
524
|
-
when *IDENTIFIER_TYPES
|
|
525
|
-
@molecule.identifiers << Model::Identifier.new(value: value, convention: type)
|
|
526
|
-
else
|
|
527
|
-
@molecule.properties << Model::Molecule::Property.new(title: type, value: value)
|
|
528
|
-
end
|
|
529
|
-
end
|
|
530
|
-
end
|
|
531
|
-
|
|
532
|
-
# Lifts the inner nodes of a `formula` capture into a flat array.
|
|
533
|
-
class FormulaNormaliser
|
|
534
|
-
def initialize(subtree)
|
|
535
|
-
@subtree = subtree
|
|
536
|
-
end
|
|
537
|
-
|
|
538
|
-
def to_a
|
|
539
|
-
case @subtree
|
|
540
|
-
when Array then @subtree
|
|
541
|
-
when nil then []
|
|
542
|
-
else [@subtree]
|
|
543
|
-
end
|
|
544
|
-
end
|
|
545
|
-
end
|
|
546
|
-
|
|
547
|
-
# Coefficient is captured as `{ value: "2" }`; we want just the
|
|
548
|
-
# string. nil when no coefficient was present.
|
|
549
|
-
class CoefficientNormaliser
|
|
550
|
-
def initialize(node)
|
|
551
|
-
@node = node
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
def to_s
|
|
555
|
-
return nil if @node.nil?
|
|
556
|
-
|
|
557
|
-
v = @node.is_a?(Hash) ? @node[:value] : @node
|
|
558
|
-
v&.to_s
|
|
559
|
-
end
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
# Multiplicity is captured as `_2` (marker + digits) or nil. Strip
|
|
563
|
-
# the leading underscore.
|
|
564
|
-
class MultiplicityNormaliser
|
|
565
|
-
def initialize(node)
|
|
566
|
-
@node = node
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
def to_s
|
|
570
|
-
return nil if @node.nil?
|
|
571
|
-
|
|
572
|
-
s = @node.to_s
|
|
573
|
-
s = s[1..] if s.start_with?('_')
|
|
574
|
-
s
|
|
575
|
-
end
|
|
576
|
-
end
|
|
577
|
-
|
|
578
|
-
# Builds an Atom, normalising nil/empty strings and pulling apart
|
|
579
|
-
# composite superscripts (charge vs oxidation state vs raw). Exactly
|
|
580
|
-
# one of { charge, oxidation_state, superscript } is set on the
|
|
581
|
-
# resulting atom — they are mutually exclusive views of the
|
|
582
|
-
# superscript position.
|
|
583
|
-
class AtomBuilder
|
|
584
|
-
# Construct from a parse-tree hash. The grammar wraps atoms in
|
|
585
|
-
# `.as(:atom)`, so the transform receives one hash with any of
|
|
586
|
-
# these keys present: `:element`, `:isotope`, `:subscript`,
|
|
587
|
-
# `:superscript`, `:lone_pairs`, `:radical_electrons`,
|
|
588
|
-
# `:ring_closures`. Absent keys default to nil. Lewis markers
|
|
589
|
-
# (`:lone_pairs`, `:radical_electrons`) are captured as
|
|
590
|
-
# colon/dot strings whose length is the count.
|
|
591
|
-
def self.from_parse_tree(attrs)
|
|
592
|
-
hash = attrs.is_a?(Hash) ? attrs : {}
|
|
593
|
-
new(
|
|
594
|
-
hash[:element],
|
|
595
|
-
isotope: hash[:isotope],
|
|
596
|
-
subscript: hash[:subscript],
|
|
597
|
-
superscript: hash[:superscript],
|
|
598
|
-
lone_pairs: lewis_count(hash[:lone_pairs]),
|
|
599
|
-
radical_electrons: lewis_count(hash[:radical_electrons]),
|
|
600
|
-
ring_closures: ring_closures_string(hash[:ring_closures]),
|
|
601
|
-
x2: float_or_nil(hash[:x2]),
|
|
602
|
-
y2: float_or_nil(hash[:y2]),
|
|
603
|
-
z2: float_or_nil(hash[:z2]),
|
|
604
|
-
atom_parity: hash[:atom_parity]&.to_s,
|
|
605
|
-
spin_multiplicity: hash[:spin_multiplicity]&.to_s,
|
|
606
|
-
atom_title: hash[:atom_title]&.to_s,
|
|
607
|
-
x_fract: float_or_nil(hash[:x_fract]),
|
|
608
|
-
y_fract: float_or_nil(hash[:y_fract]),
|
|
609
|
-
z_fract: float_or_nil(hash[:z_fract])
|
|
610
|
-
)
|
|
611
|
-
end
|
|
612
|
-
|
|
613
|
-
def self.float_or_nil(value)
|
|
614
|
-
return nil if value.nil?
|
|
615
|
-
|
|
616
|
-
value.to_s.to_f
|
|
617
|
-
end
|
|
618
|
-
private_class_method :float_or_nil
|
|
619
|
-
|
|
620
|
-
# Convert a Lewis marker (string of `:` or `.`) to its count.
|
|
621
|
-
# nil/empty → nil.
|
|
622
|
-
def self.lewis_count(value)
|
|
623
|
-
return nil if value.nil?
|
|
624
|
-
|
|
625
|
-
length = value.to_s.length
|
|
626
|
-
length.positive? ? length : nil
|
|
627
|
-
end
|
|
628
|
-
private_class_method :lewis_count
|
|
629
|
-
|
|
630
|
-
# Normalise the ring-closures capture to a string or nil.
|
|
631
|
-
# parslet delivers the matched digit string or nil if `.maybe`
|
|
632
|
-
# produced nothing.
|
|
633
|
-
def self.ring_closures_string(value)
|
|
634
|
-
s = value.to_s
|
|
635
|
-
s.empty? ? nil : s
|
|
636
|
-
end
|
|
637
|
-
private_class_method :ring_closures_string
|
|
638
|
-
|
|
639
|
-
def initialize(element, isotope: nil, subscript: nil, superscript: nil,
|
|
640
|
-
lone_pairs: nil, radical_electrons: nil,
|
|
641
|
-
ring_closures: nil,
|
|
642
|
-
x2: nil, y2: nil, z2: nil, atom_parity: nil,
|
|
643
|
-
spin_multiplicity: nil, atom_title: nil,
|
|
644
|
-
x_fract: nil, y_fract: nil, z_fract: nil)
|
|
645
|
-
@element = element
|
|
646
|
-
@isotope = isotope
|
|
647
|
-
@subscript = subscript
|
|
648
|
-
@superscript = superscript
|
|
649
|
-
@lone_pairs = lone_pairs
|
|
650
|
-
@radical_electrons = radical_electrons
|
|
651
|
-
@ring_closures = ring_closures
|
|
652
|
-
@x2 = x2
|
|
653
|
-
@y2 = y2
|
|
654
|
-
@z2 = z2
|
|
655
|
-
@atom_parity = atom_parity
|
|
656
|
-
@spin_multiplicity = spin_multiplicity
|
|
657
|
-
@atom_title = atom_title
|
|
658
|
-
@x_fract = x_fract
|
|
659
|
-
@y_fract = y_fract
|
|
660
|
-
@z_fract = z_fract
|
|
661
|
-
end
|
|
662
|
-
|
|
663
|
-
def build
|
|
664
|
-
charge = detected_charge
|
|
665
|
-
oxidation = detected_oxidation_state
|
|
666
|
-
Model::Atom.new(
|
|
667
|
-
element: @element.to_s,
|
|
668
|
-
isotope: strip_marker(@isotope),
|
|
669
|
-
subscript: strip_marker(@subscript, '_'),
|
|
670
|
-
superscript: raw_superscript(charge, oxidation),
|
|
671
|
-
charge: charge,
|
|
672
|
-
oxidation_state: oxidation,
|
|
673
|
-
lone_pairs: positive_int(@lone_pairs),
|
|
674
|
-
radical_electrons: positive_int(@radical_electrons),
|
|
675
|
-
ring_closures: @ring_closures,
|
|
676
|
-
x2: @x2,
|
|
677
|
-
y2: @y2,
|
|
678
|
-
z2: @z2,
|
|
679
|
-
atom_parity: @atom_parity,
|
|
680
|
-
spin_multiplicity: @spin_multiplicity,
|
|
681
|
-
atom_title: @atom_title,
|
|
682
|
-
x_fract: @x_fract,
|
|
683
|
-
y_fract: @y_fract,
|
|
684
|
-
z_fract: @z_fract
|
|
685
|
-
)
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
private
|
|
689
|
-
|
|
690
|
-
def positive_int(value)
|
|
691
|
-
return nil if value.nil?
|
|
692
|
-
|
|
693
|
-
n = value.to_i
|
|
694
|
-
n.positive? ? n : nil
|
|
695
|
-
end
|
|
696
|
-
|
|
697
|
-
# Returns the raw superscript only when it isn't a charge or
|
|
698
|
-
# oxidation state (otherwise those carry the info).
|
|
699
|
-
def raw_superscript(charge, oxidation)
|
|
700
|
-
return nil if charge || oxidation
|
|
701
|
-
|
|
702
|
-
strip_marker(@superscript, '^')
|
|
703
|
-
end
|
|
704
|
-
|
|
705
|
-
def detected_charge
|
|
706
|
-
s = strip_marker(@superscript, '^')
|
|
707
|
-
return nil unless s
|
|
708
|
-
|
|
709
|
-
match = s.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
|
|
710
|
-
s.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
|
|
711
|
-
return nil unless match
|
|
712
|
-
|
|
713
|
-
n = match[:n]
|
|
714
|
-
sign = match[:sign]
|
|
715
|
-
n.empty? ? sign : "#{n}#{sign}"
|
|
716
|
-
end
|
|
717
|
-
|
|
718
|
-
def detected_oxidation_state
|
|
719
|
-
s = strip_marker(@superscript, '^')
|
|
720
|
-
return nil unless s
|
|
721
|
-
|
|
722
|
-
match = s.match(/\A\(([IVXLCDM]+)\)\z/)
|
|
723
|
-
match && match[1]
|
|
724
|
-
end
|
|
725
|
-
|
|
726
|
-
# Strip a leading marker char (`^` or `_`) from the captured
|
|
727
|
-
# value. nil/empty -> nil.
|
|
728
|
-
def strip_marker(value, marker = nil)
|
|
729
|
-
return nil if value.nil?
|
|
730
|
-
|
|
731
|
-
s = value.to_s
|
|
732
|
-
s = s[1..] if marker && s.start_with?(marker)
|
|
733
|
-
s = s[1..] if s.start_with?('^', '_')
|
|
734
|
-
s.empty? ? nil : s
|
|
735
|
-
end
|
|
736
|
-
end
|
|
737
|
-
|
|
738
|
-
# Builds a Reaction, mapping the arrow hash into arrow kind and
|
|
739
|
-
# conditions.
|
|
740
|
-
class ReactionBuilder
|
|
741
|
-
ARROW_KINDS = {
|
|
742
|
-
'<=>' => :equilibrium,
|
|
743
|
-
'<->' => :resonance,
|
|
744
|
-
'->' => :forward,
|
|
745
|
-
'<-' => :reverse
|
|
746
|
-
}.freeze
|
|
747
|
-
|
|
748
|
-
def initialize(reactants, arrow, products)
|
|
749
|
-
@reactants = reactants
|
|
750
|
-
@arrow = arrow
|
|
751
|
-
@products = products
|
|
752
|
-
end
|
|
753
|
-
|
|
754
|
-
def build
|
|
755
|
-
Model::Reaction.new(
|
|
756
|
-
reactants: as_terms(@reactants),
|
|
757
|
-
products: as_terms(@products),
|
|
758
|
-
arrow: kind,
|
|
759
|
-
conditions: conditions
|
|
760
|
-
)
|
|
761
|
-
end
|
|
762
|
-
|
|
763
|
-
def kind
|
|
764
|
-
ARROW_KINDS.fetch(@arrow[:kind].to_s, :forward)
|
|
765
|
-
end
|
|
766
|
-
|
|
767
|
-
def conditions
|
|
768
|
-
above = condition_text(@arrow[:above])
|
|
769
|
-
below = condition_text(@arrow[:below])
|
|
770
|
-
return nil unless above || below
|
|
771
|
-
|
|
772
|
-
Model::Reaction::Conditions.new(above: above, below: below)
|
|
773
|
-
end
|
|
774
|
-
|
|
775
|
-
def condition_text(node)
|
|
776
|
-
return nil if node.nil?
|
|
777
|
-
|
|
778
|
-
text = node.is_a?(Hash) ? node[:text] : node
|
|
779
|
-
text&.to_s
|
|
780
|
-
end
|
|
781
|
-
|
|
782
|
-
def as_terms(side)
|
|
783
|
-
case side
|
|
784
|
-
when Array then side
|
|
785
|
-
when nil then []
|
|
786
|
-
else [side]
|
|
787
|
-
end
|
|
788
|
-
end
|
|
789
|
-
end
|
|
790
|
-
|
|
791
|
-
# Builds a ReactionCascade. Parslet delivers the cascade shape in
|
|
792
|
-
# one of two forms depending on how the grammar's sequence
|
|
793
|
-
# flattened:
|
|
794
|
-
# - Array of segment hashes:
|
|
795
|
-
# [{ first: <Reaction> }, { arrow:, products: }, ...]
|
|
796
|
-
# - Single hash with array values:
|
|
797
|
-
# { first: <Reaction>, arrow: [...], products: [...] }
|
|
798
|
-
# Normalise to a canonical form before building.
|
|
799
|
-
class CascadeBuilder
|
|
800
|
-
def initialize(data)
|
|
801
|
-
@first, @tail = canonicalise(data)
|
|
802
|
-
end
|
|
803
|
-
|
|
804
|
-
def build
|
|
805
|
-
steps = [@first]
|
|
806
|
-
@tail.each do |arrow, products|
|
|
807
|
-
prev_products = steps.last.products
|
|
808
|
-
steps << ReactionBuilder.new(prev_products, arrow, products).build
|
|
809
|
-
end
|
|
810
|
-
Model::ReactionCascade.new(steps: steps)
|
|
811
|
-
end
|
|
812
|
-
|
|
813
|
-
private
|
|
814
|
-
|
|
815
|
-
def canonicalise(data)
|
|
816
|
-
if data.is_a?(Array)
|
|
817
|
-
canonicalise_array(data)
|
|
818
|
-
else
|
|
819
|
-
canonicalise_hash(data)
|
|
820
|
-
end
|
|
821
|
-
end
|
|
822
|
-
|
|
823
|
-
def canonicalise_array(arr)
|
|
824
|
-
first = arr.find { |s| s.is_a?(Hash) && s.key?(:first) }[:first]
|
|
825
|
-
tail = arr
|
|
826
|
-
.select { |s| s.is_a?(Hash) && s.key?(:arrow) }
|
|
827
|
-
.map { |s| [s[:arrow], s[:products]] }
|
|
828
|
-
[first, tail]
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
def canonicalise_hash(hash)
|
|
832
|
-
first = hash[:first]
|
|
833
|
-
# Wrap, never Array(): Array(hash) on a scalar Hash value
|
|
834
|
-
# (the shape parsanol's transform delivers) would enumerate
|
|
835
|
-
# key/value pairs instead of wrapping it.
|
|
836
|
-
tail = wrap(hash[:arrow]).zip(wrap(hash[:products]))
|
|
837
|
-
[first, tail]
|
|
838
|
-
end
|
|
839
|
-
|
|
840
|
-
def wrap(value)
|
|
841
|
-
return [] if value.nil?
|
|
842
|
-
|
|
843
|
-
value.is_a?(Array) ? value : [value]
|
|
844
|
-
end
|
|
845
|
-
end
|
|
846
|
-
|
|
847
|
-
# Maps the captured stereo letter to the model's stereo symbol.
|
|
848
|
-
module StereoNormaliser
|
|
849
|
-
def self.normalise(letter)
|
|
850
|
-
Model::Molecule::STEREO_LETTERS.fetch(letter.to_s, :unknown)
|
|
851
|
-
end
|
|
852
|
-
end
|
|
853
|
-
|
|
854
|
-
# Maps the literal bracket character to the model's bracket symbol.
|
|
855
|
-
module Group
|
|
856
|
-
def self.bracket_kind(char)
|
|
857
|
-
case char
|
|
858
|
-
when '(' then :paren
|
|
859
|
-
when '[' then :square
|
|
860
|
-
when '{' then :brace
|
|
861
|
-
else :paren
|
|
862
|
-
end
|
|
863
|
-
end
|
|
864
|
-
end
|
|
14
|
+
include TransformRules
|
|
865
15
|
end
|
|
866
16
|
end
|