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.
@@ -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
@@ -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
@@ -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
@@ -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 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
@@ -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