asciichem 0.31.2 → 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 +73 -0
- data/README.adoc +22 -0
- data/lib/asciichem/formatter/text.rb +11 -1
- data/lib/asciichem/layout.rb +30 -3
- data/lib/asciichem/linter/balance_check.rb +1 -2
- data/lib/asciichem/mechanism_graph.rb +73 -0
- data/lib/asciichem/model/mechanism.rb +11 -2
- data/lib/asciichem/model/molecule.rb +11 -5
- data/lib/asciichem/model/reaction.rb +42 -3
- data/lib/asciichem/molfile/parser.rb +17 -1
- data/lib/asciichem/molfile/writer.rb +20 -3
- data/lib/asciichem/perception.rb +54 -19
- data/lib/asciichem/reaction_roles.rb +35 -19
- data/lib/asciichem/rinchi/binary_engine.rb +55 -0
- data/lib/asciichem/rinchi/identity.rb +10 -0
- data/lib/asciichem/rinchi.rb +36 -0
- data/lib/asciichem/rxn/writer.rb +29 -0
- data/lib/asciichem/rxn.rb +18 -0
- data/lib/asciichem/smiles/writer.rb +47 -11
- 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
- data/lib/asciichem.rb +3 -0
- metadata +7 -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,79 @@
|
|
|
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
|
+
|
|
41
|
+
## [0.31.3] - 2026-10-20
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- Perception in the shared corpus (TODO.v2/13 item 2, slice C):
|
|
45
|
+
implicit hydrogens order aromatic bonds at 1.5 (Hückel) and declare
|
|
46
|
+
a value only when a default valence fits integrally — so
|
|
47
|
+
`c1ccccc1` and `C1=CC=CC=C1` perceive identically, and
|
|
48
|
+
aromatic-schematic fusion atoms decline rather than invent. Gated
|
|
49
|
+
by `fixtures/perception.json` in all three implementations.
|
|
50
|
+
- Structured condition entries (item 4, slice 1):
|
|
51
|
+
`Conditions#entries` derives typed `cat:`/`solv:`/`reag:` entries
|
|
52
|
+
(semicolon-separated) from the verbatim bracket text; free text and
|
|
53
|
+
unknown prefixes stay lossless, and the wire form is unchanged.
|
|
54
|
+
`ReactionRoles` prefers explicit roles over the above/below
|
|
55
|
+
heuristic.
|
|
56
|
+
- Reaction stoichiometry as model data (slice 2):
|
|
57
|
+
`Reaction#stoichiometry` and `Molecule#stoichiometric_coefficient`
|
|
58
|
+
— one coefficient rule shared by the reaction, `BalanceCheck`, and
|
|
59
|
+
composition counting.
|
|
60
|
+
- Mechanism step graph (slice 4a): `MechanismGraph.for` derives
|
|
61
|
+
parsed steps, catalysts (species on both sides), and inter-step
|
|
62
|
+
links from the verbatim `mechanism{}` entries.
|
|
63
|
+
- Reaction identity via RInChI (slice 4b): `Rxn::Writer` emits MDL
|
|
64
|
+
RXN V2000 and `Rinchi::BinaryEngine` wraps the opt-in `rinchi-1`
|
|
65
|
+
binary (install guidance, `EngineMissingError`, nothing vendored).
|
|
66
|
+
- Molfile stereo (L3 slice 6): `Atom#chirality` crosses as MDL atom
|
|
67
|
+
parity and stated hydrogens as the H-count field, so the identity
|
|
68
|
+
pipe derives stereoisomer-distinct InChIs; `IdentityCrossCheck`
|
|
69
|
+
covers stereo-layer mismatches.
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
- SMILES writer ring-closure emission (#103): a closure-bearing
|
|
73
|
+
child continues the chain, so the digit lands at the end of the
|
|
74
|
+
traversal — `parse(write(parse(s))) == parse(s)` and writer
|
|
75
|
+
stability now hold for ring structures such as `C1=CCC(C)C(C)C1`.
|
|
76
|
+
- SMILES writer rendered stated zero hydrogens as a bare `H` (one);
|
|
77
|
+
it now emits `H0`.
|
|
78
|
+
|
|
6
79
|
## [0.31.2] - 2026-10-20
|
|
7
80
|
|
|
8
81
|
### 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?
|
|
@@ -51,11 +51,10 @@ module AsciiChem
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def count_molecule(molecule)
|
|
54
|
-
coefficient = molecule.coefficient.to_i
|
|
55
|
-
coefficient = 1 if coefficient.zero?
|
|
56
54
|
raw = enumerate(molecule, 1)
|
|
57
55
|
return nil if raw.nil?
|
|
58
56
|
|
|
57
|
+
coefficient = molecule.stoichiometric_coefficient
|
|
59
58
|
raw.transform_values { |v| v * coefficient }
|
|
60
59
|
end
|
|
61
60
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# The mechanism step graph (TODO.v2/13 item 4): `mechanism{}` entries
|
|
5
|
+
# stay verbatim (label + reaction text) in the model; this service
|
|
6
|
+
# derives the graph the text implies — each step's reaction parsed
|
|
7
|
+
# with the same grammar, species that appear on both sides of a step
|
|
8
|
+
# recognised as catalysts, and steps linked where a product species
|
|
9
|
+
# of an earlier step is a reactant of a later one.
|
|
10
|
+
#
|
|
11
|
+
# Everything is computed; nothing mutates. A step whose value is not
|
|
12
|
+
# a reaction (prose, "proton transfer") keeps a nil reaction and
|
|
13
|
+
# takes part in no links — never an error.
|
|
14
|
+
module MechanismGraph
|
|
15
|
+
Step = Struct.new(:label, :reaction, :catalysts, :flows, keyword_init: true)
|
|
16
|
+
Link = Struct.new(:from, :to, :species, keyword_init: true)
|
|
17
|
+
Graph = Struct.new(:steps, :links, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def for(mechanism)
|
|
22
|
+
steps = mechanism.steps.map { |step| graph_step(step) }
|
|
23
|
+
Graph.new(steps: steps, links: links(steps))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def graph_step(step)
|
|
27
|
+
reaction = parse_reaction(step.reaction)
|
|
28
|
+
sides = sides_of(reaction)
|
|
29
|
+
catalysts = sides ? sides[:reactants].keys & sides[:products].keys : []
|
|
30
|
+
Step.new(label: step.label, reaction: reaction, catalysts: catalysts,
|
|
31
|
+
flows: step.flows || [])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def links(steps)
|
|
35
|
+
steps.each_with_index.flat_map do |from, i|
|
|
36
|
+
next [] unless from.reaction
|
|
37
|
+
|
|
38
|
+
from_products = sides_of(from.reaction)[:products].keys
|
|
39
|
+
steps[(i + 1)..].filter_map { |to| link_between(from, to, from_products) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def link_between(from, to, from_products)
|
|
44
|
+
return nil unless to.reaction
|
|
45
|
+
|
|
46
|
+
shared = from_products & sides_of(to.reaction)[:reactants].keys
|
|
47
|
+
Link.new(from: from.label, to: to.label, species: shared) unless shared.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_reaction(text)
|
|
51
|
+
node = AsciiChem.parse(text).nodes.first
|
|
52
|
+
node if node.is_a?(AsciiChem::Model::Reaction) ||
|
|
53
|
+
node.is_a?(AsciiChem::Model::ReactionCascade)
|
|
54
|
+
rescue AsciiChem::ParseError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Coefficient-free species names per side; a cascade's entry side
|
|
59
|
+
# is its head and its exit side is its last step.
|
|
60
|
+
def sides_of(reaction)
|
|
61
|
+
case reaction
|
|
62
|
+
when AsciiChem::Model::Reaction
|
|
63
|
+
reaction.stoichiometry
|
|
64
|
+
when AsciiChem::Model::ReactionCascade
|
|
65
|
+
steps = reaction.steps
|
|
66
|
+
return nil if steps.empty?
|
|
67
|
+
|
|
68
|
+
{ reactants: steps.first.stoichiometry[:reactants],
|
|
69
|
+
products: steps.last.stoichiometry[:products] }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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
|
|
|
@@ -124,15 +124,21 @@ module AsciiChem
|
|
|
124
124
|
# describe the substance, not the stoichiometric multiplier.
|
|
125
125
|
def element_counts(with_coefficient: true)
|
|
126
126
|
tally = Hash.new(0)
|
|
127
|
-
multiplier = 1
|
|
128
|
-
if with_coefficient
|
|
129
|
-
own = coefficient&.to_i
|
|
130
|
-
multiplier = own if own && own.positive?
|
|
131
|
-
end
|
|
127
|
+
multiplier = with_coefficient ? stoichiometric_coefficient : 1
|
|
132
128
|
nodes.each { |node| tally_element(node, tally, multiplier) }
|
|
133
129
|
tally
|
|
134
130
|
end
|
|
135
131
|
|
|
132
|
+
# The integer stoichiometric coefficient: the authored leading
|
|
133
|
+
# number, or 1 when absent or not positive. Single source for
|
|
134
|
+
# every consumer (reaction stoichiometry, the balance check,
|
|
135
|
+
# composition) — coefficients are model data, not per-caller
|
|
136
|
+
# parsing.
|
|
137
|
+
def stoichiometric_coefficient
|
|
138
|
+
own = coefficient&.to_i
|
|
139
|
+
own && own.positive? ? own : 1
|
|
140
|
+
end
|
|
141
|
+
|
|
136
142
|
private
|
|
137
143
|
|
|
138
144
|
def tally_element(node, tally, multiplier)
|
|
@@ -5,8 +5,49 @@ module AsciiChem
|
|
|
5
5
|
# A chemical reaction: reactants, an arrow, and products. Conditions
|
|
6
6
|
# render above and below the arrow per IUPAC.
|
|
7
7
|
class Reaction < Node
|
|
8
|
+
# Conditions carry the bracket text verbatim (the round-trip
|
|
9
|
+
# source) and derive typed entries from it: `[cat: Pd]` is a
|
|
10
|
+
# catalyst entry, `[water]` stays free text. `;` separates
|
|
11
|
+
# entries inside one bracket; unknown role prefixes are free text
|
|
12
|
+
# (lossless — never an error).
|
|
8
13
|
Conditions = Struct.new(:above, :below, keyword_init: true)
|
|
9
14
|
|
|
15
|
+
class Conditions
|
|
16
|
+
ROLE_PREFIXES = {
|
|
17
|
+
'cat' => :catalyst, 'catalyst' => :catalyst,
|
|
18
|
+
'solv' => :solvent, 'solvent' => :solvent,
|
|
19
|
+
'reag' => :reagent, 'reagent' => :reagent
|
|
20
|
+
}.freeze
|
|
21
|
+
ENTRY_SEPARATOR = ';'
|
|
22
|
+
Entry = Struct.new(:role, :value, :side, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
def entries
|
|
25
|
+
[[:above, above], [:below, below]].flat_map do |side, text|
|
|
26
|
+
entries_for(side, text)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def entries_for(side, text)
|
|
33
|
+
return [] if text.nil? || text.strip.empty?
|
|
34
|
+
|
|
35
|
+
text.split(ENTRY_SEPARATOR).filter_map do |chunk|
|
|
36
|
+
chunk = chunk.strip
|
|
37
|
+
next if chunk.empty?
|
|
38
|
+
|
|
39
|
+
role, value = split_role(chunk)
|
|
40
|
+
Entry.new(role: role, value: value, side: side)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def split_role(chunk)
|
|
45
|
+
prefix, rest = chunk.split(':', 2)
|
|
46
|
+
role = ROLE_PREFIXES[prefix.strip.downcase] unless rest.nil?
|
|
47
|
+
role ? [role, rest.strip] : [nil, chunk]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
10
51
|
ARROWS = {
|
|
11
52
|
forward: { ascii: '->', mathml_entity: '→', wire: 'forward' },
|
|
12
53
|
reverse: { ascii: '<-', mathml_entity: '←', wire: 'reverse' },
|
|
@@ -72,9 +113,7 @@ module AsciiChem
|
|
|
72
113
|
|
|
73
114
|
def side_stoichiometry(side)
|
|
74
115
|
side.each_with_object(Hash.new(0)) do |molecule, out|
|
|
75
|
-
|
|
76
|
-
coefficient = 1 if coefficient.zero?
|
|
77
|
-
out[molecule.to_text.sub(/\A\d+/, '')] += coefficient
|
|
116
|
+
out[molecule.to_text.sub(/\A\d+/, '')] += molecule.stoichiometric_coefficient
|
|
78
117
|
end
|
|
79
118
|
end
|
|
80
119
|
end
|
|
@@ -73,7 +73,9 @@ module AsciiChem
|
|
|
73
73
|
Model::Atom.new(
|
|
74
74
|
element: element,
|
|
75
75
|
x2: x, y2: y, z2: z,
|
|
76
|
-
isotope: isotope_from_mass_diff(element, mass_diff)
|
|
76
|
+
isotope: isotope_from_mass_diff(element, mass_diff),
|
|
77
|
+
chirality: chirality_from_parity(line[39, 3].to_i),
|
|
78
|
+
hydrogens: hydrogens_from_hcount(line[42, 3].to_i)
|
|
77
79
|
)
|
|
78
80
|
end
|
|
79
81
|
end
|
|
@@ -153,6 +155,20 @@ module AsciiChem
|
|
|
153
155
|
def isotope_from_mass_diff(_element, mass_diff)
|
|
154
156
|
nil
|
|
155
157
|
end
|
|
158
|
+
|
|
159
|
+
# MDL atom parity: 1 odd, 2 even, 0/3 not stereo (or either).
|
|
160
|
+
# The parity is relative to the neighbour order in the bond
|
|
161
|
+
# block, which the linearizer preserves — the same ordering the
|
|
162
|
+
# SMILES TH1/TH2 convention uses.
|
|
163
|
+
def chirality_from_parity(parity)
|
|
164
|
+
{ 1 => 'TH1', 2 => 'TH2' }[parity]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# MDL H-count code: 0 default, 1 H0, 2 H1, 3 H2, ...
|
|
168
|
+
def hydrogens_from_hcount(code)
|
|
169
|
+
code >= 1 ? code - 1 : nil
|
|
170
|
+
end
|
|
171
|
+
|
|
156
172
|
def atoms_count
|
|
157
173
|
field(3, 0, 3).to_i
|
|
158
174
|
end
|
|
@@ -7,12 +7,21 @@ module AsciiChem
|
|
|
7
7
|
# computed (Layout walks atoms in the same order as
|
|
8
8
|
# Structure::Graph, so positions map by index). Charges and
|
|
9
9
|
# isotopes are emitted as `M CHG` / `M ISO` property lines.
|
|
10
|
+
#
|
|
11
|
+
# Tetrahedral chirality crosses as the atom parity field
|
|
12
|
+
# (TH1 → 1 odd, TH2 → 2 even) relative to the neighbour order in
|
|
13
|
+
# the bond block, which is the model's linear order — the InChI
|
|
14
|
+
# binary consumes it when wedges or coordinates are absent.
|
|
15
|
+
# Directional bonds (`/`, `\`, E/Z) have no V2000 field: the
|
|
16
|
+
# model's Bond#direction is documented as a molfile loss rather
|
|
17
|
+
# than silently invented from geometry.
|
|
10
18
|
class Writer
|
|
11
19
|
BOND_TYPES = {
|
|
12
20
|
single: 1, double: 2, triple: 3, aromatic: 4,
|
|
13
21
|
wedge: 1, hash: 1
|
|
14
22
|
}.freeze
|
|
15
23
|
BOND_STEREO = { wedge: 1, hash: 6 }.freeze
|
|
24
|
+
CHIRALITY_PARITY = { 'TH1' => 1, 'TH2' => 2 }.freeze
|
|
16
25
|
|
|
17
26
|
def initialize(molecule, name: nil)
|
|
18
27
|
@molecule = molecule
|
|
@@ -21,7 +30,7 @@ module AsciiChem
|
|
|
21
30
|
|
|
22
31
|
def write
|
|
23
32
|
atoms, edges = Structure::Graph.build(@molecule)
|
|
24
|
-
raise ParseError, "molecule has no bonds — a formula is not a structure" if edges.empty?
|
|
33
|
+
raise ParseError, "molecule has no bonds — a formula is not a structure" if edges.empty? && atoms.length > 1
|
|
25
34
|
|
|
26
35
|
lines = []
|
|
27
36
|
lines << @name.to_s
|
|
@@ -32,8 +41,10 @@ module AsciiChem
|
|
|
32
41
|
layout = nil
|
|
33
42
|
atoms.each_with_index do |atom, index|
|
|
34
43
|
x, y = coordinates(atom, index, atoms)
|
|
35
|
-
lines << format("%10.4f%10.4f%10.4f %-3s 0 0 0 0 0 0 0 0 0 0 0
|
|
36
|
-
x, y, atom.z2 || 0.0, atom.element
|
|
44
|
+
lines << format("%10.4f%10.4f%10.4f %-3s 0 0%3d%3d 0 0 0 0 0 0 0 0 0",
|
|
45
|
+
x, y, atom.z2 || 0.0, atom.element,
|
|
46
|
+
CHIRALITY_PARITY.fetch(atom.chirality, 0),
|
|
47
|
+
hcount_code(atom))
|
|
37
48
|
end
|
|
38
49
|
|
|
39
50
|
edges.each do |edge|
|
|
@@ -53,6 +64,12 @@ module AsciiChem
|
|
|
53
64
|
|
|
54
65
|
private
|
|
55
66
|
|
|
67
|
+
# MDL H-count code: 0 default, 1 H0, 2 H1, 3 H2, ... — so a
|
|
68
|
+
# stated count n crosses as n + 1.
|
|
69
|
+
def hcount_code(atom)
|
|
70
|
+
atom.hydrogens.nil? ? 0 : atom.hydrogens + 1
|
|
71
|
+
end
|
|
72
|
+
|
|
56
73
|
# Authored coordinates win; otherwise positions from the
|
|
57
74
|
# deterministic 2D layout (same walk order as the graph).
|
|
58
75
|
def coordinates(atom, index, atoms)
|
data/lib/asciichem/perception.rb
CHANGED
|
@@ -36,6 +36,8 @@ module AsciiChem
|
|
|
36
36
|
# Sum of explicit bond orders at an atom (wedge/hash/dative/wavy
|
|
37
37
|
# bonds order as single; aromatic orders as 1 — Hückel alternation
|
|
38
38
|
# is a ring-perception concern, not a bond-order sum concern).
|
|
39
|
+
# This is the valence-feasibility sum: the linter's max-valence
|
|
40
|
+
# check consumes it.
|
|
39
41
|
def bond_order_sum(atom_index, edges)
|
|
40
42
|
edges.sum do |edge|
|
|
41
43
|
next 0 unless edge.from == atom_index || edge.to == atom_index
|
|
@@ -44,45 +46,78 @@ module AsciiChem
|
|
|
44
46
|
end
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
# Hydrogen-counting sum: aromatic bonds order as 1.5 (Hückel), so
|
|
50
|
+
# both spellings of benzene sum to 3.0 at every carbon and perceive
|
|
51
|
+
# one implicit hydrogen — the two syntaxes share semantics. Whole
|
|
52
|
+
# numbers only: a fractional remainder means the Hückel arithmetic
|
|
53
|
+
# cannot decide (fused aromatic-schematic atoms), and perception
|
|
54
|
+
# declines rather than invents.
|
|
55
|
+
def hydrogen_valence_sum(atom_index, edges)
|
|
56
|
+
edges.sum do |edge|
|
|
57
|
+
next 0 unless edge.from == atom_index || edge.to == atom_index
|
|
58
|
+
|
|
59
|
+
edge.kind == :aromatic ? 1.5 : BOND_ORDER.fetch(edge.kind, 1)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
47
63
|
# Implicit hydrogens for an atom: the smallest default valence
|
|
48
|
-
# that the
|
|
64
|
+
# that the hydrogen-valence sum fits, minus that sum. The sum must
|
|
65
|
+
# come from #hydrogen_valence_sum (aromatic-aware).
|
|
49
66
|
# - Atoms that STATE hydrogens ([NH4+], [CH4]) return 0 — never
|
|
50
67
|
# add to what the source said.
|
|
51
68
|
# - Charged atoms return nil: charge algebra on valence is
|
|
52
69
|
# element-specific and deferred until the perception pass grows
|
|
53
70
|
# one (wrong implicit hydrogens are worse than none).
|
|
54
|
-
# - Elements without a table entry, or
|
|
55
|
-
#
|
|
56
|
-
def implicit_hydrogens(atom,
|
|
71
|
+
# - Elements without a table entry, non-integral or over-valent
|
|
72
|
+
# sums return nil — never invent.
|
|
73
|
+
def implicit_hydrogens(atom, valence_sum)
|
|
57
74
|
return 0 if atom.hydrogens
|
|
58
75
|
return nil if atom.charge
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
return nil if
|
|
77
|
+
deficits = valence_deficits(atom.element, valence_sum)
|
|
78
|
+
return nil if deficits.empty?
|
|
79
|
+
|
|
80
|
+
deficits.min.to_i
|
|
81
|
+
end
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
# Whole-number, non-negative gaps between each default valence and
|
|
84
|
+
# the hydrogen-valence sum; empty when the element is unknown or
|
|
85
|
+
# no valence fits integrally.
|
|
86
|
+
def valence_deficits(element, valence_sum)
|
|
87
|
+
valences = DEFAULT_VALENCES[element]
|
|
88
|
+
return [].freeze if valences.nil?
|
|
65
89
|
|
|
66
|
-
|
|
90
|
+
valences.map { |v| v - valence_sum }
|
|
91
|
+
.select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
|
|
67
92
|
end
|
|
68
93
|
|
|
69
|
-
# Six-membered cycles where every atom is sp2
|
|
70
|
-
# double bond
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
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.
|
|
75
105
|
def aromatic_rings(atoms, edges)
|
|
76
106
|
cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
|
|
77
|
-
|
|
78
|
-
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) } }
|
|
79
110
|
end
|
|
80
111
|
|
|
81
|
-
def sp2?(atom_index, atoms, edges)
|
|
112
|
+
def sp2?(atom_index, atoms, edges, cyclic_atoms)
|
|
82
113
|
return true if atoms[atom_index].aromatic == true
|
|
83
114
|
|
|
84
115
|
edges.any? do |edge|
|
|
85
|
-
|
|
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)
|
|
86
121
|
end
|
|
87
122
|
end
|
|
88
123
|
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module AsciiChem
|
|
4
|
-
# Reaction-
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# roles syntax (structured entries in the grammar) is the later
|
|
10
|
-
# slice.
|
|
4
|
+
# Reaction-role derivation (TODO.v2/13 item 4): explicit typed
|
|
5
|
+
# entries (`[cat: Pd]`, `[solv: THF]`, `[reag: NaH]`) win; free-text
|
|
6
|
+
# entries fall back to the writing convention — catalysts and light
|
|
7
|
+
# reagents ABOVE the arrow, solvents BELOW — classified by hint
|
|
8
|
+
# matching. Pure functions, nothing mutates.
|
|
11
9
|
module ReactionRoles
|
|
12
10
|
CATALYST_HINTS = %w[pd pt ni fe catalyst cat].freeze
|
|
13
11
|
SOLVENT_HINTS = %w[h2o thf dmso dmf etoh meoh toluene solvent sol water].freeze
|
|
@@ -15,19 +13,37 @@ module AsciiChem
|
|
|
15
13
|
module_function
|
|
16
14
|
|
|
17
15
|
# Returns { catalysts: [strings], solvents: [strings],
|
|
18
|
-
# reagents: [strings],
|
|
19
|
-
#
|
|
20
|
-
# classified by
|
|
16
|
+
# reagents: [strings], other_below: [strings] }. Explicit roles
|
|
17
|
+
# route to their bucket regardless of side; free-text entries are
|
|
18
|
+
# split and classified by the above/below convention.
|
|
21
19
|
def roles_for(reaction)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
roles = { catalysts: [], reagents: [], solvents: [], other_below: [] }
|
|
21
|
+
entries = reaction.conditions ? reaction.conditions.entries : []
|
|
22
|
+
entries.each { |entry| assign(entry, roles) }
|
|
23
|
+
roles
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def assign(entry, roles)
|
|
27
|
+
case entry.role
|
|
28
|
+
when :catalyst then roles[:catalysts] << entry.value
|
|
29
|
+
when :solvent then roles[:solvents] << entry.value
|
|
30
|
+
when :reagent then roles[:reagents] << entry.value
|
|
31
|
+
else classify_free(entry, roles)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def classify_free(entry, roles)
|
|
36
|
+
split_entries(entry.value).each do |value|
|
|
37
|
+
roles[bucket(value, entry.side)] << value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def bucket(value, side)
|
|
42
|
+
if matches?(value, CATALYST_HINTS) then :catalysts
|
|
43
|
+
elsif matches?(value, SOLVENT_HINTS) then :solvents
|
|
44
|
+
elsif side == :above then :reagents
|
|
45
|
+
else :other_below
|
|
46
|
+
end
|
|
31
47
|
end
|
|
32
48
|
|
|
33
49
|
def split_entries(text)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
module Rinchi
|
|
7
|
+
# Engine that shells out to the standalone IUPAC `rinchi-1`
|
|
8
|
+
# binary. The binary is a documented external dependency — nothing
|
|
9
|
+
# is vendored (vendoring requires the licence review recorded in
|
|
10
|
+
# TODO.v2/10, a maintainer decision).
|
|
11
|
+
#
|
|
12
|
+
# AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new
|
|
13
|
+
# AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new(bin: "/usr/local/bin/rinchi-1")
|
|
14
|
+
class BinaryEngine
|
|
15
|
+
DEFAULT_FLAGS = %w[-STDIO -AuxNone -NoLabels -Key].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :bin, :flags
|
|
18
|
+
|
|
19
|
+
def initialize(bin: 'rinchi-1', flags: DEFAULT_FLAGS)
|
|
20
|
+
super()
|
|
21
|
+
@bin = bin
|
|
22
|
+
@flags = flags.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# reaction → RXN → rinchi-1 → Identity. The RXN emission is the
|
|
26
|
+
# pipe: reactants and products cross into the engine as V2000
|
|
27
|
+
# mol blocks inside one $RXN container.
|
|
28
|
+
def identity(reaction)
|
|
29
|
+
rxn = AsciiChem::Rxn.write(reaction)
|
|
30
|
+
out = execute(rxn)
|
|
31
|
+
rinchi = out[/^RInChI=\S+/]
|
|
32
|
+
raise AsciiChem::Error, "#{bin} produced no RInChI:\n#{out}" unless rinchi
|
|
33
|
+
|
|
34
|
+
Identity.new(
|
|
35
|
+
rinchi: rinchi,
|
|
36
|
+
short_rinchikey: out[/^Short-RInChIKey=(\S+)/, 1],
|
|
37
|
+
long_rinchikey: out[/^Long-RInChIKey=(\S+)/, 1]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def execute(input)
|
|
44
|
+
out, err, status = Open3.capture3(bin, *flags, stdin_data: input)
|
|
45
|
+
unless status.success?
|
|
46
|
+
raise AsciiChem::Error, "#{bin} failed (exit #{status.exitstatus}): #{(err + out).strip[0, 500]}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
out
|
|
50
|
+
rescue Errno::ENOENT
|
|
51
|
+
raise EngineMissingError, "RInChI engine binary #{bin.inspect} not found — #{AsciiChem::Rinchi::INSTALL_GUIDE}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Rinchi
|
|
5
|
+
# Derived identity of a reaction: the standard RInChI string and
|
|
6
|
+
# its long/short InChIKeys. Value object returned by engines.
|
|
7
|
+
Identity = Struct.new(:rinchi, :short_rinchikey, :long_rinchikey,
|
|
8
|
+
keyword_init: true)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Reaction identity via RInChI (TODO.v2/13 item 4; TODO.v2/10 pattern).
|
|
5
|
+
#
|
|
6
|
+
# Like InChI, the algorithm is never reimplemented: engines wrap the
|
|
7
|
+
# IUPAC RInChI software (`rinchi-1`). The pipe is
|
|
8
|
+
# Model::Reaction → RXN → engine → RInChI keys.
|
|
9
|
+
#
|
|
10
|
+
# Engines are opt-in: set `AsciiChem::Rinchi.engine` (or pass one
|
|
11
|
+
# per call). Without an engine every derivation raises
|
|
12
|
+
# EngineMissingError with install guidance — no silent fallback.
|
|
13
|
+
module Rinchi
|
|
14
|
+
autoload :BinaryEngine, 'asciichem/rinchi/binary_engine'
|
|
15
|
+
autoload :Identity, 'asciichem/rinchi/identity'
|
|
16
|
+
|
|
17
|
+
INSTALL_GUIDE = 'install the IUPAC RInChI software ' \
|
|
18
|
+
'(https://github.com/IUPAC-InChI/RInChI) and set ' \
|
|
19
|
+
'AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new ' \
|
|
20
|
+
'(or pass bin: "path/to/rinchi-1")'
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
attr_accessor :engine
|
|
24
|
+
|
|
25
|
+
# Derives the identity of one reaction. Falls back to the
|
|
26
|
+
# configured engine when none is passed; raises
|
|
27
|
+
# EngineMissingError when neither exists.
|
|
28
|
+
def identity_for(reaction, engine: nil)
|
|
29
|
+
selected = engine || self.engine
|
|
30
|
+
raise EngineMissingError, "no RInChI engine configured — #{INSTALL_GUIDE}" if selected.nil?
|
|
31
|
+
|
|
32
|
+
selected.identity(reaction)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Rxn
|
|
5
|
+
# Model reaction → MDL RXN V2000. Layout: `$RXN`, name, program,
|
|
6
|
+
# comment, counts (`%3d%3d` = reactant count, product count), then
|
|
7
|
+
# `$MOL` + one V2000 molfile per species (reactants first, then
|
|
8
|
+
# products).
|
|
9
|
+
class Writer
|
|
10
|
+
def initialize(reaction, name: nil)
|
|
11
|
+
@reaction = reaction
|
|
12
|
+
@name = name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write
|
|
16
|
+
reactants = @reaction.reactants
|
|
17
|
+
products = @reaction.products
|
|
18
|
+
lines = ['$RXN', @name.to_s, ' AsciiChem', '',
|
|
19
|
+
format('%<reactants>3d%<products>3d',
|
|
20
|
+
reactants: reactants.length, products: products.length)]
|
|
21
|
+
(reactants + products).each do |molecule|
|
|
22
|
+
lines << '$MOL'
|
|
23
|
+
lines << AsciiChem::Molfile.write(molecule).chomp
|
|
24
|
+
end
|
|
25
|
+
lines.join("\n") << "\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# MDL RXN (reaction file) emission (TODO.v2/13 item 4, RInChI): a
|
|
5
|
+
# reaction as one $RXN container holding each reactant and product
|
|
6
|
+
# as a V2000 mol block. RXN carries species, not stoichiometry —
|
|
7
|
+
# the coefficient is model data with no place in the file, so it is
|
|
8
|
+
# not invented there.
|
|
9
|
+
module Rxn
|
|
10
|
+
autoload :Writer, 'asciichem/rxn/writer'
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def write(reaction, name: nil)
|
|
14
|
+
Writer.new(reaction, name: name).write
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -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 = {}
|
|
@@ -167,17 +197,21 @@ module AsciiChem
|
|
|
167
197
|
emit(continuation, index, @adjacency[index][continuation])
|
|
168
198
|
end
|
|
169
199
|
|
|
170
|
-
# Lower sorts first
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
200
|
+
# Lower sorts first. A closure-bearing child continues the chain
|
|
201
|
+
# (SMILES writes ring-closure digits at the end of a traversal —
|
|
202
|
+
# this also keeps the written atom order equal to the model's,
|
|
203
|
+
# so re-parsing yields an equal model), then single/aromatic
|
|
204
|
+
# bonds, then larger subtrees, then the lexicographically
|
|
205
|
+
# smallest atom token (order-independent, so the canonical form
|
|
206
|
+
# does not depend on how the input happened to number the atoms).
|
|
174
207
|
def continuation_rank(index, child)
|
|
175
208
|
kind = @adjacency[index][child]
|
|
176
209
|
priority = CONTINUATION_BONDS.include?(kind) ? 0 : 1
|
|
210
|
+
closure_first = @closures[child].empty? ? 1 : 0
|
|
177
211
|
# The order-independent token: this rank runs inside the
|
|
178
212
|
# written-order pre-pass, where stereo tokens are not yet
|
|
179
213
|
# computable (and stereo does not affect the order).
|
|
180
|
-
[priority, -@tree_size[child], atom_token_for(child, stereo: false)]
|
|
214
|
+
[closure_first, priority, -@tree_size[child], atom_token_for(child, stereo: false)]
|
|
181
215
|
end
|
|
182
216
|
|
|
183
217
|
def ring_digit_for(a, b)
|
|
@@ -201,6 +235,11 @@ module AsciiChem
|
|
|
201
235
|
token = BOND_TOKENS[kind]
|
|
202
236
|
raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
|
|
203
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
|
+
|
|
204
243
|
return token if kind != :single && kind != :aromatic
|
|
205
244
|
|
|
206
245
|
if kind == :single
|
|
@@ -228,7 +267,7 @@ module AsciiChem
|
|
|
228
267
|
return @plain_tokens[index] if !stereo && @plain_tokens[index]
|
|
229
268
|
|
|
230
269
|
atom = @atoms[index]
|
|
231
|
-
aromatic =
|
|
270
|
+
aromatic = aromatic?(index)
|
|
232
271
|
lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
|
|
233
272
|
needs_bracket =
|
|
234
273
|
atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
|
|
@@ -243,7 +282,8 @@ module AsciiChem
|
|
|
243
282
|
t << chirality_token(index) if stereo && atom.chirality
|
|
244
283
|
if atom.hydrogens
|
|
245
284
|
t << 'H'
|
|
246
|
-
|
|
285
|
+
# Stated zero is H0, not a bare H (which means one).
|
|
286
|
+
t << atom.hydrogens.to_s unless atom.hydrogens == 1
|
|
247
287
|
end
|
|
248
288
|
t << charge_suffix(atom.charge) if atom.charge
|
|
249
289
|
t << ']'
|
|
@@ -266,10 +306,6 @@ module AsciiChem
|
|
|
266
306
|
|
|
267
307
|
"#{sign}#{count}"
|
|
268
308
|
end
|
|
269
|
-
|
|
270
|
-
def aromatic?(index)
|
|
271
|
-
@atoms[index].aromatic == true
|
|
272
|
-
end
|
|
273
309
|
end
|
|
274
310
|
end
|
|
275
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",
|
data/lib/asciichem.rb
CHANGED
|
@@ -29,11 +29,14 @@ module AsciiChem
|
|
|
29
29
|
autoload :ModelAdapter, "asciichem/model_adapter"
|
|
30
30
|
autoload :Molfile, "asciichem/molfile"
|
|
31
31
|
autoload :Parser, "asciichem/parser"
|
|
32
|
+
autoload :MechanismGraph, 'asciichem/mechanism_graph'
|
|
32
33
|
autoload :Perception, 'asciichem/perception'
|
|
33
34
|
autoload :ReactionRoles, 'asciichem/reaction_roles'
|
|
34
35
|
autoload :PeriodicTable, "asciichem/periodic_table"
|
|
35
36
|
autoload :Resolver, "asciichem/resolver"
|
|
36
37
|
autoload :RingBonds, "asciichem/ring_bonds"
|
|
38
|
+
autoload :Rinchi, "asciichem/rinchi"
|
|
39
|
+
autoload :Rxn, "asciichem/rxn"
|
|
37
40
|
autoload :Smiles, "asciichem/smiles"
|
|
38
41
|
autoload :Structure, "asciichem/structure"
|
|
39
42
|
autoload :Transform, "asciichem/transform"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asciichem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.31.
|
|
4
|
+
version: 0.31.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -238,6 +238,7 @@ files:
|
|
|
238
238
|
- lib/asciichem/linter/unclosed_ring_check.rb
|
|
239
239
|
- lib/asciichem/linter/valence_check.rb
|
|
240
240
|
- lib/asciichem/linter/zmatrix_reference_check.rb
|
|
241
|
+
- lib/asciichem/mechanism_graph.rb
|
|
241
242
|
- lib/asciichem/model.rb
|
|
242
243
|
- lib/asciichem/model/atom.rb
|
|
243
244
|
- lib/asciichem/model/bond.rb
|
|
@@ -275,7 +276,12 @@ files:
|
|
|
275
276
|
- lib/asciichem/resolver/opsin.rb
|
|
276
277
|
- lib/asciichem/resolver/pubchem.rb
|
|
277
278
|
- lib/asciichem/resolver/substance.rb
|
|
279
|
+
- lib/asciichem/rinchi.rb
|
|
280
|
+
- lib/asciichem/rinchi/binary_engine.rb
|
|
281
|
+
- lib/asciichem/rinchi/identity.rb
|
|
278
282
|
- lib/asciichem/ring_bonds.rb
|
|
283
|
+
- lib/asciichem/rxn.rb
|
|
284
|
+
- lib/asciichem/rxn/writer.rb
|
|
279
285
|
- lib/asciichem/smiles.rb
|
|
280
286
|
- lib/asciichem/smiles/parser.rb
|
|
281
287
|
- lib/asciichem/smiles/writer.rb
|