asciichem 0.3.3 → 0.4.0

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: 9dcc9efdb2f096fa1b8e71fa12f23df5ff2c7246d017d71fe3cf87309ed65504
4
- data.tar.gz: f64ddf08f74a2c38fcce98bae1cd4010b4ae55ede0f38bbf0f248fea475dead6
3
+ metadata.gz: 7d401ee7215f54961b4126b1da5451a2e71ac316c05e3864d199c5482e28d366
4
+ data.tar.gz: c132a8c376dd7485bf0319f8d971d89cb952c27d9f2466c0f3af4af945b0ca4b
5
5
  SHA512:
6
- metadata.gz: 283b735f960be1ab4cc2f3d4b98977dc8ef49dab16e19305ab64cd5054ac09a6f69e942c56a93705c48f6e9710ecf7c3e6f462bdd12e0a0081a58eea8b7bc8aa
7
- data.tar.gz: 2c9d3f021372984bd52177269bcae201f2b176393883c774533232a2e21b3498e083d2874b1ccb1c06a8d6596cf8d309c88aed2fd457e92b8a0b6c78ea714423
6
+ metadata.gz: ab9ee8f2410f46c659cc97426a7272c511e983b601cbf043809c5b657621f0d7a48e79f17c2c4f79707cfebaf265e963077a5f636d8e03c87be2942e4fddc27e
7
+ data.tar.gz: 1cf4d97ef6459a29b9d7f05ddbd8b8b1db0ca512f547cb48badd7ebb5abc42f9c70c98cdade086af9dedf48f2e16c96ddd199097ee90c3e2ba56984e66a91aa7
@@ -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.
@@ -57,15 +57,19 @@ module AsciiChem
57
57
  end
58
58
 
59
59
  def atom_annotation(atom)
60
- annotation = +""
60
+ parts = []
61
61
  if atom.x2 && atom.y2
62
- annotation << "@(#{format_coord(atom.x2)},#{format_coord(atom.y2)}"
63
- annotation << ",#{format_coord(atom.z2)}" if atom.z2
64
- annotation << ")"
65
- elsif atom.atom_parity
66
- annotation << "@#{atom.atom_parity}"
62
+ coord = "@(#{format_coord(atom.x2)},#{format_coord(atom.y2)}"
63
+ coord << ",#{format_coord(atom.z2)}" if atom.z2
64
+ parts << "#{coord})"
65
+ end
66
+ parts << "@#{atom.atom_parity}" if atom.atom_parity
67
+ parts << "@m(#{atom.spin_multiplicity})" if atom.spin_multiplicity
68
+ parts << %(@t("#{atom.atom_title}")) if atom.atom_title
69
+ if atom.x_fract && atom.y_fract && atom.z_fract
70
+ parts << "@f(#{format_coord(atom.x_fract)},#{format_coord(atom.y_fract)},#{format_coord(atom.z_fract)})"
67
71
  end
68
- annotation
72
+ parts.join
69
73
  end
70
74
 
71
75
  def format_coord(value)
@@ -119,6 +123,23 @@ module AsciiChem
119
123
  %("#{text.content}")
120
124
  end
121
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
+
122
143
  private
123
144
 
124
145
  def render_node(node)
@@ -27,7 +27,29 @@ 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 | 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
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
31
53
 
32
54
  # Annotated molecule: a molecule followed by one or more
33
55
  # `@key("value")` annotations for CML metadata (names,
@@ -182,7 +204,7 @@ module AsciiChem
182
204
  atom_suffix >>
183
205
  lewis_radicals.maybe >>
184
206
  ring_closures.maybe.as(:ring_closures) >>
185
- atom_annotation.maybe).as(:atom)
207
+ atom_annotation).as(:atom)
186
208
  end
187
209
 
188
210
  rule(:plain_atom) do
@@ -191,15 +213,18 @@ module AsciiChem
191
213
  atom_suffix >>
192
214
  lewis_radicals.maybe >>
