asciichem 0.3.4 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82e379bdd585e67154ee2ac11685d34d343fc7603dad41c8e629d27610f66a0e
4
- data.tar.gz: 443e0c865b127e706b61ec9d8781b3516aecb8dfb86a83630e1363af5ae52779
3
+ metadata.gz: 1272affa5aa88924aaa06424dbc59bb115255963b675f596b58fd9588cd94fdc
4
+ data.tar.gz: bc6e6da1096c7a1c4e404d23036ec9513eae0f2866feeff9bd54a31c04540473
5
5
  SHA512:
6
- metadata.gz: 1dca3abf1149dbbeb1166053cdc6afd58e90c4ad1635c5d13a8884aa11e00b1f43dbdadda6a860d5110dc3924f1ce8f01cba41992faaabbe5bd8a5b05382cdf6
7
- data.tar.gz: 2fb960db615687093e7cb30f70752303d1b143fc9c3af8cd582d0fef7afcff00e45feabe88769bbb8f6af6431171b3b431d2689339a1d52903f21f397acc23a3
6
+ metadata.gz: f1fb3e8ae50d397af66fa526f4351b6876f2c28c76c13604d8bd4c488abbff0b10eace074df3d48b1ff6b12b86a81f6f88ddabf07a6bf1588c63540ef0ad0113
7
+ data.tar.gz: c0c694ec1edf82c9d7dfc57adaa8853cf37c1d94dca7a1288e0587f669776e5d68275345ebcc89717628b12711acd88feb3adcc885aeb945b53bf4d7f8f08d42
@@ -0,0 +1,69 @@
1
+ # 01 — Crystallography: unit cell and space group
2
+
3
+ - **Priority:** P1
4
+ - **Status:** in progress
5
+
6
+ ## Motivation
7
+
8
+ Crystallography is the foundation of materials science. CML's
9
+ `<crystal>`, `<scalar>` (for a/b/c/α/β/γ), and `<symmetry>` elements
10
+ capture crystal structures. An ASCII encoding is dramatically more
11
+ readable than XML:
12
+
13
+ ```
14
+ crystal[NaCl](a=5.64,b=5.64,c=5.64,alpha=90,beta=90,gamma=90,sg=Fm-3m){
15
+ Na@f(0,0,0)
16
+ Cl@f(0.5,0.5,0.5)
17
+ }
18
+ ```
19
+
20
+ vs CML's verbose `<crystal><scalar title="a">5.64</scalar>...`.
21
+
22
+ ## Syntax design
23
+
24
+ ```
25
+ crystal[<name>](
26
+ a=<float>, b=<float>, c=<float>,
27
+ alpha=<float>, beta=<float>, gamma=<float>,
28
+ sg=<spacegroup>
29
+ ) {
30
+ <atoms with @f(x,y,z) fractional coords>
31
+ }
32
+ ```
33
+
34
+ - `crystal` is a keyword (not an element symbol — parsed before
35
+ molecule).
36
+ - Square brackets carry the optional crystal name/title.
37
+ - Parentheses carry unit cell parameters as `key=value` pairs.
38
+ - Curly braces carry the asymmetric unit atoms with fractional coords.
39
+ - `sg` is the Hermann-Mauguin space group symbol.
40
+
41
+ ## Model
42
+
43
+ ```ruby
44
+ class Crystal < Node
45
+ attr_accessor :name, :a, :b, :c, :alpha, :beta, :gamma,
46
+ :spacegroup, :atoms
47
+ end
48
+ ```
49
+
50
+ Atoms inside a crystal use `@f(x,y,z)` fractional coordinates
51
+ (already implemented in v0.3.4).
52
+
53
+ ## CML mapping
54
+
55
+ - `Crystal` → `<crystal>` with child `<scalar>` elements for cell
56
+ parameters and `<symmetry>` for space group.
57
+ - Atoms with fractional coords → `<atom xFract="..." yFract="..."`
58
+ zFract="..."/> inside `<atomArray>`.
59
+ - The crystal is a child of `<molecule>` (CML convention: crystal
60
+ structures live inside molecules).
61
+
62
+ ## Acceptance criteria
63
+
64
+ - [ ] `crystal[NaCl](...)` parses to a Crystal model node
65
+ - [ ] Text round-trip: `parse(s).to_text == s`
66
+ - [ ] CML round-trip: `parse(s).to_cml → parse → .to_text == s`
67
+ - [ ] Cell parameters carry through as CML `<scalar>` elements
68
+ - [ ] Space group carries as CML `<symmetry>` element
69
+ - [ ] Fractional coordinates on atoms (already supported)
@@ -0,0 +1,39 @@
1
+ # 02 — Spectroscopy: NMR / IR / MS spectra
2
+
3
+ - **Priority:** P2
4
+ - **Status:** pending
5
+
6
+ ## Syntax
7
+
8
+ ```
9
+ spectrum[nmr](type=1H,solvent=CDCl3,freq=400){
10
+ 1.2: 3H s "CH3"
11
+ 3.5: 2H q J=7 "CH2"
12
+ 7.2: 5H m "C6H5"
13
+ }
14
+
15
+ spectrum[ir]{
16
+ 3300: broad "O-H stretch"
17
+ 1700: strong "C=O stretch"
18
+ }
19
+
20
+ spectrum[ms]{
21
+ 18: 100% "M+"
22
+ 17: 23% "M-1"
23
+ }
24
+ ```
25
+
26
+ ## Model
27
+
28
+ ```ruby
29
+ class Spectrum < Node
30
+ attr_accessor :type, :params, :peaks
31
+ # peaks: [{position:, intensity:, multiplicity:, assignment:}, ...]
32
+ end
33
+ ```
34
+
35
+ ## CML mapping
36
+
37
+ Maps to `<spectrum>` with `<peakList>` containing `<peak>` children.
38
+ CML peak attributes: `xValue`, `xUnits`, `yValue`, `yUnits`,
39
+ `atomRefs`, `title`.
@@ -0,0 +1,31 @@
1
+ # 03 — Computational chemistry: QC calculation results
2
+
3
+ - **Priority:** P2
4
+ - **Status:** pending
5
+
6
+ ## Syntax
7
+
8
+ ```
9
+ calc(dft, b3lyp/6-31G*){
10
+ energy: -234.5 Hartree
11
+ dipole: [0.1, 0.2, 0.3] Debye
12
+ homo: -0.32 Hartree
13
+ lumo: 0.15 Hartree
14
+ gap: 0.47 Hartree
15
+ zpe: 0.05 Hartree
16
+ }
17
+ ```
18
+
19
+ ## Model
20
+
21
+ ```ruby
22
+ class Calculation < Node
23
+ attr_accessor :method, :basis, :properties
24
+ # properties: [{title:, value:, units:}, ...]
25
+ end
26
+ ```
27
+
28
+ ## CML mapping
29
+
30
+ Maps to `<module convention="convention:compchem">` containing
31
+ `<parameterList>` (method/basis) and `<propertyList>` (results).
@@ -0,0 +1,48 @@
1
+ # 04 — Structural extensions: Z-Matrix and fragments
2
+
3
+ - **Priority:** P3
4
+ - **Status:** pending
5
+
6
+ ## Syntax
7
+
8
+ ### Z-Matrix
9
+
10
+ ```
11
+ zmatrix{
12
+ C1
13
+ H2 C1 1.09
14
+ H3 C1 1.09 H2 109.5
15
+ H4 C1 1.09 H2 109.5 H3 120.0
16
+ }
17
+ ```
18
+
19
+ Each line: atom, reference atom, bond length, [reference atom, angle,
20
+ [reference atom, dihedral]].
21
+
22
+ ### Fragments
23
+
24
+ ```
25
+ fragment(phenyl){
26
+ C1-C2=C3-C4=C5-C6=1
27
+ }
28
+ ```
29
+
30
+ Named structural fragments that can be referenced by other molecules.
31
+
32
+ ## Model
33
+
34
+ ```ruby
35
+ class ZMatrix < Node
36
+ attr_accessor :rows
37
+ # rows: [{atom:, ref1:, r12:, ref2:, angle:, ref3:, dihedral:}, ...]
38
+ end
39
+
40
+ class Fragment < Node
41
+ attr_accessor :name, :molecule
42
+ end
43
+ ```
44
+
45
+ ## CML mapping
46
+
47
+ ZMatrix → `<zMatrix>` with rows as `<atom>` references.
48
+ Fragment → `<fragment>` containing a `<molecule>`.
@@ -0,0 +1,40 @@
1
+ # 05 — Reaction mechanisms and spectators
2
+
3
+ - **Priority:** P3
4
+ - **Status:** pending
5
+
6
+ ## Syntax
7
+
8
+ ### Reaction mechanism
9
+
10
+ ```
11
+ mechanism{
12
+ step1: Cl- + CH3Br -> [TS: Cl...C...Br] -> ClCH3 + Br-
13
+ step2: ClCH3 + Na+ -> CH3Cl + Na+
14
+ spectator: Na+
15
+ }
16
+ ```
17
+
18
+ ### Spectator ions
19
+
20
+ ```
21
+ Ag+(aq) + Cl-(aq) -> AgCl(s) | spectator: Na+(aq) NO3-(aq)
22
+ ```
23
+
24
+ Inline notation: `| spectator: <atoms>` after the reaction.
25
+
26
+ ## Model
27
+
28
+ ```ruby
29
+ class Mechanism < Node
30
+ attr_accessor :steps, :spectators, :reactive_centre
31
+ # steps: [{label:, transition_state:, reaction:}, ...]
32
+ end
33
+ ```
34
+
35
+ ## CML mapping
36
+
37
+ Mechanism → `<reactionScheme>` with `<reactionStepList>`
38
+ containing `<reactionStep>` children.
39
+ Spectators → `<spectatorList>` with `<spectator>` children.
40
+ ReactiveCentre → `<reactiveCentre>` with atom references.
@@ -0,0 +1,69 @@
1
+ # TODO.beyond-formulas index
2
+
3
+ Expanding AsciiChem beyond molecular formulas into the full CML
4
+ domain. Each workstream adds a new construct type with grammar,
5
+ model, formatter, and CML round-trip support.
6
+
7
+ ## Phase 1: Crystallography
8
+
9
+ | # | Title | Status |
10
+ |---|---|---|
11
+ | 01 | [Crystallography — unit cell and space group](01-crystallography.md) | **in progress** |
12
+
13
+ Syntax: `crystal[NaCl](a=5.64,b=5.64,c=5.64,alpha=90,beta=90,gamma=90,sg=Fm-3m){Na@f(0,0,0) Cl@f(0.5,0.5,0.5)}`
14
+
15
+ ## Phase 2: Spectroscopy
16
+
17
+ | # | Title | Status |
18
+ |---|---|---|
19
+ | 02 | [NMR / IR / MS spectra](02-spectroscopy.md) | pending |
20
+
21
+ Syntax:
22
+ ```
23
+ spectrum[nmr](type=1H,solvent=CDCl3){
24
+ 1.2: 3H s "CH3"
25
+ 7.2: 5H m "ArH"
26
+ }
27
+ ```
28
+
29
+ ## Phase 3: Computational Chemistry
30
+
31
+ | # | Title | Status |
32
+ |---|---|---|
33
+ | 03 | [QC calculation results](03-compchem.md) | pending |
34
+
35
+ Syntax: `calc(b3lyp/6-31G*){energy:-234.5 dipole:[0.1,0.2,0.3]}`
36
+
37
+ ## Phase 4: Structural Extensions
38
+
39
+ | # | Title | Status |
40
+ |---|---|---|
41
+ | 04 | [Z-Matrix and fragments](04-structural.md) | pending |
42
+
43
+ Syntax:
44
+ ```
45
+ zmatrix{C1; H2 C1 1.09; H3 C1 1.09 H2 109.5}
46
+ fragment(phenyl){C1-C2=C3-C4=C5-C6=1}
47
+ ```
48
+
49
+ ## Phase 5: Reaction Mechanisms
50
+
51
+ | # | Title | Status |
52
+ |---|---|---|
53
+ | 05 | [Mechanisms and spectators](05-mechanisms.md) | pending |
54
+
55
+ Syntax: `mechanism{step: A->[TS:B*]->C; spectator:Na+}`
56
+
57
+ ## Architecture
58
+
59
+ Each domain adds:
60
+ 1. A new `Model::*` class (autoload from `lib/asciichem/model/`)
61
+ 2. A grammar rule in `grammar.rb`
62
+ 3. A transform rule in `transform.rb`
63
+ 4. `visit_*` methods on formatters (Text, CML, others as needed)
64
+ 5. ModelAdapter mapping to `Chemicalml::Cml::*` wire classes
65
+ 6. Specs
66
+
67
+ The OCP principle: each domain is a self-contained construct that
68
+ plugs into the existing `Formula` → `nodes` array. No changes to
69
+ existing model classes.
@@ -123,6 +123,41 @@ module AsciiChem
123
123
  %("#{text.content}")
