asciichem 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +21 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +50 -0
- data/ARCHITECTURE.adoc +239 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +318 -0
- data/Gemfile +14 -0
- data/LICENSE +24 -0
- data/README.adoc +55 -0
- data/RELEASING.md +102 -0
- data/Rakefile +8 -0
- data/asciichem.gemspec +39 -0
- data/benchmarks/RESULTS.md +49 -0
- data/benchmarks/benchmark.rb +106 -0
- data/exe/asciichem +6 -0
- data/lib/asciichem/cli.rb +96 -0
- data/lib/asciichem/cml/extensions.rb +360 -0
- data/lib/asciichem/cml/group_extensions.rb +290 -0
- data/lib/asciichem/cml/translator.rb +69 -0
- data/lib/asciichem/cml.rb +40 -0
- data/lib/asciichem/errors.rb +9 -0
- data/lib/asciichem/formatter/base.rb +21 -0
- data/lib/asciichem/formatter/html.rb +111 -0
- data/lib/asciichem/formatter/latex.rb +173 -0
- data/lib/asciichem/formatter/mathml.rb +309 -0
- data/lib/asciichem/formatter/structural_svg.rb +286 -0
- data/lib/asciichem/formatter/svg.rb +141 -0
- data/lib/asciichem/formatter/text.rb +143 -0
- data/lib/asciichem/formatter.rb +43 -0
- data/lib/asciichem/grammar.rb +344 -0
- data/lib/asciichem/greek.rb +80 -0
- data/lib/asciichem/layout.rb +313 -0
- data/lib/asciichem/linter/balance_check.rb +103 -0
- data/lib/asciichem/linter/base.rb +52 -0
- data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
- data/lib/asciichem/linter/diagnostic.rb +16 -0
- data/lib/asciichem/linter/element_validation_check.rb +39 -0
- data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
- data/lib/asciichem/linter/registry.rb +31 -0
- data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
- data/lib/asciichem/linter/valence_check.rb +91 -0
- data/lib/asciichem/linter.rb +40 -0
- data/lib/asciichem/model/atom.rb +73 -0
- data/lib/asciichem/model/bond.rb +41 -0
- data/lib/asciichem/model/electron_configuration.rb +36 -0
- data/lib/asciichem/model/embedded_math.rb +26 -0
- data/lib/asciichem/model/formula.rb +31 -0
- data/lib/asciichem/model/group.rb +50 -0
- data/lib/asciichem/model/identifier.rb +14 -0
- data/lib/asciichem/model/molecule.rb +70 -0
- data/lib/asciichem/model/name.rb +15 -0
- data/lib/asciichem/model/node.rb +100 -0
- data/lib/asciichem/model/reaction.rb +49 -0
- data/lib/asciichem/model/reaction_cascade.rb +33 -0
- data/lib/asciichem/model/text.rb +23 -0
- data/lib/asciichem/model.rb +23 -0
- data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
- data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
- data/lib/asciichem/model_adapter.rb +56 -0
- data/lib/asciichem/parser.rb +36 -0
- data/lib/asciichem/periodic_table.rb +112 -0
- data/lib/asciichem/ring_bonds.rb +68 -0
- data/lib/asciichem/transform.rb +513 -0
- data/lib/asciichem/version.rb +5 -0
- data/lib/asciichem/xml_builder.rb +9 -0
- data/lib/asciichem.rb +34 -0
- metadata +197 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "chemicalml"
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
module ModelAdapter
|
|
7
|
+
# AsciiChem::Model -> Chemicalml::Model. Walks the AsciiChem tree
|
|
8
|
+
# and builds a canonical document. Pure transformation; no I/O.
|
|
9
|
+
#
|
|
10
|
+
# Mapping rules:
|
|
11
|
+
#
|
|
12
|
+
# - `Formula` -> `Chemicalml::Model::Document`.
|
|
13
|
+
# - `Molecule` -> `Chemicalml::Model::Molecule`. Inner atoms
|
|
14
|
+
# collect IDs; bonds reference consecutive
|
|
15
|
+
# IDs. Groups flatten with their multiplicity
|
|
16
|
+
# applied to each contained atom's count.
|
|
17
|
+
# - `Atom` -> `Chemicalml::Model::Atom` (element,
|
|
18
|
+
# isotope, charge, count, lone pairs,
|
|
19
|
+
# radical electrons).
|
|
20
|
+
# - `Bond` -> `Chemicalml::Model::Bond` (kind, refs).
|
|
21
|
+
# - `Reaction` -> `Chemicalml::Model::Reaction`.
|
|
22
|
+
# - `ReactionCascade` -> `Chemicalml::Model::ReactionList`.
|
|
23
|
+
# - `ElectronConfiguration`, `EmbeddedMath`, `Text` -> skipped
|
|
24
|
+
# (no canonical representation yet; a future extension can carry
|
|
25
|
+
# them as namespaced metadata).
|
|
26
|
+
class ToCanonical
|
|
27
|
+
attr_reader :atom_mapping, :groups
|
|
28
|
+
|
|
29
|
+
def initialize(formula)
|
|
30
|
+
@formula = formula
|
|
31
|
+
@ids = IdRegistry.new
|
|
32
|
+
@atom_mapping = {}
|
|
33
|
+
# Map of canonical molecule ID -> array of GroupRecord.
|
|
34
|
+
# Keyed by molecule ID because aci:group elements live inside
|
|
35
|
+
# their parent <molecule>, and multiple molecules can exist
|
|
36
|
+
# (top-level + reactants + products).
|
|
37
|
+
@groups = Hash.new { |hash, key| hash[key] = [] }
|
|
38
|
+
@molecule_id_stack = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build
|
|
42
|
+
molecules = []
|
|
43
|
+
reactions = []
|
|
44
|
+
reaction_lists = []
|
|
45
|
+
@formula.nodes.each do |node|
|
|
46
|
+
case node
|
|
47
|
+
when AsciiChem::Model::Molecule
|
|
48
|
+
molecules << molecule_to_canonical(node)
|
|
49
|
+
when AsciiChem::Model::Reaction
|
|
50
|
+
reactions << reaction_to_canonical(node)
|
|
51
|
+
when AsciiChem::Model::ReactionCascade
|
|
52
|
+
reaction_lists << reaction_cascade_to_canonical(node)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
Chemicalml::Model::Document.new(
|
|
56
|
+
molecules: molecules,
|
|
57
|
+
reactions: reactions,
|
|
58
|
+
reaction_lists: reaction_lists
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# -- Molecules --------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def molecule_to_canonical(molecule)
|
|
67
|
+
walker = MoleculeWalker.new(@ids)
|
|
68
|
+
atoms, bonds = walker.walk(molecule)
|
|
69
|
+
@atom_mapping.merge!(walker.atom_mapping)
|
|
70
|
+
canonical_molecule = Chemicalml::Model::Molecule.new(
|
|
71
|
+
id: @ids.next(:molecule),
|
|
72
|
+
atoms: atoms,
|
|
73
|
+
bonds: bonds,
|
|
74
|
+
count: molecule.coefficient,
|
|
75
|
+
formal_charge: total_formal_charge(atoms),
|
|
76
|
+
names: map_names(molecule.names),
|
|
77
|
+
identifiers: map_identifiers(molecule.identifiers),
|
|
78
|
+
title: molecule.title,
|
|
79
|
+
formulas: map_formulas(molecule.formulas),
|
|
80
|
+
properties: map_properties(molecule.properties),
|
|
81
|
+
labels: map_labels(molecule.labels)
|
|
82
|
+
)
|
|
83
|
+
@groups[canonical_molecule.id].concat(walker.groups) unless walker.groups.empty?
|
|
84
|
+
canonical_molecule
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def map_names(names)
|
|
88
|
+
return [] if names.nil? || names.empty?
|
|
89
|
+
|
|
90
|
+
names.map do |n|
|
|
91
|
+
Chemicalml::Model::Name.new(
|
|
92
|
+
content: n.content,
|
|
93
|
+
convention: n.convention,
|
|
94
|
+
dict_ref: n.dict_ref
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def map_identifiers(identifiers)
|
|
100
|
+
return [] if identifiers.nil? || identifiers.empty?
|
|
101
|
+
|
|
102
|
+
identifiers.map do |i|
|
|
103
|
+
Chemicalml::Model::Identifier.new(
|
|
104
|
+
value: i.value,
|
|
105
|
+
convention: i.convention,
|
|
106
|
+
dict_ref: i.dict_ref
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def map_formulas(formulas)
|
|
112
|
+
return [] if formulas.nil? || formulas.empty?
|
|
113
|
+
|
|
114
|
+
formulas.map do |f|
|
|
115
|
+
Chemicalml::Model::Formula.new(
|
|
116
|
+
concise: f[:concise],
|
|
117
|
+
inline: f[:inline],
|
|
118
|
+
formal_charge: f[:formal_charge],
|
|
119
|
+
count: f[:count],
|
|
120
|
+
title: f[:title],
|
|
121
|
+
convention: f[:convention],
|
|
122
|
+
dict_ref: f[:dict_ref]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def map_properties(properties)
|
|
128
|
+
return [] if properties.nil? || properties.empty?
|
|
129
|
+
|
|
130
|
+
properties.map do |p|
|
|
131
|
+
Chemicalml::Model::Property.new(
|
|
132
|
+
title: p[:title],
|
|
133
|
+
value: build_scalar(p[:value]),
|
|
134
|
+
dict_ref: p[:dict_ref],
|
|
135
|
+
convention: p[:convention]
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_scalar(value)
|
|
141
|
+
return nil if value.nil?
|
|
142
|
+
|
|
143
|
+
Chemicalml::Model::Scalar.new(
|
|
144
|
+
value: value.to_s,
|
|
145
|
+
dict_ref: nil
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def map_labels(labels)
|
|
150
|
+
return [] if labels.nil? || labels.empty?
|
|
151
|
+
|
|
152
|
+
labels.map do |l|
|
|
153
|
+
Chemicalml::Model::Label.new(
|
|
154
|
+
value: l[:value],
|
|
155
|
+
dict_ref: l[:dict_ref],
|
|
156
|
+
convention: l[:convention]
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def total_formal_charge(canonical_atoms)
|
|
162
|
+
charges = canonical_atoms.map { |a| parse_charge(a.formal_charge) }.compact
|
|
163
|
+
return nil if charges.empty?
|
|
164
|
+
|
|
165
|
+
sum = charges.sum
|
|
166
|
+
sum.positive? ? "+#{sum}" : sum.to_s
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def parse_charge(value)
|
|
170
|
+
return nil if value.nil? || value.to_s.empty?
|
|
171
|
+
|
|
172
|
+
match = value.to_s.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
|
|
173
|
+
value.to_s.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
|
|
174
|
+
return nil unless match
|
|
175
|
+
|
|
176
|
+
n = match[:n].empty? ? 1 : match[:n].to_i
|
|
177
|
+
match[:sign] == "+" ? n : -n
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# -- Reactions --------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def reaction_to_canonical(reaction)
|
|
183
|
+
Chemicalml::Model::Reaction.new(
|
|
184
|
+
id: @ids.next(:reaction),
|
|
185
|
+
reactant_list: reactant_list_to_canonical(reaction.reactants),
|
|
186
|
+
product_list: product_list_to_canonical(reaction.products),
|
|
187
|
+
arrow: reaction.arrow,
|
|
188
|
+
conditions_above: conditions_above(reaction),
|
|
189
|
+
conditions_below: conditions_below(reaction),
|
|
190
|
+
title: reaction.arrow.to_s,
|
|
191
|
+
type: reaction.arrow.to_s
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def reaction_cascade_to_canonical(cascade)
|
|
196
|
+
Chemicalml::Model::ReactionList.new(
|
|
197
|
+
reactions: cascade.steps.map { |s| reaction_to_canonical(s) }
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def reactant_list_to_canonical(reactants)
|
|
202
|
+
Chemicalml::Model::ReactantList.new(
|
|
203
|
+
reactants: reactants.map { |m| participant_to_canonical(m, :reactant) }
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def product_list_to_canonical(products)
|
|
208
|
+
Chemicalml::Model::ProductList.new(
|
|
209
|
+
products: products.map { |m| participant_to_canonical(m, :product) }
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def participant_to_canonical(molecule, role)
|
|
214
|
+
Chemicalml::Model::Reactant.new(
|
|
215
|
+
substance: Chemicalml::Model::Substance.new(
|
|
216
|
+
molecule: molecule_to_canonical(molecule),
|
|
217
|
+
role: role
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def conditions_above(reaction)
|
|
223
|
+
reaction.conditions&.above
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def conditions_below(reaction)
|
|
227
|
+
reaction.conditions&.below
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Walks an AsciiChem::Model::Molecule and produces an ordered
|
|
231
|
+
# atom list + a bond list with proper atomRefs. The walker is
|
|
232
|
+
# the single source of truth for atom ID assignment — bonds,
|
|
233
|
+
# reactants, and products all reference the IDs it produces.
|
|
234
|
+
# Also exposes `atom_mapping` (canonical_id => source Atom) so
|
|
235
|
+
# downstream consumers (CML extension serializer, debug tools)
|
|
236
|
+
# can match canonical atoms back to their AsciiChem source, and
|
|
237
|
+
# `groups` (per-molecule list of group structure records) so the
|
|
238
|
+
# aci: extension can preserve grouping through CML round-trip.
|
|
239
|
+
class MoleculeWalker
|
|
240
|
+
attr_reader :atom_mapping, :groups
|
|
241
|
+
|
|
242
|
+
def initialize(ids)
|
|
243
|
+
@ids = ids
|
|
244
|
+
@atoms = []
|
|
245
|
+
@bonds = []
|
|
246
|
+
@atom_mapping = {}
|
|
247
|
+
@atom_id_by_object_id = {}
|
|
248
|
+
@groups = []
|
|
249
|
+
@group_stack = []
|
|
250
|
+
@pending_bond_kind = nil
|
|
251
|
+
@last_atom_id = nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def walk(molecule)
|
|
255
|
+
walk_nodes(molecule.nodes, multiplier: nil)
|
|
256
|
+
emit_ring_bonds(molecule)
|
|
257
|
+
[@atoms, @bonds]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Emit canonical bonds for each ring-closure pair on atoms
|
|
261
|
+
# in this molecule. The walker has already assigned IDs to
|
|
262
|
+
# every atom; RingBonds.each_in yields pairs which we look up
|
|
263
|
+
# in @atom_id_by_object_id to translate Ruby object identity
|
|
264
|
+
# into canonical atom IDs. Skips pairs that already have a
|
|
265
|
+
# positional bond between them (degenerate case like `C1-C1`).
|
|
266
|
+
def emit_ring_bonds(molecule)
|
|
267
|
+
existing_pair_keys = @bonds.map { |b| pair_key(b.atom_refs) }.to_set
|
|
268
|
+
AsciiChem::RingBonds.each_in(molecule) do |ring_bond|
|
|
269
|
+
from_id = @atom_id_by_object_id[ring_bond.from_atom.object_id]
|
|
270
|
+
to_id = @atom_id_by_object_id[ring_bond.to_atom.object_id]
|
|
271
|
+
next unless from_id && to_id
|
|
272
|
+
|
|
273
|
+
key = pair_key([from_id, to_id])
|
|
274
|
+
next if existing_pair_keys.include?(key)
|
|
275
|
+
|
|
276
|
+
@bonds << Chemicalml::Model::Bond.new(
|
|
277
|
+
id: @ids.next(:bond),
|
|
278
|
+
atom_refs: [from_id, to_id],
|
|
279
|
+
kind: :single
|
|
280
|
+
)
|
|
281
|
+
existing_pair_keys << key
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Order-independent key for a pair of atom IDs, so a-b and b-a
|
|
286
|
+
# compare equal.
|
|
287
|
+
def pair_key(refs)
|
|
288
|
+
refs.sort.join(":")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private
|
|
292
|
+
|
|
293
|
+
def walk_nodes(nodes, multiplier:)
|
|
294
|
+
nodes.each do |node|
|
|
295
|
+
case node
|
|
296
|
+
when AsciiChem::Model::Atom
|
|
297
|
+
emit_atom(node, multiplier)
|
|
298
|
+
when AsciiChem::Model::Bond
|
|
299
|
+
@pending_bond_kind = node.kind
|
|
300
|
+
when AsciiChem::Model::Group
|
|
301
|
+
enter_group(node, multiplier)
|
|
302
|
+
when AsciiChem::Model::Molecule
|
|
303
|
+
walk_nodes(node.nodes, multiplier: combine(multiplier, node.coefficient))
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def enter_group(group, multiplier)
|
|
309
|
+
record = GroupRecord.new(
|
|
310
|
+
id: @ids.next(:group),
|
|
311
|
+
multiplicity: group.multiplicity,
|
|
312
|
+
bracket: group.bracket,
|
|
313
|
+
atom_ids: []
|
|
314
|
+
)
|
|
315
|
+
@groups << record
|
|
316
|
+
@group_stack.push(record)
|
|
317
|
+
walk_nodes(group.nodes, multiplier: combine(multiplier, group.multiplicity))
|
|
318
|
+
@group_stack.pop
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def emit_atom(atom, multiplier)
|
|
322
|
+
id = @ids.next(:atom)
|
|
323
|
+
@atom_mapping[id] = atom
|
|
324
|
+
@atom_id_by_object_id[atom.object_id] = id
|
|
325
|
+
@group_stack.each { |record| record.atom_ids << id }
|
|
326
|
+
attrs = {
|
|
327
|
+
id: id,
|
|
328
|
+
element: atom.element,
|
|
329
|
+
isotope: atom.isotope,
|
|
330
|
+
formal_charge: atom.charge,
|
|
331
|
+
count: effective_count(atom, multiplier),
|
|
332
|
+
lone_pairs: atom.lone_pairs,
|
|
333
|
+
radical_electrons: atom.radical_electrons
|
|
334
|
+
}
|
|
335
|
+
merge_coordinates(attrs, atom)
|
|
336
|
+
@atoms << Chemicalml::Model::Atom.new(**attrs)
|
|
337
|
+
emit_pending_bond(id) if @pending_bond_kind && @last_atom_id
|
|
338
|
+
@last_atom_id = id
|
|
339
|
+
@pending_bond_kind = nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def emit_pending_bond(next_atom_id)
|
|
343
|
+
@bonds << Chemicalml::Model::Bond.new(
|
|
344
|
+
id: @ids.next(:bond),
|
|
345
|
+
atom_refs: [@last_atom_id, next_atom_id],
|
|
346
|
+
kind: @pending_bond_kind,
|
|
347
|
+
bond_stereo: bond_stereo_for(@pending_bond_kind)
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# CML bondStereo codes for stereo bonds. Wedge (coming out of
|
|
352
|
+
# page) → "W"; hash (going into page) → "H". Other bond kinds
|
|
353
|
+
# have no stereo meaning.
|
|
354
|
+
BOND_STEREO_CODES = { wedge: "W", hash: "H" }.freeze
|
|
355
|
+
|
|
356
|
+
def bond_stereo_for(kind)
|
|
357
|
+
code = BOND_STEREO_CODES[kind]
|
|
358
|
+
return nil unless code
|
|
359
|
+
|
|
360
|
+
Chemicalml::Model::BondStereo.new(value: code)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def effective_count(atom, multiplier)
|
|
364
|
+
sub = atom.subscript
|
|
365
|
+
return combine(multiplier, sub) if multiplier
|
|
366
|
+
return sub if sub
|
|
367
|
+
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Map AsciiChem 2D/3D coordinates onto canonical atom attrs.
|
|
372
|
+
# If z2 is present, it's 3D → x3/y3/z3. Otherwise 2D → x2/y2.
|
|
373
|
+
def merge_coordinates(attrs, atom)
|
|
374
|
+
return unless atom.x2 && atom.y2
|
|
375
|
+
|
|
376
|
+
if atom.z2
|
|
377
|
+
attrs[:x3] = atom.x2
|
|
378
|
+
attrs[:y3] = atom.y2
|
|
379
|
+
attrs[:z3] = atom.z2
|
|
380
|
+
else
|
|
381
|
+
attrs[:x2] = atom.x2
|
|
382
|
+
attrs[:y2] = atom.y2
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def combine(left, right)
|
|
387
|
+
lv = integer_or_nil(left)
|
|
388
|
+
rv = integer_or_nil(right)
|
|
389
|
+
return nil unless lv || rv
|
|
390
|
+
|
|
391
|
+
(lv || 1) * (rv || 1)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def integer_or_nil(value)
|
|
395
|
+
return nil if value.nil? || value.to_s.empty?
|
|
396
|
+
|
|
397
|
+
Integer(value, exception: false)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
private_constant :MoleculeWalker
|
|
401
|
+
|
|
402
|
+
# Captures the structure of an AsciiChem::Model::Group during
|
|
403
|
+
# the canonical walk. The walker assigns IDs to atoms in source
|
|
404
|
+
# order; the GroupRecord lists which atoms were inside the group
|
|
405
|
+
# (transitively, for nested groups). Used by `Cml::GroupExtensions`
|
|
406
|
+
# to preserve grouping through the canonical model.
|
|
407
|
+
GroupRecord = Struct.new(:id, :multiplicity, :bracket, :atom_ids, keyword_init: true)
|
|
408
|
+
private_constant :GroupRecord
|
|
409
|
+
|
|
410
|
+
# Per-build ID registry. Issues stable IDs within a single
|
|
411
|
+
# translation pass (a1, a2, b1, m1, r1, ...) so re-running the
|
|
412
|
+
# adapter on equivalent input yields byte-equal output.
|
|
413
|
+
class IdRegistry
|
|
414
|
+
PREFIXES = { atom: "a", bond: "b", molecule: "m",
|
|
415
|
+
reaction: "r", group: "g" }.freeze
|
|
416
|
+
|
|
417
|
+
def initialize
|
|
418
|
+
@counters = Hash.new(0)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def next(kind)
|
|
422
|
+
@counters[kind] += 1
|
|
423
|
+
"#{PREFIXES.fetch(kind)}#{@counters[kind]}"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
private_constant :IdRegistry
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Adapter between AsciiChem::Model (the chemistry-semantic tree the
|
|
5
|
+
# text parser produces) and Chemicalml::Model (the format-agnostic
|
|
6
|
+
# canonical hub every chemistry interchange format speaks).
|
|
7
|
+
#
|
|
8
|
+
# The adapter is bidirectional and pure — no I/O, no wire format
|
|
9
|
+
# concerns. Composed with `Chemicalml::Cml::Translator` it forms the
|
|
10
|
+
# AsciiChem <-> CML pipeline:
|
|
11
|
+
#
|
|
12
|
+
# AsciiChem::Model <-> ModelAdapter <-> Chemicalml::Model
|
|
13
|
+
# ^
|
|
14
|
+
# |
|
|
15
|
+
# Chemicalml::Cml::Translator
|
|
16
|
+
# |
|
|
17
|
+
# v
|
|
18
|
+
# Chemicalml::Cml::* (wire)
|
|
19
|
+
#
|
|
20
|
+
# Adding a new field to either model means updating this adapter's
|
|
21
|
+
# mapping rules; the parsers, formatters, and wire classes stay
|
|
22
|
+
# independent.
|
|
23
|
+
module ModelAdapter
|
|
24
|
+
autoload :ToCanonical, "asciichem/model_adapter/to_canonical"
|
|
25
|
+
autoload :FromCanonical, "asciichem/model_adapter/from_canonical"
|
|
26
|
+
|
|
27
|
+
# Result of a translation that also exposes the per-atom mapping
|
|
28
|
+
# (canonical_atom_id => AsciiChem::Model::Atom) and the per-molecule
|
|
29
|
+
# group structure (molecule_id => Array<GroupRecord>). Used by
|
|
30
|
+
# callers that need to track per-atom side data through the
|
|
31
|
+
# canonical pipeline (e.g. the CML extension namespace).
|
|
32
|
+
Translation = Struct.new(:document, :atom_mapping, :groups, keyword_init: true)
|
|
33
|
+
|
|
34
|
+
def self.to_canonical(formula)
|
|
35
|
+
ToCanonical.new(formula).build
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the canonical document plus the per-atom mapping and
|
|
39
|
+
# per-molecule group structure. Use this when you need to know
|
|
40
|
+
# which canonical atom corresponds to which AsciiChem::Model::Atom
|
|
41
|
+
# or which atoms were originally grouped (e.g. for extension data).
|
|
42
|
+
def self.to_canonical_with_mapping(formula)
|
|
43
|
+
builder = ToCanonical.new(formula)
|
|
44
|
+
document = builder.build
|
|
45
|
+
Translation.new(
|
|
46
|
+
document: document,
|
|
47
|
+
atom_mapping: builder.atom_mapping,
|
|
48
|
+
groups: builder.groups
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from_canonical(document)
|
|
53
|
+
FromCanonical.new(document).build
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
class Parser
|
|
5
|
+
attr_reader :text
|
|
6
|
+
|
|
7
|
+
def initialize(text)
|
|
8
|
+
@text = text.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def parse
|
|
12
|
+
tree = AsciiChem::Grammar.new.parse(text)
|
|
13
|
+
AsciiChem::Transform.new.apply(tree)
|
|
14
|
+
rescue Parslet::ParseFailed => e
|
|
15
|
+
raise AsciiChem::ParseError, format_error(e)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def format_error(error)
|
|
21
|
+
pos = error.message.match(/char (\d+)/)
|
|
22
|
+
char = pos ? pos[1].to_i : 0
|
|
23
|
+
snippet = text[[char - 10, 0].max, 20] || text
|
|
24
|
+
pointer = " " * (char - [char - 10, 0].max) + "^"
|
|
25
|
+
expected = extract_expected(error.message)
|
|
26
|
+
"Parse error at char #{char}: expected #{expected}\n" \
|
|
27
|
+
" ...#{snippet}...\n" \
|
|
28
|
+
" #{pointer}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def extract_expected(message)
|
|
32
|
+
match = message.match(/Expected:? (.+?)(?:\s+at|\s*$)/)
|
|
33
|
+
match ? match[1].strip : "valid AsciiChem syntax"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Periodic table as a single source of truth for element data.
|
|
5
|
+
# Used by linter checks (element validation, isotope sanity, valence)
|
|
6
|
+
# and any future code that needs chemically-grounded defaults.
|
|
7
|
+
#
|
|
8
|
+
# Adding a new field (e.g. covalent radius for layout, common
|
|
9
|
+
# oxidation states for redox checks) means adding one column to
|
|
10
|
+
# `Element` and populating it across `ELEMENTS` — no other code
|
|
11
|
+
# changes.
|
|
12
|
+
module PeriodicTable
|
|
13
|
+
Element = Struct.new(:symbol, :atomic_number, :max_valence, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Subset covering the elements chemistry most commonly deals with.
|
|
16
|
+
# Each entry: symbol => Element. Adding more elements is one
|
|
17
|
+
# line per element; the linter picks them up automatically.
|
|
18
|
+
ELEMENTS = {
|
|
19
|
+
# Period 1
|
|
20
|
+
"H" => Element.new(symbol: "H", atomic_number: 1, max_valence: 1),
|
|
21
|
+
"He" => Element.new(symbol: "He", atomic_number: 2, max_valence: 0),
|
|
22
|
+
# Period 2
|
|
23
|
+
"Li" => Element.new(symbol: "Li", atomic_number: 3, max_valence: 1),
|
|
24
|
+
"Be" => Element.new(symbol: "Be", atomic_number: 4, max_valence: 2),
|
|
25
|
+
"B" => Element.new(symbol: "B", atomic_number: 5, max_valence: 3),
|
|
26
|
+
"C" => Element.new(symbol: "C", atomic_number: 6, max_valence: 4),
|
|
27
|
+
"N" => Element.new(symbol: "N", atomic_number: 7, max_valence: 3),
|
|
28
|
+
"O" => Element.new(symbol: "O", atomic_number: 8, max_valence: 2),
|
|
29
|
+
"F" => Element.new(symbol: "F", atomic_number: 9, max_valence: 1),
|
|
30
|
+
"Ne" => Element.new(symbol: "Ne", atomic_number: 10, max_valence: 0),
|
|
31
|
+
# Period 3
|
|
32
|
+
"Na" => Element.new(symbol: "Na", atomic_number: 11, max_valence: 1),
|
|
33
|
+
"Mg" => Element.new(symbol: "Mg", atomic_number: 12, max_valence: 2),
|
|
34
|
+
"Al" => Element.new(symbol: "Al", atomic_number: 13, max_valence: 3),
|
|
35
|
+
"Si" => Element.new(symbol: "Si", atomic_number: 14, max_valence: 4),
|
|
36
|
+
"P" => Element.new(symbol: "P", atomic_number: 15, max_valence: 5),
|
|
37
|
+
"S" => Element.new(symbol: "S", atomic_number: 16, max_valence: 6),
|
|
38
|
+
"Cl" => Element.new(symbol: "Cl", atomic_number: 17, max_valence: 1),
|
|
39
|
+
"Ar" => Element.new(symbol: "Ar", atomic_number: 18, max_valence: 0),
|
|
40
|
+
# Period 4 (common)
|
|
41
|
+
"K" => Element.new(symbol: "K", atomic_number: 19, max_valence: 1),
|
|
42
|
+
"Ca" => Element.new(symbol: "Ca", atomic_number: 20, max_valence: 2),
|
|
43
|
+
"Sc" => Element.new(symbol: "Sc", atomic_number: 21, max_valence: 3),
|
|
44
|
+
"Ti" => Element.new(symbol: "Ti", atomic_number: 22, max_valence: 4),
|
|
45
|
+
"V" => Element.new(symbol: "V", atomic_number: 23, max_valence: 5),
|
|
46
|
+
"Cr" => Element.new(symbol: "Cr", atomic_number: 24, max_valence: 6),
|
|
47
|
+
"Mn" => Element.new(symbol: "Mn", atomic_number: 25, max_valence: 7),
|
|
48
|
+
"Fe" => Element.new(symbol: "Fe", atomic_number: 26, max_valence: 6),
|
|
49
|
+
"Co" => Element.new(symbol: "Co", atomic_number: 27, max_valence: 4),
|
|
50
|
+
"Ni" => Element.new(symbol: "Ni", atomic_number: 28, max_valence: 4),
|
|
51
|
+
"Cu" => Element.new(symbol: "Cu", atomic_number: 29, max_valence: 4),
|
|
52
|
+
"Zn" => Element.new(symbol: "Zn", atomic_number: 30, max_valence: 2),
|
|
53
|
+
"Ga" => Element.new(symbol: "Ga", atomic_number: 31, max_valence: 3),
|
|
54
|
+
"Ge" => Element.new(symbol: "Ge", atomic_number: 32, max_valence: 4),
|
|
55
|
+
"As" => Element.new(symbol: "As", atomic_number: 33, max_valence: 3),
|
|
56
|
+
"Se" => Element.new(symbol: "Se", atomic_number: 34, max_valence: 2),
|
|
57
|
+
"Br" => Element.new(symbol: "Br", atomic_number: 35, max_valence: 1),
|
|
58
|
+
"Kr" => Element.new(symbol: "Kr", atomic_number: 36, max_valence: 0),
|
|
59
|
+
# Period 5 (common)
|
|
60
|
+
"Rb" => Element.new(symbol: "Rb", atomic_number: 37, max_valence: 1),
|
|
61
|
+
"Sr" => Element.new(symbol: "Sr", atomic_number: 38, max_valence: 2),
|
|
62
|
+
"Y" => Element.new(symbol: "Y", atomic_number: 39, max_valence: 3),
|
|
63
|
+
"Zr" => Element.new(symbol: "Zr", atomic_number: 40, max_valence: 4),
|
|
64
|
+
"Nb" => Element.new(symbol: "Nb", atomic_number: 41, max_valence: 5),
|
|
65
|
+
"Mo" => Element.new(symbol: "Mo", atomic_number: 42, max_valence: 6),
|
|
66
|
+
"Tc" => Element.new(symbol: "Tc", atomic_number: 43, max_valence: 6),
|
|
67
|
+
"Ru" => Element.new(symbol: "Ru", atomic_number: 44, max_valence: 6),
|
|
68
|
+
"Rh" => Element.new(symbol: "Rh", atomic_number: 45, max_valence: 6),
|
|
69
|
+
"Pd" => Element.new(symbol: "Pd", atomic_number: 46, max_valence: 4),
|
|
70
|
+
"Ag" => Element.new(symbol: "Ag", atomic_number: 47, max_valence: 4),
|
|
71
|
+
"Cd" => Element.new(symbol: "Cd", atomic_number: 48, max_valence: 2),
|
|
72
|
+
"In" => Element.new(symbol: "In", atomic_number: 49, max_valence: 3),
|
|
73
|
+
"Sn" => Element.new(symbol: "Sn", atomic_number: 50, max_valence: 4),
|
|
74
|
+
"Sb" => Element.new(symbol: "Sb", atomic_number: 51, max_valence: 3),
|
|
75
|
+
"Te" => Element.new(symbol: "Te", atomic_number: 52, max_valence: 2),
|
|
76
|
+
"I" => Element.new(symbol: "I", atomic_number: 53, max_valence: 1),
|
|
77
|
+
"Xe" => Element.new(symbol: "Xe", atomic_number: 54, max_valence: 0),
|
|
78
|
+
# Period 6 (common)
|
|
79
|
+
"Cs" => Element.new(symbol: "Cs", atomic_number: 55, max_valence: 1),
|
|
80
|
+
"Ba" => Element.new(symbol: "Ba", atomic_number: 56, max_valence: 2),
|
|
81
|
+
"W" => Element.new(symbol: "W", atomic_number: 74, max_valence: 6),
|
|
82
|
+
"Pt" => Element.new(symbol: "Pt", atomic_number: 78, max_valence: 4),
|
|
83
|
+
"Au" => Element.new(symbol: "Au", atomic_number: 79, max_valence: 6),
|
|
84
|
+
"Hg" => Element.new(symbol: "Hg", atomic_number: 80, max_valence: 2),
|
|
85
|
+
"Tl" => Element.new(symbol: "Tl", atomic_number: 81, max_valence: 3),
|
|
86
|
+
"Pb" => Element.new(symbol: "Pb", atomic_number: 82, max_valence: 4),
|
|
87
|
+
"Bi" => Element.new(symbol: "Bi", atomic_number: 83, max_valence: 3),
|
|
88
|
+
# Period 7 (common)
|
|
89
|
+
"U" => Element.new(symbol: "U", atomic_number: 92, max_valence: 6)
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
def self.element(symbol)
|
|
93
|
+
ELEMENTS[symbol.to_s]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.known?(symbol)
|
|
97
|
+
ELEMENTS.key?(symbol.to_s)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.atomic_number(symbol)
|
|
101
|
+
element(symbol.to_s)&.atomic_number
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.max_valence(symbol)
|
|
105
|
+
element(symbol.to_s)&.max_valence
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.symbols
|
|
109
|
+
ELEMENTS.keys
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|