asciichem 0.31.3 → 0.31.4
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/CHANGELOG.md +35 -0
- data/README.adoc +22 -0
- data/lib/asciichem/formatter/text.rb +11 -1
- data/lib/asciichem/layout.rb +30 -3
- data/lib/asciichem/mechanism_graph.rb +3 -2
- data/lib/asciichem/model/mechanism.rb +11 -2
- data/lib/asciichem/perception.rb +20 -10
- data/lib/asciichem/smiles/writer.rb +36 -5
- data/lib/asciichem/transform_rules.rb +33 -2
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem/wire/extended.rb +11 -0
- data/lib/asciichem/wire_adapter.rb +37 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9c3f297f95612987dbe2a4e7f15bcaa0d4628dbae0d074dce5aeea2fb5e0efa
|
|
4
|
+
data.tar.gz: 75a1ef4ea31451fccfb37369299d86afa1a08b26c825054de2eda109c636a561
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cba5c440d3da6c3a436b207d36203f07641ad1b76c61ade69ac5e39697c1b79a81374f7e7106a5764611b3f400f28f9dae89a7054a70c4124b6c21da0d6f8c8a
|
|
7
|
+
data.tar.gz: e7cb123f63497d962297ea6629e684a0b8af6417c494a7332e02a9729585e977a0c02e2acdd6ee7eb99868fa70475b9a8e57e748eb38fde789957c66a8dc2afa
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,41 @@
|
|
|
3
3
|
All notable changes to AsciiChem are documented here.
|
|
4
4
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.31.4] - 2026-10-20
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- Canonical SMILES now consumes the perception pass (item 2): atoms
|
|
10
|
+
in perceived aromatic rings are written lowercase with implicit ring
|
|
11
|
+
bonds, so Kekulé input canonicalizes — `C1=CC=CC=C1` → `c1ccccc1`,
|
|
12
|
+
aspirin → `CC(=O)Oc1ccccc1C(=O)O`. Canonical-form stability is a
|
|
13
|
+
fixed point (`write(parse(write(x))) == write(x)`); composition and
|
|
14
|
+
connectivity are preserved. Perception's sp2 rule now requires the
|
|
15
|
+
double-bond partner to itself be in a ring, keeping quinone rings
|
|
16
|
+
Kekulé — the guard that makes aromatic canonicalization chemically
|
|
17
|
+
safe. Corpus contract 0.7.0 (`canonicalSmiles` fixture field).
|
|
18
|
+
- `Molecule#stoichiometric_coefficient` is the single coefficient
|
|
19
|
+
rule shared by `Reaction#stoichiometry`, `BalanceCheck`, and
|
|
20
|
+
`element_counts`.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Mechanism electron-flow markers (item 4, slice 4c): a step value may
|
|
24
|
+
carry `| flow: SOURCE > TARGET` clauses (semicolon-separated),
|
|
25
|
+
typed as `Mechanism::Flow`, carried by `MechanismGraph`, emitted on
|
|
26
|
+
the wire (`MechanismStep.flows`, asciichem-model v1 additive), and
|
|
27
|
+
re-emitted in canonical text. Malformed clauses raise; nothing is
|
|
28
|
+
dropped.
|
|
29
|
+
- Depiction backends (item 7): `AsciiChem::Layout.backend` lets a
|
|
30
|
+
depiction engine (RDKit, CDK, a Rust layout core) replace the
|
|
31
|
+
built-in elkrb pipeline without touching the renderer or the model.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- Mechanism wire emission: the adapter passed raw step/spectator
|
|
35
|
+
strings to the reaction serializer, so `Mechanism#to_model_json`
|
|
36
|
+
had never worked. Steps and spectators are now parsed with the same
|
|
37
|
+
grammar; spectators that are not valid AsciiChem raise clearly.
|
|
38
|
+
- Test isolation: the CLI `--engine parsanol` spec leaked the engine
|
|
39
|
+
selection into later examples.
|
|
40
|
+
|
|
6
41
|
## [0.31.3] - 2026-10-20
|
|
7
42
|
|
|
8
43
|
### Added
|
data/README.adoc
CHANGED
|
@@ -45,6 +45,28 @@ formula.to_mathml # => "<math>...</math>"
|
|
|
45
45
|
formula.to_text # => "H_2O" (round trip)
|
|
46
46
|
----
|
|
47
47
|
|
|
48
|
+
== Conformance
|
|
49
|
+
|
|
50
|
+
The reference implementation of https://www.asciichem.org[the AsciiChem
|
|
51
|
+
contract]. Every claim is gated by the shared corpus
|
|
52
|
+
(https://github.com/asciichem/asciichem-tests[asciichem-tests]) in CI —
|
|
53
|
+
a level without the green corpus run behind it does not ship.
|
|
54
|
+
|
|
55
|
+
[cols="1,1,4"]
|
|
56
|
+
|===
|
|
57
|
+
|Level |Claim |Basis
|
|
58
|
+
|
|
59
|
+
|L0 notation |yes |parse/reject contract; every `roundTrip` fixture returns its input
|
|
60
|
+
|L1 semantics |yes |wire form validates against the v1 schemas and round-trips; the perception pass (implicit hydrogens, aromatic rings) is corpus-gated
|
|
61
|
+
|L2 render |yes |MathML byte-goldens, canonical text, and the wire form for every construct
|
|
62
|
+
|L3 stereo |yes |tetrahedral chirality, directional bonds, wedge/hash; parity-correct canonical SMILES including ring closures
|
|
63
|
+
|L4 identity |partial |opt-in InChI engine with stereo layers, OPSIN adapter, identifier cross-check, citation bibitems; CAS source decision pending
|
|
64
|
+
|===
|
|
65
|
+
|
|
66
|
+
TypeScript and Python claim the same levels they pass; see their
|
|
67
|
+
READMEs and the https://www.asciichem.org/guides/conformance-levels/[levels
|
|
68
|
+
document].
|
|
69
|
+
|
|
48
70
|
== Design
|
|
49
71
|
|
|
50
72
|
See link:ARCHITECTURE.adoc[] and the specification at
|
|
@@ -199,13 +199,23 @@ module AsciiChem
|
|
|
199
199
|
def visit_mechanism(mech)
|
|
200
200
|
parts = ["mechanism"]
|
|
201
201
|
unless mech.steps.empty? && mech.spectators.empty?
|
|
202
|
-
lines = mech.steps.map { |s| "#{s.label}: #{s
|
|
202
|
+
lines = mech.steps.map { |s| "#{s.label}: #{mechanism_step_text(s)}" }
|
|
203
203
|
mech.spectators.each { |sp| lines << "spectator: #{sp}" }
|
|
204
204
|
parts << "{\n #{lines.join("\n ")}\n}"
|
|
205
205
|
end
|
|
206
206
|
parts.join
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
+
# Reaction plus its electron-flow clauses in canonical form:
|
|
210
|
+
# ` | flow: SRC > DST; SRC2 > DST2`.
|
|
211
|
+
def mechanism_step_text(step)
|
|
212
|
+
flows = step.flows
|
|
213
|
+
return step.reaction if flows.nil? || flows.empty?
|
|
214
|
+
|
|
215
|
+
arrows = flows.map { |f| "#{f.source} > #{f.target}" }.join('; ')
|
|
216
|
+
"#{step.reaction} | flow: #{arrows}"
|
|
217
|
+
end
|
|
218
|
+
|
|
209
219
|
def visit_opaque_cml(opaque)
|
|
210
220
|
%(<!-- opaque: #{opaque.element_name} -->)
|
|
211
221
|
end
|
data/lib/asciichem/layout.rb
CHANGED
|
@@ -46,13 +46,40 @@ module AsciiChem
|
|
|
46
46
|
Result.new(atoms: [], bonds: [], width: 0.0, height: 0.0)
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
49
|
+
# The built-in backend: elkrb layered layout (or authored
|
|
50
|
+
# coordinates when present).
|
|
51
|
+
class BuiltinBackend
|
|
52
|
+
def layout(molecule, algorithm: 'layered')
|
|
53
|
+
Layout.builtin_layout(molecule, algorithm: algorithm)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# Depictions are views: a backend maps a molecule to a
|
|
59
|
+
# Layout::Result and the renderer never learns which engine
|
|
60
|
+
# placed the atoms. The built-in elkrb backend is the default;
|
|
61
|
+
# external backends (RDKit, CDK, a Rust layout core) plug in
|
|
62
|
+
# with `AsciiChem::Layout.backend = backend`, where backend
|
|
63
|
+
# responds to `layout(molecule, algorithm:)`.
|
|
64
|
+
attr_writer :backend
|
|
65
|
+
|
|
66
|
+
def backend
|
|
67
|
+
@backend ||= BuiltinBackend.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compute 2D positions through the configured backend.
|
|
71
|
+
def layout(molecule, algorithm: 'layered')
|
|
72
|
+
backend.layout(molecule, algorithm: algorithm)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Compute 2D positions for a molecule's atoms and bonds with the
|
|
77
|
+
# built-in elkrb pipeline. Algorithm defaults to `layered`
|
|
51
78
|
# (Sugiyama-style hierarchical) which is deterministic across
|
|
52
79
|
# runs — essential for visual regression testing. Pass
|
|
53
80
|
# `algorithm: "force"` for organic-looking layouts, with the
|
|
54
81
|
# caveat that output may vary between runs.
|
|
55
|
-
def self.
|
|
82
|
+
def self.builtin_layout(molecule, algorithm: 'layered')
|
|
56
83
|
walker = MoleculeWalker.new(molecule)
|
|
57
84
|
walk = walker.walk
|
|
58
85
|
return empty_result if walk.atoms.empty?
|
|
@@ -12,7 +12,7 @@ module AsciiChem
|
|
|
12
12
|
# a reaction (prose, "proton transfer") keeps a nil reaction and
|
|
13
13
|
# takes part in no links — never an error.
|
|
14
14
|
module MechanismGraph
|
|
15
|
-
Step = Struct.new(:label, :reaction, :catalysts, keyword_init: true)
|
|
15
|
+
Step = Struct.new(:label, :reaction, :catalysts, :flows, keyword_init: true)
|
|
16
16
|
Link = Struct.new(:from, :to, :species, keyword_init: true)
|
|
17
17
|
Graph = Struct.new(:steps, :links, keyword_init: true)
|
|
18
18
|
|
|
@@ -27,7 +27,8 @@ module AsciiChem
|
|
|
27
27
|
reaction = parse_reaction(step.reaction)
|
|
28
28
|
sides = sides_of(reaction)
|
|
29
29
|
catalysts = sides ? sides[:reactants].keys & sides[:products].keys : []
|
|
30
|
-
Step.new(label: step.label, reaction: reaction, catalysts: catalysts
|
|
30
|
+
Step.new(label: step.label, reaction: reaction, catalysts: catalysts,
|
|
31
|
+
flows: step.flows || [])
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def links(steps)
|
|
@@ -7,12 +7,21 @@ module AsciiChem
|
|
|
7
7
|
# Syntax:
|
|
8
8
|
# mechanism{
|
|
9
9
|
# step1: A + B -> C
|
|
10
|
-
# step2: C -> D + E
|
|
10
|
+
# step2: C -> D + E | flow: C > D; C-B > D
|
|
11
11
|
# spectator: Na+
|
|
12
12
|
# }
|
|
13
|
+
#
|
|
14
|
+
# A step's value is its reaction, optionally followed by
|
|
15
|
+
# `| flow: SOURCE > TARGET` clauses (semicolon-separated) naming
|
|
16
|
+
# the electron-flow arrows drawn for that step. Sources/targets
|
|
17
|
+
# stay verbatim strings: an atom-level binding needs references
|
|
18
|
+
# the mechanism text does not yet carry.
|
|
13
19
|
class Mechanism < Node
|
|
20
|
+
# One electron-flow marker within a step.
|
|
21
|
+
Flow = Struct.new(:source, :target, keyword_init: true)
|
|
22
|
+
|
|
14
23
|
# A single step in the mechanism, identified by a label.
|
|
15
|
-
Step = Struct.new(:label, :reaction, keyword_init: true)
|
|
24
|
+
Step = Struct.new(:label, :reaction, :flows, keyword_init: true)
|
|
16
25
|
|
|
17
26
|
attr_accessor :steps, :spectators
|
|
18
27
|
|
data/lib/asciichem/perception.rb
CHANGED
|
@@ -91,23 +91,33 @@ module AsciiChem
|
|
|
91
91
|
.select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# Six-membered cycles where every atom is sp2
|
|
95
|
-
# double bond
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
94
|
+
# Six-membered cycles where every atom is sp2 — participates in a
|
|
95
|
+
# double bond to an atom that is itself in a ring, or is
|
|
96
|
+
# aromatic-flagged. The partner-in-a-ring requirement keeps
|
|
97
|
+
# quinones out: their carbons are sp2, but the double bonds that
|
|
98
|
+
# make them so point at exocyclic oxygens, and writing such a
|
|
99
|
+
# ring as aromatic SMILES would state the wrong bond orders. A
|
|
100
|
+
# fused system still qualifies — naphthalene's fusion carbons
|
|
101
|
+
# double-bond to atoms of the neighbouring ring. Per-ring strict
|
|
102
|
+
# alternation can never see both rings (the shared bond is
|
|
103
|
+
# single); per-atom sp2-in-the-system does. Full Hückel
|
|
104
|
+
# pi-electron counting is a later slice.
|
|
100
105
|
def aromatic_rings(atoms, edges)
|
|
101
106
|
cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
|
|
102
|
-
|
|
103
|
-
cycles.select { |cycle| cycle.
|
|
107
|
+
cyclic_atoms = cycles.flatten.uniq
|
|
108
|
+
six = cycles.select { |cycle| cycle.length == 6 }
|
|
109
|
+
six.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges, cyclic_atoms) } }
|
|
104
110
|
end
|
|
105
111
|
|
|
106
|
-
def sp2?(atom_index, atoms, edges)
|
|
112
|
+
def sp2?(atom_index, atoms, edges, cyclic_atoms)
|
|
107
113
|
return true if atoms[atom_index].aromatic == true
|
|
108
114
|
|
|
109
115
|
edges.any? do |edge|
|
|
110
|
-
|
|
116
|
+
next false unless edge.from == atom_index || edge.to == atom_index
|
|
117
|
+
next false unless edge.kind == :double
|
|
118
|
+
|
|
119
|
+
partner = edge.from == atom_index ? edge.to : edge.from
|
|
120
|
+
cyclic_atoms.include?(partner)
|
|
111
121
|
end
|
|
112
122
|
end
|
|
113
123
|
|
|
@@ -36,6 +36,7 @@ module AsciiChem
|
|
|
36
36
|
raise ParseError, 'molecule has no bonds — a formula is not a structure' if edges.empty? && atoms.length > 1
|
|
37
37
|
|
|
38
38
|
@atoms = atoms
|
|
39
|
+
perceive_aromaticity(atoms, edges)
|
|
39
40
|
build_adjacency(edges)
|
|
40
41
|
@visited = {}
|
|
41
42
|
@digits = Array.new(atoms.length) { [] }
|
|
@@ -66,6 +67,35 @@ module AsciiChem
|
|
|
66
67
|
|
|
67
68
|
private
|
|
68
69
|
|
|
70
|
+
# The canonicalizer consumes the perception pass (TODO.v2/13
|
|
71
|
+
# item 2): atoms in perceived aromatic rings are written as
|
|
72
|
+
# aromatic (lowercase) and the ring bonds between them carry no
|
|
73
|
+
# bond symbol — Kekulé benzene canonicalizes to `c1ccccc1`, so
|
|
74
|
+
# both spellings converge. Perception's in-ring double-bond
|
|
75
|
+
# rule keeps quinone-like rings out.
|
|
76
|
+
def perceive_aromaticity(atoms, edges)
|
|
77
|
+
@perceived_aromatic = {}
|
|
78
|
+
@perceived_ring_edges = {}
|
|
79
|
+
AsciiChem::Perception.aromatic_rings(atoms, edges).each do |ring|
|
|
80
|
+
ring.each { |index| @perceived_aromatic[index] = true }
|
|
81
|
+
ring.each_with_index do |atom, i|
|
|
82
|
+
other = ring[(i + 1) % ring.length]
|
|
83
|
+
@perceived_ring_edges[[atom, other].minmax] = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Aromatic for WRITING: the source flagged the atom, or
|
|
89
|
+
# perception found it in an aromatic ring.
|
|
90
|
+
def aromatic?(index)
|
|
91
|
+
@atoms[index].aromatic == true || @perceived_aromatic.key?(index)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def perceived_ring_bond?(from, to)
|
|
95
|
+
@perceived_ring_edges.key?([from, to].minmax) &&
|
|
96
|
+
aromatic?(from) && aromatic?(to)
|
|
97
|
+
end
|
|
98
|
+
|
|
69
99
|
def build_adjacency(edges)
|
|
70
100
|
@adjacency = Array.new(@atoms.length) { {} }
|
|
71
101
|
@directions = {}
|
|
@@ -205,6 +235,11 @@ module AsciiChem
|
|
|
205
235
|
token = BOND_TOKENS[kind]
|
|
206
236
|
raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
|
|
207
237
|
|
|
238
|
+
# A bond inside a perceived aromatic ring is implied by the
|
|
239
|
+
# lowercase atoms: writing `=`/`-` there would state Kekulé
|
|
240
|
+
# alternation the canonical form does not carry.
|
|
241
|
+
return '' if perceived_ring_bond?(from, to)
|
|
242
|
+
|
|
208
243
|
return token if kind != :single && kind != :aromatic
|
|
209
244
|
|
|
210
245
|
if kind == :single
|
|
@@ -232,7 +267,7 @@ module AsciiChem
|
|
|
232
267
|
return @plain_tokens[index] if !stereo && @plain_tokens[index]
|
|
233
268
|
|
|
234
269
|
atom = @atoms[index]
|
|
235
|
-
aromatic =
|
|
270
|
+
aromatic = aromatic?(index)
|
|
236
271
|
lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
|
|
237
272
|
needs_bracket =
|
|
238
273
|
atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
|
|
@@ -271,10 +306,6 @@ module AsciiChem
|
|
|
271
306
|
|
|
272
307
|
"#{sign}#{count}"
|
|
273
308
|
end
|
|
274
|
-
|
|
275
|
-
def aromatic?(index)
|
|
276
|
-
@atoms[index].aromatic == true
|
|
277
|
-
end
|
|
278
309
|
end
|
|
279
310
|
end
|
|
280
311
|
end
|
|
@@ -441,13 +441,44 @@ module AsciiChem
|
|
|
441
441
|
if key == 'spectator'
|
|
442
442
|
spectators.concat(value.split(/\s+/).map(&:strip))
|
|
443
443
|
else
|
|
444
|
-
|
|
444
|
+
reaction, flows = split_flows(key, value)
|
|
445
|
+
steps << Model::Mechanism::Step.new(label: key, reaction: reaction, flows: flows)
|
|
445
446
|
end
|
|
446
447
|
end
|
|
447
448
|
Model::Mechanism.new(steps: steps, spectators: spectators)
|
|
448
449
|
end
|
|
449
|
-
|
|
450
|
+
|
|
450
451
|
private
|
|
452
|
+
|
|
453
|
+
# A step value is its reaction, optionally followed by
|
|
454
|
+
# `| flow: SOURCE > TARGET` clauses (semicolon-separated).
|
|
455
|
+
# Unknown clause kinds are rejected, never dropped.
|
|
456
|
+
def split_flows(label, value)
|
|
457
|
+
clauses = value.split('|').map(&:strip)
|
|
458
|
+
reaction = clauses.shift
|
|
459
|
+
flows = clauses.flat_map { |clause| parse_flow_clause(label, clause) }
|
|
460
|
+
[reaction, flows]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def parse_flow_clause(label, clause)
|
|
464
|
+
kind, payload = clause.split(':', 2)
|
|
465
|
+
unless kind&.strip == 'flow' && payload
|
|
466
|
+
raise AsciiChem::ParseError,
|
|
467
|
+
"mechanism step #{label.inspect} has an unknown clause: #{clause.inspect} " \
|
|
468
|
+
'(supported: "flow: SOURCE > TARGET")'
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
payload.split(';').map(&:strip).reject(&:empty?).map do |arrow|
|
|
472
|
+
source, target = arrow.split('>', 2).map(&:strip)
|
|
473
|
+
if source.nil? || target.nil? || source.empty? || target.empty?
|
|
474
|
+
raise AsciiChem::ParseError,
|
|
475
|
+
"mechanism step #{label.inspect} has a malformed flow: #{arrow.inspect} " \
|
|
476
|
+
'(expected "SOURCE > TARGET")'
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
Model::Mechanism::Flow.new(source: source, target: target)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
451
482
|
|
|
452
483
|
def parse_entries(str)
|
|
453
484
|
return [] unless str
|
data/lib/asciichem/version.rb
CHANGED
|
@@ -6,12 +6,23 @@ module AsciiChem
|
|
|
6
6
|
# zmatrix, calculation. Emission covers the full v1 schemas;
|
|
7
7
|
# ingestion (from_wire) is currently implemented for the core
|
|
8
8
|
# chemistry set only — see WireAdapter.
|
|
9
|
+
class MechanismFlow < Lutaml::Model::Serializable
|
|
10
|
+
attribute :source, :string
|
|
11
|
+
attribute :target, :string
|
|
12
|
+
json do
|
|
13
|
+
map "source", to: :source
|
|
14
|
+
map "target", to: :target
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
class MechanismStep < Lutaml::Model::Serializable
|
|
10
19
|
attribute :label, :string
|
|
11
20
|
attribute :reaction, "AsciiChem::Wire::Reaction"
|
|
21
|
+
attribute :flows, MechanismFlow, collection: true
|
|
12
22
|
json do
|
|
13
23
|
map "label", to: :label
|
|
14
24
|
map "reaction", to: :reaction
|
|
25
|
+
map "flows", to: :flows
|
|
15
26
|
end
|
|
16
27
|
end
|
|
17
28
|
|
|
@@ -236,13 +236,46 @@ module AsciiChem
|
|
|
236
236
|
def mechanism_to_wire(node)
|
|
237
237
|
AsciiChem::Wire::Mechanism.new(
|
|
238
238
|
type: "mechanism",
|
|
239
|
-
steps: node.steps.map
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
239
|
+
steps: node.steps.map { |step| mechanism_step_to_wire(step) },
|
|
240
|
+
spectators: node.spectators.map { |sp| mechanism_spectator_to_wire(sp) }
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Spectators are verbatim strings in the model (the mechanism
|
|
245
|
+
# body is text); the wire wants molecules, so the same grammar
|
|
246
|
+
# parses them here — a spectator that is not valid AsciiChem
|
|
247
|
+
# cannot reach the wire and says so.
|
|
248
|
+
def mechanism_spectator_to_wire(text)
|
|
249
|
+
molecule_to_wire(AsciiChem.parse(text).nodes.first)
|
|
250
|
+
rescue AsciiChem::ParseError
|
|
251
|
+
raise AsciiChem::ParseError,
|
|
252
|
+
"mechanism spectator is not valid AsciiChem: #{text.inspect}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def mechanism_step_to_wire(step)
|
|
256
|
+
AsciiChem::Wire::MechanismStep.new(
|
|
257
|
+
label: step.label,
|
|
258
|
+
reaction: mechanism_reaction_to_wire(step.reaction),
|
|
259
|
+
flows: (step.flows || []).map do |flow|
|
|
260
|
+
AsciiChem::Wire::MechanismFlow.new(source: flow.source, target: flow.target)
|
|
261
|
+
end
|
|
243
262
|
)
|
|
244
263
|
end
|
|
245
264
|
|
|
265
|
+
# The model keeps a step's reaction as TEXT (the mechanism body
|
|
266
|
+
# is verbatim); the wire carries the typed node, so the same
|
|
267
|
+
# grammar parses it here.
|
|
268
|
+
def mechanism_reaction_to_wire(text)
|
|
269
|
+
node = AsciiChem.parse(text).nodes.first
|
|
270
|
+
case node
|
|
271
|
+
when AsciiChem::Model::Reaction then reaction_to_wire(node)
|
|
272
|
+
when AsciiChem::Model::ReactionCascade then cascade_to_wire(node)
|
|
273
|
+
else
|
|
274
|
+
raise AsciiChem::ParseError,
|
|
275
|
+
"mechanism step is not a reaction: #{text.inspect}"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
246
279
|
def spectrum_to_wire(node)
|
|
247
280
|
AsciiChem::Wire::Spectrum.new(
|
|
248
281
|
type: "spectrum",
|