193
215
  ring_closures.maybe.as(:ring_closures) >>
194
- atom_annotation.maybe).as(:atom)
216
+ atom_annotation).as(:atom)
195
217
  end
196
218
 
197
- # Atom annotations: stereo parity (@R / @S) or 2D/3D coordinates
198
- # (@(x,y) / @(x,y,z)). Both use the `@` prefix. An atom can carry
199
- # at most one annotation in the grammar; multiple annotations
200
- # would require compound syntax (deferred).
219
+ # Atom annotations: each is independently optional via .maybe.
220
+ # Order matters: @(x,y) @R/@S → @m(N) → @t("...") → @f(x,y,z).
221
+ # Example: C@(10,20)@R@m(2)@t("C1")@f(0.5,0.5,0.5)
201
222
  rule(:atom_annotation) do
202
- coordinate_annotation | parity_annotation
223
+ coordinate_annotation.maybe >>
224
+ parity_annotation.maybe >>
225
+ multiplicity_annotation.maybe >>
226
+ atom_title_annotation.maybe >>
227
+ fractional_annotation.maybe
203
228
  end
204
229
 
205
230
  rule(:parity_annotation) do
@@ -214,6 +239,27 @@ module AsciiChem
214
239
  str(')')
215
240
  end
216
241
 
242
+ # Spin multiplicity: @m(2) for doublet, @m(1) for singlet
243
+ rule(:multiplicity_annotation) do
244
+ str('@m(') >> match('[0-9]').repeat(1).as(:spin_multiplicity) >> str(')')
245
+ end
246
+
247
+ # Atom title/label: @t("C1")
248
+ rule(:atom_title_annotation) do
249
+ str('@t(') >> str('"') >>
250
+ (str('"').absent? >> any).repeat.as(:atom_title) >>
251
+ str('"') >> str(')')
252
+ end
253
+
254
+ # Fractional coordinates (crystallographic): @f(0.5,0.25,0.75)
255
+ rule(:fractional_annotation) do
256
+ str('@f(') >>
257
+ float_number.as(:x_fract) >> str(',') >>
258
+ float_number.as(:y_fract) >> str(',') >>
259
+ float_number.as(:z_fract) >>
260
+ str(')')
261
+ end
262
+
217
263
  rule(:float_number) do
218
264
  str('-').maybe >> match('[0-9]').repeat(1) >> (str('.') >> match('[0-9]').repeat(0)).maybe
219
265
  end
@@ -20,13 +20,17 @@ module AsciiChem
20
20
  :charge, :oxidation_state,
21
21
  :lone_pairs, :radical_electrons,
22
22
  :ring_closures,
23
- :x2, :y2, :z2, :atom_parity
23
+ :x2, :y2, :z2, :atom_parity,
24
+ :spin_multiplicity, :atom_title,
25
+ :x_fract, :y_fract, :z_fract
24
26
 
25
27
  def initialize(element:, isotope: nil, subscript: nil,
26
28
  superscript: nil, charge: nil, oxidation_state: nil,
27
29
  lone_pairs: nil, radical_electrons: nil,
28
30
  ring_closures: nil,
29
- x2: nil, y2: nil, z2: nil, atom_parity: nil)
31
+ x2: nil, y2: nil, z2: nil, atom_parity: nil,
32
+ spin_multiplicity: nil, atom_title: nil,
33
+ x_fract: nil, y_fract: nil, z_fract: nil)
30
34
  @element = element
31
35
  @isotope = isotope
32
36
  @subscript = subscript
@@ -40,6 +44,11 @@ module AsciiChem
40
44
  @y2 = y2
41
45
  @z2 = z2
42
46
  @atom_parity = atom_parity
47
+ @spin_multiplicity = spin_multiplicity
48
+ @atom_title = atom_title
49
+ @x_fract = x_fract
50
+ @y_fract = y_fract
51
+ @z_fract = z_fract
43
52
  end
44
53
 