124
124
  end
125
125
 
126
+ def visit_crystal(crystal)
127
+ parts = ["crystal"]
128
+ parts << "[#{crystal.name}]" if crystal.name
129
+ params = []
130
+ params << "a=#{crystal.a}" if crystal.a
131
+ params << "b=#{crystal.b}" if crystal.b
132
+ params << "c=#{crystal.c}" if crystal.c
133
+ params << "alpha=#{crystal.alpha}" if crystal.alpha
134
+ params << "beta=#{crystal.beta}" if crystal.beta
135
+ params << "gamma=#{crystal.gamma}" if crystal.gamma
136
+ params << "sg=#{crystal.spacegroup}" if crystal.spacegroup
137
+ parts << "(#{params.join(',')})" unless params.empty?
138
+ atom_strs = crystal.atoms.map { |a| render_node(a) }
139
+ parts << "{#{atom_strs.join(' ')}}" unless atom_strs.empty?
140
+ parts.join
141
+ end
142
+
143
+ def visit_spectrum(spectrum)
144
+ parts = ["spectrum"]
145
+ parts << "[#{spectrum.type}]" if spectrum.type
146
+ params = spectrum.params.map { |k, v| "#{k}=#{v}" }.join(',')
147
+ parts << "(#{params})" unless params.empty?
148
+ peak_lines = spectrum.peaks.map do |peak|
149
+ line = "#{peak[:position]}: #{peak[:intensity]}"
150
+ line += " #{peak[:multiplicity]}" if peak[:multiplicity]
151
+ line += %( "#{peak[:assignment]}") if peak[:assignment]
152
+ line
153
+ end
154
+ unless peak_lines.empty?
155
+ body = peak_lines.join("\n ")
156
+ parts << "{\n #{body}\n}"
157
+ end
158
+ parts.join
159
+ end
160
+
126
161
  private
