asciichem 0.31.0 → 0.31.2

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.
@@ -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: { 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: ":" }
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: "S", double: "D", triple: "T",
26
- quadruple: "Q", wedge: "W", hash: "H",
27
- dative: "A", wavy: "V"
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: "W", hash: "H" }.freeze
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, "S")
60
+ CML_ORDER_CODES.fetch(kind, 'S')
53
61
  end
54
62
 
55
63
  def cml_stereo_code
@@ -29,6 +29,14 @@ module AsciiChem
29
29
  conditions: conditions }
30
30
  end
31
31
 
32
+ # Normalized stoichiometry (item 4): each side maps the species'
33
+ # coefficient-free canonical text to its integer coefficient
34
+ # (absent coefficient = 1). A species can appear on both sides.
35
+ def stoichiometry
36
+ { reactants: side_stoichiometry(reactants),
37
+ products: side_stoichiometry(products) }
38
+ end
39
+
32
40
  def children
33
41
  reactants + products
34
42
  end
@@ -59,6 +67,16 @@ module AsciiChem
59
67
  "Reaction(#{reactants.map(&:to_s).join(' + ')} " \
60
68
  "#{arrow_ascii} #{products.map(&:to_s).join(' + ')})"
61
69
  end
70
+
71
+ private
72
+
73
+ def side_stoichiometry(side)
74
+ side.each_with_object(Hash.new(0)) do |molecule, out|
75
+ coefficient = (molecule.coefficient || '1').to_i
76
+ coefficient = 1 if coefficient.zero?
77
+ out[molecule.to_text.sub(/\A\d+/, '')] += coefficient
78
+ end
79
+ end
62
80
  end
63
81
  end
64
82
  end