45
54
  def value_attributes
@@ -48,7 +57,9 @@ module AsciiChem
48
57
  oxidation_state: oxidation_state,
49
58
  lone_pairs: lone_pairs, radical_electrons: radical_electrons,
50
59
  ring_closures: ring_closures,
51
- x2: x2, y2: y2, z2: z2, atom_parity: atom_parity }
60
+ x2: x2, y2: y2, z2: z2, atom_parity: atom_parity,
61
+ spin_multiplicity: spin_multiplicity, atom_title: atom_title,
62
+ x_fract: x_fract, y_fract: y_fract, z_fract: z_fract }
52
63
  end
53
64
 
54
65
  def diagnostic_label
@@ -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
@@ -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"
@@ -215,7 +215,10 @@ module AsciiChem
215
215
  isotope: atom.isotope,
216
216
  subscript: subscript_from_count(atom.count),
217
217
  charge: atom.formal_charge,
218
- **extract_coordinates(atom)
218
+ spin_multiplicity: atom.spin_multiplicity,
219
+ atom_title: atom.title,
220
+ **extract_coordinates(atom),
221
+ **extract_fractional(atom)
219
222
  )
220
223
  end
221
224
 
@@ -232,6 +235,14 @@ module AsciiChem
232
235
  end
233
236
  end
234
237
 
238
+ def extract_fractional(atom)
239
+ return {} unless atom.xFract && atom.yFract && atom.zFract
240
+
241
+ { x_fract: atom.xFract.to_f,
242
+ y_fract: atom.yFract.to_f,
243
+ z_fract: atom.zFract.to_f }
244
+ end
245
+
235
246
  def subscript_from_count(count)
236
247
  return nil if count.nil? || count.to_s == "1"
237
248
 
@@ -330,9 +330,12 @@ module AsciiChem
330
330
  formal_charge: atom.charge,
331
331
  count: effective_count(atom, multiplier),
332
332
  lone_pairs: atom.lone_pairs,
333
- radical_electrons: atom.radical_electrons
333
+ radical_electrons: atom.radical_electrons,
334
+ spin_multiplicity: atom.spin_multiplicity,
335
+ title: atom.atom_title
334
336
  }
335
337
  merge_coordinates(attrs, atom)
338
+ merge_fractional_coords(attrs, atom)
336
339
  @atoms << Chemicalml::Cml::Atom.new(**attrs)
337
340
  emit_pending_bond(id) if @pending_bond_kind && @last_atom_id
338
341
  @last_atom_id = id
@@ -386,6 +389,14 @@ module AsciiChem
386
389
  end
387
390
  end
388
391
 
392
+ def merge_fractional_coords(attrs, atom)
393
+ return unless atom.x_fract && atom.y_fract && atom.z_fract
394
+
395
+ attrs[:xFract] = atom.x_fract
396
+ attrs[:yFract] = atom.y_fract
397
+ attrs[:zFract] = atom.z_fract
398
+ end
399
+
389
400
  def combine(left, right)
390
401
  lv = integer_or_nil(left)
391
402
  rv = integer_or_nil(right)
@@ -146,8 +146,75 @@ 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_name: subtree(:name),
155
+ crystal_params: subtree(:params),
156
+ crystal_body: subtree(:body)) do
157
+ CrystalBuilder.new(name, params, body).build
158
+ end
159
+
149
160
  # -- internal helpers ------------------------------------------------
150
161
 