127
162
 
128
163
  def render_node(node)
@@ -27,7 +27,51 @@ module AsciiChem
27
27
 
28
28
  rule(:nodes) { node >> (spaces? >> node).repeat }
29
29
 
30
- rule(:node) { reaction_cascade | reaction | electron_config | annotated_molecule | molecule | embedded_math | text_run.as(:text_run) }
30
+ rule(:node) { reaction_cascade | reaction | electron_config | crystal | spectrum | annotated_molecule | molecule | embedded_math | text_run.as(:text_run) }
31
+
32
+ # -- crystallography -------------------------------------------------
33
+
34
+ # crystal[Name](a=X,b=Y,...,sg=SG){atoms with @f(x,y,z)}
35
+ rule(:crystal) do
36
+ (str('crystal') >>
37
+ crystal_name.maybe >>
38
+ crystal_params.maybe >>
39
+ crystal_body.maybe).as(:crystal_node)
40
+ end
41
+
42
+ rule(:crystal_name) do
43
+ str('[') >> (str(']').absent? >> any).repeat.as(:crystal_name) >> str(']')
44
+ end
45
+
46
+ rule(:crystal_params) do
47
+ str('(') >> (str(')').absent? >> any).repeat.as(:crystal_params) >> str(')')
48
+ end
49
+
50
+ rule(:crystal_body) do
51
+ str('{') >> (str('}').absent? >> any).repeat.as(:crystal_body) >> str('}')
52
+ end
53
+
54
+ # -- spectroscopy ---------------------------------------------------
55
+
56
+ # spectrum[type](params){peak data}
57
+ rule(:spectrum) do
58
+ (str('spectrum') >>
59
+ spectrum_type.maybe >>
60
+ spectrum_params.maybe >>
61
+ spectrum_body.maybe).as(:spectrum_node)
62
+ end
63
+
64
+ rule(:spectrum_type) do
65
+ str('[') >> (str(']').absent? >> any).repeat.as(:spectrum_type) >> str(']')
66
+ end
67
+
68
+ rule(:spectrum_params) do
69
+ str('(') >> (str(')').absent? >> any).repeat.as(:spectrum_params) >> str(')')
70
+ end
71
+
72
+ rule(:spectrum_body) do
73
+ str('{') >> (str('}').absent? >> any).repeat.as(:spectrum_body) >> str('}')
74
+ end
31
75
 
