asciichem 0.31.4 → 0.31.6
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/.github/workflows/ci.yml +8 -2
- data/ARCHITECTURE.adoc +78 -0
- data/CHANGELOG.md +44 -0
- data/lib/asciichem/linter/balance_check.rb +12 -1
- data/lib/asciichem/linter/base.rb +6 -6
- data/lib/asciichem/linter/diagnostic.rb +6 -1
- data/lib/asciichem/perception.rb +44 -15
- data/lib/asciichem/rinchi/binary_engine.rb +27 -12
- data/lib/asciichem/rinchi.rb +5 -3
- data/lib/asciichem/smiles/writer.rb +41 -6
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem/wire_adapter.rb +64 -31
- data/scripts/update-model-schemas.sh +3 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac2c54a74f212244c74efeb88156c34398cdfe87751de14a597833533f4ccb92
|
|
4
|
+
data.tar.gz: 8f5614c2703ae6ee1440be65fa2c7654bc3cc12d2c8b1207f43a974ab804e3f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 487d422ca183453e7e9a82de50aaa1c6e671383047838ca0acab124678520bf08edbe20a115f3c416407683e45b07cc5654b46bdcb71559004ef22271989d90b
|
|
7
|
+
data.tar.gz: ae81c712c9d31ffd440b8ad49200aea40c52251f8a6017344d0ce6bbd70b3b236ac89c850e2aac57c6866f87cec986a0c26694989b5f4b40eed7ffc147c1bbdb
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -5,6 +5,12 @@ on:
|
|
|
5
5
|
push:
|
|
6
6
|
branches: [main]
|
|
7
7
|
|
|
8
|
+
env:
|
|
9
|
+
# Hermetic CI: the corpus ref is pinned, never floating. Bump this
|
|
10
|
+
# one line deliberately with each corpus release (the tag carries
|
|
11
|
+
# what changed).
|
|
12
|
+
CORPUS_REF: v0.8.0
|
|
13
|
+
|
|
8
14
|
jobs:
|
|
9
15
|
test:
|
|
10
16
|
runs-on: ubuntu-latest
|
|
@@ -18,12 +24,12 @@ jobs:
|
|
|
18
24
|
uses: actions/checkout@v7
|
|
19
25
|
with:
|
|
20
26
|
repository: asciichem/asciichem-tests
|
|
27
|
+
ref: ${{ env.CORPUS_REF }}
|
|
21
28
|
path: .asciichem-tests
|
|
22
29
|
- name: Point the suite at the corpus and record its version
|
|
23
30
|
run: |
|
|
24
31
|
echo "ASCIICHEM_CORPUS=$(pwd)/.asciichem-tests/corpus/fixtures" >> "$GITHUB_ENV"
|
|
25
|
-
|
|
26
|
-
echo "ASCIICHEM_CORPUS_VERSION=$(git -C .asciichem-tests describe --tags --abbrev=0 2>/dev/null || echo main)" >> "$GITHUB_ENV"
|
|
32
|
+
echo "ASCIICHEM_CORPUS_VERSION=${{ env.CORPUS_REF }}" >> "$GITHUB_ENV"
|
|
27
33
|
- uses: ruby/setup-ruby@v1
|
|
28
34
|
with:
|
|
29
35
|
ruby-version: ${{ matrix.ruby }}
|
data/ARCHITECTURE.adoc
CHANGED
|
@@ -390,3 +390,81 @@ Ten registered checks (auto-load via `Linter::Base.register`):
|
|
|
390
390
|
* `#atom_count` — total atoms (recurses through groups/nested molecules)
|
|
391
391
|
* `#hill_formula` — Hill-system canonical formula (C first, H second, alpha)
|
|
392
392
|
* `#formula_weight` — sum of atomic_mass * count (v0.13+)
|
|
393
|
+
|
|
394
|
+
== Perception pass (v0.31+)
|
|
395
|
+
|
|
396
|
+
`AsciiChem::Perception` derives the chemistry the syntax leaves
|
|
397
|
+
implicit, as pure functions over the neutral graph pair
|
|
398
|
+
(`Structure::Graph.build` returns `[atoms, edges]`):
|
|
399
|
+
|
|
400
|
+
* `bond_order_sum` — valence-feasibility sum (aromatic orders as 1);
|
|
401
|
+
consumed by the linter's `ValenceCheck`.
|
|
402
|
+
* `hydrogen_valence_sum` — hydrogen-counting sum (aromatic orders as
|
|
403
|
+
1.5, Hückel); paired with `implicit_hydrogens`, which returns a
|
|
404
|
+
whole, non-negative count or declines (`nil`): stated hydrogens win,
|
|
405
|
+
charged atoms and non-integral/over-valent sums decline.
|
|
406
|
+
* `aromatic_rings` — π-electron counting for 5- and 6-membered cycles
|
|
407
|
+
(in-system double = 1, lone-pair donor N/O/S/P/Se/As = 2,
|
|
408
|
+
exocyclic-double carbon = 0; aromatic at totals 6 or 10). Charged
|
|
409
|
+
rings decline; aromatic-flagged rings are taken as stated. This
|
|
410
|
+
perceives furan/pyrrole/thiophene, fused systems, keeps quinones
|
|
411
|
+
Kekulé, and rejects four-π and three-membered rings.
|
|
412
|
+
|
|
413
|
+
Semantics are shared through the corpus (`fixtures/perception.json`) —
|
|
414
|
+
per-atom implicit hydrogens plus perceived ring indexes, declines
|
|
415
|
+
included — and mirrored in TypeScript and Python.
|
|
416
|
+
|
|
417
|
+
== Canonical SMILES (v0.31+)
|
|
418
|
+
|
|
419
|
+
`Smiles::Writer` is a deterministic serialiser that consumes the
|
|
420
|
+
perception pass: perceived aromatic rings are written lowercase with
|
|
421
|
+
their ring bonds implicit, so Kekulé input canonicalizes
|
|
422
|
+
(`C1=CC=CC=C1` → `c1ccccc1`, pyrrole → `c1c[nH]cc1`). Ring-closure
|
|
423
|
+
digits stay out of branches (a closure-bearing child continues the
|
|
424
|
+
chain). Stated-zero hydrogens emit `H0`. The canonical form is a fixed
|
|
425
|
+
point; corpus fixtures whose input is not canonical state the expected
|
|
426
|
+
output in `canonicalSmiles`.
|
|
427
|
+
|
|
428
|
+
== Reactions as semantics (v0.31+)
|
|
429
|
+
|
|
430
|
+
* `Reaction::Conditions#entries` — typed role entries
|
|
431
|
+
(`[cat: Pd; solv: THF]`) derived from the verbatim bracket text;
|
|
432
|
+
`ReactionRoles.roles_for` prefers explicit roles, falls back to the
|
|
433
|
+
above/below convention for free text.
|
|
434
|
+
* `Reaction#stoichiometry` and `Molecule#stoichiometric_coefficient` —
|
|
435
|
+
coefficient-free species → integer coefficient, one rule shared by
|
|
436
|
+
the reaction, `BalanceCheck`, and `element_counts`.
|
|
437
|
+
* `MechanismGraph.for(mechanism)` — parsed steps, catalysts (species on
|
|
438
|
+
both sides), inter-step links (product → later reactant), and the
|
|
439
|
+
step's electron-flow markers.
|
|
440
|
+
* `Mechanism::Flow` — `| flow: SOURCE > TARGET` clauses on a step
|
|
441
|
+
(semicolon-separated), verbatim names, carried on the wire as
|
|
442
|
+
`steps[].flows`; unknown clauses and malformed arrows raise.
|
|
443
|
+
* `Rxn::Writer` — MDL RXN V2000 emission (species, not
|
|
444
|
+
stoichiometry); `Rinchi::BinaryEngine` wraps the opt-in `rinchi-1`
|
|
445
|
+
binary for reaction identity (same contract as the InChI engine:
|
|
446
|
+
install guidance, `EngineMissingError`, nothing vendored).
|
|
447
|
+
|
|
448
|
+
== Depiction backends (v0.31+)
|
|
449
|
+
|
|
450
|
+
`AsciiChem::Layout.backend` is the depiction seam: a backend responds
|
|
451
|
+
to `layout(molecule, algorithm:)` and returns a `Layout::Result`.
|
|
452
|
+
`Layout::BuiltinBackend` (authored coordinates or deterministic elkrb
|
|
453
|
+
layered layout) is the default; external engines (RDKit, CDK, a Rust
|
|
454
|
+
layout core) plug in with one assignment. The renderer, the model, and
|
|
455
|
+
the wire form never learn which engine placed the atoms.
|
|
456
|
+
|
|
457
|
+
== Conformance contracts (v0.31+)
|
|
458
|
+
|
|
459
|
+
Two sibling repositories are normative, not the implementation:
|
|
460
|
+
|
|
461
|
+
* `asciichem-model` — versioned wire schemas (additive changes only),
|
|
462
|
+
LUTAML definitions, generated TS types; the gem vendors the schemas
|
|
463
|
+
into `spec/schemas` via `scripts/update-model-schemas.sh`.
|
|
464
|
+
* `asciichem-tests` — the shared corpus. Fixture families: parser
|
|
465
|
+
acceptance/rejection, canonical-text round-trip, CML, MathML
|
|
466
|
+
goldens, identifiers, linter expectations, SMILES/molfile
|
|
467
|
+
interchange, perception (`implicitHydrogens`, `aromaticRings`),
|
|
468
|
+
condition entries, mechanism flows, and 300 seeded generated models
|
|
469
|
+
shared across implementations. Contract changes are versioned and
|
|
470
|
+
state new expectations; fixtures are never weakened.
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
3
3
|
All notable changes to AsciiChem are documented here.
|
|
4
4
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.31.6] - 2026-10-21
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **Mechanism wire ingestion** (TODO.v2/13 item 4): `from_model_json`
|
|
10
|
+
restores mechanisms — steps return as canonical reaction text, flows
|
|
11
|
+
and spectators intact; cascade steps have no wire form and raise
|
|
12
|
+
instead of silently flattening. Mechanism round-trip is asserted
|
|
13
|
+
corpus-wide (the `modelJsonRoundTrip` flag is gone).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **BalanceCheck diagnostics carry structured details**: each
|
|
17
|
+
imbalance reports `element => [reactants, products]` deltas for
|
|
18
|
+
every element whose counts differ, alongside the message.
|
|
19
|
+
- **RInChI engine matches the real `rinchi_cmdline` contract**
|
|
20
|
+
(validated end-to-end against the IUPAC binary built from source):
|
|
21
|
+
the RXN file is the positional argument (no flags), direction-distinct
|
|
22
|
+
identities for a reaction and its reverse.
|
|
23
|
+
- Hermetic CI: the conformance corpus is pinned (`CORPUS_REF: v0.8.0`)
|
|
24
|
+
and the schema re-vendor script defaults to the released
|
|
25
|
+
asciichem-model tag (v0.6.0) — CI inputs no longer float at `main`.
|
|
26
|
+
|
|
27
|
+
## [0.31.5] - 2026-10-20
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- Aromaticity perception is now **Hückel counting** (TODO.v2/13 item 2,
|
|
31
|
+
finishing the documented later slice): 5- and 6-membered cycles
|
|
32
|
+
count π electrons — in-system double = 1, lone-pair donor
|
|
33
|
+
(N/O/S/P/Se/As) with no double = 2, exocyclic-double carbon = 0 —
|
|
34
|
+
and are aromatic at totals 6 or 10. Charged rings decline;
|
|
35
|
+
aromatic-flagged rings are taken as stated. Furan, pyrrole, and
|
|
36
|
+
thiophene are perceived from Kekulé bond orders; quinones remain
|
|
37
|
+
excluded, as do four-π and three-membered rings.
|
|
38
|
+
- Canonical SMILES covers the five-membered heteroaromatics:
|
|
39
|
+
`C1=COC=C1` → `c1cocc1`, `C1=CNC=C1` → `c1c[nH]cc1`,
|
|
40
|
+
`C1=CSC=C1` → `c1cscc1`. A perceived pyrrole nitrogen is written
|
|
41
|
+
`[nH]` (bare aromatic `n` implies no hydrogen); inferred heteroatom
|
|
42
|
+
hydrogens apply only to rings perceived from Kekulé orders, so
|
|
43
|
+
source-aromatic thiophene never gains a phantom H.
|
|
44
|
+
- Corpus contract 0.8.0 (`asciichem-tests`): Hückel fixtures for
|
|
45
|
+
furan/pyrrole/thiophene, cyclopentadiene and cyclopropene pinned as
|
|
46
|
+
not aromatic, plus `canonicalSmiles` expectations. TS and Python
|
|
47
|
+
parity merged; the Python writer also emits `H0` for stated-zero
|
|
48
|
+
hydrogens.
|
|
49
|
+
|
|
6
50
|
## [0.31.4] - 2026-10-20
|
|
7
51
|
|
|
8
52
|
### Changed
|
|
@@ -33,7 +33,8 @@ module AsciiChem
|
|
|
33
33
|
diffs = compute_diffs(reactants, products)
|
|
34
34
|
diagnostics << error(
|
|
35
35
|
"Reaction is not balanced: #{diffs}",
|
|
36
|
-
node: reaction
|
|
36
|
+
node: reaction,
|
|
37
|
+
details: element_deltas(reactants, products)
|
|
37
38
|
)
|
|
38
39
|
end
|
|
39
40
|
|
|
@@ -87,6 +88,16 @@ module AsciiChem
|
|
|
87
88
|
result
|
|
88
89
|
end
|
|
89
90
|
|
|
91
|
+
# Element => [reactant_count, product_count] for every element
|
|
92
|
+
# whose counts differ — the numbers behind the message.
|
|
93
|
+
def element_deltas(reactants, products)
|
|
94
|
+
(reactants.keys | products.keys).sort.each_with_object({}) do |element, out|
|
|
95
|
+
r = reactants.fetch(element, 0)
|
|
96
|
+
p = products.fetch(element, 0)
|
|
97
|
+
out[element] = [r, p] if r != p
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
90
101
|
def compute_diffs(reactants, products)
|
|
91
102
|
all_elements = (reactants.keys + products.keys).uniq.sort
|
|
92
103
|
all_elements.map do |element|
|
|
@@ -22,16 +22,16 @@ module AsciiChem
|
|
|
22
22
|
|
|
23
23
|
protected
|
|
24
24
|
|
|
25
|
-
def error(message, node: nil)
|
|
26
|
-
Diagnostic.new(severity: :error, message: message, node: node)
|
|
25
|
+
def error(message, node: nil, details: nil)
|
|
26
|
+
Diagnostic.new(severity: :error, message: message, node: node, details: details)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def warning(message, node: nil)
|
|
30
|
-
Diagnostic.new(severity: :warning, message: message, node: node)
|
|
29
|
+
def warning(message, node: nil, details: nil)
|
|
30
|
+
Diagnostic.new(severity: :warning, message: message, node: node, details: details)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def info(message, node: nil)
|
|
34
|
-
Diagnostic.new(severity: :info, message: message, node: node)
|
|
33
|
+
def info(message, node: nil, details: nil)
|
|
34
|
+
Diagnostic.new(severity: :info, message: message, node: node, details: details)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Depth-first walk over every node in the formula. Yields each
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module AsciiChem
|
|
4
4
|
module Linter
|
|
5
|
-
|
|
5
|
+
# `details` carries optional structured data behind the message
|
|
6
|
+
# (e.g. BalanceCheck's per-element [reactants, products] counts)
|
|
7
|
+
# for consumers that want the numbers, not the prose. Human-facing
|
|
8
|
+
# output stays the message.
|
|
9
|
+
Diagnostic = Struct.new(:severity, :message, :node, :details,
|
|
10
|
+
keyword_init: true) do
|
|
6
11
|
def to_s
|
|
7
12
|
context = node&.diagnostic_label
|
|
8
13
|
if context
|
data/lib/asciichem/perception.rb
CHANGED
|
@@ -91,27 +91,56 @@ module AsciiChem
|
|
|
91
91
|
.select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
94
|
+
# π-electron-donating heteroatoms: a ring atom with no double bond
|
|
95
|
+
# of its own contributes its lone pair (pyrrole N, furan O,
|
|
96
|
+
# thiophene S) — the 2 electrons that make those five-membered
|
|
97
|
+
# rings 4n+2.
|
|
98
|
+
LONE_PAIR_DONORS = %w[N O S P Se As].freeze
|
|
99
|
+
|
|
100
|
+
# Aromatic rings by Hückel counting: 5- and 6-membered cycles whose
|
|
101
|
+
# π-electron contributions total 6 or 10 (4n+2 with n >= 1; the
|
|
102
|
+
# n = 0 case is a charged three-membered ring, which perception
|
|
103
|
+
# declines, and a 2π six-ring would otherwise misread a diene as
|
|
104
|
+
# aromatic). Per
|
|
105
|
+
# atom: an in-system double bond contributes 1; a lone-pair donor
|
|
106
|
+
# with no double contributes 2; a carbon with only single bonds or
|
|
107
|
+
# an exocyclic double contributes 0. Charged atoms make the count
|
|
108
|
+
# undecidable and the ring is declined; aromatic-flagged rings are
|
|
109
|
+
# taken as stated.
|
|
110
|
+
#
|
|
111
|
+
# This sees fused systems (naphthalene's shared bond is single, but
|
|
112
|
+
# each atom's double bond lies in the ring system) and keeps
|
|
113
|
+
# quinones out (their carbons' only double bonds point at
|
|
114
|
+
# exocyclic oxygens, so the ring counts 5, not 4n+2).
|
|
105
115
|
def aromatic_rings(atoms, edges)
|
|
106
116
|
cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
|
|
107
117
|
cyclic_atoms = cycles.flatten.uniq
|
|
108
|
-
|
|
109
|
-
six.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges, cyclic_atoms) } }
|
|
118
|
+
cycles.select { |cycle| aromatic_cycle?(cycle, atoms, edges, cyclic_atoms) }
|
|
110
119
|
end
|
|
111
120
|
|
|
112
|
-
def
|
|
113
|
-
return true if atoms[
|
|
121
|
+
def aromatic_cycle?(cycle, atoms, edges, cyclic_atoms)
|
|
122
|
+
return true if cycle.any? { |i| atoms[i].aromatic == true }
|
|
123
|
+
return false unless [5, 6].include?(cycle.length)
|
|
114
124
|
|
|
125
|
+
contributions = cycle.map do |i|
|
|
126
|
+
ring_pi_electrons(atoms[i], i, edges, cyclic_atoms)
|
|
127
|
+
end
|
|
128
|
+
return false if contributions.any?(&:nil?)
|
|
129
|
+
|
|
130
|
+
[6, 10].include?(contributions.sum)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# π electrons one ring atom contributes, or nil when the count is
|
|
134
|
+
# undecidable (charged atom).
|
|
135
|
+
def ring_pi_electrons(atom, atom_index, edges, cyclic_atoms)
|
|
136
|
+
return nil if atom.charge
|
|
137
|
+
return 1 if in_system_double?(atom_index, edges, cyclic_atoms)
|
|
138
|
+
return 2 if LONE_PAIR_DONORS.include?(atom.element)
|
|
139
|
+
|
|
140
|
+
0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def in_system_double?(atom_index, edges, cyclic_atoms)
|
|
115
144
|
edges.any? do |edge|
|
|
116
145
|
next false unless edge.from == atom_index || edge.to == atom_index
|
|
117
146
|
next false unless edge.kind == :double
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
|
+
require 'tempfile'
|
|
4
5
|
|
|
5
6
|
module AsciiChem
|
|
6
7
|
module Rinchi
|
|
@@ -10,24 +11,28 @@ module AsciiChem
|
|
|
10
11
|
# TODO.v2/10, a maintainer decision).
|
|
11
12
|
#
|
|
12
13
|
# AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new
|
|
13
|
-
# AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new(bin: "/usr/local/bin/
|
|
14
|
+
# AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new(bin: "/usr/local/bin/rinchi_cmdline")
|
|
15
|
+
# The reference CLI (IUPAC-InChI/RInChI's `rinchi_cmdline`) takes a
|
|
16
|
+
# FILE argument, not stdin, needs no flags, and prints RInChI=,
|
|
17
|
+
# RAuxInfo=, Long-RInChIKey=, Short-RInChIKey= and Web-RInChIKey=
|
|
18
|
+
# lines. The engine writes the RXN to a temp file, runs the binary
|
|
19
|
+
# on it, and reads the identity lines back.
|
|
14
20
|
class BinaryEngine
|
|
15
|
-
DEFAULT_FLAGS =
|
|
21
|
+
DEFAULT_FLAGS = [].freeze
|
|
16
22
|
|
|
17
23
|
attr_reader :bin, :flags
|
|
18
24
|
|
|
19
|
-
def initialize(bin: '
|
|
25
|
+
def initialize(bin: 'rinchi_cmdline', flags: DEFAULT_FLAGS)
|
|
20
26
|
super()
|
|
21
27
|
@bin = bin
|
|
22
28
|
@flags = flags.freeze
|
|
23
29
|
end
|
|
24
30
|
|
|
25
|
-
# reaction → RXN → rinchi
|
|
26
|
-
# pipe: reactants and products cross into the engine as V2000
|
|
31
|
+
# reaction → RXN → rinchi binary → Identity. The RXN emission is
|
|
32
|
+
# the pipe: reactants and products cross into the engine as V2000
|
|
27
33
|
# mol blocks inside one $RXN container.
|
|
28
34
|
def identity(reaction)
|
|
29
|
-
|
|
30
|
-
out = execute(rxn)
|
|
35
|
+
out = execute(AsciiChem::Rxn.write(reaction))
|
|
31
36
|
rinchi = out[/^RInChI=\S+/]
|
|
32
37
|
raise AsciiChem::Error, "#{bin} produced no RInChI:\n#{out}" unless rinchi
|
|
33
38
|
|
|
@@ -40,16 +45,26 @@ module AsciiChem
|
|
|
40
45
|
|
|
41
46
|
private
|
|
42
47
|
|
|
43
|
-
def execute(
|
|
44
|
-
out
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
def execute(rxn)
|
|
49
|
+
out = err = nil
|
|
50
|
+
status = nil
|
|
51
|
+
Tempfile.create(['asciichem', '.rxn']) do |file|
|
|
52
|
+
file.write(rxn)
|
|
53
|
+
file.flush
|
|
54
|
+
out, err, status = Open3.capture3(bin, file.path, *flags)
|
|
47
55
|
end
|
|
48
|
-
|
|
56
|
+
report_failure(status, err, out)
|
|
49
57
|
out
|
|
50
58
|
rescue Errno::ENOENT
|
|
51
59
|
raise EngineMissingError, "RInChI engine binary #{bin.inspect} not found — #{AsciiChem::Rinchi::INSTALL_GUIDE}"
|
|
52
60
|
end
|
|
61
|
+
|
|
62
|
+
def report_failure(status, err, out)
|
|
63
|
+
return if status&.success?
|
|
64
|
+
|
|
65
|
+
raise AsciiChem::Error,
|
|
66
|
+
"#{bin} failed (exit #{status&.exitstatus}): #{((err || '') + (out || '')).strip[0, 500]}"
|
|
67
|
+
end
|
|
53
68
|
end
|
|
54
69
|
end
|
|
55
70
|
end
|
data/lib/asciichem/rinchi.rb
CHANGED
|
@@ -15,9 +15,11 @@ module AsciiChem
|
|
|
15
15
|
autoload :Identity, 'asciichem/rinchi/identity'
|
|
16
16
|
|
|
17
17
|
INSTALL_GUIDE = 'install the IUPAC RInChI software ' \
|
|
18
|
-
'(https://github.com/IUPAC-InChI/RInChI
|
|
19
|
-
'
|
|
20
|
-
'
|
|
18
|
+
'(https://github.com/IUPAC-InChI/RInChI — build ' \
|
|
19
|
+
'src/example_apps/rinchi_cmdline against InChI v1.07.5) ' \
|
|
20
|
+
'and set AsciiChem::Rinchi.engine = ' \
|
|
21
|
+
'AsciiChem::Rinchi::BinaryEngine.new ' \
|
|
22
|
+
'(or pass bin: "path/to/rinchi_cmdline")'
|
|
21
23
|
|
|
22
24
|
class << self
|
|
23
25
|
attr_accessor :engine
|
|
@@ -25,6 +25,10 @@ module AsciiChem
|
|
|
25
25
|
}.freeze
|
|
26
26
|
CONTINUATION_BONDS = %i[single aromatic].freeze
|
|
27
27
|
|
|
28
|
+
# Aromatic lowercase heteroatoms with an implicit hydrogen need
|
|
29
|
+
# brackets: bare `n`/`o`/`s` imply none.
|
|
30
|
+
HETERO_IMPLICIT_H = %w[N O S P Se As].freeze
|
|
31
|
+
|
|
28
32
|
def initialize(molecule)
|
|
29
33
|
@molecule = molecule
|
|
30
34
|
end
|
|
@@ -71,13 +75,27 @@ module AsciiChem
|
|
|
71
75
|
# item 2): atoms in perceived aromatic rings are written as
|
|
72
76
|
# aromatic (lowercase) and the ring bonds between them carry no
|
|
73
77
|
# bond symbol — Kekulé benzene canonicalizes to `c1ccccc1`, so
|
|
74
|
-
# both spellings converge. Perception's
|
|
75
|
-
#
|
|
78
|
+
# both spellings converge. Perception's Hückel counting keeps
|
|
79
|
+
# quinone-like rings out.
|
|
80
|
+
#
|
|
81
|
+
# Hydrogens are inferred for lowercase carbon but NOT for
|
|
82
|
+
# heteroatoms: pyrrole's N must be written `[nH]` or the SMILES
|
|
83
|
+
# states a pyridine-type nitrogen instead. The perceived
|
|
84
|
+
# implicit count for ring atoms is recorded for that decision.
|
|
76
85
|
def perceive_aromaticity(atoms, edges)
|
|
77
86
|
@perceived_aromatic = {}
|
|
78
87
|
@perceived_ring_edges = {}
|
|
88
|
+
@perceived_hydrogens = {}
|
|
79
89
|
AsciiChem::Perception.aromatic_rings(atoms, edges).each do |ring|
|
|
80
|
-
ring.each
|
|
90
|
+
ring.each do |index|
|
|
91
|
+
@perceived_aromatic[index] = true
|
|
92
|
+
next if atoms[index].aromatic == true
|
|
93
|
+
|
|
94
|
+
@perceived_hydrogens[index] = AsciiChem::Perception.implicit_hydrogens(
|
|
95
|
+
atoms[index],
|
|
96
|
+
AsciiChem::Perception.hydrogen_valence_sum(index, edges)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
81
99
|
ring.each_with_index do |atom, i|
|
|
82
100
|
other = ring[(i + 1) % ring.length]
|
|
83
101
|
@perceived_ring_edges[[atom, other].minmax] = true
|
|
@@ -85,6 +103,19 @@ module AsciiChem
|
|
|
85
103
|
end
|
|
86
104
|
end
|
|
87
105
|
|
|
106
|
+
# Hydrogens the written token must state: the source's own count
|
|
107
|
+
# wins; otherwise only a ring PERCEIVED from Kekulé bond orders
|
|
108
|
+
# gets perception's implicit count. A source-aromatic atom
|
|
109
|
+
# already states its hydrogens the way SMILES does (`[nH]`), and
|
|
110
|
+
# aromatic 1.5 bond orders do not map onto heteroatom valences
|
|
111
|
+
# (thiophene's S would otherwise acquire a phantom H).
|
|
112
|
+
def writing_hydrogens(index)
|
|
113
|
+
stated = @atoms[index].hydrogens
|
|
114
|
+
return stated unless stated.nil?
|
|
115
|
+
|
|
116
|
+
@perceived_hydrogens[index]
|
|
117
|
+
end
|
|
118
|
+
|
|
88
119
|
# Aromatic for WRITING: the source flagged the atom, or
|
|
89
120
|
# perception found it in an aromatic ring.
|
|
90
121
|
def aromatic?(index)
|
|
@@ -269,8 +300,12 @@ module AsciiChem
|
|
|
269
300
|
atom = @atoms[index]
|
|
270
301
|
aromatic = aromatic?(index)
|
|
271
302
|
lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
|
|
303
|
+
hyd = writing_hydrogens(index)
|
|
304
|
+
hetero_needs_h = aromatic && lowercase && hyd&.positive? &&
|
|
305
|
+
HETERO_IMPLICIT_H.include?(atom.element)
|
|
272
306
|
needs_bracket =
|
|
273
|
-
atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
|
|
307
|
+
atom.charge || atom.isotope || !atom.hydrogens.nil? || atom.chirality ||
|
|
308
|
+
hetero_needs_h ||
|
|
274
309
|
(!ORGANIC.include?(atom.element) && !lowercase) ||
|
|
275
310
|
(aromatic && !lowercase)
|
|
276
311
|
token =
|
|
@@ -280,10 +315,10 @@ module AsciiChem
|
|
|
280
315
|
t << atom.isotope if atom.isotope
|
|
281
316
|
t << symbol
|
|
282
317
|
t << chirality_token(index) if stereo && atom.chirality
|
|
283
|
-
if atom.hydrogens
|
|
318
|
+
if !atom.hydrogens.nil? || hetero_needs_h
|
|
284
319
|
t << 'H'
|
|
285
320
|
# Stated zero is H0, not a bare H (which means one).
|
|
286
|
-
t <<
|
|
321
|
+
t << hyd.to_s unless hyd == 1
|
|
287
322
|
end
|
|
288
323
|
t << charge_suffix(atom.charge) if atom.charge
|
|
289
324
|
t << ']'
|
data/lib/asciichem/version.rb
CHANGED
|
@@ -9,17 +9,19 @@ module AsciiChem
|
|
|
9
9
|
# fields.
|
|
10
10
|
#
|
|
11
11
|
# Emission (to_wire/to_model_json) covers every wire type.
|
|
12
|
-
# Ingestion (from_wire/from_model_json)
|
|
13
|
-
#
|
|
12
|
+
# Ingestion (from_wire/from_model_json) covers the lossless core
|
|
13
|
+
# set plus mechanism: formula, atom, molecule, group, bond,
|
|
14
14
|
# identifier, name, reaction (+conditions), reaction-cascade,
|
|
15
|
-
# electron-configuration, embedded-math, text
|
|
16
|
-
#
|
|
15
|
+
# electron-configuration, embedded-math, text, mechanism (steps as
|
|
16
|
+
# canonical reaction text, flows, spectators; cascade steps have no
|
|
17
|
+
# wire form). Spectrum, crystal, zmatrix and calculation remain
|
|
17
18
|
# emission-only until their round-trip acceptance lands in the
|
|
18
19
|
# corpus.
|
|
19
20
|
module WireAdapter
|
|
20
21
|
CORE_INGEST_TYPES = %w[
|
|
21
22
|
formula atom molecule group bond identifier name reaction
|
|
22
23
|
reaction-cascade electron-configuration embedded-math text
|
|
24
|
+
mechanism
|
|
23
25
|
].freeze
|
|
24
26
|
|
|
25
27
|
class << self
|
|
@@ -72,20 +74,27 @@ module AsciiChem
|
|
|
72
74
|
# only routes `type` and re-injects routed children into
|
|
73
75
|
# polymorphic collections (which lutaml deserialises flat).
|
|
74
76
|
def wire_tree_from_hash(hash)
|
|
75
|
-
type = hash.fetch(
|
|
77
|
+
type = hash.fetch('type') { raise KeyError, 'wire node missing type discriminator' }
|
|
76
78
|
klass = AsciiChem::Wire::REGISTRY.fetch(type) do
|
|
77
79
|
raise KeyError, "unknown wire node type #{type.inspect}"
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
wire = klass.from_json(JSON.generate(hash))
|
|
81
83
|
case type
|
|
82
|
-
when
|
|
83
|
-
wire.nodes = hash.fetch(
|
|
84
|
-
when
|
|
85
|
-
wire.reactants = hash.fetch(
|
|
86
|
-
wire.products = hash.fetch(
|
|
87
|
-
when
|
|
88
|
-
wire.steps = hash.fetch(
|
|
84
|
+
when 'formula', 'molecule', 'group'
|
|
85
|
+
wire.nodes = hash.fetch('nodes', []).map { |child| wire_tree_from_hash(child) }
|
|
86
|
+
when 'reaction'
|
|
87
|
+
wire.reactants = hash.fetch('reactants', []).map { |child| wire_tree_from_hash(child) }
|
|
88
|
+
wire.products = hash.fetch('products', []).map { |child| wire_tree_from_hash(child) }
|
|
89
|
+
when 'reaction-cascade'
|
|
90
|
+
wire.steps = hash.fetch('steps', []).map { |child| wire_tree_from_hash(child) }
|
|
91
|
+
when 'mechanism'
|
|
92
|
+
wire.steps = hash.fetch('steps', []).map do |step_hash|
|
|
93
|
+
step = AsciiChem::Wire::MechanismStep.from_json(JSON.generate(step_hash))
|
|
94
|
+
step.reaction = wire_tree_from_hash(step_hash.fetch('reaction'))
|
|
95
|
+
step
|
|
96
|
+
end
|
|
97
|
+
wire.spectators = hash.fetch('spectators', []).map { |child| wire_tree_from_hash(child) }
|
|
89
98
|
end
|
|
90
99
|
wire
|
|
91
100
|
end
|
|
@@ -93,12 +102,12 @@ module AsciiChem
|
|
|
93
102
|
private
|
|
94
103
|
|
|
95
104
|
def formula_to_wire(node)
|
|
96
|
-
AsciiChem::Wire::Formula.new(type:
|
|
105
|
+
AsciiChem::Wire::Formula.new(type: 'formula', nodes: node.nodes.map { |n| to_wire(n) })
|
|
97
106
|
end
|
|
98
107
|
|
|
99
108
|
def atom_to_wire(atom)
|
|
100
109
|
AsciiChem::Wire::Atom.new(
|
|
101
|
-
type:
|
|
110
|
+
type: 'atom',
|
|
102
111
|
element: atom.element,
|
|
103
112
|
isotope: digits_or_nil(atom.isotope), charge: str_or_nil(atom.charge),
|
|
104
113
|
subscript: digits_or_nil(atom.subscript),
|
|
@@ -134,7 +143,7 @@ module AsciiChem
|
|
|
134
143
|
|
|
135
144
|
def molecule_to_wire(node)
|
|
136
145
|
AsciiChem::Wire::Molecule.new(
|
|
137
|
-
type:
|
|
146
|
+
type: 'molecule',
|
|
138
147
|
nodes: node.nodes.map { |n| to_wire(n) },
|
|
139
148
|
coefficient: node.coefficient,
|
|
140
149
|
stereo: stereo_to_wire(node.stereo),
|
|
@@ -158,7 +167,7 @@ module AsciiChem
|
|
|
158
167
|
|
|
159
168
|
def group_to_wire(node)
|
|
160
169
|
AsciiChem::Wire::Group.new(
|
|
161
|
-
type:
|
|
170
|
+
type: 'group',
|
|
162
171
|
nodes: node.nodes.map { |n| to_wire(n) },
|
|
163
172
|
multiplicity: node.multiplicity,
|
|
164
173
|
bracket: node.bracket.to_s
|
|
@@ -166,27 +175,27 @@ module AsciiChem
|
|
|
166
175
|
end
|
|
167
176
|
|
|
168
177
|
def bond_to_wire(node)
|
|
169
|
-
AsciiChem::Wire::Bond.new(type:
|
|
178
|
+
AsciiChem::Wire::Bond.new(type: 'bond', kind: node.kind.to_s,
|
|
170
179
|
direction: str_or_nil(node.direction&.to_s))
|
|
171
180
|
end
|
|
172
181
|
|
|
173
182
|
def identifier_to_wire(node)
|
|
174
183
|
AsciiChem::Wire::Identifier.new(
|
|
175
|
-
type:
|
|
184
|
+
type: 'identifier',
|
|
176
185
|
value: node.value, convention: node.convention.to_s, dict_ref: node.dict_ref
|
|
177
186
|
)
|
|
178
187
|
end
|
|
179
188
|
|
|
180
189
|
def name_to_wire(node)
|
|
181
190
|
AsciiChem::Wire::Name.new(
|
|
182
|
-
type:
|
|
191
|
+
type: 'name',
|
|
183
192
|
content: node.content, convention: node.convention, dict_ref: node.dict_ref
|
|
184
193
|
)
|
|
185
194
|
end
|
|
186
195
|
|
|
187
196
|
def reaction_to_wire(node)
|
|
188
197
|
AsciiChem::Wire::Reaction.new(
|
|
189
|
-
type:
|
|
198
|
+
type: 'reaction',
|
|
190
199
|
reactants: node.reactants.map { |m| molecule_to_wire(m) },
|
|
191
200
|
products: node.products.map { |m| molecule_to_wire(m) },
|
|
192
201
|
arrow: node.arrow.to_s,
|
|
@@ -202,14 +211,14 @@ module AsciiChem
|
|
|
202
211
|
|
|
203
212
|
def cascade_to_wire(node)
|
|
204
213
|
AsciiChem::Wire::ReactionCascade.new(
|
|
205
|
-
type:
|
|
214
|
+
type: 'reaction-cascade',
|
|
206
215
|
steps: node.steps.map { |r| reaction_to_wire(r) }
|
|
207
216
|
)
|
|
208
217
|
end
|
|
209
218
|
|
|
210
219
|
def econfig_to_wire(node)
|
|
211
220
|
AsciiChem::Wire::ElectronConfiguration.new(
|
|
212
|
-
type:
|
|
221
|
+
type: 'electron-configuration',
|
|
213
222
|
orbitals: node.orbitals.map do |(label, count)|
|
|
214
223
|
AsciiChem::Wire::OrbitalOccupancy.new(orbital: label.to_s, occupancy: count.to_s)
|
|
215
224
|
end,
|
|
@@ -226,16 +235,16 @@ module AsciiChem
|
|
|
226
235
|
end
|
|
227
236
|
|
|
228
237
|
def math_to_wire(node)
|
|
229
|
-
AsciiChem::Wire::EmbeddedMath.new(type:
|
|
238
|
+
AsciiChem::Wire::EmbeddedMath.new(type: 'embedded-math', source: node.source)
|
|
230
239
|
end
|
|
231
240
|
|
|
232
241
|
def text_to_wire(node)
|
|
233
|
-
AsciiChem::Wire::Text.new(type:
|
|
242
|
+
AsciiChem::Wire::Text.new(type: 'text', content: node.content)
|
|
234
243
|
end
|
|
235
244
|
|
|
236
245
|
def mechanism_to_wire(node)
|
|
237
246
|
AsciiChem::Wire::Mechanism.new(
|
|
238
|
-
type:
|
|
247
|
+
type: 'mechanism',
|
|
239
248
|
steps: node.steps.map { |step| mechanism_step_to_wire(step) },
|
|
240
249
|
spectators: node.spectators.map { |sp| mechanism_spectator_to_wire(sp) }
|
|
241
250
|
)
|
|
@@ -264,12 +273,16 @@ module AsciiChem
|
|
|
264
273
|
|
|
265
274
|
# The model keeps a step's reaction as TEXT (the mechanism body
|
|
266
275
|
# is verbatim); the wire carries the typed node, so the same
|
|
267
|
-
# grammar parses it here.
|
|
276
|
+
# grammar parses it here. A cascade step has no wire form —
|
|
277
|
+
# MechanismStep.reaction is typed Reaction — and says so instead
|
|
278
|
+
# of silently flattening the chain.
|
|
268
279
|
def mechanism_reaction_to_wire(text)
|
|
269
280
|
node = AsciiChem.parse(text).nodes.first
|
|
270
281
|
case node
|
|
271
282
|
when AsciiChem::Model::Reaction then reaction_to_wire(node)
|
|
272
|
-
when AsciiChem::Model::ReactionCascade
|
|
283
|
+
when AsciiChem::Model::ReactionCascade
|
|
284
|
+
raise AsciiChem::ParseError,
|
|
285
|
+
"mechanism step cascades have no wire form: #{text.inspect}"
|
|
273
286
|
else
|
|
274
287
|
raise AsciiChem::ParseError,
|
|
275
288
|
"mechanism step is not a reaction: #{text.inspect}"
|
|
@@ -278,7 +291,7 @@ module AsciiChem
|
|
|
278
291
|
|
|
279
292
|
def spectrum_to_wire(node)
|
|
280
293
|
AsciiChem::Wire::Spectrum.new(
|
|
281
|
-
type:
|
|
294
|
+
type: 'spectrum',
|
|
282
295
|
technique: node.type,
|
|
283
296
|
params: node.params,
|
|
284
297
|
peaks: node.peaks.map do |peak|
|
|
@@ -292,7 +305,7 @@ module AsciiChem
|
|
|
292
305
|
|
|
293
306
|
def crystal_to_wire(node)
|
|
294
307
|
AsciiChem::Wire::Crystal.new(
|
|
295
|
-
type:
|
|
308
|
+
type: 'crystal',
|
|
296
309
|
name: node.name, a: node.a, b: node.b, c: node.c,
|
|
297
310
|
alpha: node.alpha, beta: node.beta, gamma: node.gamma,
|
|
298
311
|
spacegroup: node.spacegroup,
|
|
@@ -302,7 +315,7 @@ module AsciiChem
|
|
|
302
315
|
|
|
303
316
|
def zmatrix_to_wire(node)
|
|
304
317
|
AsciiChem::Wire::ZMatrix.new(
|
|
305
|
-
type:
|
|
318
|
+
type: 'zmatrix',
|
|
306
319
|
rows: node.rows.map do |row|
|
|
307
320
|
AsciiChem::Wire::ZRow.new(
|
|
308
321
|
atom: row.atom, ref1: row.ref1, distance: row.distance,
|
|
@@ -314,7 +327,7 @@ module AsciiChem
|
|
|
314
327
|
|
|
315
328
|
def calculation_to_wire(node)
|
|
316
329
|
AsciiChem::Wire::Calculation.new(
|
|
317
|
-
type:
|
|
330
|
+
type: 'calculation',
|
|
318
331
|
method: node.method, basis: node.basis,
|
|
319
332
|
properties: node.properties.map do |prop|
|
|
320
333
|
AsciiChem::Wire::CalculatedProperty.new(
|
|
@@ -394,6 +407,26 @@ module AsciiChem
|
|
|
394
407
|
)
|
|
395
408
|
end
|
|
396
409
|
|
|
410
|
+
def mechanism_from_wire(wire)
|
|
411
|
+
AsciiChem::Model::Mechanism.new(
|
|
412
|
+
steps: Array(wire.steps).map { |s| mechanism_step_from_wire(s) },
|
|
413
|
+
spectators: Array(wire.spectators).map { |sp| molecule_from_wire(sp).to_text }
|
|
414
|
+
)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# The model keeps a step's reaction as TEXT; the wire carries the
|
|
418
|
+
# typed node, so ingestion renders it back through the canonical
|
|
419
|
+
# text form.
|
|
420
|
+
def mechanism_step_from_wire(step)
|
|
421
|
+
AsciiChem::Model::Mechanism::Step.new(
|
|
422
|
+
label: step.label,
|
|
423
|
+
reaction: from_wire(step.reaction).to_text,
|
|
424
|
+
flows: Array(step.flows).map do |flow|
|
|
425
|
+
AsciiChem::Model::Mechanism::Flow.new(source: flow.source, target: flow.target)
|
|
426
|
+
end
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
|
|
397
430
|
def electron_configuration_from_wire(wire)
|
|
398
431
|
AsciiChem::Model::ElectronConfiguration.new(
|
|
399
432
|
orbitals: Array(wire.orbitals).map { |o| [o.orbital, o.occupancy] },
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Re-vendor the asciichem-model v1 JSON Schemas (YAML) and examples.
|
|
3
|
-
# Usage: scripts/update-model-schemas.sh [model-tag]
|
|
3
|
+
# Usage: scripts/update-model-schemas.sh [model-tag]. Default is the
|
|
4
|
+
# released tag — vendored schemas stay hermetic, never floating.
|
|
4
5
|
set -euo pipefail
|
|
5
6
|
|
|
6
|
-
tag="${1:-
|
|
7
|
+
tag="${1:-v0.6.0}"
|
|
7
8
|
work="$(mktemp -d)"
|
|
8
9
|
trap 'rm -rf "$work"' EXIT
|
|
9
10
|
|