162
+ # Builds a Crystal from parsed grammar captures. The grammar
163
+ # captures the name, params, and body as raw strings; this class
164
+ # parses them into the model fields.
165
+ class CrystalBuilder
166
+ def initialize(name, params_str, body_str)
167
+ @name = strip_parslet(name)
168
+ @params_str = strip_parslet(params_str)
169
+ @body_str = strip_parslet(body_str)
170
+ end
171
+
172
+ def build
173
+ params = parse_params(@params_str)
174
+ atoms = parse_atoms(@body_str)
175
+ Model::Crystal.new(
176
+ name: @name,
177
+ a: params['a'],
178
+ b: params['b'],
179
+ c: params['c'],
180
+ alpha: params['alpha'],
181
+ beta: params['beta'],
182
+ gamma: params['gamma'],
183
+ spacegroup: params['sg'],
184
+ atoms: atoms
185
+ )
186
+ end
187
+
188
+ private
189
+
190
+ def strip_parslet(value)
191
+ return nil if value.nil?
192
+
193
+ s = value.to_s.strip
194
+ s.empty? ? nil : s
195
+ end
196
+
197
+ def parse_params(str)
198
+ return {} unless str
199
+
200
+ str.split(',').each_with_object({}) do |pair, memo|
201
+ key, val = pair.strip.split('=', 2)
202
+ memo[key] = val&.strip if key
203
+ end
204
+ end
205
+
206
+ def parse_atoms(str)
207
+ return [] unless str
208
+
209
+ formula = AsciiChem.parse(str)
210
+ formula.nodes.flat_map do |node|
211
+ next [] unless node.is_a?(Model::Molecule)
212
+
213
+ node.nodes.select { |n| n.is_a?(Model::Atom) }
214
+ end
215
+ end
216
+ end
217
+
151
218
  # Strips the surrounding `"..."` quotes from a quoted text match.
152
219
  # Used by both `text_run` and `group_text_run` rules so the
153
220
  # model never carries the delimiters — the formatter re-adds them
@@ -272,7 +339,12 @@ module AsciiChem
272
339
  x2: float_or_nil(hash[:x2]),
273
340
  y2: float_or_nil(hash[:y2]),
274
341
  z2: float_or_nil(hash[:z2]),
275
- atom_parity: hash[:atom_parity]&.to_s
342
+ atom_parity: hash[:atom_parity]&.to_s,
343
+ spin_multiplicity: hash[:spin_multiplicity]&.to_s,
344
+ atom_title: hash[:atom_title]&.to_s,
345
+ x_fract: float_or_nil(hash[:x_fract]),
346
+ y_fract: float_or_nil(hash[:y_fract]),
347
+ z_fract: float_or_nil(hash[:z_fract])
276
348
  )
277
349
  end
278
350
 
@@ -305,7 +377,9 @@ module AsciiChem
305
377
  def initialize(element, isotope: nil, subscript: nil, superscript: nil,
306
378
  lone_pairs: nil, radical_electrons: nil,
307
379
  ring_closures: nil,
308
- x2: nil, y2: nil, z2: nil, atom_parity: nil)
380
+ x2: nil, y2: nil, z2: nil, atom_parity: nil,
381
+ spin_multiplicity: nil, atom_title: nil,
382
+ x_fract: nil, y_fract: nil, z_fract: nil)
309
383
  @element = element
310
384
  @isotope = isotope
311
385
  @subscript = subscript
@@ -317,6 +391,11 @@ module AsciiChem
317
391
  @y2 = y2
318
392
  @z2 = z2
319
393
  @atom_parity = atom_parity
394
+ @spin_multiplicity = spin_multiplicity
395
+ @atom_title = atom_title
396
+ @x_fract = x_fract
397
+ @y_fract = y_fract
398
+ @z_fract = z_fract
320
399
  end
321
400
 
322
401
  def build
@@ -335,7 +414,12 @@ module AsciiChem
335
414
  x2: @x2,
336
415
  y2: @y2,
337
416
  z2: @z2,
338
- atom_parity: @atom_parity
417
+ atom_parity: @atom_parity,
418
+ spin_multiplicity: @spin_multiplicity,
419
+ atom_title: @atom_title,
420
+ x_fract: @x_fract,
421
+ y_fract: @y_fract,
422
+ z_fract: @z_fract
339
423
  )
340
424
  end
341
425
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.3.3"
4
+ VERSION = "0.4.0"
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.3
4
+ version: 0.4.0
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