32
76
  # Annotated molecule: a molecule followed by one or more
33
77
  # `@key("value")` annotations for CML metadata (names,
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Model
5
+ # A crystal structure: unit cell parameters + space group +
6
+ # asymmetric-unit atoms with fractional coordinates.
7
+ #
8
+ # Syntax:
9
+ # crystal[NaCl](a=5.64,b=5.64,c=5.64,alpha=90,beta=90,gamma=90,sg=Fm-3m){
10
+ # Na@f(0,0,0)
11
+ # Cl@f(0.5,0.5,0.5)
12
+ # }
13
+ class Crystal < Node
14
+ attr_accessor :name, :a, :b, :c, :alpha, :beta, :gamma,
15
+ :spacegroup, :atoms
16
+
17
+ def initialize(name: nil, a: nil, b: nil, c: nil,
18
+ alpha: nil, beta: nil, gamma: nil,
19
+ spacegroup: nil, atoms: [])
20
+ @name = name
21
+ @a = a
22
+ @b = b
23
+ @c = c
24
+ @alpha = alpha
25
+ @beta = beta
26
+ @gamma = gamma
27
+ @spacegroup = spacegroup
28
+ @atoms = atoms
29
+ end
30
+
31
+ def value_attributes
32
+ { name: name, a: a, b: b, c: c, alpha: alpha,
33
+ beta: beta, gamma: gamma, spacegroup: spacegroup,
34
+ atoms: atoms }
35
+ end
36
+
37
+ def children
38
+ atoms
39
+ end
40
+
41
+ def diagnostic_label
42
+ "Crystal(#{name || 'unnamed'})"
43
+ end
44
+
45
+ def to_s
46
+ params = []
47
+ params << "a=#{a}" if a
48
+ params << "b=#{b}" if b
49
+ params << "c=#{c}" if c
50
+ params << "alpha=#{alpha}" if alpha
51
+ params << "beta=#{beta}" if beta
52
+ params << "gamma=#{gamma}" if gamma
53
+ params << "sg=#{spacegroup}" if spacegroup
54
+ "crystal[#{name}](#{params.join(',')}){#{atoms.map(&:to_s).join(' ')}}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Model
5
+ # A spectroscopy result: NMR, IR, MS, UV-Vis peaks.
6
+ #
7
+ # Syntax:
8
+ # spectrum[nmr](type=1H,solvent=CDCl3){
9
+ # 1.2: 3H s "CH3"
10
+ # 7.2: 5H m "C6H5"
11
+ # }
12
+ #
13
+ # spectrum[ir]{
14
+ # 3300: broad "O-H stretch"
15
+ # }
16
+ #
17
+ # spectrum[ms]{
18
+ # 18: 100% "M+"
19
+ # }
20
+ class Spectrum < Node
21
+ attr_accessor :type, :params, :peaks
22
+
23
+ def initialize(type: nil, params: {}, peaks: [])
24
+ @type = type
25
+ @params = params
26
+ @peaks = peaks
27
+ end
28
+
29
+ def value_attributes
30
+ { type: type, params: params, peaks: peaks }
31
+ end
32
+
33
+ def children
34
+ []
35
+ end
36
+
37
+ def diagnostic_label
38
+ "Spectrum(#{type || 'unknown'})"
39
+ end
40
+
41
+ def to_s
42
+ "spectrum[#{type}](#{params.map { |k, v| "#{k}=#{v}" }.join(',')})"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -8,6 +8,7 @@ module AsciiChem
8
8
  module Model