@@ -0,0 +1,117 @@
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
+ def bond_order_sum(atom_index, edges)
40
+ edges.sum do |edge|
41
+ next 0 unless edge.from == atom_index || edge.to == atom_index
42
+
43
+ BOND_ORDER.fetch(edge.kind, 1)
44
+ end
45
+ end
46
+
47
+ # Implicit hydrogens for an atom: the smallest default valence
48
+ # that the explicit bond order fits, minus the bond order.
49
+ # - Atoms that STATE hydrogens ([NH4+], [CH4]) return 0 — never
50
+ # add to what the source said.
51
+ # - Charged atoms return nil: charge algebra on valence is
52
+ # element-specific and deferred until the perception pass grows
53
+ # one (wrong implicit hydrogens are worse than none).
54
+ # - Elements without a table entry, or explicit orders exceeding
55
+ # every default, return nil — never invent.
56
+ def implicit_hydrogens(atom, bond_order_sum)
57
+ return 0 if atom.hydrogens
58
+ return nil if atom.charge
59
+
60
+ valences = DEFAULT_VALENCES[atom.element]
61
+ return nil if valences.nil?
62
+
63
+ fitting = valences.select { |v| v >= bond_order_sum }
64
+ return nil if fitting.empty?
65
+
66
+ fitting.min - bond_order_sum
67
+ end
68
+
69
+ # Six-membered cycles where every atom is sp2 (participates in a
70
+ # double bond) or aromatic-flagged — the practical fused-system
71
+ # rule: per-ring strict alternation can never see both rings of
72
+ # naphthalene (the shared bond is single), but per-atom sp2 does.
73
+ # Returns arrays of atom indexes, one per perceived ring, or an
74
+ # empty list. Full Hückel pi-electron counting is a later slice.
75
+ def aromatic_rings(atoms, edges)
76
+ cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
77
+ .select { |cycle| cycle.length == 6 }
78
+ cycles.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges) } }
79
+ end
80
+
81
+ def sp2?(atom_index, atoms, edges)
82
+ return true if atoms[atom_index].aromatic == true
83
+
84
+ edges.any? do |edge|
85
+ (edge.from == atom_index || edge.to == atom_index) && edge.kind == :double
86
+ end
87
+ end
88
+
89
+ def bond_kind(a, b, edges)
90
+ edge = edges.find do |e|
91
+ (e.from == a && e.to == b) || (e.from == b && e.to == a)
92
+ end
93
+ edge&.kind
94
+ end
95
+
96
+ # Simple cycles up to max_size via DFS (molecules are small).
97
+ def ring_cycles(edges, max_size:, atom_count:)
98
+ found = []
99
+ atom_count.times do |start|
100
+ dfs_cycles(edges, start, start, [start], max_size, found)
101
+ end
102
+ found.uniq { |cycle| cycle.minmax }
103
+ end
104
+
105
+ def dfs_cycles(edges, start, current, path, max_size, found)
106
+ neighbours(current, edges).each do |nb|
107
+ next if nb == path[-2]
108
+
109
+ if nb == start
110
+ found << path.dup if path.length >= 3
111
+ elsif !path.include?(nb) && path.length < max_size
112
+ dfs_cycles(edges, start, nb, path + [nb], max_size, found)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Reaction-roles perception (TODO.v2/13 item 4, slice A): the
5
+ # chemistry-writing convention puts catalysts and light reagents
6
+ # ABOVE the arrow and solvents/conditions BELOW; the conditions
7
+ # text carries them free-form. This derives structured role hints
8
+ # from that convention — pure functions, nothing mutates. A full
9
+ # roles syntax (structured entries in the grammar) is the later
10
+ # slice.
11
+ module ReactionRoles
12
+ CATALYST_HINTS = %w[pd pt ni fe catalyst cat].freeze
13
+ SOLVENT_HINTS = %w[h2o thf dmso dmf etoh meoh toluene solvent sol water].freeze
14
+
15
+ module_function
16
+
17
+ # Returns { catalysts: [strings], solvents: [strings],
18
+ # reagents: [strings], other_above: [strings], other_below: [] }
19
+ # — entries split from the conditions text by whitespace, each
20
+ # classified by hint matching against the role vocabularies.
21
+ def roles_for(reaction)
22
+ conditions = reaction.conditions
23
+ above = split_entries(conditions&.above)
24
+ below = split_entries(conditions&.below)
25
+ {
26
+ catalysts: above.select { |e| matches?(e, CATALYST_HINTS) },
27
+ reagents: above.reject { |e| matches?(e, CATALYST_HINTS) },
28
+ solvents: below.select { |e| matches?(e, SOLVENT_HINTS) },
29
+ other_below: below.reject { |e| matches?(e, SOLVENT_HINTS) }
30
+ }
31
+ end
32
+
33
+ def split_entries(text)
34
+ return [] if text.nil? || text.strip.empty?
35
+
36
+ text.strip.split(/[;,\s]+/).reject(&:empty?)
37
+ end
38
+
39
+ def matches?(entry, hints)
40
+ hints.any? { |hint| entry.to_s.downcase.include?(hint) }
41
+ end
42
+ end
43
+ 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
@@ -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
 
@@ -22,7 +22,7 @@ module AsciiChem
22
22
  # Returns a Model::Formula with one Molecule per dot component.
23
23
  def parse
24
24
  molecules = [component]
25
- molecules << component while eat(".")
25
+ molecules << component while eat('.')
26
26
  unless @pos == @source.length
27
27
  raise ParseError,
28
28
  "unexpected character #{peek.inspect} at position #{@pos} in #{@source.inspect}"
@@ -35,9 +35,11 @@ module AsciiChem
35
35
  def component
36
36
  @atoms = []
37
37
  @adjacency = []
38
+ @edge_directions = {}
38
39
  @open_rings = {}
39
40
  @parent = nil
40
41
  @pending_kind = nil
42
+ @pending_direction = nil
41
43
 
42
44
  chain
43
45
 
@@ -48,7 +50,14 @@ module AsciiChem
48
50
 
49
51
  edges = []
50
52
  @adjacency.each_with_index do |neighbors, index|
51
- neighbors.each { |to, kind| edges << Structure::Graph::Edge.new(from: index, to: to, kind: kind) if to > index }
53
+ neighbors.each do |to, kind|
54
+ next unless to > index
55
+
56
+ edges << Structure::Graph::Edge.new(
57
+ from: index, to: to, kind: kind,
58
+ direction: @edge_directions[[index, to]]
59
+ )
60
+ end
52
61
  end
