asciichem 0.31.1 → 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 +59 -0
- data/lib/asciichem/engine/parg/asciichem.json +1 -1
- data/lib/asciichem/engine/parg_engine.rb +1 -1
- data/lib/asciichem/formatter/text.rb +6 -0
- data/lib/asciichem/grammar_rules.rb +10 -1
- data/lib/asciichem/linter/balance_check.rb +1 -2
- data/lib/asciichem/linter/valence_check.rb +10 -42
- data/lib/asciichem/mechanism_graph.rb +72 -0
- data/lib/asciichem/model/atom.rb +4 -1
- data/lib/asciichem/model/bond.rb +25 -17
- data/lib/asciichem/model/molecule.rb +11 -5
- data/lib/asciichem/model/reaction.rb +57 -0
- data/lib/asciichem/molfile/parser.rb +17 -1
- data/lib/asciichem/molfile/writer.rb +20 -3
- data/lib/asciichem/perception.rb +142 -0
- data/lib/asciichem/reaction_roles.rb +59 -0
- data/lib/asciichem/resolver/opsin.rb +68 -0
- data/lib/asciichem/resolver.rb +1 -0
- 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/parser.rb +67 -35
- data/lib/asciichem/smiles/writer.rb +108 -38
- data/lib/asciichem/structure/graph.rb +3 -2
- data/lib/asciichem/structure/linearizer.rb +6 -3
- data/lib/asciichem/transform_rules.rb +6 -1
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem/wire/core.rb +4 -0
- data/lib/asciichem/wire_adapter.rb +8 -4
- data/lib/asciichem.rb +5 -0
- data/scripts/generate-conformance-cases +57 -0
- metadata +11 -1
|
@@ -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
|
data/lib/asciichem/model/atom.rb
CHANGED
|
@@ -35,7 +35,7 @@ module AsciiChem
|
|
|
35
35
|
:charge, :oxidation_state,
|
|
36
36
|
:lone_pairs, :radical_electrons,
|
|
37
37
|
:ring_closures, :aromatic, :hydrogens,
|
|
38
|
-
:x2, :y2, :z2, :atom_parity,
|
|
38
|
+
:x2, :y2, :z2, :atom_parity, :chirality,
|
|
39
39
|
:spin_multiplicity, :atom_title,
|
|
40
40
|
:x_fract, :y_fract, :z_fract
|
|
41
41
|
|
|
@@ -60,6 +60,7 @@ module AsciiChem
|
|
|
60
60
|
lone_pairs: nil, radical_electrons: nil,
|
|
61
61
|
ring_closures: nil, aromatic: nil, hydrogens: nil,
|
|
62
62
|
x2: nil, y2: nil, z2: nil, atom_parity: nil,
|
|
63
|
+
chirality: nil,
|
|
63
64
|
spin_multiplicity: nil, atom_title: nil,
|
|
64
65
|
x_fract: nil, y_fract: nil, z_fract: nil)
|
|
65
66
|
@element = element
|
|
@@ -77,6 +78,7 @@ module AsciiChem
|
|
|
77
78
|
@y2 = y2
|
|
78
79
|
@z2 = z2
|
|
79
80
|
@atom_parity = atom_parity
|
|
81
|
+
@chirality = chirality
|
|
80
82
|
@spin_multiplicity = spin_multiplicity
|
|
81
83
|
@atom_title = atom_title
|
|
82
84
|
@x_fract = x_fract
|
|
@@ -92,6 +94,7 @@ module AsciiChem
|
|
|
92
94
|
ring_closures: ring_closures, aromatic: aromatic,
|
|
93
95
|
hydrogens: hydrogens,
|
|
94
96
|
x2: x2, y2: y2, z2: z2, atom_parity: atom_parity,
|
|
97
|
+
chirality: chirality,
|
|
95
98
|
spin_multiplicity: spin_multiplicity, atom_title: atom_title,
|
|
96
99
|
x_fract: x_fract, y_fract: y_fract, z_fract: z_fract }
|
|
97
100
|
end
|
data/lib/asciichem/model/bond.rb
CHANGED
|
@@ -4,40 +4,48 @@ module AsciiChem
|
|
|
4
4
|
module Model
|
|
5
5
|
# A bond between two adjacent nodes in a structural chain.
|
|
6
6
|
class Bond < Node
|
|
7
|
-
attr_accessor :kind
|
|
7
|
+
attr_accessor :kind, :direction
|
|
8
8
|
|
|
9
9
|
KINDS = {
|
|
10
|
-
single:
|
|
11
|
-
double:
|
|
12
|
-
triple:
|
|
13
|
-
quadruple: { ascii:
|
|
14
|
-
wedge:
|
|
15
|
-
hash:
|
|
16
|
-
dative:
|
|
17
|
-
wavy:
|
|
18
|
-
aromatic:
|
|
10
|
+
single: { ascii: '-', mathml_entity: '-' },
|
|
11
|
+
double: { ascii: '=', mathml_entity: '=' },
|
|
12
|
+
triple: { ascii: '#', mathml_entity: '≡' },
|
|
13
|
+
quadruple: { ascii: '##', mathml_entity: '≣' },
|
|
14
|
+
wedge: { ascii: '>-', mathml_entity: '↑' },
|
|
15
|
+
hash: { ascii: '-<', mathml_entity: '↓' },
|
|
16
|
+
dative: { ascii: '~>', mathml_entity: '→' },
|
|
17
|
+
wavy: { ascii: '~~', mathml_entity: '∼' },
|
|
18
|
+
aromatic: { ascii: ':', mathml_entity: ':' }
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
# CML wire order codes per bond kind. Single source of truth
|
|
22
22
|
# for both adapter directions — to_canonical emits, from_canonical
|
|
23
23
|
# parses. Adding a new bond kind = one entry here + one in KINDS.
|
|
24
24
|
CML_ORDER_CODES = {
|
|
25
|
-
single:
|
|
26
|
-
quadruple:
|
|
27
|
-
dative:
|
|
25
|
+
single: 'S', double: 'D', triple: 'T',
|
|
26
|
+
quadruple: 'Q', wedge: 'W', hash: 'H',
|
|
27
|
+
dative: 'A', wavy: 'V'
|
|
28
28
|
}.freeze
|
|
29
29
|
KIND_BY_CML_ORDER = CML_ORDER_CODES.invert.freeze
|
|
30
30
|
|
|
31
31
|
# Subset that carries stereo meaning (wedge = out of page,
|
|
32
32
|
# hash = into page). Used for <bondStereo> elements.
|
|
33
|
-
CML_STEREO_CODES = { wedge:
|
|
33
|
+
CML_STEREO_CODES = { wedge: 'W', hash: 'H' }.freeze
|
|
34
34
|
|
|
35
|
-
def initialize(kind: :single)
|
|
35
|
+
def initialize(kind: :single, direction: nil)
|
|
36
36
|
@kind = kind
|
|
37
|
+
@direction = direction
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# SMILES directional-bond orientation (`/` up, `\` down) on the
|
|
41
|
+
# single bonds flanking a stereogenic double bond. Orthogonal to
|
|
42
|
+
# the bond kind; only :single carries it.
|
|
43
|
+
def direction
|
|
44
|
+
@direction if kind == :single
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
def value_attributes
|
|
40
|
-
{ kind: kind }
|
|
48
|
+
{ kind: kind, direction: direction }
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
def ascii
|
|
@@ -49,7 +57,7 @@ module AsciiChem
|
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def cml_order_code
|
|
52
|
-
CML_ORDER_CODES.fetch(kind,
|
|
60
|
+
CML_ORDER_CODES.fetch(kind, 'S')
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def cml_stereo_code
|
|
@@ -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' },
|
|
@@ -29,6 +70,14 @@ module AsciiChem
|
|
|
29
70
|
conditions: conditions }
|
|
30
71
|
end
|
|
31
72
|
|
|
73
|
+
# Normalized stoichiometry (item 4): each side maps the species'
|
|
74
|
+
# coefficient-free canonical text to its integer coefficient
|
|
75
|
+
# (absent coefficient = 1). A species can appear on both sides.
|
|
76
|
+
def stoichiometry
|
|
77
|
+
{ reactants: side_stoichiometry(reactants),
|
|
78
|
+
products: side_stoichiometry(products) }
|
|
79
|
+
end
|
|
80
|
+
|
|
32
81
|
def children
|
|
33
82
|
reactants + products
|
|
34
83
|
end
|
|
@@ -59,6 +108,14 @@ module AsciiChem
|
|
|
59
108
|
"Reaction(#{reactants.map(&:to_s).join(' + ')} " \
|
|
60
109
|
"#{arrow_ascii} #{products.map(&:to_s).join(' + ')})"
|
|
61
110
|
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def side_stoichiometry(side)
|
|
115
|
+
side.each_with_object(Hash.new(0)) do |molecule, out|
|
|
116
|
+
out[molecule.to_text.sub(/\A\d+/, '')] += molecule.stoichiometric_coefficient
|
|
117
|
+
end
|
|
118
|
+
end
|
|
62
119
|
end
|
|
63
120
|
end
|
|
64
121
|
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)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# The perception pass (TODO.v2/13 item 2): chemistry that the SYNTAX
|
|
5
|
+
# leaves implicit, derived once here and consumed everywhere — the
|
|
6
|
+
# linter, the canonicalizer, renderers, and the SMILES writer.
|
|
7
|
+
#
|
|
8
|
+
# Everything is a pure function of the model; nothing mutates.
|
|
9
|
+
module Perception
|
|
10
|
+
# Default valences per element (the common ground-state values;
|
|
11
|
+
# multi-valent elements list their allowed valences in order).
|
|
12
|
+
DEFAULT_VALENCES = {
|
|
13
|
+
'H' => [1], 'B' => [3], 'C' => [4], 'N' => [3], 'O' => [2],
|
|
14
|
+
'F' => [1], 'Al' => [3], 'Si' => [4], 'P' => [3, 5],
|
|
15
|
+
'S' => [2, 4, 6], 'Cl' => [1], 'Br' => [1], 'I' => [1]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
BOND_ORDER = {
|
|
19
|
+
single: 1, double: 2, triple: 3, quadruple: 4,
|
|
20
|
+
aromatic: 1, wedge: 1, hash: 1, dative: 1, wavy: 1
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Perception runs over the neutral graph pair from
|
|
26
|
+
# Structure::Graph.build: [atoms, edges].
|
|
27
|
+
|
|
28
|
+
# Neighbour indexes of an atom, in edge order.
|
|
29
|
+
def neighbours(atom_index, edges)
|
|
30
|
+
edges.each_with_object([]) do |edge, acc|
|
|
31
|
+
acc << edge.to if edge.from == atom_index
|
|
32
|
+
acc << edge.from if edge.to == atom_index
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sum of explicit bond orders at an atom (wedge/hash/dative/wavy
|
|
37
|
+
# bonds order as single; aromatic orders as 1 — Hückel alternation
|
|
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.
|
|
41
|
+
def bond_order_sum(atom_index, edges)
|
|
42
|
+
edges.sum do |edge|
|
|
43
|
+
next 0 unless edge.from == atom_index || edge.to == atom_index
|
|
44
|
+
|
|
45
|
+
BOND_ORDER.fetch(edge.kind, 1)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
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
|
+
|
|
63
|
+
# Implicit hydrogens for an atom: the smallest default valence
|
|
64
|
+
# that the hydrogen-valence sum fits, minus that sum. The sum must
|
|
65
|
+
# come from #hydrogen_valence_sum (aromatic-aware).
|
|
66
|
+
# - Atoms that STATE hydrogens ([NH4+], [CH4]) return 0 — never
|
|
67
|
+
# add to what the source said.
|
|
68
|
+
# - Charged atoms return nil: charge algebra on valence is
|
|
69
|
+
# element-specific and deferred until the perception pass grows
|
|
70
|
+
# one (wrong implicit hydrogens are worse than none).
|
|
71
|
+
# - Elements without a table entry, non-integral or over-valent
|
|
72
|
+
# sums return nil — never invent.
|
|
73
|
+
def implicit_hydrogens(atom, valence_sum)
|
|
74
|
+
return 0 if atom.hydrogens
|
|
75
|
+
return nil if atom.charge
|
|
76
|
+
|
|
77
|
+
deficits = valence_deficits(atom.element, valence_sum)
|
|
78
|
+
return nil if deficits.empty?
|
|
79
|
+
|
|
80
|
+
deficits.min.to_i
|
|
81
|
+
end
|
|
82
|
+
|
|
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?
|
|
89
|
+
|
|
90
|
+
valences.map { |v| v - valence_sum }
|
|
91
|
+
.select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Six-membered cycles where every atom is sp2 (participates in a
|
|
95
|
+
# double bond) or aromatic-flagged — the practical fused-system
|
|
96
|
+
# rule: per-ring strict alternation can never see both rings of
|
|
97
|
+
# naphthalene (the shared bond is single), but per-atom sp2 does.
|
|
98
|
+
# Returns arrays of atom indexes, one per perceived ring, or an
|
|
99
|
+
# empty list. Full Hückel pi-electron counting is a later slice.
|
|
100
|
+
def aromatic_rings(atoms, edges)
|
|
101
|
+
cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
|
|
102
|
+
.select { |cycle| cycle.length == 6 }
|
|
103
|
+
cycles.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges) } }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sp2?(atom_index, atoms, edges)
|
|
107
|
+
return true if atoms[atom_index].aromatic == true
|
|
108
|
+
|
|
109
|
+
edges.any? do |edge|
|
|
110
|
+
(edge.from == atom_index || edge.to == atom_index) && edge.kind == :double
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def bond_kind(a, b, edges)
|
|
115
|
+
edge = edges.find do |e|
|
|
116
|
+
(e.from == a && e.to == b) || (e.from == b && e.to == a)
|
|
117
|
+
end
|
|
118
|
+
edge&.kind
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Simple cycles up to max_size via DFS (molecules are small).
|
|
122
|
+
def ring_cycles(edges, max_size:, atom_count:)
|
|
123
|
+
found = []
|
|
124
|
+
atom_count.times do |start|
|
|
125
|
+
dfs_cycles(edges, start, start, [start], max_size, found)
|
|
126
|
+
end
|
|
127
|
+
found.uniq { |cycle| cycle.minmax }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def dfs_cycles(edges, start, current, path, max_size, found)
|
|
131
|
+
neighbours(current, edges).each do |nb|
|
|
132
|
+
next if nb == path[-2]
|
|
133
|
+
|
|
134
|
+
if nb == start
|
|
135
|
+
found << path.dup if path.length >= 3
|
|
136
|
+
elsif !path.include?(nb) && path.length < max_size
|
|
137
|
+
dfs_cycles(edges, start, nb, path + [nb], max_size, found)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
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.
|
|
9
|
+
module ReactionRoles
|
|
10
|
+
CATALYST_HINTS = %w[pd pt ni fe catalyst cat].freeze
|
|
11
|
+
SOLVENT_HINTS = %w[h2o thf dmso dmf etoh meoh toluene solvent sol water].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Returns { catalysts: [strings], solvents: [strings],
|
|
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.
|
|
19
|
+
def roles_for(reaction)
|
|
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
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def split_entries(text)
|
|
50
|
+
return [] if text.nil? || text.strip.empty?
|
|
51
|
+
|
|
52
|
+
text.strip.split(/[;,\s]+/).reject(&:empty?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def matches?(entry, hints)
|
|
56
|
+
hints.any? { |hint| entry.to_s.downcase.include?(hint) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'net/https'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module AsciiChem
|
|
9
|
+
module Resolver
|
|
10
|
+
# OPSIN source adapter (TODO.v2/13 item 3): IUPAC name → structure
|
|
11
|
+
# — name-to-structure perception for systematic names, the piece
|
|
12
|
+
# PubChem's name lookup cannot do (PubChem indexes a curated name
|
|
13
|
+
# list; OPSIN *derives* the structure from any systematic IUPAC
|
|
14
|
+
# name). Uses the public OPSIN web service
|
|
15
|
+
# (https://opsin.ch.cam.ac.uk), which answers with CML or SMILES.
|
|
16
|
+
#
|
|
17
|
+
# Citation profile: publisher University of Cambridge Centre for
|
|
18
|
+
# Molecular Informatics; per-access retrievedAt provenance.
|
|
19
|
+
class Opsin < Adapter
|
|
20
|
+
source_name :opsin
|
|
21
|
+
# Derivation only: 'name' is the curated-lookup convention
|
|
22
|
+
# (PubChem/Common Chemistry). OPSIN DERIVES structures from
|
|
23
|
+
# systematic names - reachable via 'iupac-name' so it can
|
|
24
|
+
# never preempt a lookup source.
|
|
25
|
+
supports 'iupac-name'
|
|
26
|
+
|
|
27
|
+
BASE = 'https://opsin.ch.cam.ac.uk/opsin'
|
|
28
|
+
private_constant :BASE
|
|
29
|
+
|
|
30
|
+
def fetch_record(value:, convention: nil, fetch:)
|
|
31
|
+
body = fetch.get("#{BASE}/#{CGI.escape(value.to_s)}.smiles")
|
|
32
|
+
return nil unless body
|
|
33
|
+
|
|
34
|
+
substance_from(body.strip)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
raise Error, "opsin request failed: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def attribution
|
|
40
|
+
'OPSIN, University of Cambridge Centre for Molecular Informatics'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def substance_from(smiles)
|
|
46
|
+
return nil if smiles.empty?
|
|
47
|
+
|
|
48
|
+
provenance = default_provenance
|
|
49
|
+
Substance.new(
|
|
50
|
+
preferred_name: nil,
|
|
51
|
+
identifiers: [Identifier.new(value: smiles, convention: 'canonical-smiles',
|
|
52
|
+
provenance: provenance)],
|
|
53
|
+
properties: [],
|
|
54
|
+
structure: parse_structure(smiles)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# OPSIN answers SMILES that may use features outside the
|
|
59
|
+
# supported subset (wildcards, exotic stereo); then the SMILES
|
|
60
|
+
# stays as an identifier and structure is nil — no silent loss.
|
|
61
|
+
def parse_structure(smiles)
|
|
62
|
+
AsciiChem.parse_smiles(smiles).nodes.first
|
|
63
|
+
rescue AsciiChem::ParseError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/asciichem/resolver.rb
CHANGED
|
@@ -17,6 +17,7 @@ module AsciiChem
|
|
|
17
17
|
autoload :Adapter, "asciichem/resolver/adapter"
|
|
18
18
|
autoload :Cache, "asciichem/resolver/cache"
|
|
19
19
|
autoload :PubChem, "asciichem/resolver/pubchem"
|
|
20
|
+
autoload :Opsin, "asciichem/resolver/opsin"
|
|
20
21
|
autoload :CommonChemistry, "asciichem/resolver/common_chemistry"
|
|
21
22
|
autoload :Substance, "asciichem/resolver/substance"
|
|
22
23
|
|