9
9
  autoload :Atom, "asciichem/model/atom"
10
10
  autoload :Bond, "asciichem/model/bond"
11
+ autoload :Crystal, "asciichem/model/crystal"
11
12
  autoload :ElectronConfiguration, "asciichem/model/electron_configuration"
12
13
  autoload :EmbeddedMath, "asciichem/model/embedded_math"
13
14
  autoload :Formula, "asciichem/model/formula"
@@ -18,6 +19,7 @@ module AsciiChem
18
19
  autoload :Node, "asciichem/model/node"
19
20
  autoload :Reaction, "asciichem/model/reaction"
20
21
  autoload :ReactionCascade, "asciichem/model/reaction_cascade"
22
+ autoload :Spectrum, "asciichem/model/spectrum"
21
23
  autoload :Text, "asciichem/model/text"
22
24
  end
23
25
  end
@@ -146,8 +146,152 @@ module AsciiChem
146
146
  Model::ElectronConfiguration.new(orbitals: pairs)
147
147
  end
148
148
 
149
+ # -- crystals -------------------------------------------------------
150
+ #
151
+ # Grammar captures crystal_name, crystal_params, and crystal_body
152
+ # as optional strings. CrystalBuilder parses them into the model.
153
+
154
+ rule(crystal_node: subtree(:data)) do
155
+ hash = data.is_a?(Hash) ? data : {}
156
+ CrystalBuilder.new(
157
+ hash[:crystal_name],
158
+ hash[:crystal_params],
159
+ hash[:crystal_body]
160
+ ).build
161
+ end
162
+
163
+ # -- spectra --------------------------------------------------------
164
+
165
+ rule(spectrum_node: subtree(:data)) do
166
+ hash = data.is_a?(Hash) ? data : {}
167
+ SpectrumBuilder.new(
168
+ hash[:spectrum_type],
169
+ hash[:spectrum_params],
170
+ hash[:spectrum_body]
171
+ ).build
172
+ end
173
+
149
174
  # -- internal helpers ------------------------------------------------