53
62
  Model::Molecule.new(nodes: Structure::Linearizer.new(atoms: @atoms, edges: edges).nodes)
54
63
  end
@@ -58,42 +67,51 @@ module AsciiChem
58
67
  def chain
59
68
  atom_token
60
69
  until eoc?
61
- if peek == "("
70
+ if peek == '('
62
71
  branch
63
72
  elsif bond_start?
64
- kind = bond_token
65
- if peek == "("
66
- branch(kind)
73
+ kind, direction = bond_token
74
+ if peek == '('
75
+ branch(kind, direction)
67
76
  elsif digit_start?
68
- ringbond(kind)
77
+ ringbond(kind, direction)
69
78
  else
70
79
  @pending_kind = kind
80
+ @pending_direction = direction
71
81
  atom_token
72
82
  end
73
83
  elsif digit_start?
74
84
  ringbond(nil)
75
85
  else
76
86
  @pending_kind = nil
87
+ @pending_direction = nil
77
88
  atom_token
78
89
  end
79
90
  end
80
91
  end
81
92
 
82
- def branch(kind = nil)
83
- expect("(")
93
+ def branch(kind = nil, direction = nil)
94
+ expect('(')
84
95
  # The bond may lead the branch ("C-(O)") or open it ("C(=O)").
85
- kind = bond_token if kind.nil? && bond_start?
96
+ kind, direction = bond_token if kind.nil? && bond_start?
86
97
  saved_parent = @parent
87
98
  saved_pending = @pending_kind
99
+ saved_direction = @pending_direction
88
100
  @pending_kind = kind
101
+ @pending_direction = direction
89
102
  chain
90
- expect(")")
103
+ expect(')')
91
104
  @parent = saved_parent
92
105
  @pending_kind = saved_pending
106
+ @pending_direction = saved_direction
93
107
  end
94
108
 
95
- def ringbond(kind)
109
+ def ringbond(kind, direction = nil)
96
110
  digit = ring_digit
111
+ if direction
112
+ raise ParseError,
113
+ "directional ring closures (#{direction}) are not representable in the model's ring-closure form"
114
+ end
97
115
  if @open_rings.key?(digit)
98
116
  opener = @open_rings.delete(digit)
99
117
  # The model's ring-closure digits carry no bond kind, so an
@@ -115,9 +133,13 @@ module AsciiChem
115
133
  index = @atoms.length
116
134
  @atoms << atom
117
135
  @adjacency[index] = {}
118
- add_edge(@parent, index, @pending_kind) if @parent
136
+ if @parent
137
+ add_edge(@parent, index, @pending_kind)
138
+ @edge_directions[[@parent, index].minmax] = @pending_direction if @pending_direction
139
+ end
119
140
  @parent = index
120
141
  @pending_kind = nil
142
+ @pending_direction = nil
121
143
  end
122
144
 
123
145
  def add_edge(from, to, explicit)
@@ -133,7 +155,7 @@ module AsciiChem
133
155
  # -- tokens ----------------------------------------------------------
134
156
 
135
157
  def read_atom
136
- return bracket_atom if peek == "["
158
+ return bracket_atom if peek == '['
137
159
 
138
160
  two = @source[@pos, 2].to_s
139
161
  if (symbol = ORGANIC.find { |s| two.start_with?(s) })
@@ -149,19 +171,20 @@ module AsciiChem
149
171
  end
150
172
 
151
173
  def bracket_atom
152
- expect("[")
174
+ expect('[')
153
175
  isotope = digits
154
176
  symbol = bracket_symbol
155
- reject_chirality
177
+ chirality = read_chirality
156
178
  hydrogens = hcount
157
179
  charge = bracket_charge
158
180
  skip_class
159
- expect("]")
181
+ expect(']')
160
182
  Model::Atom.new(
161
183
  element: symbol.capitalize,
162
184
  isotope: isotope,
163
185
  charge: charge,
164
186
  hydrogens: hydrogens,
187
+ chirality: chirality,
165
188
  aromatic: symbol.match?(/\A[a-z]/) || nil
166
189
  )
