asciichem 0.31.2 → 0.31.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/lib/asciichem/linter/balance_check.rb +1 -2
- data/lib/asciichem/mechanism_graph.rb +72 -0
- 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 +34 -9
- 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 +11 -6
- data/lib/asciichem/version.rb +1 -1
- 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: ab5305dc2ec00320b62efbfae105108d23c90b6f5704c64e0f2ef59ec8c85983
|
|
4
|
+
data.tar.gz: 7bee58c136c5590646a736c42b900c4c4322fc7beeefcc9980b4ca5bfae6c4b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a59410198a58a61ff195ed6ca2e4a5a7d20d751af9dc8690e1d38405e68a81ff3470ac532c9cd4517ed470fe814dab34db1221357a09ab63919bab78b57795b4
|
|
7
|
+
data.tar.gz: e1da749ccc43b3b1e00db74640e761b249d2445159026729780c11fa8b54d18c6c9d51f0655317229a9cd35cc1ffa727477f0c5be9033438be25fb8388acfa35
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,44 @@
|
|
|
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.3] - 2026-10-20
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Perception in the shared corpus (TODO.v2/13 item 2, slice C):
|
|
10
|
+
implicit hydrogens order aromatic bonds at 1.5 (Hückel) and declare
|
|
11
|
+
a value only when a default valence fits integrally — so
|
|
12
|
+
`c1ccccc1` and `C1=CC=CC=C1` perceive identically, and
|
|
13
|
+
aromatic-schematic fusion atoms decline rather than invent. Gated
|
|
14
|
+
by `fixtures/perception.json` in all three implementations.
|
|
15
|
+
- Structured condition entries (item 4, slice 1):
|
|
16
|
+
`Conditions#entries` derives typed `cat:`/`solv:`/`reag:` entries
|
|
17
|
+
(semicolon-separated) from the verbatim bracket text; free text and
|
|
18
|
+
unknown prefixes stay lossless, and the wire form is unchanged.
|
|
19
|
+
`ReactionRoles` prefers explicit roles over the above/below
|
|
20
|
+
heuristic.
|
|
21
|
+
- Reaction stoichiometry as model data (slice 2):
|
|
22
|
+
`Reaction#stoichiometry` and `Molecule#stoichiometric_coefficient`
|
|
23
|
+
— one coefficient rule shared by the reaction, `BalanceCheck`, and
|
|
24
|
+
composition counting.
|
|
25
|
+
- Mechanism step graph (slice 4a): `MechanismGraph.for` derives
|
|
26
|
+
parsed steps, catalysts (species on both sides), and inter-step
|
|
27
|
+
links from the verbatim `mechanism{}` entries.
|
|
28
|
+
- Reaction identity via RInChI (slice 4b): `Rxn::Writer` emits MDL
|
|
29
|
+
RXN V2000 and `Rinchi::BinaryEngine` wraps the opt-in `rinchi-1`
|
|
30
|
+
binary (install guidance, `EngineMissingError`, nothing vendored).
|
|
31
|
+
- Molfile stereo (L3 slice 6): `Atom#chirality` crosses as MDL atom
|
|
32
|
+
parity and stated hydrogens as the H-count field, so the identity
|
|
33
|
+
pipe derives stereoisomer-distinct InChIs; `IdentityCrossCheck`
|
|
34
|
+
covers stereo-layer mismatches.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- SMILES writer ring-closure emission (#103): a closure-bearing
|
|
38
|
+
child continues the chain, so the digit lands at the end of the
|
|
39
|
+
traversal — `parse(write(parse(s))) == parse(s)` and writer
|
|
40
|
+
stability now hold for ring structures such as `C1=CCC(C)C(C)C1`.
|
|
41
|
+
- SMILES writer rendered stated zero hydrogens as a bare `H` (one);
|
|
42
|
+
it now emits `H0`.
|
|
43
|
+
|
|
6
44
|
## [0.31.2] - 2026-10-20
|
|
7
45
|
|
|
8
46
|
### Added
|
|
@@ -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,72 @@
|
|
|
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, 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
|
+
end
|
|
32
|
+
|
|
33
|
+
def links(steps)
|
|
34
|
+
steps.each_with_index.flat_map do |from, i|
|
|
35
|
+
next [] unless from.reaction
|
|
36
|
+
|
|
37
|
+
from_products = sides_of(from.reaction)[:products].keys
|
|
38
|
+
steps[(i + 1)..].filter_map { |to| link_between(from, to, from_products) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def link_between(from, to, from_products)
|
|
43
|
+
return nil unless to.reaction
|
|
44
|
+
|
|
45
|
+
shared = from_products & sides_of(to.reaction)[:reactants].keys
|
|
46
|
+
Link.new(from: from.label, to: to.label, species: shared) unless shared.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_reaction(text)
|
|
50
|
+
node = AsciiChem.parse(text).nodes.first
|
|
51
|
+
node if node.is_a?(AsciiChem::Model::Reaction) ||
|
|
52
|
+
node.is_a?(AsciiChem::Model::ReactionCascade)
|
|
53
|
+
rescue AsciiChem::ParseError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Coefficient-free species names per side; a cascade's entry side
|
|
58
|
+
# is its head and its exit side is its last step.
|
|
59
|
+
def sides_of(reaction)
|
|
60
|
+
case reaction
|
|
61
|
+
when AsciiChem::Model::Reaction
|
|
62
|
+
reaction.stoichiometry
|
|
63
|
+
when AsciiChem::Model::ReactionCascade
|
|
64
|
+
steps = reaction.steps
|
|
65
|
+
return nil if steps.empty?
|
|
66
|
+
|
|
67
|
+
{ reactants: steps.first.stoichiometry[:reactants],
|
|
68
|
+
products: steps.last.stoichiometry[:products] }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -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,26 +46,49 @@ 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
94
|
# Six-membered cycles where every atom is sp2 (participates in a
|
|
@@ -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
|
|
@@ -167,17 +167,21 @@ module AsciiChem
|
|
|
167
167
|
emit(continuation, index, @adjacency[index][continuation])
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
-
# Lower sorts first
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
170
|
+
# Lower sorts first. A closure-bearing child continues the chain
|
|
171
|
+
# (SMILES writes ring-closure digits at the end of a traversal —
|
|
172
|
+
# this also keeps the written atom order equal to the model's,
|
|
173
|
+
# so re-parsing yields an equal model), then single/aromatic
|
|
174
|
+
# bonds, then larger subtrees, then the lexicographically
|
|
175
|
+
# smallest atom token (order-independent, so the canonical form
|
|
176
|
+
# does not depend on how the input happened to number the atoms).
|
|
174
177
|
def continuation_rank(index, child)
|
|
175
178
|
kind = @adjacency[index][child]
|
|
176
179
|
priority = CONTINUATION_BONDS.include?(kind) ? 0 : 1
|
|
180
|
+
closure_first = @closures[child].empty? ? 1 : 0
|
|
177
181
|
# The order-independent token: this rank runs inside the
|
|
178
182
|
# written-order pre-pass, where stereo tokens are not yet
|
|
179
183
|
# computable (and stereo does not affect the order).
|
|
180
|
-
[priority, -@tree_size[child], atom_token_for(child, stereo: false)]
|
|
184
|
+
[closure_first, priority, -@tree_size[child], atom_token_for(child, stereo: false)]
|
|
181
185
|
end
|
|
182
186
|
|
|
183
187
|
def ring_digit_for(a, b)
|
|
@@ -243,7 +247,8 @@ module AsciiChem
|
|
|
243
247
|
t << chirality_token(index) if stereo && atom.chirality
|
|
244
248
|
if atom.hydrogens
|
|
245
249
|
t << 'H'
|
|
246
|
-
|
|
250
|
+
# Stated zero is H0, not a bare H (which means one).
|
|
251
|
+
t << atom.hydrogens.to_s unless atom.hydrogens == 1
|
|
247
252
|
end
|
|
248
253
|
t << charge_suffix(atom.charge) if atom.charge
|
|
249
254
|
t << ']'
|
data/lib/asciichem/version.rb
CHANGED
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.3
|
|
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
|