150
175
 
176
+ # Builds a Crystal from parsed grammar captures. The grammar
177
+ # captures the name, params, and body as raw strings; this class
178
+ # parses them into the model fields.
179
+ class CrystalBuilder
180
+ def initialize(name, params_str, body_str)
181
+ @name = strip_parslet(name)
182
+ @params_str = strip_parslet(params_str)
183
+ @body_str = strip_parslet(body_str)
184
+ end
185
+
186
+ def build
187
+ params = parse_params(@params_str)
188
+ atoms = parse_atoms(@body_str)
189
+ Model::Crystal.new(
190
+ name: @name,
191
+ a: params['a'],
192
+ b: params['b'],
193
+ c: params['c'],
194
+ alpha: params['alpha'],
195
+ beta: params['beta'],
196
+ gamma: params['gamma'],
197
+ spacegroup: params['sg'],
198
+ atoms: atoms
199
+ )
200
+ end
201
+
202
+ private
203
+
204
+ def strip_parslet(value)
205
+ return nil if value.nil?
206
+
207
+ s = value.to_s.strip
208
+ s.empty? ? nil : s
209
+ end
210
+
211
+ def parse_params(str)
212
+ return {} unless str
213
+
214
+ str.split(',').each_with_object({}) do |pair, memo|
215
+ key, val = pair.strip.split('=', 2)
216
+ memo[key] = val&.strip if key
217
+ end
218
+ end
219
+
220
+ def parse_atoms(str)
221
+ return [] unless str
222
+
223
+ formula = AsciiChem.parse(str)
224
+ formula.nodes.flat_map do |node|
225
+ next [] unless node.is_a?(Model::Molecule)
226
+
227
+ node.nodes.select { |n| n.is_a?(Model::Atom) }
228
+ end
229
+ end
230
+ end
231
+
232
+ # Builds a Spectrum from parsed grammar captures. Parses peak
233
+ # lines from the body string.
234
+ class SpectrumBuilder
235
+ def initialize(type_str, params_str, body_str)
236
+ @type = strip_value(type_str)
237
+ @params_str = strip_value(params_str)
238
+ @body_str = strip_value(body_str)
239
+ end
240
+
241
+ def build
242
+ Model::Spectrum.new(
243
+ type: @type,
244
+ params: parse_params(@params_str),
245
+ peaks: parse_peaks(@body_str)
246
+ )
247
+ end
248
+
249
+ private
250
+
251
+ def strip_value(value)
252
+ return nil if value.nil?
253
+
254
+ s = value.to_s.strip
255
+ s.empty? ? nil : s
256
+ end
257
+
258
+ def parse_params(str)
259
+ return {} unless str
260
+
261
+ str.split(',').each_with_object({}) do |pair, memo|
262
+ key, val = pair.strip.split('=', 2)
263
+ memo[key] = val&.strip if key
264
+ end
265
+ end
266
+
267
+ def parse_peaks(str)
268
+ return [] unless str
269
+
270
+ str.split("\n").filter_map { |line| parse_peak(line.strip) }
271
+ end
272
+
273
+ def parse_peak(line)
274
+ return nil if line.empty?
275
+
276
+ assignment = nil
277
+ match = line.match(/"([^"]*)"/)
278
+ if match
279
+ assignment = match[1]
280
+ line = line.sub(/"[^"]*"/, '').strip
281
+ end
282
+
283
+ pos, rest = line.split(':', 2)
284
+ tokens = rest&.strip&.split(/\s+/) || []
285
+
286
+ {
287
+ position: pos&.strip,
288
+ intensity: tokens[0],
289
+ multiplicity: tokens[1],
290
+ assignment: assignment
291
+ }
292
+ end
293
+ end
294
+
151
295
  # Strips the surrounding `"..."` quotes from a quoted text match.