167
190
  end
@@ -188,15 +211,23 @@ module AsciiChem
188
211
  raise ParseError, "expected an element symbol at position #{@pos} in #{@source.inspect}"
189
212
  end
190
213
 
191
- def reject_chirality
192
- return unless peek == "@"
214
+ # `@' (TH1) / `@@' (TH2): SMILES ordered-neighbour tetrahedral
215
+ # parity, stored verbatim on the atom — the writer recomputes
216
+ # the token for its own traversal order.
217
+ def read_chirality
218
+ return nil unless peek == '@'
193
219
 
194
- token = @source[@pos, 2] == "@@" ? "'@@'" : "'@'"
195
- raise ParseError, "chirality #{token} is not supported in the v1 subset"
220
+ if @source[@pos, 2] == '@@'
221
+ @pos += 2
222
+ 'TH2'
223
+ else
224
+ @pos += 1
225
+ 'TH1'
226
+ end
196
227
  end
197
228
 
198
229
  def hcount
199
- return nil unless peek == "H"
230
+ return nil unless peek == 'H'
200
231
 
201
232
  @pos += 1
202
233
  count = digits
@@ -206,7 +237,7 @@ module AsciiChem
206
237
  # "+" | "++" | "+n" | "-" | "--" | "-n" → number-then-sign.
207
238
  def bracket_charge
208
239
  sign = peek
209
- return nil unless sign == "+" || sign == "-"
240
+ return nil unless ['+', '-'].include?(sign)
210
241
 
211
242
  @pos += 1
212
243
  second = @source[@pos].to_s
@@ -221,32 +252,35 @@ module AsciiChem
221
252
  end
222
253
 
223
254
  def skip_class
224
- return unless peek == ":"
255
+ return unless peek == ':'
225
256
 
226
257
  @pos += 1
227
258
  digits
228
259
  end
229
260
 
261
+ # Returns [kind, direction]: `/' and `\' are DIRECTIONAL SINGLE
262
+ # bonds (SMILES E/Z stereo) — kind stays :single, the direction
263
+ # rides the model's Bond#direction.
230
264
  def bond_token
231
265
  ch = peek
232
- kind = { "-" => :single, "=" => :double, "#" => :triple, "$" => :quadruple,
233
- ":" => :aromatic }[ch]
266
+ kind = { '-' => :single, '=' => :double, '#' => :triple, '$' => :quadruple,
267
+ ':' => :aromatic }[ch]
234
268
  unless kind
235
- if ch == "/" || ch == "\\"
236
- raise ParseError,
237
- "bond direction #{ch.inspect} (E/Z stereo) is not supported in the v1 subset"
269
+ if ['/', '\\'].include?(ch)
270
+ @pos += 1
271
+ return [:single, ch == '/' ? :up : :down]
238
272
  end
239
273
  raise ParseError, "expected a bond or atom at position #{@pos} in #{@source.inspect}"
240
274
  end
241
275
  @pos += 1
242
- kind
276
+ [kind, nil]
243
277
  end
244
278
 
245
279
  def ring_digit
246
- if peek == "%"
280
+ if peek == '%'
247
281
  @pos += 1
248
282
  digit = @source[@pos, 2].to_s
249
- raise ParseError, "malformed %nn ring closure" unless digit.match?(/\A\d\d\z/)
283
+ raise ParseError, 'malformed %nn ring closure' unless digit.match?(/\A\d\d\z/)
250
284
 
251
285
  @pos += 2
252
286
  digit
@@ -291,9 +325,7 @@ module AsciiChem
291
325
  end
292
326
 
293
327
  def expect(char)
294
- unless peek == char
295
- raise ParseError, "expected #{char.inspect} at position #{@pos} in #{@source.inspect}"
296
- end
328
+ raise ParseError, "expected #{char.inspect} at position #{@pos} in #{@source.inspect}" unless peek == char
297
329
 
298
330
  @pos += 1
299
331
  end