152
296
  # Used by both `text_run` and `group_text_run` rules so the
153
297
  # model never carries the delimiters — the formatter re-adds them
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.3.4"
4
+ VERSION = "0.4.1"
5
5
  end
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.3.4
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -114,6 +114,12 @@ files:
114
114
  - README.adoc
115
115
  - RELEASING.md
116
116
  - Rakefile
117
+ - TODO.beyond-formulas/01-crystallography.md
118
+ - TODO.beyond-formulas/02-spectroscopy.md
119
+ - TODO.beyond-formulas/03-compchem.md
120
+ - TODO.beyond-formulas/04-structural.md
121
+ - TODO.beyond-formulas/05-mechanisms.md
122
+ - TODO.beyond-formulas/README.md
117
123
  - asciichem.gemspec
118
124
  - benchmarks/RESULTS.md
119
125
  - benchmarks/benchmark.rb
@@ -149,6 +155,7 @@ files:
149
155
  - lib/asciichem/model.rb
150
156
  - lib/asciichem/model/atom.rb
151
157
  - lib/asciichem/model/bond.rb
158
+ - lib/asciichem/model/crystal.rb
152
159
  - lib/asciichem/model/electron_configuration.rb
153
160
  - lib/asciichem/model/embedded_math.rb
154
161
  - lib/asciichem/model/formula.rb
@@ -159,6 +166,7 @@ files:
159
166
  - lib/asciichem/model/node.rb
160
167
  - lib/asciichem/model/reaction.rb
161
168
  - lib/asciichem/model/reaction_cascade.rb
169
+ - lib/asciichem/model/spectrum.rb
162
170
  - lib/asciichem/model/text.rb
163
171
  - lib/asciichem/model_adapter.rb
164
172
  - lib/asciichem/model_adapter/from_canonical.rb